diff --git a/cmd/cert.go b/cmd/cert.go index 01e2721..4759873 100644 --- a/cmd/cert.go +++ b/cmd/cert.go @@ -76,7 +76,7 @@ func runCertInit(cmd *cobra.Command, args []string) error { serverCert := filepath.Join(dir, "server.crt") serverKey := filepath.Join(dir, "server.key") fmt.Fprintf(os.Stderr, "Generating server cert... ") - if err := tlsconfig.GenerateSignedCert("server", serverCert, serverKey, serverCAcert, serverCAkey); err != nil { + if err := tlsconfig.GenerateSignedCert("server", serverCert, serverKey, serverCAcert, serverCAkey, nil, nil); err != nil { return fmt.Errorf("generate server cert: %w", err) } fmt.Fprintln(os.Stderr, "done") @@ -94,7 +94,7 @@ func runCertInit(cmd *cobra.Command, args []string) error { hostCert := filepath.Join(dir, "host.crt") hostKey := filepath.Join(dir, "host.key") fmt.Fprintf(os.Stderr, "Generating host cert... ") - if err := tlsconfig.GenerateSignedCert("host", hostCert, hostKey, clientCAcert, clientCAkey); err != nil { + if err := tlsconfig.GenerateSignedCert("host", hostCert, hostKey, clientCAcert, clientCAkey, nil, nil); err != nil { return fmt.Errorf("generate host cert: %w", err) } fmt.Fprintln(os.Stderr, "done") @@ -133,7 +133,7 @@ func runCertIssue(cmd *cobra.Command, args []string) error { keyOut := filepath.Join(dir, name+".key") fmt.Fprintf(os.Stderr, "Issuing cert for %q...\n", name) - if err := tlsconfig.GenerateSignedCert(name, certOut, keyOut, clientCAcert, clientCAkey); err != nil { + if err := tlsconfig.GenerateSignedCert(name, certOut, keyOut, clientCAcert, clientCAkey, nil, nil); err != nil { return fmt.Errorf("issue cert: %w", err) } fmt.Printf("Certificate: %s\nKey: %s\n", certOut, keyOut) diff --git a/cmd/init.go b/cmd/init.go index 5b04a0e..2b1b8e6 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -79,10 +79,36 @@ func runInit(cmd *cobra.Command, args []string) error { } fmt.Fprintln(os.Stderr, "done") + // Prompt for server cert DNS names and IPs. + fmt.Fprintf(os.Stderr, " Server DNS names [%s]: ", "localhost") + serverDNS, _ := reader.ReadString('\n') + serverDNS = strings.TrimSpace(serverDNS) + fmt.Fprintf(os.Stderr, " Server IPs [%s]: ", "127.0.0.1, ::1") + serverIPs, _ := reader.ReadString('\n') + serverIPs = strings.TrimSpace(serverIPs) + serverCert := filepath.Join(home, "server.crt") serverKey := filepath.Join(home, "server.key") fmt.Fprintf(os.Stderr, " Generating server cert... ") - if err := tlsconfig.GenerateSignedCert("server", serverCert, serverKey, serverCAcert, serverCAkey); err != nil { + var serverDns []string + if serverDNS != "" { + for _, s := range strings.Split(serverDNS, ",") { + s = strings.TrimSpace(s) + if s != "" { + serverDns = append(serverDns, s) + } + } + } + var serverIps []string + if serverIPs != "" { + for _, s := range strings.Split(serverIPs, ",") { + s = strings.TrimSpace(s) + if s != "" { + serverIps = append(serverIps, s) + } + } + } + if err := tlsconfig.GenerateSignedCert("server", serverCert, serverKey, serverCAcert, serverCAkey, serverDns, serverIps); err != nil { return fmt.Errorf("generate server cert: %w", err) } fmt.Fprintln(os.Stderr, "done") @@ -93,10 +119,36 @@ func runInit(cmd *cobra.Command, args []string) error { return fmt.Errorf("generate client CA: %w", err) } + // Prompt for host cert DNS names and IPs. + fmt.Fprintf(os.Stderr, " Host DNS names [%s]: ", "localhost") + hostDNS, _ := reader.ReadString('\n') + hostDNS = strings.TrimSpace(hostDNS) + fmt.Fprintf(os.Stderr, " Host IPs [%s]: ", "127.0.0.1, ::1") + hostIPs, _ := reader.ReadString('\n') + hostIPs = strings.TrimSpace(hostIPs) + hostCert := filepath.Join(home, "host.crt") hostKey := filepath.Join(home, "host.key") fmt.Fprintf(os.Stderr, " Generating host cert... ") - if err := tlsconfig.GenerateSignedCert("host", hostCert, hostKey, clientCAcert, clientCAkey); err != nil { + var hostDns []string + if hostDNS != "" { + for _, s := range strings.Split(hostDNS, ",") { + s = strings.TrimSpace(s) + if s != "" { + hostDns = append(hostDns, s) + } + } + } + var hostIps []string + if hostIPs != "" { + for _, s := range strings.Split(hostIPs, ",") { + s = strings.TrimSpace(s) + if s != "" { + hostIps = append(hostIps, s) + } + } + } + if err := tlsconfig.GenerateSignedCert("host", hostCert, hostKey, clientCAcert, clientCAkey, hostDns, hostIps); err != nil { return fmt.Errorf("generate host cert: %w", err) } fmt.Fprintln(os.Stderr, "done") diff --git a/cmd/root.go b/cmd/root.go index f35a85f..59dec64 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -16,11 +16,12 @@ import ( var chCli = alog.UseChannel("CLI") var ( - flagServerURLs string - flagClientCert string - flagClientKey string - flagClientCA string - flagCExpr string + flagServerURLs string + flagClientCert string + flagClientKey string + flagClientCA string + flagSkipHostname bool + flagCExpr string ) // knownSubcommands are named subcommands that take priority over wrap mode. @@ -36,12 +37,13 @@ var knownSubcommands = map[string]bool{ // knownRCFlagValues are RC flags that consume the next argument as their value. // Used when scanning os.Args to find the wrapped command boundary. var knownRCFlagValues = map[string]bool{ - "--server-urls": true, - "--client-cert": true, - "--client-key": true, - "--client-ca": true, - "-c": true, - "--c": true, + "--server-urls": true, + "--client-cert": true, + "--client-key": true, + "--client-ca": true, + "--skip-hostname-verify": true, + "-c": true, + "--c": true, } var rootCmd = &cobra.Command{ @@ -88,6 +90,7 @@ func init() { rootCmd.PersistentFlags().StringVar(&flagClientCert, "client-cert", "", "Client TLS certificate file") rootCmd.PersistentFlags().StringVar(&flagClientKey, "client-key", "", "Client TLS key file") rootCmd.PersistentFlags().StringVar(&flagClientCA, "client-ca", "", "CA cert file to trust for server certificate") + rootCmd.PersistentFlags().BoolVar(&flagSkipHostname, "skip-hostname-verification", false, "Skip server TLS hostname verification") rootCmd.Flags().StringVarP(&flagCExpr, "c", "c", "", "Shell expression to execute via sh -c") } @@ -178,5 +181,8 @@ func cliOverrides() map[string]string { if flagClientCA != "" { overrides["client-ca"] = flagClientCA } + if flagSkipHostname { + overrides["skip-hostname-verification"] = "true" + } return overrides } diff --git a/internal/common/apiclient/apiclient.go b/internal/common/apiclient/apiclient.go index 8067097..a922325 100644 --- a/internal/common/apiclient/apiclient.go +++ b/internal/common/apiclient/apiclient.go @@ -44,6 +44,7 @@ func buildHTTPClient(cfg *config.Config) (*http.Client, *tls.Config) { cfg.ClientTLS.CertFile, cfg.ClientTLS.KeyFile, cfg.ClientTLS.TrustedCAFile, + cfg.ClientTLS.SkipHostnameVerification, cfg.Auth.Mode, ) timeout := time.Duration(cfg.ClientTimeoutSeconds) * time.Second diff --git a/internal/common/config/config.go b/internal/common/config/config.go index 166c20a..6c263c1 100644 --- a/internal/common/config/config.go +++ b/internal/common/config/config.go @@ -35,10 +35,10 @@ type LoggingConfig struct { // TLSBundle holds TLS certificate configuration for one side of a connection. type TLSBundle struct { - CertFile string `json:"cert_file"` - KeyFile string `json:"key_file"` - TrustedCAFile string `json:"trusted_ca_file"` - InsecureSkipVerify bool `json:"insecure_skip_verify"` + CertFile string `json:"cert_file"` + KeyFile string `json:"key_file"` + TrustedCAFile string `json:"trusted_ca_file"` + SkipHostnameVerification bool `json:"skip_hostname_verification"` } // Config holds the full remote-control configuration. @@ -188,7 +188,7 @@ func applyEnvOverrides(cfg *Config) error { if v := os.Getenv("REMOTE_CONTROL_SERVER_KEY"); v != "" { cfg.ServerTLS.KeyFile = v } - if v := os.Getenv("REMOTE_CONTROL_SERVER_CA"); v != "" { + if v := os.Getenv("REMOTE_CONTROL_SERVER_TRUSTED_CA"); v != "" { cfg.ServerTLS.TrustedCAFile = v } if v := os.Getenv("REMOTE_CONTROL_CLIENT_CERT"); v != "" { @@ -197,14 +197,14 @@ func applyEnvOverrides(cfg *Config) error { if v := os.Getenv("REMOTE_CONTROL_CLIENT_KEY"); v != "" { cfg.ClientTLS.KeyFile = v } - if v := os.Getenv("REMOTE_CONTROL_CLIENT_CA"); v != "" { + if v := os.Getenv("REMOTE_CONTROL_CLIENT_TRUSTED_CA"); v != "" { cfg.ClientTLS.TrustedCAFile = v } - if v := os.Getenv("REMOTE_CONTROL_INSECURE_SKIP_VERIFY"); v != "" { + if v := os.Getenv("REMOTE_CONTROL_SKIP_HOSTNAME_VERIFICATION"); v != "" { if val, err := strToBool(v); nil != err { return err } else { - cfg.ClientTLS.InsecureSkipVerify = val + cfg.ClientTLS.SkipHostnameVerification = val } } if v := os.Getenv("REMOTE_CONTROL_AUTH_MODE"); v != "" { @@ -266,11 +266,11 @@ func applyCLIOverrides(cfg *Config, overrides map[string]string) { if v, ok := overrides["client-ca"]; ok { cfg.ClientTLS.TrustedCAFile = v } - if v, ok := overrides["insecure-skip-verify"]; ok { + if v, ok := overrides["skip-hostname-verification"]; ok { if val, err := strToBool(v); nil != err { return } else { - cfg.ClientTLS.InsecureSkipVerify = val + cfg.ClientTLS.SkipHostnameVerification = val } } if v, ok := overrides["auth-mode"]; ok { diff --git a/internal/common/config/config_test.go b/internal/common/config/config_test.go index bdca5ab..be60d44 100644 --- a/internal/common/config/config_test.go +++ b/internal/common/config/config_test.go @@ -22,11 +22,11 @@ func cleanEnv(t *testing.T, dir string) { t.Setenv("REMOTE_CONTROL_SERVER_URLS", "") t.Setenv("REMOTE_CONTROL_SERVER_CERT", "") t.Setenv("REMOTE_CONTROL_SERVER_KEY", "") - t.Setenv("REMOTE_CONTROL_SERVER_CA", "") + t.Setenv("REMOTE_CONTROL_SERVER_TRUSTED_CA", "") t.Setenv("REMOTE_CONTROL_CLIENT_CERT", "") t.Setenv("REMOTE_CONTROL_CLIENT_KEY", "") - t.Setenv("REMOTE_CONTROL_CLIENT_CA", "") - t.Setenv("REMOTE_CONTROL_INSECURE_SKIP_VERIFY", "") + t.Setenv("REMOTE_CONTROL_CLIENT_TRUSTED_CA", "") + t.Setenv("REMOTE_CONTROL_SKIP_HOSTNAME_VERIFICATION", "") t.Setenv("LOG_LEVEL", "") t.Setenv("LOG_FILTERS", "") t.Setenv("LOG_JSON", "") @@ -115,10 +115,10 @@ func TestLoadWithTLSEnvOverrides(t *testing.T) { cleanEnv(t, t.TempDir()) t.Setenv("REMOTE_CONTROL_CLIENT_CERT", "/tmp/client.crt") t.Setenv("REMOTE_CONTROL_CLIENT_KEY", "/tmp/client.key") - t.Setenv("REMOTE_CONTROL_CLIENT_CA", "/tmp/ca.crt") + t.Setenv("REMOTE_CONTROL_CLIENT_TRUSTED_CA", "/tmp/ca.crt") t.Setenv("REMOTE_CONTROL_SERVER_CERT", "/tmp/server.crt") t.Setenv("REMOTE_CONTROL_SERVER_KEY", "/tmp/server.key") - t.Setenv("REMOTE_CONTROL_SERVER_CA", "/tmp/server-ca.crt") + t.Setenv("REMOTE_CONTROL_SERVER_TRUSTED_CA", "/tmp/server-ca.crt") cfg, err := Load(nil) if err != nil { diff --git a/internal/common/tlsconfig/config.go b/internal/common/tlsconfig/config.go index f93a88a..8045eb9 100644 --- a/internal/common/tlsconfig/config.go +++ b/internal/common/tlsconfig/config.go @@ -5,6 +5,7 @@ import ( "crypto/x509" "fmt" "os" + "time" "github.com/IBM/alchemy-logging/src/go/alog" "github.com/gabe-l-hart/remote-control/internal/common/types" @@ -22,13 +23,11 @@ func BuildServerTLSConfig(serverCertFile, serverKeyFile, clientCAFile string, au switch authMode { case types.AuthModeMTLS: ch.Log(alog.DEBUG, "Configuring server mTLS Auth") - // Require and verify client certificates - clientCA, err := loadCertPool(clientCAFile) - if err != nil { - return nil, fmt.Errorf("load client CA: %w", err) + // Require and verify client certificates (clientCAFile is optional, falls back to system CAs) + clientCAs = loadCertPoolOrSystem(clientCAFile) + if clientCAs != nil { + clientAuth = tls.RequireAndVerifyClientCert } - clientAuth = tls.RequireAndVerifyClientCert - clientCAs = clientCA case types.AuthModeProxy: ch.Log(alog.DEBUG, "Configuring server w/out TLS for proxy auth") @@ -49,19 +48,21 @@ func BuildServerTLSConfig(serverCertFile, serverKeyFile, clientCAFile string, au } // BuildClientTLSConfig constructs the TLS configuration for clients (host wrapper, connect). -// clientCertFile/clientKeyFile: this client's identity certificate and key. -// serverCAFile: CA certificate to trust when verifying the server certificate. +// clientCertFile/clientKeyFile: this client's identity certificate and key (both optional). +// serverCAFile: CA certificate to trust when verifying the server certificate (optional, falls back to system CAs). +// skipHostnameVerification: if true, skips only hostname verification (all other cert verification still occurs). // authMode: determines whether client certificates are sent (mtls) or not (proxy/none). -func BuildClientTLSConfig(clientCertFile, clientKeyFile, serverCAFile string, authMode types.AuthMode) (*tls.Config, error) { +func BuildClientTLSConfig(clientCertFile, clientKeyFile, serverCAFile string, skipHostnameVerification bool, authMode types.AuthMode) (*tls.Config, error) { switch authMode { // If no auth, no TLS case types.AuthModeNone: return nil, nil case types.AuthModeMTLS: - // mTLS mode - load client cert - if clientCertFile == "" || clientKeyFile == "" { - ch.Log(alog.WARNING, "[remote-control] mTLS mode but client certs not configured") - return nil, fmt.Errorf("mTLS mode missing client credential") + // Log if client credentials are partially configured + if clientCertFile == "" && clientKeyFile == "" { + ch.Log(alog.DEBUG, "[remote-control] mTLS mode but no client credentials configured") + } else if clientCertFile == "" || clientKeyFile == "" { + ch.Log(alog.DEBUG, "[remote-control] mTLS mode with partial credentials (cert and key must both be present)") } case types.AuthModeProxy: // proxy mode - ignore client cert @@ -72,18 +73,22 @@ func BuildClientTLSConfig(clientCertFile, clientKeyFile, serverCAFile string, au } } - // NOTE: If empty, default to system CAs - serverCA, err := loadCertPool(serverCAFile) - if err != nil { - return nil, fmt.Errorf("load server CA: %w", err) - } + // Load CA pool (falls back to system CAs if empty) + ch.Log(alog.DEBUG, "[BuildClientTLSConfig] Attempting to load server CA from %s", serverCAFile) + rootCAs := loadCertPoolOrSystem(serverCAFile) config := &tls.Config{ MinVersion: tls.VersionTLS13, - RootCAs: serverCA, + RootCAs: rootCAs, } - // Only load client cert in mTLS mode + if skipHostnameVerification { + ch.Log(alog.DEBUG, "Skipping hostname verification") + config.InsecureSkipVerify = true + config.VerifyConnection = verifyConnectionNoHostname(rootCAs) + } + + // Only load client cert when both cert AND key are present if clientCertFile != "" && clientKeyFile != "" { cert, err := tls.LoadX509KeyPair(clientCertFile, clientKeyFile) if err != nil { @@ -107,3 +112,62 @@ func loadCertPool(caFile string) (*x509.CertPool, error) { } return pool, nil } + +// loadCertPoolOrSystem loads a PEM CA certificate, or returns the system +// root CAs if caFile is empty. Returns nil only if caFile is empty and +// system roots are unavailable (very rare). +func loadCertPoolOrSystem(caFile string) *x509.CertPool { + if caFile == "" { + // Fall back to system root CAs + pool, err := x509.SystemCertPool() + if err != nil { + ch.Log(alog.DEBUG, "[remote-control] no system cert pool available: %v", err) + return nil + } + ch.Log(alog.DEBUG2, "[remote-control] loaded system cert pool") + return pool + } + pool, err := loadCertPool(caFile) + if err != nil { + ch.Log(alog.DEBUG, "[remote-control] failed to load CA from %s, falling back to system CAs: %v", caFile, err) + systemPool, sysErr := x509.SystemCertPool() + if sysErr != nil { + ch.Log(alog.WARNING, "[remote-control] no system cert pool available: %v", sysErr) + return nil + } + return systemPool + } + ch.Log(alog.DEBUG2, "[remote-control] loaded cert pool from %s", caFile) + return pool +} + +func verifyConnectionNoHostname(rootCAs *x509.CertPool) func(cs tls.ConnectionState) error { + return func(cs tls.ConnectionState) error { + // When InsecureSkipVerify is true, we need to manually verify the cert chain + if len(cs.PeerCertificates) == 0 { + return fmt.Errorf("no certificates received from server") + } + + leaf := cs.PeerCertificates[0] + intermediates := x509.NewCertPool() + for _, cert := range cs.PeerCertificates[1:] { + intermediates.AddCert(cert) + } + + // Perform full verification WITHOUT hostname check + // by not setting DNSName in VerifyOptions + _, err := leaf.Verify(x509.VerifyOptions{ + Roots: rootCAs, + Intermediates: intermediates, + CurrentTime: time.Now(), + // Explicitly NOT setting DNSName - this skips hostname verification + // while still doing all other checks (chain, expiry, key usage, etc.) + }) + if err != nil { + return fmt.Errorf("certificate verification failed: %w", err) + } + + ch.Log(alog.DEBUG2, "Certificate verified (hostname check skipped)") + return nil + } +} diff --git a/internal/common/tlsconfig/generate.go b/internal/common/tlsconfig/generate.go index f743fc0..9e82af3 100644 --- a/internal/common/tlsconfig/generate.go +++ b/internal/common/tlsconfig/generate.go @@ -56,10 +56,12 @@ func GenerateCACert(certOut, keyOut string) error { } // GenerateSignedCert creates a certificate signed by the given CA. -// commonName is used as the certificate's CN and DNS SAN. +// commonName is used as the certificate's CN and is always included in DNS SANs. +// dnsNames is a list of additional DNS names; if empty, defaults to [commonName, "localhost"]. +// ipAddresses is a list of IP addresses; if empty, defaults to [127.0.0.1, ::1]. // This is a pure signing utility: the CA files contain all signing state. // Future extension: replace the self-signing step with an ACME or Vault call. -func GenerateSignedCert(commonName, certOut, keyOut, caCertFile, caKeyFile string) error { +func GenerateSignedCert(commonName, certOut, keyOut, caCertFile, caKeyFile string, dnsNames []string, ipAddresses []string) error { // Load CA cert. caCertPEM, err := os.ReadFile(caCertFile) if err != nil { @@ -99,17 +101,42 @@ func GenerateSignedCert(commonName, certOut, keyOut, caCertFile, caKeyFile strin return err } + // Build DNS SANs: include commonName, add user-provided names, default to "localhost" if none given. + var allDNS []string + for _, name := range dnsNames { + if name != "" && !contains(allDNS, name) { + allDNS = append(allDNS, name) + } + } + if !contains(allDNS, commonName) { + allDNS = append(allDNS, commonName) + } + if !contains(allDNS, "localhost") { + allDNS = append(allDNS, "localhost") + } + + // Build IP SANs: use user-provided IPs, default to loopback addresses if none given. + var allIPs []net.IP + for _, ipStr := range ipAddresses { + if ip := net.ParseIP(ipStr); ip != nil { + allIPs = append(allIPs, ip) + } + } + if len(allIPs) == 0 { + allIPs = []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")} + } + template := &x509.Certificate{ - SerialNumber: serial, + SerialNumber: serial, Subject: pkix.Name{ Organization: []string{"remote-control"}, CommonName: commonName, }, - DNSNames: []string{commonName, "localhost"}, - IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}, - NotBefore: time.Now().Add(-time.Minute), - NotAfter: time.Now().Add(365 * 24 * time.Hour), - KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + DNSNames: allDNS, + IPAddresses: allIPs, + NotBefore: time.Now().Add(-time.Minute), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, ExtKeyUsage: []x509.ExtKeyUsage{ x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth, @@ -174,3 +201,12 @@ type pemError struct{ file string } func (e *pemError) Error() string { return "invalid PEM data in " + e.file } func errInvalidPEM(file string) error { return &pemError{file} } + +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} diff --git a/internal/common/tlsconfig/tlsconfig_test.go b/internal/common/tlsconfig/tlsconfig_test.go index 6526a24..bfdaa34 100644 --- a/internal/common/tlsconfig/tlsconfig_test.go +++ b/internal/common/tlsconfig/tlsconfig_test.go @@ -1,6 +1,7 @@ package tlsconfig import ( + "net" "os" "path/filepath" "testing" @@ -27,10 +28,16 @@ func generateCA(t *testing.T, dir string) (certFile, keyFile string) { // generateSigned creates a CA-signed cert+key in dir. func generateSigned(t *testing.T, dir, cn, caCert, caKey string) (certFile, keyFile string) { + t.Helper() + return generateSignedWithSANs(t, dir, cn, caCert, caKey, nil, nil) +} + +// generateSignedWithSANs creates a CA-signed cert+key in dir with custom SANs. +func generateSignedWithSANs(t *testing.T, dir, cn, caCert, caKey string, dnsNames []string, ipAddresses []string) (certFile, keyFile string) { t.Helper() certFile = filepath.Join(dir, cn+".crt") keyFile = filepath.Join(dir, cn+".key") - if err := GenerateSignedCert(cn, certFile, keyFile, caCert, caKey); err != nil { + if err := GenerateSignedCert(cn, certFile, keyFile, caCert, caKey, dnsNames, ipAddresses); err != nil { t.Fatalf("GenerateSignedCert(%s): %v", cn, err) } return certFile, keyFile @@ -133,6 +140,7 @@ func TestGenerateSignedCertCANotFound(t *testing.T) { filepath.Join(dir, "server.key"), "/nonexistent/ca.crt", "/nonexistent/ca.key", + nil, nil, ) if err == nil { t.Fatal("expected error for missing CA files") @@ -151,12 +159,181 @@ func TestGenerateSignedCertInvalidCAKeyPEM(t *testing.T) { filepath.Join(dir, "server.key"), caCert, badKey, + nil, nil, ) if err == nil { t.Fatal("expected error for invalid CA key PEM") } } +// --- GenerateSignedCert SANs --- + +func TestGenerateSignedCertDefaultSANs(t *testing.T) { + dir := t.TempDir() + caCert, caKey := generateCA(t, dir) + certFile, _ := generateSigned(t, dir, "server", caCert, caKey) + + cert, err := LoadCACert(certFile) + if err != nil { + t.Fatalf("LoadCACert: %v", err) + } + if len(cert.DNSNames) != 2 { + t.Errorf("expected 2 DNS SANs, got %d: %v", len(cert.DNSNames), cert.DNSNames) + } + if !containsStr(cert.DNSNames, "localhost") { + t.Errorf("expected 'localhost' in DNS SANs: %v", cert.DNSNames) + } + if !containsStr(cert.DNSNames, "server") { + t.Errorf("expected 'server' (CN) in DNS SANs: %v", cert.DNSNames) + } + if len(cert.IPAddresses) != 2 { + t.Errorf("expected 2 IP SANs, got %d: %v", len(cert.IPAddresses), cert.IPAddresses) + } + hasIPv4 := false + hasIPv6 := false + for _, ip := range cert.IPAddresses { + if ip.Equal(net.ParseIP("127.0.0.1")) { + hasIPv4 = true + } + if ip.Equal(net.ParseIP("::1")) { + hasIPv6 = true + } + } + if !hasIPv4 { + t.Error("expected 127.0.0.1 in IP SANs") + } + if !hasIPv6 { + t.Error("expected ::1 in IP SANs") + } +} + +func TestGenerateSignedCertCustomDNSNames(t *testing.T) { + dir := t.TempDir() + caCert, caKey := generateCA(t, dir) + certFile, _ := generateSignedWithSANs(t, dir, "api", caCert, caKey, + []string{"api.example.com", "api.internal.local"}, nil) + + cert, err := LoadCACert(certFile) + if err != nil { + t.Fatalf("LoadCACert: %v", err) + } + // Should have custom DNS names + CN + "localhost" + expectedDNS := map[string]bool{"api.example.com": false, "api.internal.local": false, "api": false, "localhost": false} + for _, dns := range cert.DNSNames { + if _, ok := expectedDNS[dns]; ok { + expectedDNS[dns] = true + } + } + for dns, found := range expectedDNS { + if !found { + t.Errorf("expected DNS name %q in SANs: %v", dns, cert.DNSNames) + } + } +} + +func TestGenerateSignedCertCustomIPs(t *testing.T) { + dir := t.TempDir() + caCert, caKey := generateCA(t, dir) + certFile, _ := generateSignedWithSANs(t, dir, "server", caCert, caKey, + nil, []string{"10.0.0.1", "192.168.1.100"}) + + cert, err := LoadCACert(certFile) + if err != nil { + t.Fatalf("LoadCACert: %v", err) + } + if len(cert.IPAddresses) != 2 { + t.Errorf("expected 2 IP SANs, got %d: %v", len(cert.IPAddresses), cert.IPAddresses) + } + ips := make(map[string]bool) + for _, ip := range cert.IPAddresses { + ips[ip.String()] = true + } + if !ips["10.0.0.1"] { + t.Errorf("expected 10.0.0.1 in IP SANs: %v", cert.IPAddresses) + } + if !ips["192.168.1.100"] { + t.Errorf("expected 192.168.1.100 in IP SANs: %v", cert.IPAddresses) + } +} + +func TestGenerateSignedCertMixedCustomSANs(t *testing.T) { + dir := t.TempDir() + caCert, caKey := generateCA(t, dir) + certFile, _ := generateSignedWithSANs(t, dir, "myhost", caCert, caKey, + []string{"myhost.example.com"}, []string{"10.0.0.5"}) + + cert, err := LoadCACert(certFile) + if err != nil { + t.Fatalf("LoadCACert: %v", err) + } + // Custom DNS + CN + localhost + if len(cert.DNSNames) != 3 { + t.Errorf("expected 3 DNS SANs, got %d: %v", len(cert.DNSNames), cert.DNSNames) + } + // Single custom IP + if len(cert.IPAddresses) != 1 { + t.Errorf("expected 1 IP SAN, got %d: %v", len(cert.IPAddresses), cert.IPAddresses) + } + if !containsStr(cert.DNSNames, "myhost.example.com") { + t.Errorf("expected 'myhost.example.com' in DNS SANs: %v", cert.DNSNames) + } + if !containsStr(cert.DNSNames, "myhost") { + t.Errorf("expected 'myhost' (CN) in DNS SANs: %v", cert.DNSNames) + } + if !containsStr(cert.DNSNames, "localhost") { + t.Errorf("expected 'localhost' in DNS SANs: %v", cert.DNSNames) + } +} + +func TestGenerateSignedCertDedupeDNSNames(t *testing.T) { + dir := t.TempDir() + caCert, caKey := generateCA(t, dir) + certFile, _ := generateSignedWithSANs(t, dir, "localhost", caCert, caKey, + []string{"localhost", "example.com"}, nil) + + cert, err := LoadCACert(certFile) + if err != nil { + t.Fatalf("LoadCACert: %v", err) + } + // CN is "localhost" (duplicate of provided name), should only appear once + localhostCount := 0 + for _, dns := range cert.DNSNames { + if dns == "localhost" { + localhostCount++ + } + } + if localhostCount != 1 { + t.Errorf("expected 'localhost' to appear once, got %d: %v", localhostCount, cert.DNSNames) + } +} + +func TestGenerateSignedCertInvalidIPIgnored(t *testing.T) { + dir := t.TempDir() + caCert, caKey := generateCA(t, dir) + certFile, _ := generateSignedWithSANs(t, dir, "server", caCert, caKey, + nil, []string{"not-an-ip", "10.0.0.1"}) + + cert, err := LoadCACert(certFile) + if err != nil { + t.Fatalf("LoadCACert: %v", err) + } + if len(cert.IPAddresses) != 1 { + t.Errorf("expected 1 IP SAN (invalid IP ignored), got %d: %v", len(cert.IPAddresses), cert.IPAddresses) + } + if !cert.IPAddresses[0].Equal(net.ParseIP("10.0.0.1")) { + t.Errorf("expected only 10.0.0.1, got: %v", cert.IPAddresses) + } +} + +func containsStr(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} + // --- CertExpiry --- func TestCertExpiryCA(t *testing.T) { @@ -216,7 +393,7 @@ func TestBuildClientTLSConfig(t *testing.T) { caCert, caKey := generateCA(t, dir) clientCert, clientKey := generateSigned(t, dir, "client", caCert, caKey) - tlsCfg, err := BuildClientTLSConfig(clientCert, clientKey, caCert, types.AuthModeMTLS) + tlsCfg, err := BuildClientTLSConfig(clientCert, clientKey, caCert, false, types.AuthModeMTLS) if err != nil { t.Fatalf("BuildClientTLSConfig error: %v", err) } @@ -229,6 +406,12 @@ func TestBuildClientTLSConfig(t *testing.T) { if tlsCfg.RootCAs == nil { t.Error("expected non-nil RootCAs") } + if tlsCfg.VerifyConnection != nil { + t.Error("expected VerifyConnection to be nil when skipHostnameVerification=false") + } + if tlsCfg.InsecureSkipVerify { + t.Error("expected InsecureSkipVerify to be false when skipHostnameVerification=false") + } } func TestBuildClientTLSConfigBadCert(t *testing.T) { @@ -240,7 +423,7 @@ func TestBuildClientTLSConfigBadCert(t *testing.T) { os.WriteFile(badKey, []byte("not a key"), 0600) //nolint:errcheck os.WriteFile(caCert, []byte("not a CA"), 0600) //nolint:errcheck - _, err := BuildClientTLSConfig(badCert, badKey, caCert, types.AuthModeMTLS) + _, err := BuildClientTLSConfig(badCert, badKey, caCert, false, types.AuthModeMTLS) if err == nil { t.Fatal("expected error for invalid cert") } @@ -254,9 +437,87 @@ func TestBuildClientTLSConfigBadCA(t *testing.T) { badCA := filepath.Join(dir, "bad-ca.crt") os.WriteFile(badCA, []byte("not a CA cert"), 0600) //nolint:errcheck - _, err := BuildClientTLSConfig(clientCert, clientKey, badCA, types.AuthModeMTLS) - if err == nil { - t.Fatal("expected error for invalid CA cert") + // Bad CA should fall back to system CAs (no error) + tlsCfg, err := BuildClientTLSConfig(clientCert, clientKey, badCA, false, types.AuthModeMTLS) + if err != nil { + t.Fatalf("BuildClientTLSConfig error: %v", err) + } + if tlsCfg == nil { + t.Fatal("expected non-nil TLS config") + } + // Client cert should still be loaded + if len(tlsCfg.Certificates) != 1 { + t.Errorf("expected 1 certificate, got %d", len(tlsCfg.Certificates)) + } +} + +// --- BuildClientTLSConfig with partial credentials --- + +func TestBuildClientTLSConfigNoServerCA(t *testing.T) { + _ = t.TempDir() + // No server CA at all — should succeed (uses system CAs) + tlsCfg, err := BuildClientTLSConfig("", "", "", false, types.AuthModeMTLS) + if err != nil { + t.Fatalf("BuildClientTLSConfig error: %v", err) + } + if tlsCfg == nil { + t.Fatal("expected non-nil TLS config") + } + if tlsCfg.VerifyConnection != nil { + t.Error("expected VerifyConnection to be nil when skipHostnameVerification=false") + } + if tlsCfg.InsecureSkipVerify { + t.Error("expected InsecureSkipVerify to be false when skipHostnameVerification=false") + } +} + +func TestBuildClientTLSConfigSkipHostnameVerification(t *testing.T) { + _ = t.TempDir() + tlsCfg, err := BuildClientTLSConfig("", "", "", true, types.AuthModeMTLS) + if err != nil { + t.Fatalf("BuildClientTLSConfig error: %v", err) + } + if tlsCfg.VerifyConnection == nil { + t.Error("expected VerifyConnection to be set when skipHostnameVerification=true") + } + if !tlsCfg.InsecureSkipVerify { + t.Error("expected InsecureSkipVerify to be true when skipHostnameVerification=false") + } +} + +func TestBuildClientTLSConfigPartialClientCerts(t *testing.T) { + dir := t.TempDir() + caCert, caKey := generateCA(t, dir) + clientCert, _ := generateSigned(t, dir, "client", caCert, caKey) + + // Only cert, no key — should succeed but not load the cert + tlsCfg, err := BuildClientTLSConfig(clientCert, "", caCert, false, types.AuthModeMTLS) + if err != nil { + t.Fatalf("BuildClientTLSConfig error: %v", err) + } + if tlsCfg == nil { + t.Fatal("expected non-nil TLS config") + } + if len(tlsCfg.Certificates) != 0 { + t.Errorf("expected 0 client certificates (key missing), got %d", len(tlsCfg.Certificates)) + } +} + +func TestBuildClientTLSConfigPartialClientCertsKeyOnly(t *testing.T) { + dir := t.TempDir() + caCert, caKey := generateCA(t, dir) + _, clientKey := generateSigned(t, dir, "client", caCert, caKey) + + // Only key, no cert — should succeed but not load the cert + tlsCfg, err := BuildClientTLSConfig("", clientKey, caCert, false, types.AuthModeMTLS) + if err != nil { + t.Fatalf("BuildClientTLSConfig error: %v", err) + } + if tlsCfg == nil { + t.Fatal("expected non-nil TLS config") + } + if len(tlsCfg.Certificates) != 0 { + t.Errorf("expected 0 client certificates (cert missing), got %d", len(tlsCfg.Certificates)) } } diff --git a/test/e2e/mtls_test.go b/test/e2e/mtls_test.go index 17e68bd..a2169b3 100644 --- a/test/e2e/mtls_test.go +++ b/test/e2e/mtls_test.go @@ -28,7 +28,7 @@ func mtlsServer(t *testing.T, dir string) (serverURL, serverCAFile, clientCAFile } serverCert := filepath.Join(dir, "server.crt") serverKey := filepath.Join(dir, "server.key") - if err := tlsconfig.GenerateSignedCert("localhost", serverCert, serverKey, serverCAcert, serverCAkey); err != nil { + if err := tlsconfig.GenerateSignedCert("localhost", serverCert, serverKey, serverCAcert, serverCAkey, nil, nil); err != nil { t.Fatalf("generate server cert: %v", err) } @@ -70,11 +70,11 @@ func TestMTLSClientWithValidCert(t *testing.T) { clientCAkey := filepath.Join(dir, "client-ca.key") clientCert := filepath.Join(dir, "client.crt") clientKey := filepath.Join(dir, "client.key") - if err := tlsconfig.GenerateSignedCert("test-client", clientCert, clientKey, clientCAFile, clientCAkey); err != nil { + if err := tlsconfig.GenerateSignedCert("test-client", clientCert, clientKey, clientCAFile, clientCAkey, nil, nil); err != nil { t.Fatalf("generate client cert: %v", err) } - clientTLSCfg, err := tlsconfig.BuildClientTLSConfig(clientCert, clientKey, serverCAFile, types.AuthModeMTLS) + clientTLSCfg, err := tlsconfig.BuildClientTLSConfig(clientCert, clientKey, serverCAFile, false, types.AuthModeMTLS) if err != nil { t.Fatalf("build client TLS: %v", err) }