Skip to content

Commit fb73dba

Browse files
committed
derphttp: add TLSConfigBypassesTLSDial opt-in flag
Today derphttp.Client.tlsConfig always passes the caller-supplied TLSConfig through tlsdial.Config, which wraps it with a VerifyConnection hook that runs system-root verification with a baked-in Let's Encrypt fallback. tlsdial.Config also panics on base configs that already set InsecureSkipVerify or VerifyConnection. That contract works well when DERP is reachable directly over a publicly trusted PKI, but it breaks for callers who legitimately need to perform their own server verification — for example when DERP is fronted by a reverse proxy that presents a non-publicly-trusted certificate, or when authenticating with an mTLS framework that uses custom CAs / SPIFFE-style identity / app-name verification (i.e. the standard Go pattern of InsecureSkipVerify=true paired with a custom VerifyPeerCertificate). This adds an opt-in TLSConfigBypassesTLSDial bool. When true (and TLSConfig is non-nil), the supplied config is used as-is after a Clone + ServerName fallback. tlsdial.Config is bypassed entirely. node-level InsecureForTests is still honored; node.CertName (a tlsdial-specific domain-fronting hook) is intentionally ignored on the bypass path — callers bringing their own verifier are expected to encode any cert pinning in their own VerifyPeerCertificate / VerifyConnection. Plumbing: - derphttp.Client.TLSConfigBypassesTLSDial bool - magicsock.Conn.derpTLSConfigBypassesTLSDial atomic + Conn.SetDERPTLSConfigBypassesTLSDial setter - magicsock/derp.go propagates the flag onto the constructed DERP client alongside the existing TLSConfig plumbing. Backward compatible: bypass=false (the default) preserves the existing behavior and existing callers see no change. New unit tests cover the default path, the bypass path, ServerName behavior, the nil-config fallback, and node.InsecureForTests under bypass.
1 parent 85c03fc commit fb73dba

4 files changed

Lines changed: 165 additions & 9 deletions

File tree

