Skip to content

Commit df77af4

Browse files
committed
derphttp,magicsock,netcheck: 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. The doc comment also explicitly warns that with bypass=true the supplied TLSConfig is the sole source of server verification. Plumbing: - derphttp.Client.TLSConfigBypassesTLSDial bool. - magicsock.Conn stores the (TLSConfig, bypass) pair as a single atomic.Pointer[derpTLSPair] so reconnects observe a coherent pair and never see a custom-verifier config briefly paired with the stale bypass=false (which would panic) nor a public config briefly paired with a stale bypass=true (which would silently skip server verification). - SetDERPTLSConfig(cfg) is preserved (existing public API): it sets cfg with bypass=false in one atomic store. - SetDERPTLSConfigBypassesTLSDial(v) does a load-modify-store (serialized via derpTLSConfigSetMu) that preserves the current cfg. - SetDERPTLSConfigWithBypass(cfg, bypass) is the recommended setter for callers that want both fields set together. - magicsock/derp.go reads the pair atomically and propagates both onto the constructed derphttp.Client. - netcheck.Client gains DERPTLSConfigBypassesTLSDial alongside the existing DERPTLSConfig field, propagated to the netcheck DERP probe client so health checks / `coder netcheck` don't panic when DERPTLSConfig is a custom-verifier config. Backward compatible: bypass=false (the default) preserves the existing behavior and existing callers see no change. Existing SetDERPTLSConfig(cfg) callers continue to work; their effective bypass state is false. Test coverage: - derp/derphttp/derphttp_test.go: TestTLSConfigBypassesTLSDial covers the default path, the bypass path, ServerName behavior, the nil-config fallback, and node.InsecureForTests under bypass. - wgengine/magicsock/derp_tls_pair_test.go: TestSetDERPTLSConfigPair covers the legacy setter, the bypass-flag setter preserving the cfg, the combined setter, and a -race-friendly concurrent writers/readers test that verifies the (cfg, bypass) atomic-pair invariant.
1 parent 85c03fc commit df77af4

6 files changed

Lines changed: 371 additions & 14 deletions

File tree

derp/derphttp/derphttp_client.go

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,42 @@ 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+
// SECURITY: when this flag is true, the supplied TLSConfig is the
75+
// SOLE source of server verification. A TLSConfig with
76+
// InsecureSkipVerify=true and no VerifyPeerCertificate /
77+
// VerifyConnection callback will result in a TLS handshake that
78+
// performs no server identity check at all. Callers MUST either rely
79+
// on stock RootCAs + hostname matching (i.e. leave InsecureSkipVerify
80+
// false), or provide a VerifyPeerCertificate / VerifyConnection that
81+
// implements an equivalent check.
82+
//
83+
// When true, TLSConfig must be non-nil; the flag is ignored otherwise.
84+
// Per-DERPNode overrides are still honored: InsecureForTests still
85+
// disables verification (intentionally), but CertName (which is
86+
// implemented by tlsdial) is ignored.
87+
TLSConfigBypassesTLSDial bool
88+
89+
DNSCache *dnscache.Resolver // optional; nil means no caching
90+
MeshKey string // optional; for trusted clients
91+
IsProber bool // optional; for probers to optional declare themselves as such
6092

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

