feat: wire DERPTLSConfig through CLI, SDK, tailnet, VPN, agent, and health checks (#24435)

Wire DERPTLSConfig through the CLI, SDK, tailnet, VPN client, agent, and
health checks to allow custom TLS configuration for DERP connections.
The main use case is to be able to set a custom CA and also present
client certs (mTLS). See https://github.com/coder/tailscale/pull/105 for
related changes.

Adds three new global CLI flags:
- `--client-tls-ca-file` / `CODER_CLIENT_TLS_CA_FILE`
- `--client-tls-cert-file` / `CODER_CLIENT_TLS_CERT_FILE`
- `--client-tls-key-file` / `CODER_CLIENT_TLS_KEY_FILE`

Based on community PR #22695 by @ibdafna, with autogeneration issues
fixed (protobuf version mismatches in .pb.go files, golden file
regeneration, lint fixes).

> [!NOTE]
> This PR was authored by Coder Agents on behalf of a Coder team member.

<details>
<summary>Relationship to #22695</summary>

This is a clean reimplementation of the changes from #22695 on top of
current `main`, with the following differences:
- **Removed**: Accidental protobuf version changes in `.pb.go` files
(contributor had `protoc v6.33.4` vs project's `protoc v4.23.4`)
- **Added**: Properly regenerated golden files and docs via `make gen`
- **Fixed**: Lint issue (`var-declaration` revive warning on explicit
type in `createHTTPClient`)
- All meaningful code changes are identical to the original PR
</details>
This commit is contained in:
Spike Curtis
2026-04-16 12:46:52 -04:00
committed by GitHub
parent 7270e01390
commit 4c1a32cd7c
12 changed files with 296 additions and 8 deletions
+7
View File
@@ -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,
+2 -1
View File
@@ -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()
+124 -4
View File
@@ -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 <url>'."
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
+72
View File
@@ -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")
})
}
+11
View File
@@ -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.
+16 -3
View File
@@ -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()
+15
View File
@@ -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
}
+1
View File
@@ -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,
+27
View File
@@ -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 | <code>string</code> |
| Environment | <code>$CODER_CLIENT_TLS_CA_FILE</code> |
Path to a CA certificate file to trust for API and DERP connections.
### --client-tls-cert-file
| | |
|-------------|------------------------------------------|
| Type | <code>string</code> |
| Environment | <code>$CODER_CLIENT_TLS_CERT_FILE</code> |
Path to a client certificate file for mTLS authentication with API and DERP. Requires --client-tls-key-file.
### --client-tls-key-file
| | |
|-------------|-----------------------------------------|
| Type | <code>string</code> |
| Environment | <code>$CODER_CLIENT_TLS_KEY_FILE</code> |
Path to a client private key file for mTLS authentication with API and DERP. Requires --client-tls-cert-file.
### --use-keyring
| | |
+11
View File
@@ -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.
+6
View File
@@ -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)
}
+4
View File
@@ -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,