diff --git a/agent/agent.go b/agent/agent.go index 4a68150bdd..f60531399b 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -3,6 +3,7 @@ package agent import ( "bytes" "context" + "crypto/tls" "encoding/json" "errors" "fmt" @@ -112,6 +113,8 @@ type Options struct { SocketServerEnabled bool SocketPath string // Path for the agent socket server socket BoundaryLogProxySocketPath string + // DERPTLSConfig is an optional TLS config for DERP connections. + DERPTLSConfig *tls.Config } type Client interface { @@ -229,6 +232,7 @@ func New(options Options) Agent { socketPath: options.SocketPath, socketServerEnabled: options.SocketServerEnabled, boundaryLogProxySocketPath: options.BoundaryLogProxySocketPath, + derpTLSConfig: options.DERPTLSConfig, } // Initially, we have a closed channel, reflecting the fact that we are not initially connected. // Each time we connect we replace the channel (while holding the closeMutex) with a new one @@ -326,6 +330,8 @@ type agent struct { socketServerEnabled bool socketPath string socketServer *agentsocket.Server + + derpTLSConfig *tls.Config } func (a *agent) TailnetConn() *tailnet.Conn { @@ -1614,6 +1620,7 @@ func (a *agent) createTailnet( DERPMap: derpMap, DERPForceWebSockets: derpForceWebSockets, DERPHeader: &header, + DERPTLSConfig: a.derpTLSConfig, Logger: a.logger.Named("net.tailnet"), ListenPort: a.tailnetListenPort, BlockEndpoints: disableDirectConnections, diff --git a/cli/netcheck.go b/cli/netcheck.go index 58a3dfe2ad..1291455562 100644 --- a/cli/netcheck.go +++ b/cli/netcheck.go @@ -36,7 +36,8 @@ func (r *RootCmd) netcheck() *serpent.Command { var derpReport derphealth.Report derpReport.Run(ctx, &derphealth.ReportOptions{ - DERPMap: connInfo.DERPMap, + DERPMap: connInfo.DERPMap, + DERPTLSConfig: r.tlsConfig, }) ifReport, err := healthsdk.RunInterfacesReport() diff --git a/cli/root.go b/cli/root.go index ed97bb88f5..3d1ad8b97b 100644 --- a/cli/root.go +++ b/cli/root.go @@ -4,6 +4,8 @@ import ( "bufio" "bytes" "context" + "crypto/tls" + "crypto/x509" "encoding/base64" "encoding/json" "errors" @@ -73,13 +75,19 @@ const ( varDisableDirect = "disable-direct-connections" varDisableNetworkTelemetry = "disable-network-telemetry" varUseKeyring = "use-keyring" + varClientTLSCAFile = "client-tls-ca-file" + varClientTLSCertFile = "client-tls-cert-file" + varClientTLSKeyFile = "client-tls-key-file" notLoggedInMessage = "You are not logged in. Try logging in using '%s login '." - envNoVersionCheck = "CODER_NO_VERSION_WARNING" - envNoFeatureWarning = "CODER_NO_FEATURE_WARNING" - envSessionToken = "CODER_SESSION_TOKEN" - envUseKeyring = "CODER_USE_KEYRING" + envNoVersionCheck = "CODER_NO_VERSION_WARNING" + envNoFeatureWarning = "CODER_NO_FEATURE_WARNING" + envSessionToken = "CODER_SESSION_TOKEN" + envUseKeyring = "CODER_USE_KEYRING" + envClientTLSCAFile = "CODER_CLIENT_TLS_CA_FILE" + envClientTLSCertFile = "CODER_CLIENT_TLS_CERT_FILE" + envClientTLSKeyFile = "CODER_CLIENT_TLS_KEY_FILE" //nolint:gosec envAgentToken = "CODER_AGENT_TOKEN" //nolint:gosec @@ -489,6 +497,27 @@ func (r *RootCmd) Command(subcommands []*serpent.Command) (*serpent.Command, err Value: serpent.BoolOf(&r.disableNetworkTelemetry), Group: globalGroup, }, + { + Flag: varClientTLSCAFile, + Env: envClientTLSCAFile, + Description: "Path to a CA certificate file to trust for API and DERP connections.", + Value: serpent.StringOf(&r.tlsCAFile), + Group: globalGroup, + }, + { + Flag: varClientTLSCertFile, + Env: envClientTLSCertFile, + Description: "Path to a client certificate file for mTLS authentication with API and DERP. Requires --client-tls-key-file.", + Value: serpent.StringOf(&r.tlsClientCertFile), + Group: globalGroup, + }, + { + Flag: varClientTLSKeyFile, + Env: envClientTLSKeyFile, + Description: "Path to a client private key file for mTLS authentication with API and DERP. Requires --client-tls-cert-file.", + Value: serpent.StringOf(&r.tlsClientKeyFile), + Group: globalGroup, + }, { Flag: varUseKeyring, Env: envUseKeyring, @@ -556,6 +585,12 @@ type RootCmd struct { // clock is used for time-dependent operations. Initialized to // quartz.NewReal() in Command() if not set via SetClock. clock quartz.Clock + + // TLS configuration for custom CA or client certificates. + tlsCAFile string + tlsClientCertFile string + tlsClientKeyFile string + tlsConfig *tls.Config } // SetClock sets the clock used for time-dependent operations. @@ -586,6 +621,55 @@ func (r *RootCmd) ensureClientURL() error { return err } +// ensureTLSConfig loads the TLS configuration from files if specified. +// The resulting config is used for both API requests and DERP connections. +// If tlsConfig is already set programmatically, file-based configuration is skipped. +func (r *RootCmd) ensureTLSConfig() error { + // Already loaded or programmatically set - skip file loading + if r.tlsConfig != nil { + return nil + } + + // No TLS config needed + if r.tlsCAFile == "" && r.tlsClientCertFile == "" && r.tlsClientKeyFile == "" { + return nil + } + + // Validate that cert and key are specified together + if (r.tlsClientCertFile == "") != (r.tlsClientKeyFile == "") { + return xerrors.Errorf("--%s and --%s must be specified together", varClientTLSCertFile, varClientTLSKeyFile) + } + + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + } + + // Load CA certificate if specified + if r.tlsCAFile != "" { + caData, err := os.ReadFile(r.tlsCAFile) + if err != nil { + return xerrors.Errorf("read TLS CA file %q: %w", r.tlsCAFile, err) + } + caPool := x509.NewCertPool() + if !caPool.AppendCertsFromPEM(caData) { + return xerrors.Errorf("failed to parse CA certificate in %q", r.tlsCAFile) + } + tlsConfig.RootCAs = caPool + } + + // Load client certificate if specified + if r.tlsClientCertFile != "" && r.tlsClientKeyFile != "" { + cert, err := tls.LoadX509KeyPair(r.tlsClientCertFile, r.tlsClientKeyFile) + if err != nil { + return xerrors.Errorf("load TLS client certificate: %w", err) + } + tlsConfig.Certificates = []tls.Certificate{cert} + } + + r.tlsConfig = tlsConfig + return nil +} + // InitClient creates and configures a new client with authentication, telemetry, // and version checks. func (r *RootCmd) InitClient(inv *serpent.Invocation) (*codersdk.Client, error) { @@ -607,6 +691,11 @@ func (r *RootCmd) InitClient(inv *serpent.Invocation) (*codersdk.Client, error) } } + // Load TLS config from files if specified + if err := r.ensureTLSConfig(); err != nil { + return nil, err + } + // Configure HTTP client with transport wrappers httpClient, err := r.createHTTPClient(inv.Context(), r.clientURL, inv) if err != nil { @@ -622,6 +711,10 @@ func (r *RootCmd) InitClient(inv *serpent.Invocation) (*codersdk.Client, error) clientOpts = append(clientOpts, codersdk.WithDisableDirectConnections()) } + if r.tlsConfig != nil { + clientOpts = append(clientOpts, codersdk.WithDERPTLSConfig(r.tlsConfig)) + } + if r.debugHTTP { clientOpts = append(clientOpts, codersdk.WithPlainLogger(os.Stderr), @@ -669,6 +762,11 @@ func (r *RootCmd) TryInitClient(inv *serpent.Invocation) (*codersdk.Client, erro // Only configure the client if we have a URL if r.clientURL != nil && r.clientURL.String() != "" { + // Load TLS config from files if specified + if err := r.ensureTLSConfig(); err != nil { + return nil, err + } + // Configure HTTP client with transport wrappers httpClient, err := r.createHTTPClient(inv.Context(), r.clientURL, inv) if err != nil { @@ -684,6 +782,10 @@ func (r *RootCmd) TryInitClient(inv *serpent.Invocation) (*codersdk.Client, erro clientOpts = append(clientOpts, codersdk.WithDisableDirectConnections()) } + if r.tlsConfig != nil { + clientOpts = append(clientOpts, codersdk.WithDERPTLSConfig(r.tlsConfig)) + } + if r.debugHTTP { clientOpts = append(clientOpts, codersdk.WithPlainLogger(os.Stderr), @@ -706,6 +808,19 @@ func (r *RootCmd) HeaderTransport(ctx context.Context, serverURL *url.URL) (*cod func (r *RootCmd) createHTTPClient(ctx context.Context, serverURL *url.URL, inv *serpent.Invocation) (*http.Client, error) { transport := http.DefaultTransport + + // Apply custom TLS config if specified + if r.tlsConfig != nil { + // Clone the default transport and apply TLS config + defaultTransport, ok := http.DefaultTransport.(*http.Transport) + if !ok { + return nil, xerrors.New("cannot apply TLS config: http.DefaultTransport is not *http.Transport") + } + customTransport := defaultTransport.Clone() + customTransport.TLSClientConfig = r.tlsConfig + transport = customTransport + } + transport = wrapTransportWithTelemetryHeader(transport, inv) transport = wrapTransportWithUserAgentHeader(transport, inv) if !r.noVersionCheck { @@ -733,6 +848,11 @@ func (r *RootCmd) createHTTPClient(ctx context.Context, serverURL *url.URL, inv } func (r *RootCmd) createUnauthenticatedClient(ctx context.Context, serverURL *url.URL, inv *serpent.Invocation) (*codersdk.Client, error) { + // Load TLS config for login and other unauthenticated requests + if err := r.ensureTLSConfig(); err != nil { + return nil, err + } + httpClient, err := r.createHTTPClient(ctx, serverURL, inv) if err != nil { return nil, err diff --git a/cli/root_internal_test.go b/cli/root_internal_test.go index dd61a95a0e..a9284b40b3 100644 --- a/cli/root_internal_test.go +++ b/cli/root_internal_test.go @@ -3,6 +3,7 @@ package cli import ( "bytes" "context" + "crypto/tls" "encoding/base64" "encoding/json" "fmt" @@ -401,3 +402,74 @@ func Test_wrapTransportWithEntitlementsCheck(t *testing.T) { pretty.Sprint(cliui.DefaultStyles.Warn, lines[1])) require.Equal(t, expectedOutput, buf.String()) } + +func Test_ensureTLSConfig(t *testing.T) { + t.Parallel() + + t.Run("NoFilesSpecified", func(t *testing.T) { + t.Parallel() + r := &RootCmd{} + err := r.ensureTLSConfig() + require.NoError(t, err) + require.Nil(t, r.tlsConfig) + }) + + t.Run("OnlyCertFileErrors", func(t *testing.T) { + t.Parallel() + r := &RootCmd{ + tlsClientCertFile: "/some/cert.pem", + } + err := r.ensureTLSConfig() + require.Error(t, err) + require.Contains(t, err.Error(), "must be specified together") + }) + + t.Run("OnlyKeyFileErrors", func(t *testing.T) { + t.Parallel() + r := &RootCmd{ + tlsClientKeyFile: "/some/key.pem", + } + err := r.ensureTLSConfig() + require.Error(t, err) + require.Contains(t, err.Error(), "must be specified together") + }) + + t.Run("InvalidCAFileErrors", func(t *testing.T) { + t.Parallel() + r := &RootCmd{ + tlsCAFile: "/nonexistent/ca.pem", + } + err := r.ensureTLSConfig() + require.Error(t, err) + require.Contains(t, err.Error(), "read TLS CA file") + }) + + t.Run("AlreadySetSkipsLoading", func(t *testing.T) { + t.Parallel() + existingConfig := &tls.Config{MinVersion: tls.VersionTLS13} + r := &RootCmd{ + tlsConfig: existingConfig, + tlsClientCertFile: "/some/cert.pem", + } + err := r.ensureTLSConfig() + require.NoError(t, err) + require.Same(t, existingConfig, r.tlsConfig) + }) + + t.Run("InvalidPEMContentErrors", func(t *testing.T) { + t.Parallel() + tmpFile, err := os.CreateTemp("", "invalid-ca-*.pem") + require.NoError(t, err) + defer os.Remove(tmpFile.Name()) + _, err = tmpFile.WriteString("this is not valid PEM data") + require.NoError(t, err) + require.NoError(t, tmpFile.Close()) + + r := &RootCmd{ + tlsCAFile: tmpFile.Name(), + } + err = r.ensureTLSConfig() + require.Error(t, err) + require.Contains(t, err.Error(), "failed to parse CA certificate") + }) +} diff --git a/cli/testdata/coder_--help.golden b/cli/testdata/coder_--help.golden index 5fa43d2ebf..cb667c3a5c 100644 --- a/cli/testdata/coder_--help.golden +++ b/cli/testdata/coder_--help.golden @@ -70,6 +70,17 @@ GLOBAL OPTIONS: Global options are applied to all commands. They can be set using environment variables or flags. + --client-tls-ca-file string, $CODER_CLIENT_TLS_CA_FILE + Path to a CA certificate file to trust for API and DERP connections. + + --client-tls-cert-file string, $CODER_CLIENT_TLS_CERT_FILE + Path to a client certificate file for mTLS authentication with API and + DERP. Requires --client-tls-key-file. + + --client-tls-key-file string, $CODER_CLIENT_TLS_KEY_FILE + Path to a client private key file for mTLS authentication with API and + DERP. Requires --client-tls-cert-file. + --debug-options bool Print all options, how they're set, then exit. diff --git a/coderd/healthcheck/derphealth/derp.go b/coderd/healthcheck/derphealth/derp.go index e6d34cdff3..0c8cab5e9f 100644 --- a/coderd/healthcheck/derphealth/derp.go +++ b/coderd/healthcheck/derphealth/derp.go @@ -2,6 +2,7 @@ package derphealth import ( "context" + "crypto/tls" "fmt" "net" "net/netip" @@ -40,19 +41,24 @@ type ReportOptions struct { Dismissed bool DERPMap *tailcfg.DERPMap + + // DERPTLSConfig is an optional TLS config for DERP connections. + DERPTLSConfig *tls.Config } type Report healthsdk.DERPHealthReport type RegionReport struct { healthsdk.DERPRegionReport - mu sync.Mutex + mu sync.Mutex + derpTLSConfig *tls.Config } type NodeReport struct { healthsdk.DERPNodeReport mu sync.Mutex clientCounter int + derpTLSConfig *tls.Config } func (r *Report) Run(ctx context.Context, opts *ReportOptions) { @@ -74,6 +80,7 @@ func (r *Report) Run(ctx context.Context, opts *ReportOptions) { DERPRegionReport: healthsdk.DERPRegionReport{ Region: region, }, + derpTLSConfig: opts.DERPTLSConfig, } ) go func() { @@ -103,8 +110,9 @@ func (r *Report) Run(ctx context.Context, opts *ReportOptions) { mu.Unlock() } nc := &netcheck.Client{ - PortMapper: portmapper.NewClient(tslogger.WithPrefix(ncLogf, "portmap: "), nil, nil, nil), - Logf: tslogger.WithPrefix(ncLogf, "netcheck: "), + PortMapper: portmapper.NewClient(tslogger.WithPrefix(ncLogf, "portmap: "), nil, nil, nil), + Logf: tslogger.WithPrefix(ncLogf, "netcheck: "), + DERPTLSConfig: opts.DERPTLSConfig, } ncReport, netcheckErr := nc.GetReport(ctx, opts.DERPMap) r.Netcheck = ncReport @@ -159,6 +167,7 @@ func (r *RegionReport) Run(ctx context.Context) { Healthy: true, Node: node, }, + derpTLSConfig: r.derpTLSConfig, } ) @@ -476,6 +485,10 @@ func (r *NodeReport) derpClient(ctx context.Context, derpURL *url.URL) (*derphtt return nil, id, err } + if r.derpTLSConfig != nil { + client.TLSConfig = r.derpTLSConfig + } + go func() { <-ctx.Done() _ = client.Close() diff --git a/codersdk/client.go b/codersdk/client.go index 75d6e1a534..b01b5e4fb3 100644 --- a/codersdk/client.go +++ b/codersdk/client.go @@ -3,6 +3,7 @@ package codersdk import ( "bytes" "context" + "crypto/tls" "encoding/json" "errors" "fmt" @@ -154,6 +155,9 @@ type Client struct { // connection. // Deprecated: Use WithDisableDirectConnections to set this. DisableDirectConnections bool + + // derpTLSConfig is an optional TLS config for DERP connections. + derpTLSConfig *tls.Config } // Logger returns the logger for the client. @@ -725,3 +729,14 @@ func WithDisableDirectConnections() ClientOption { c.DisableDirectConnections = true } } + +func WithDERPTLSConfig(cfg *tls.Config) ClientOption { + return func(c *Client) { + c.derpTLSConfig = cfg + } +} + +// DERPTLSConfig returns the optional TLS config for DERP connections. +func (c *Client) DERPTLSConfig() *tls.Config { + return c.derpTLSConfig +} diff --git a/codersdk/workspacesdk/workspacesdk.go b/codersdk/workspacesdk/workspacesdk.go index 018759f25b..9aa00646fc 100644 --- a/codersdk/workspacesdk/workspacesdk.go +++ b/codersdk/workspacesdk/workspacesdk.go @@ -254,6 +254,7 @@ func (c *Client) DialAgent(dialCtx context.Context, agentID uuid.UUID, options * Addresses: []netip.Prefix{netip.PrefixFrom(ip, 128)}, DERPMap: connInfo.DERPMap, DERPHeader: &header, + DERPTLSConfig: c.client.DERPTLSConfig(), DERPForceWebSockets: connInfo.DERPForceWebSockets, Logger: options.Logger, BlockEndpoints: c.client.DisableDirectConnections || options.BlockEndpoints, diff --git a/docs/reference/cli/index.md b/docs/reference/cli/index.md index fc242c85b6..211cba86c8 100644 --- a/docs/reference/cli/index.md +++ b/docs/reference/cli/index.md @@ -174,6 +174,33 @@ Disable direct (P2P) connections to workspaces. Disable network telemetry. Network telemetry is collected when connecting to workspaces using the CLI, and is forwarded to the server. If telemetry is also enabled on the server, it may be sent to Coder. Network telemetry is used to measure network quality and detect regressions. +### --client-tls-ca-file + +| | | +|-------------|----------------------------------------| +| Type | string | +| Environment | $CODER_CLIENT_TLS_CA_FILE | + +Path to a CA certificate file to trust for API and DERP connections. + +### --client-tls-cert-file + +| | | +|-------------|------------------------------------------| +| Type | string | +| Environment | $CODER_CLIENT_TLS_CERT_FILE | + +Path to a client certificate file for mTLS authentication with API and DERP. Requires --client-tls-key-file. + +### --client-tls-key-file + +| | | +|-------------|-----------------------------------------| +| Type | string | +| Environment | $CODER_CLIENT_TLS_KEY_FILE | + +Path to a client private key file for mTLS authentication with API and DERP. Requires --client-tls-cert-file. + ### --use-keyring | | | diff --git a/enterprise/cli/testdata/coder_--help.golden b/enterprise/cli/testdata/coder_--help.golden index b421002bc8..1db07b1801 100644 --- a/enterprise/cli/testdata/coder_--help.golden +++ b/enterprise/cli/testdata/coder_--help.golden @@ -29,6 +29,17 @@ GLOBAL OPTIONS: Global options are applied to all commands. They can be set using environment variables or flags. + --client-tls-ca-file string, $CODER_CLIENT_TLS_CA_FILE + Path to a CA certificate file to trust for API and DERP connections. + + --client-tls-cert-file string, $CODER_CLIENT_TLS_CERT_FILE + Path to a client certificate file for mTLS authentication with API and + DERP. Requires --client-tls-key-file. + + --client-tls-key-file string, $CODER_CLIENT_TLS_KEY_FILE + Path to a client private key file for mTLS authentication with API and + DERP. Requires --client-tls-cert-file. + --debug-options bool Print all options, how they're set, then exit. diff --git a/tailnet/conn.go b/tailnet/conn.go index e9f0899405..0bc47bd8e9 100644 --- a/tailnet/conn.go +++ b/tailnet/conn.go @@ -2,6 +2,7 @@ package tailnet import ( "context" + "crypto/tls" "encoding/binary" "fmt" "net" @@ -92,6 +93,8 @@ type Options struct { Addresses []netip.Prefix DERPMap *tailcfg.DERPMap DERPHeader *http.Header + // DERPTLSConfig is an optional TLS config for DERP connections. + DERPTLSConfig *tls.Config // DERPForceWebSockets determines whether websockets is always used for DERP // connections, rather than trying `Upgrade: derp` first and potentially // falling back. This is useful for misbehaving proxies that prevent @@ -239,6 +242,9 @@ func NewConn(options *Options) (conn *Conn, err error) { if options.DERPHeader != nil { magicConn.SetDERPHeader(options.DERPHeader.Clone()) } + if options.DERPTLSConfig != nil { + magicConn.SetDERPTLSConfig(options.DERPTLSConfig) + } if options.ForceNetworkUp { magicConn.SetNetworkUp(true) } diff --git a/vpn/client.go b/vpn/client.go index 561c598311..b93c4304e1 100644 --- a/vpn/client.go +++ b/vpn/client.go @@ -2,6 +2,7 @@ package vpn import ( "context" + "crypto/tls" "net/http" "net/netip" "net/url" @@ -74,6 +75,8 @@ type Options struct { TUNDevice tun.Device WireguardMonitor *netmon.Monitor UpdateHandler tailnet.UpdatesHandler + // DERPTLSConfig is an optional TLS config for DERP connections. + DERPTLSConfig *tls.Config } type derpMapRewriter struct { @@ -158,6 +161,7 @@ func (*client) NewConn(initCtx context.Context, serverURL *url.URL, token string Addresses: []netip.Prefix{netip.PrefixFrom(ip, 128)}, DERPMap: connInfo.DERPMap, DERPHeader: &clonedHeaders, + DERPTLSConfig: options.DERPTLSConfig, DERPForceWebSockets: connInfo.DERPForceWebSockets, Logger: options.Logger, BlockEndpoints: connInfo.DisableDirectConnections,