706738
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 {
739+
var tlsConf *tls.Config
740+
if c.TLSConfigBypassesTLSDial && c.TLSConfig != nil {
741+
// Caller has opted to bring their own server verification (custom
742+
// CAs / mTLS / SPIFFE-style identity / non-publicly-trusted PKI).
743+
// Use the supplied config as-is, only filling in ServerName when
744+
// the caller didn't pin one. node.CertName (a tlsdial-specific
745+
// domain-fronting hook) is intentionally not applied here; callers
746+
// using bypass are expected to encode any cert-pinning in their
747+
// own VerifyPeerCertificate / VerifyConnection.
748+
tlsConf = c.TLSConfig.Clone()
749+
if tlsConf.ServerName == "" {
750+
tlsConf.ServerName = c.tlsServerName(node)
751+
}
752+
if node != nil && node.InsecureForTests {
710753
tlsConf.InsecureSkipVerify = true
711754
tlsConf.VerifyConnection = nil
755+
tlsConf.VerifyPeerCertificate = nil
712756
}
713-
if node.CertName != "" {
714-
tlsdial.SetConfigExpectedCert(tlsConf, node.CertName)
757+
} else {
758+
tlsConf = tlsdial.Config(c.tlsServerName(node), c.TLSConfig)
759+
if node != nil {
760+
if node.InsecureForTests {
761+
tlsConf.InsecureSkipVerify = true
762+
tlsConf.VerifyConnection = nil
763+
}
764+
if node.CertName != "" {
765+
tlsdial.SetConfigExpectedCert(tlsConf, node.CertName)
766+
}
715767
}
716768
}
717769
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+
}

net/netcheck/netcheck.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,15 @@ type Client struct {
211211
// DERPTLSConfig is an optional TLS config for DERP connections.
212212
DERPTLSConfig *tls.Config
213213

214+
// DERPTLSConfigBypassesTLSDial mirrors
215+
// derphttp.Client.TLSConfigBypassesTLSDial: when true (and
216+
// DERPTLSConfig is non-nil), the netcheck DERP client uses the
217+
// supplied TLS config as-is instead of wrapping it via tlsdial.Config.
218+
// Use this when DERPTLSConfig performs its own server verification
219+
// (e.g. mTLS frameworks with custom CAs / SPIFFE-style identity), as
220+
// such configs cause tlsdial.Config to panic.
221+
DERPTLSConfigBypassesTLSDial bool
222+
214223
// For tests
215224
testEnoughRegions int
216225
testCaptivePortalDelay time.Duration
@@ -1307,6 +1316,7 @@ func (c *Client) measureHTTPLatency(ctx context.Context, reg *tailcfg.DERPRegion
13071316
dc.Header = derpHeaders
13081317
if c.DERPTLSConfig != nil {
13091318
dc.TLSConfig = c.DERPTLSConfig
1319+
dc.TLSConfigBypassesTLSDial = c.DERPTLSConfigBypassesTLSDial
13101320
}
13111321
defer dc.Close()
13121322

wgengine/magicsock/derp.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -347,8 +347,14 @@ func (c *Conn) derpWriteChanOfAddr(addr netip.AddrPort, peer key.NodePublic) cha
347347
dc.SetAddressFamilySelector(derpAddrFamSelector{c})
348348
dc.SetForcedWebsocketCallback(c.derpForcedWebsocketFunc)
349349
dc.DNSCache = dnscache.Get()
350-
if tlsCfg := c.derpTLSConfig.Load(); tlsCfg != nil {
351-
dc.TLSConfig = tlsCfg
350+
// Read the (TLS config, bypass-tlsdial) pair atomically so we never
351+
// observe a new custom-verifier TLS config paired with a stale
352+
// bypass=false (which would panic in tlsdial.Config) nor a new
353+
// publicly-trusted config paired with a stale bypass=true (which would
354+
// silently skip server verification). See Conn.derpTLSConfig.
355+
if pair := c.derpTLSConfig.Load(); pair != nil && pair.cfg != nil {
356+
dc.TLSConfig = pair.cfg
357+
dc.TLSConfigBypassesTLSDial = pair.bypassTLSDial
352358
}
353359
header := c.derpHeader.Load()
354360
if header != nil {
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// Copyright (c) Tailscale Inc & AUTHORS
2+
// SPDX-License-Identifier: BSD-3-Clause
3+
4+
package magicsock
5+
6+
import (
7+
"crypto/tls"
8+
"sync"
9+
"testing"
10+
)
11+
12+
// loadDERPTLSPair reads the (cfg, bypass) pair the way magicsock/derp.go
13+
// reads it on every reconnect. Used by tests to assert that paired writers
14+
// never expose an inconsistent (cfg, bypass) pairing to readers.
15+
func (c *Conn) loadDERPTLSPair() (cfg *tls.Config, bypass bool) {
16+
if p := c.derpTLSConfig.Load(); p != nil {
17+
return p.cfg, p.bypassTLSDial
18+
}
19+
return nil, false
20+
}
21+
22+
func TestSetDERPTLSConfigPair(t *testing.T) {
23+
t.Run("legacy SetDERPTLSConfig: bypass false, cfg set", func(t *testing.T) {
24+
c := &Conn{}
25+
want := &tls.Config{}
26+
c.SetDERPTLSConfig(want)
27+
got, bypass := c.loadDERPTLSPair()
28+
if got != want {
29+
t.Fatalf("cfg: got %p, want %p", got, want)
30+
}
31+
if bypass {
32+
t.Fatal("bypass should default to false for SetDERPTLSConfig")
33+
}
34+
})
35+
36+
t.Run("SetDERPTLSConfigBypassesTLSDial preserves cfg", func(t *testing.T) {
37+
c := &Conn{}
38+
want := &tls.Config{}
39+
c.SetDERPTLSConfig(want)
40+
c.SetDERPTLSConfigBypassesTLSDial(true)
41+
got, bypass := c.loadDERPTLSPair()
42+
if got != want {
43+
t.Fatalf("cfg should be preserved across bypass flip: got %p, want %p", got, want)
44+
}
45+
if !bypass {
46+
t.Fatal("bypass should be true after SetDERPTLSConfigBypassesTLSDial(true)")
47+
}
48+
})
49+
50+
t.Run("SetDERPTLSConfigBypassesTLSDial with no prior cfg", func(t *testing.T) {
51+
c := &Conn{}
52+
c.SetDERPTLSConfigBypassesTLSDial(true)
53+
cfg, bypass := c.loadDERPTLSPair()
54+
if cfg != nil {
55+
t.Fatalf("cfg should be nil with no prior SetDERPTLSConfig, got %p", cfg)
56+
}
57+
if !bypass {
58+
t.Fatal("bypass should be true")
59+
}
60+
})
61+
62+
t.Run("SetDERPTLSConfigWithBypass updates pair atomically", func(t *testing.T) {
63+
c := &Conn{}
64+
want := &tls.Config{}
65+
c.SetDERPTLSConfigWithBypass(want, true)
66+
got, bypass := c.loadDERPTLSPair()
67+
if got != want {
68+
t.Fatalf("cfg: got %p, want %p", got, want)
69+
}
70+
if !bypass {
71+
t.Fatal("bypass should be true")
72+
}
73+
74+
// Switch to a different cfg with bypass=false in one call.
75+
other := &tls.Config{}
76+
c.SetDERPTLSConfigWithBypass(other, false)
77+
got, bypass = c.loadDERPTLSPair()
78+
if got != other {
79+
t.Fatalf("cfg after second set: got %p, want %p", got, other)
80+
}
81+
if bypass {
82+
t.Fatal("bypass should be false after second set")
83+
}
84+
})
85+
86+
t.Run("readers never observe inconsistent pairing under concurrent writers", func(t *testing.T) {
87+
// Two writers race: one keeps flipping between (publicCfg, false)
88+
// and (metatronCfg, true). A reader observes the pair atomically
89+
// on every iteration. The invariant: any time we observe
90+
// metatronCfg we must observe bypass=true; any time we observe
91+
// publicCfg we must observe bypass=false. If the (cfg, bypass)
92+
// pair were stored as two independent atomics, this test would
93+
// fail with -race because we'd occasionally observe a
94+
// half-updated pairing.
95+
c := &Conn{}
96+
publicCfg := &tls.Config{}
97+
metatronCfg := &tls.Config{InsecureSkipVerify: true}
98+
c.SetDERPTLSConfigWithBypass(publicCfg, false)
99+
100+
const iterations = 5_000
101+
var wg sync.WaitGroup
102+
wg.Add(2)
103+
go func() {
104+
defer wg.Done()
105+
for i := 0; i < iterations; i++ {
106+
if i%2 == 0 {
107+
c.SetDERPTLSConfigWithBypass(metatronCfg, true)
108+
} else {
109+
c.SetDERPTLSConfigWithBypass(publicCfg, false)
110+
}
111+
}
112+
}()
113+
go func() {
114+
defer wg.Done()
115+
for i := 0; i < iterations; i++ {
116+
cfg, bypass := c.loadDERPTLSPair()
117+
switch cfg {
118+
case publicCfg:
119+
if bypass {
120+
t.Errorf("invariant violated: publicCfg paired with bypass=true (iter %d)", i)
121+
return
122+
}
123+
case metatronCfg:
124+
if !bypass {
125+
t.Errorf("invariant violated: metatronCfg paired with bypass=false (iter %d)", i)
126+
return
127+
}
128+
}
129+
}
130+
}()
131+
wg.Wait()
132+
})
133+
}

0 commit comments

Comments
 (0)