NewClient creates a new remote gRPC client that implements LLMClient.
(ctx context.Context, cfg Config, logger *zap.Logger)
| 52 | |
| 53 | // NewClient creates a new remote gRPC client that implements LLMClient. |
| 54 | func NewClient(ctx context.Context, cfg Config, logger *zap.Logger) (*Client, error) { |
| 55 | var dialOpts []grpc.DialOption |
| 56 | |
| 57 | // Security (H4): TLS configuration with TLS 1.3 minimum. |
| 58 | // Use CHATCLI_TLS_CLIENT_CERT/KEY for mTLS client certificates. |
| 59 | if cfg.TLS { |
| 60 | tlsCfg := &tls.Config{ |
| 61 | MinVersion: tls.VersionTLS13, |
| 62 | } |
| 63 | |
| 64 | if cfg.CertFile != "" { |
| 65 | caCert, err := os.ReadFile(cfg.CertFile) |
| 66 | if err != nil { |
| 67 | return nil, fmt.Errorf("failed to read CA certificate: %w", err) |
| 68 | } |
| 69 | certPool := x509.NewCertPool() |
| 70 | if !certPool.AppendCertsFromPEM(caCert) { |
| 71 | return nil, fmt.Errorf("failed to parse CA certificate") |
| 72 | } |
| 73 | tlsCfg.RootCAs = certPool |
| 74 | } |
| 75 | |
| 76 | // mTLS: load client certificate if configured |
| 77 | if certPath := os.Getenv("CHATCLI_TLS_CLIENT_CERT"); certPath != "" { |
| 78 | keyPath := os.Getenv("CHATCLI_TLS_CLIENT_KEY") |
| 79 | if keyPath == "" { |
| 80 | return nil, fmt.Errorf("CHATCLI_TLS_CLIENT_CERT set but CHATCLI_TLS_CLIENT_KEY is missing") |
| 81 | } |
| 82 | clientCert, err := tls.LoadX509KeyPair(certPath, keyPath) |
| 83 | if err != nil { |
| 84 | return nil, fmt.Errorf("failed to load client TLS cert/key: %w", err) |
| 85 | } |
| 86 | tlsCfg.Certificates = []tls.Certificate{clientCert} |
| 87 | } |
| 88 | |
| 89 | dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(tlsCfg))) |
| 90 | } else { |
| 91 | // Security: Require explicit opt-in for insecure connections |
| 92 | if strings.EqualFold(os.Getenv("CHATCLI_ALLOW_INSECURE"), "true") { |
| 93 | logger.Warn("SECURITY WARNING: TLS is disabled. Connection is unencrypted. Set CHATCLI_ALLOW_INSECURE=false for production.") |
| 94 | dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials())) |
| 95 | } else { |
| 96 | logger.Warn("TLS disabled but CHATCLI_ALLOW_INSECURE not set — using system TLS") |
| 97 | dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{ |
| 98 | MinVersion: tls.VersionTLS13, |
| 99 | }))) |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | // Keepalive: detect dead connections without flooding the server. |
| 104 | // Server EnforcementPolicy.MinTime is 20s, so Time must be >= 20s. |
| 105 | dialOpts = append(dialOpts, grpc.WithKeepaliveParams(keepalive.ClientParameters{ |
| 106 | Time: 30 * time.Second, // ping every 30s if no activity |
| 107 | Timeout: 5 * time.Second, // wait 5s for pong before considering dead |
| 108 | PermitWithoutStream: true, // ping even without active RPCs |
| 109 | })) |
| 110 | |
| 111 | // Client-side round-robin load balancing for headless Services with multiple replicas |