derp/derphttp/derphttp_client.go

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,33 @@ import (
5353
// has been called).
5454
type Client struct {
5555
Header http.Header
56-
TLSConfig *tls.Config // optional; nil means default
57-
DNSCache *dnscache.Resolver // optional; nil means no caching
58-
MeshKey string // optional; for trusted clients
59-
IsProber bool // optional; for probers to optional declare themselves as such
56+
TLSConfig *tls.Config // optional; nil means default
57+
58+
// TLSConfigBypassesTLSDial, if true, causes TLSConfig to be used as-is
59+
// (after a Clone and ServerName housekeeping) instead of being passed
60+
// through tlsdial.Config. The default behavior wraps TLSConfig in
61+
// Tailscale's hosted-DERP verification helper, which installs a
62+
// VerifyConnection hook with a baked-in Let's Encrypt fallback and
63+
// rejects (panics on) base configs that already set InsecureSkipVerify
64+
// or VerifyConnection.
65+
//
66+
// Set this to true when the caller is responsible for server
67+
// verification — e.g. when DERP is fronted by a reverse proxy that
68+
// presents a non-publicly-trusted certificate, when using an mTLS
69+
// framework that performs its own peer verification (custom CAs,
70+
// SPIFFE-style identity), or any case in which the caller has supplied
71+
// VerifyPeerCertificate / VerifyConnection on TLSConfig and does not
72+
// want the bake-in fallback.
73+
//
74+
// When true, TLSConfig must be non-nil; the flag is ignored otherwise.
75+
// Per-DERPNode overrides are still honored: InsecureForTests still
76+
// disables verification, but CertName (which is implemented by
77+
// tlsdial) is ignored.
78+
TLSConfigBypassesTLSDial bool
79+
80+
DNSCache *dnscache.Resolver // optional; nil means no caching
81+
MeshKey string // optional; for trusted clients
82+
IsProber bool // optional; for probers to optional declare themselves as such
6083

6184
// Allow forcing WebSocket fallback for situations where proxies do not
6285
// play well with `Upgrade: derp`. Turning this on will cause the client to
@@ -704,14 +727,34 @@ func (c *Client) DialRegion(ctx context.Context, reg *tailcfg.DERPRegion) (net.C
704727
}
705728

706729
func (c *Client) tlsConfig(node *tailcfg.DERPNode) *tls.Config {
707-
tlsConf := tlsdial.Config(c.tlsServerName(node), c.TLSConfig)
708-
if node != nil {
709-
if node.InsecureForTests {
730+
var tlsConf *tls.Config
731+
if c.TLSConfigBypassesTLSDial && c.TLSConfig != nil {
732+
// Caller has opted to bring their own server verification (custom
733+
// CAs / mTLS / SPIFFE-style identity / non-publicly-trusted PKI).
734+
// Use the supplied config as-is, only filling in ServerName when
735+
// the caller didn't pin one. node.CertName (a tlsdial-specific
736+
// domain-fronting hook) is intentionally not applied here; callers
737+
// using bypass are expected to encode any cert-pinning in their
738+
// own VerifyPeerCertificate / VerifyConnection.
739+
tlsConf = c.TLSConfig.Clone()
740+
if tlsConf.ServerName == "" {
741+
tlsConf.ServerName = c.tlsServerName(node)
742+
}
743+
if node != nil && node.InsecureForTests {
710744
tlsConf.InsecureSkipVerify = true
711745
tlsConf.VerifyConnection = nil
746+
tlsConf.VerifyPeerCertificate = nil
712747
}
713-
if node.CertName != "" {
714-
tlsdial.SetConfigExpectedCert(tlsConf, node.CertName)
748+
} else {
749+
tlsConf = tlsdial.Config(c.tlsServerName(node), c.TLSConfig)
750+
if node != nil {
751+
if node.InsecureForTests {
752+
tlsConf.InsecureSkipVerify = true
753+
tlsConf.VerifyConnection = nil
754+
}
755+
if node.CertName != "" {
756+
tlsdial.SetConfigExpectedCert(tlsConf, node.CertName)
757+
}
715758
}
716759
}
717760
tlsConf.NextProtos = []string{"http/1.1"}

derp/derphttp/derphttp_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"bytes"
99
"context"
1010
"crypto/tls"
11+
"crypto/x509"
1112
"net"
1213
"net/http"
1314
"net/http/httptest"
@@ -17,6 +18,7 @@ import (
1718

1819
"github.com/coder/websocket"
1920
"tailscale.com/derp"
21+
"tailscale.com/tailcfg"
2022
"tailscale.com/types/key"
2123
)
2224

@@ -324,3 +326,101 @@ func TestForceWebsockets(t *testing.T) {
324326

325327
c.Close()
326328
}
329+
330+
func TestTLSConfigBypassesTLSDial(t *testing.T) {
331+
node := &tailcfg.DERPNode{HostName: "derp.example.com"}
332+
333+
t.Run("default path wraps via tlsdial.Config", func(t *testing.T) {
334+
// tlsdial.Config installs its own VerifyConnection. Confirming it
335+
// runs is the easiest way to assert we did NOT bypass it. We must
336+
// not pass a base config that would cause tlsdial.Config to panic.
337+
c := &Client{TLSConfig: &tls.Config{RootCAs: x509.NewCertPool()}}
338+
got := c.tlsConfig(node)
339+
if got.VerifyConnection == nil {
340+
t.Fatal("expected default path to install tlsdial's VerifyConnection")
341+
}
342+
if !got.InsecureSkipVerify {
343+
t.Fatal("expected default path to set InsecureSkipVerify (tlsdial does its own verify)")
344+
}
345+
if got.ServerName != node.HostName {
346+
t.Fatalf("ServerName: got %q, want %q", got.ServerName, node.HostName)
347+
}
348+
})
349+
350+
t.Run("bypass path uses caller config as-is", func(t *testing.T) {
351+
// A typical "I'm bringing my own verifier" config that would make
352+
// tlsdial.Config panic.
353+
var verifyCalls int
354+
base := &tls.Config{
355+
InsecureSkipVerify: true,
356+
VerifyPeerCertificate: func(_ [][]byte, _ [][]*x509.Certificate) error {
357+
verifyCalls++
358+
return nil
359+
},
360+
}
361+
c := &Client{TLSConfig: base, TLSConfigBypassesTLSDial: true}
362+
got := c.tlsConfig(node)
363+
if got == base {
364+
t.Fatal("expected tlsConfig to clone the base config")
365+
}
366+
if got.VerifyConnection != nil {
367+
t.Fatal("bypass path must not install tlsdial's VerifyConnection")
368+
}
369+
if !got.InsecureSkipVerify {
370+
t.Fatal("bypass path must preserve caller's InsecureSkipVerify")
371+
}
372+
if got.VerifyPeerCertificate == nil {
373+
t.Fatal("bypass path must preserve caller's VerifyPeerCertificate")
374+
}
375+
if got.ServerName != node.HostName {
376+
t.Fatalf("ServerName fallback: got %q, want %q", got.ServerName, node.HostName)
377+
}
378+
if want := []string{"http/1.1"}; len(got.NextProtos) != 1 || got.NextProtos[0] != want[0] {
379+
t.Fatalf("NextProtos: got %v, want %v", got.NextProtos, want)
380+
}
381+
})
382+
383+
t.Run("bypass path preserves caller-set ServerName", func(t *testing.T) {
384+
base := &tls.Config{
385+
InsecureSkipVerify: true,
386+
VerifyPeerCertificate: func(_ [][]byte, _ [][]*x509.Certificate) error {
387+
return nil
388+
},
389+
ServerName: "explicit.example.com",
390+
}
391+
c := &Client{TLSConfig: base, TLSConfigBypassesTLSDial: true}
392+
got := c.tlsConfig(node)
393+
if got.ServerName != "explicit.example.com" {
394+
t.Fatalf("ServerName: got %q, want explicit.example.com", got.ServerName)
395+
}
396+
})
397+
398+
t.Run("bypass flag without TLSConfig falls back to default path", func(t *testing.T) {
399+
c := &Client{TLSConfigBypassesTLSDial: true}
400+
got := c.tlsConfig(node)
401+
if got.VerifyConnection == nil {
402+
t.Fatal("expected fallback to tlsdial path when TLSConfig is nil")
403+
}
404+
})
405+
406+
t.Run("bypass path honors node.InsecureForTests", func(t *testing.T) {
407+
base := &tls.Config{
408+
InsecureSkipVerify: true,
409+
VerifyPeerCertificate: func(_ [][]byte, _ [][]*x509.Certificate) error {
410+
return nil
411+
},
412+
}
413+
c := &Client{TLSConfig: base, TLSConfigBypassesTLSDial: true}
414+
insecureNode := &tailcfg.DERPNode{HostName: "derp.example.com", InsecureForTests: true}
415+
got := c.tlsConfig(insecureNode)
416+
if !got.InsecureSkipVerify {
417+
t.Fatal("expected InsecureForTests to keep InsecureSkipVerify=true")
418+
}
419+
if got.VerifyPeerCertificate != nil {
420+
t.Fatal("expected InsecureForTests to clear VerifyPeerCertificate")
421+
}
422+
if got.VerifyConnection != nil {
423+
t.Fatal("expected InsecureForTests to clear VerifyConnection")
424+
}
425+
})
426+
}

wgengine/magicsock/derp.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,7 @@ func (c *Conn) derpWriteChanOfAddr(addr netip.AddrPort, peer key.NodePublic) cha
349349
dc.DNSCache = dnscache.Get()
350350
if tlsCfg := c.derpTLSConfig.Load(); tlsCfg != nil {
351351
dc.TLSConfig = tlsCfg
352+
dc.TLSConfigBypassesTLSDial = c.derpTLSConfigBypassesTLSDial.Load()
352353
}
353354
header := c.derpHeader.Load()
354355
if header != nil {

wgengine/magicsock/magicsock.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,11 @@ type Conn struct {
171171
// headers that are passed to the DERP HTTP client
172172
derpHeader atomic.Pointer[http.Header]
173173

174+
// derpTLSConfigBypassesTLSDial mirrors derphttp.Client.TLSConfigBypassesTLSDial.
175+
// When true (and derpTLSConfig is non-nil), the DERP client uses the
176+
// supplied TLSConfig as-is rather than wrapping it via tlsdial.Config.
177+
derpTLSConfigBypassesTLSDial atomic.Bool
178+
174179
// whether websocket is always used by the DERP HTTP client
175180
derpForceWebsockets atomic.Bool
176181

@@ -1767,6 +1772,13 @@ func (c *Conn) SetDERPTLSConfig(cfg *tls.Config) {
17671772
c.derpTLSConfig.Store(cfg)
17681773
}
17691774

1775+
// SetDERPTLSConfigBypassesTLSDial sets whether the DERP client should use the
1776+
// configured TLS config as-is instead of wrapping it via tlsdial.Config.
1777+
// See derphttp.Client.TLSConfigBypassesTLSDial.
1778+
func (c *Conn) SetDERPTLSConfigBypassesTLSDial(v bool) {
1779+
c.derpTLSConfigBypassesTLSDial.Store(v)
1780+
}
1781+
17701782
func (c *Conn) SetDERPRegionDialer(dialer func(ctx context.Context, region *tailcfg.DERPRegion) net.Conn) {
17711783
c.derpRegionDialer.Store(&dialer)
17721784
c.mu.Lock()

0 commit comments

Comments
 (0)