Skip to content

Commit 2f6322f

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 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. Existing SetDERPTLSConfig(cfg) remains the default path and stores (cfg, false); new SetDERPTLSConfigWithBypass(cfg, bypass) stores the pair together for callers that need the bypass path. - 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 combined setter, and a -race-friendly concurrent writers/readers test that verifies the (cfg, bypass) atomic-pair invariant.
1 parent 85c03fc commit 2f6322f

6 files changed

Lines changed: 322 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: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
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("SetDERPTLSConfigWithBypass updates pair atomically", func(t *testing.T) {
37+
c := &Conn{}
38+
want := &tls.Config{}
39+
c.SetDERPTLSConfigWithBypass(want, true)
40+
got, bypass := c.loadDERPTLSPair()
41+
if got != want {
42+
t.Fatalf("cfg: got %p, want %p", got, want)
43+
}
44+
if !bypass {
45+
t.Fatal("bypass should be true")
46+
}
47+
48+
// Switch to a different cfg with bypass=false in one call.
49+
other := &tls.Config{}
50+
c.SetDERPTLSConfigWithBypass(other, false)
51+
got, bypass = c.loadDERPTLSPair()
52+
if got != other {
53+
t.Fatalf("cfg after second set: got %p, want %p", got, other)
54+
}
55+
if bypass {
56+
t.Fatal("bypass should be false after second set")
57+
}
58+
})
59+
60+
t.Run("readers never observe inconsistent pairing under concurrent writers", func(t *testing.T) {
61+
// Two writers race: one keeps flipping between (publicCfg, false)
62+
// and (metatronCfg, true). A reader observes the pair atomically
63+
// on every iteration. The invariant: any time we observe
64+
// metatronCfg we must observe bypass=true; any time we observe
65+
// publicCfg we must observe bypass=false. If the (cfg, bypass)
66+
// pair were stored as two independent atomics, this test would
67+
// fail with -race because we'd occasionally observe a
68+
// half-updated pairing.
69+
c := &Conn{}
70+
publicCfg := &tls.Config{}
71+
metatronCfg := &tls.Config{InsecureSkipVerify: true}
72+
c.SetDERPTLSConfigWithBypass(publicCfg, false)
73+
74+
const iterations = 5_000
75+
var wg sync.WaitGroup
76+
wg.Add(2)
77+
go func() {
78+
defer wg.Done()
79+
for i := 0; i < iterations; i++ {
80+
if i%2 == 0 {
81+
c.SetDERPTLSConfigWithBypass(metatronCfg, true)
82+
} else {
83+
c.SetDERPTLSConfigWithBypass(publicCfg, false)
84+
}
85+
}
86+
}()
87+
go func() {
88+
defer wg.Done()
89+
for i := 0; i < iterations; i++ {
90+
cfg, bypass := c.loadDERPTLSPair()
91+
switch cfg {
92+
case publicCfg:
93+
if bypass {
94+
t.Errorf("invariant violated: publicCfg paired with bypass=true (iter %d)", i)
95+
return
96+
}
97+
case metatronCfg:
98+
if !bypass {
99+
t.Errorf("invariant violated: metatronCfg paired with bypass=false (iter %d)", i)
100+
return
101+
}
102+
}
103+
}
104+
}()
105+
wg.Wait()
106+
})
107+
}

0 commit comments

Comments
 (0)