Skip to content
Merged
6 changes: 3 additions & 3 deletions cmd/cert.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
56 changes: 54 additions & 2 deletions cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
Expand Down
28 changes: 17 additions & 11 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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{
Expand Down Expand Up @@ -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")
}

Expand Down Expand Up @@ -178,5 +181,8 @@ func cliOverrides() map[string]string {
if flagClientCA != "" {
overrides["client-ca"] = flagClientCA
}
if flagSkipHostname {
overrides["skip-hostname-verification"] = "true"
}
return overrides
}
1 change: 1 addition & 0 deletions internal/common/apiclient/apiclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 10 additions & 10 deletions internal/common/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 != "" {
Expand All @@ -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 != "" {
Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 5 additions & 5 deletions internal/common/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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", "")
Expand Down Expand Up @@ -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 {
Expand Down
104 changes: 84 additions & 20 deletions internal/common/tlsconfig/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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")
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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
}
}
Loading