package haclient import ( "context" "crypto/tls" "crypto/x509" "fmt" "log/slog" "os" "path/filepath" 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" ) // Client is alexa-bridge's single mTLS connection to ha-gateway. It // implements driven.Controller directly (see controller.go) and also // exposes the raw EntityServiceClient for internal/entities to wrap, so the // whole process shares one connection/handshake to ha-gateway rather than // dialing it twice for two different RPC families. type Client struct { conn *grpc.ClientConn lightClient hav1.LightServiceClient switchClient hav1.SwitchServiceClient climateClient hav1.ClimateServiceClient entityClient hav1.EntityServiceClient log *slog.Logger } // New constructs a gRPC client for ha-gateway with optional mTLS. func New(ctx context.Context, addr, tlsDir, serverName string, log *slog.Logger) (*Client, error) { transportCreds := insecure.NewCredentials() if tlsDir != "" { creds, err := loadTransportCredentials(tlsDir, serverName) if err != nil { return nil, fmt.Errorf("load mTLS credentials: %w", err) } transportCreds = creds } conn, err := grpc.NewClient( addr, grpc.WithTransportCredentials(transportCreds), grpc.WithStatsHandler(otelgrpc.NewClientHandler()), ) if err != nil { return nil, fmt.Errorf("dial ha-gateway: %w", err) } return &Client{ conn: conn, lightClient: hav1.NewLightServiceClient(conn), switchClient: hav1.NewSwitchServiceClient(conn), climateClient: hav1.NewClimateServiceClient(conn), entityClient: hav1.NewEntityServiceClient(conn), log: log, }, nil } // EntityServiceClient exposes the shared connection's EntityService stub for // internal/entities to wrap, instead of that package dialing its own // second connection to ha-gateway. func (c *Client) EntityServiceClient() hav1.EntityServiceClient { return c.entityClient } // Close closes the underlying gRPC connection. func (c *Client) Close() error { if err := c.conn.Close(); err != nil { return fmt.Errorf("close ha-gateway client: %w", err) } return nil } func loadTransportCredentials(tlsDir, serverName 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: serverName, MinVersion: tls.VersionTLS13, }), nil }