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" hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1" grpcadapter "gitea.nik4nao.com/nik/home-services/ha-gateway/internal/adapters/primary/grpc" "gitea.nik4nao.com/nik/home-services/ha-gateway/internal/adapters/secondary/ha" "gitea.nik4nao.com/nik/home-services/ha-gateway/internal/app" "gitea.nik4nao.com/nik/home-services/ha-gateway/internal/config" "gitea.nik4nao.com/nik/home-services/ha-gateway/internal/logger" "gitea.nik4nao.com/nik/home-services/ha-gateway/internal/telemetry" ) // MEMO: auth is not implemented. // Add one of the following before exposing this service to any untrusted network: // Option A — shared API key per client: unary + stream interceptors read // "authorization" from gRPC metadata and compare to a secret // from config. Good for small number of known clients. // Option B — mTLS (recommended): tls.Config with ClientAuth: RequireAndVerifyClientCert, // cert pool from the internal CA. Each client gets a cert from // cert-manager. No runtime auth dependency, identity in the cert CN/SAN. // version is set at build time via -ldflags "-X main.version=". var version = "dev" func main() { _ = godotenv.Load() // Config is loaded before logger setup so fatal startup errors still stop early. cfg, err := config.Load() if err != nil { os.Stderr.WriteString("config error: " + err.Error() + "\n") os.Exit(1) } log := logger.New(cfg.LogFormat, cfg.LogLevel) log.Info("starting ha-gateway", "version", version, "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, ) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) defer stop() ctx = logger.WithLogger(ctx, log) // Telemetry is optional; an empty OTEL endpoint installs no-op providers. shutdown, err := telemetry.Setup(ctx, "ha-gateway", version, cfg) if err != nil { log.Error("telemetry setup failed", "err", err) os.Exit(1) } if cfg.OTELEndpoint != "" { log.Info("telemetry enabled", "endpoint", cfg.OTELEndpoint) } else { log.Debug("telemetry disabled") } haClient := ha.NewClient(cfg, log) // App services stay free of gRPC and HTTP details; adapters are wired here. entityApp := app.NewEntityApp(haClient) lightApp := app.NewLightApp(haClient) switchApp := app.NewSwitchApp(haClient) if err := lightApp.Refresh(ctx); err != nil { log.Warn("initial light discovery failed, will retry on first request", "err", err) } if err := switchApp.Refresh(ctx); err != nil { log.Warn("initial switch discovery failed, will retry on first request", "err", err) } 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) hav1.RegisterEntityServiceServer(srv, grpcadapter.NewEntityGRPC(entityApp)) hav1.RegisterLightServiceServer(srv, grpcadapter.NewLightGRPC(lightApp)) hav1.RegisterSwitchServiceServer(srv, grpcadapter.NewSwitchGRPC(switchApp)) hav1.RegisterEventServiceServer(srv, &grpcadapter.EventGRPC{}) grpc_health_v1.RegisterHealthServer(srv, healthSrv) reflection.Register(srv) lis, err := net.Listen("tcp", ":"+cfg.GRPCPort) if err != nil { log.Error("listen failed", "err", err) os.Exit(1) } go func() { log.Info("ha-gateway listening", "addr", lis.Addr().String()) if err := srv.Serve(lis); err != nil { log.Error("serve failed", "err", err) } }() <-ctx.Done() log.Info("shutdown signal received, draining") healthSrv.SetServingStatus("", grpc_health_v1.HealthCheckResponse_NOT_SERVING) srv.GracefulStop() log.Info("shutdown complete") if err := shutdown(context.Background()); err != nil { log.Error("telemetry shutdown error", "err", err) } } // redactToken logs only a short prefix so startup logs remain useful without // leaking a full Home Assistant credential. func redactToken(token string) string { if token == "" { return "[not set]" } if len(token) <= 8 { return token + "..." } 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 }