From c581e7943488522a20cc8c10b220b0148820af29 Mon Sep 17 00:00:00 2001 From: Nik Afiq Date: Thu, 9 Apr 2026 22:34:22 +0900 Subject: [PATCH] feat: add mTLS support and TLS directory configuration for ha-gateway and discord-bot --- discord-bot/cmd/bot/main.go | 3 +- .../adapters/secondary/gateway/client.go | 45 ++++++++++++++++- discord-bot/internal/config/config.go | 2 + ha-gateway/cmd/gateway/main.go | 50 ++++++++++++++++++- ha-gateway/internal/config/config.go | 2 + 5 files changed, 97 insertions(+), 5 deletions(-) diff --git a/discord-bot/cmd/bot/main.go b/discord-bot/cmd/bot/main.go index e399d64..5511088 100644 --- a/discord-bot/cmd/bot/main.go +++ b/discord-bot/cmd/bot/main.go @@ -39,6 +39,7 @@ func main() { "version", version, "ha_gateway_addr", cfg.HAGatewayAddr, "discord_token", redactToken(cfg.DiscordToken), + "tls_dir", cfg.TLSDir, "log_level", cfg.LogLevel, "log_format", cfg.LogFormat, ) @@ -59,7 +60,7 @@ func main() { log.Debug("telemetry disabled") } - haClient, err := gateway.New(ctx, cfg.HAGatewayAddr, log) + haClient, err := gateway.New(ctx, cfg.HAGatewayAddr, cfg.TLSDir, log) if err != nil { log.Error("ha-gateway client setup failed", "err", err) os.Exit(1) diff --git a/discord-bot/internal/adapters/secondary/gateway/client.go b/discord-bot/internal/adapters/secondary/gateway/client.go index 5bba96e..20dbb44 100644 --- a/discord-bot/internal/adapters/secondary/gateway/client.go +++ b/discord-bot/internal/adapters/secondary/gateway/client.go @@ -2,8 +2,12 @@ package gateway import ( "context" + "crypto/tls" + "crypto/x509" "fmt" "log/slog" + "os" + "path/filepath" "time" "gitea.nik4nao.com/nik/home-services/discord-bot/internal/core/ports/driven" @@ -11,6 +15,7 @@ import ( hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1" "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" "google.golang.org/grpc" + "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" ) @@ -23,10 +28,19 @@ type Client struct { } // New constructs a gRPC client for the internal ha-gateway service. -func New(ctx context.Context, addr string, log *slog.Logger) (*Client, error) { +func New(ctx context.Context, addr, tlsDir string, log *slog.Logger) (*Client, error) { + transportCreds := insecure.NewCredentials() + if tlsDir != "" { + creds, err := loadTransportCredentials(tlsDir) + if err != nil { + return nil, fmt.Errorf("load mTLS credentials: %w", err) + } + transportCreds = creds + } + conn, err := grpc.NewClient( addr, - grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithTransportCredentials(transportCreds), grpc.WithStatsHandler(otelgrpc.NewClientHandler()), ) if err != nil { @@ -49,6 +63,33 @@ func (c *Client) Close() error { return nil } +func loadTransportCredentials(tlsDir string) (credentials.TransportCredentials, error) { + cert, err := tls.LoadX509KeyPair( + filepath.Join(tlsDir, "tls.crt"), + filepath.Join(tlsDir, "tls.key"), + ) + if err != nil { + return nil, fmt.Errorf("load client key pair: %w", err) + } + + caPEM, err := os.ReadFile(filepath.Join(tlsDir, "ca.crt")) + if err != nil { + return nil, fmt.Errorf("read server CA: %w", err) + } + + rootCAs := x509.NewCertPool() + if !rootCAs.AppendCertsFromPEM(caPEM) { + return nil, fmt.Errorf("append server CA: invalid PEM") + } + + return credentials.NewTLS(&tls.Config{ + Certificates: []tls.Certificate{cert}, + RootCAs: rootCAs, + ServerName: "ha-gateway.home-services.svc.cluster.local", + MinVersion: tls.VersionTLS13, + }), nil +} + // ListLights calls ha-gateway discovery RPCs and maps protobuf messages into // the driven port type expected by the app layer. func (c *Client) ListLights(ctx context.Context) ([]driven.Light, error) { diff --git a/discord-bot/internal/config/config.go b/discord-bot/internal/config/config.go index cdd50fe..bfa676c 100644 --- a/discord-bot/internal/config/config.go +++ b/discord-bot/internal/config/config.go @@ -10,6 +10,7 @@ type Config struct { DiscordToken string GuildID string HAGatewayAddr string + TLSDir string OTELEndpoint string LogLevel string LogFormat string @@ -31,6 +32,7 @@ func Load() (*Config, error) { DiscordToken: token, GuildID: os.Getenv("GUILD_ID"), HAGatewayAddr: addr, + TLSDir: os.Getenv("TLS_DIR"), OTELEndpoint: os.Getenv("OTEL_ENDPOINT"), LogLevel: getenvDefault("LOG_LEVEL", "info"), LogFormat: getenvDefault("LOG_FORMAT", "json"), diff --git a/ha-gateway/cmd/gateway/main.go b/ha-gateway/cmd/gateway/main.go index b7a23a4..e55b5bc 100644 --- a/ha-gateway/cmd/gateway/main.go +++ b/ha-gateway/cmd/gateway/main.go @@ -2,14 +2,19 @@ package main import ( "context" + "crypto/tls" + "crypto/x509" + "fmt" "net" "os" "os/signal" + "path/filepath" "syscall" "github.com/joho/godotenv" "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" "google.golang.org/grpc" + "google.golang.org/grpc/credentials" "google.golang.org/grpc/health" grpc_health_v1 "google.golang.org/grpc/health/grpc_health_v1" "google.golang.org/grpc/reflection" @@ -51,6 +56,7 @@ func main() { "grpc_port", cfg.GRPCPort, "ha_base_url", cfg.HABaseURL, "ha_token", redactToken(cfg.HAToken), + "tls_dir", cfg.TLSDir, "otel_endpoint", cfg.OTELEndpoint, "log_level", cfg.LogLevel, "log_format", cfg.LogFormat, @@ -86,11 +92,24 @@ func main() { log.Warn("initial switch discovery failed, will retry on first request", "err", err) } - srv := grpc.NewServer( + serverOpts := []grpc.ServerOption{ grpc.StatsHandler(otelgrpc.NewServerHandler()), grpc.ChainUnaryInterceptor(grpcadapter.LoggingUnaryInterceptor(log)), grpc.ChainStreamInterceptor(grpcadapter.LoggingStreamInterceptor(log)), - ) + } + if cfg.TLSDir != "" { + creds, err := loadServerCredentials(cfg.TLSDir) + if err != nil { + log.Error("load mTLS credentials failed", "tls_dir", cfg.TLSDir, "err", err) + os.Exit(1) + } + serverOpts = append(serverOpts, grpc.Creds(creds)) + log.Info("mTLS enabled", "tls_dir", cfg.TLSDir) + } else { + log.Info("mTLS disabled") + } + + srv := grpc.NewServer(serverOpts...) healthSrv := health.NewServer() healthSrv.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING) @@ -137,3 +156,30 @@ func redactToken(token string) string { } return token[:8] + "..." } + +func loadServerCredentials(tlsDir string) (credentials.TransportCredentials, error) { + cert, err := tls.LoadX509KeyPair( + filepath.Join(tlsDir, "tls.crt"), + filepath.Join(tlsDir, "tls.key"), + ) + if err != nil { + return nil, fmt.Errorf("load server key pair: %w", err) + } + + caPEM, err := os.ReadFile(filepath.Join(tlsDir, "ca.crt")) + if err != nil { + return nil, fmt.Errorf("read client CA: %w", err) + } + + clientCAs := x509.NewCertPool() + if !clientCAs.AppendCertsFromPEM(caPEM) { + return nil, fmt.Errorf("append client CA: invalid PEM") + } + + return credentials.NewTLS(&tls.Config{ + Certificates: []tls.Certificate{cert}, + ClientCAs: clientCAs, + ClientAuth: tls.RequireAndVerifyClientCert, + MinVersion: tls.VersionTLS13, + }), nil +} diff --git a/ha-gateway/internal/config/config.go b/ha-gateway/internal/config/config.go index d05f6a3..cd51838 100644 --- a/ha-gateway/internal/config/config.go +++ b/ha-gateway/internal/config/config.go @@ -10,6 +10,7 @@ type Config struct { GRPCPort string // GRPC_PORT, default "50051" HABaseURL string // HA_BASE_URL, e.g. "http://ha.home.arpa:8123" HAToken string // HA_TOKEN — long-lived access token (required) + TLSDir string // TLS_DIR, empty disables mTLS for local dev OTELEndpoint string // OTEL_ENDPOINT, e.g. "otel-collector.monitoring.svc:4317" LogLevel string // LOG_LEVEL, default "info" LogFormat string // LOG_FORMAT, default "json" @@ -32,6 +33,7 @@ func Load() (*Config, error) { GRPCPort: port, HABaseURL: os.Getenv("HA_BASE_URL"), HAToken: token, + TLSDir: os.Getenv("TLS_DIR"), OTELEndpoint: os.Getenv("OTEL_ENDPOINT"), LogLevel: getenvDefault("LOG_LEVEL", "info"), LogFormat: getenvDefault("LOG_FORMAT", "json"),