Skip to content

Commit 5319d47

Browse files
authored
chore: add support for tailscale soft isolation in VPN (#19023)
1 parent 28789d7 commit 5319d47

File tree

10 files changed

+205
-126
lines changed

10 files changed

+205
-126
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ replace github.com/tcnksm/go-httpstat => github.com/coder/go-httpstat v0.0.0-202
3636

3737
// There are a few minor changes we make to Tailscale that we're slowly upstreaming. Compare here:
3838
// https://github.com/tailscale/tailscale/compare/main...coder:tailscale:main
39-
replace tailscale.com => github.com/coder/tailscale v1.1.1-0.20250611020837-f14d20d23d8c
39+
replace tailscale.com => github.com/coder/tailscale v1.1.1-0.20250724015444-494197765996
4040

4141
// This is replaced to include
4242
// 1. a fix for a data race: c.f. https://github.com/tailscale/wireguard-go/pull/25

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -926,8 +926,8 @@ github.com/coder/serpent v0.10.0 h1:ofVk9FJXSek+SmL3yVE3GoArP83M+1tX+H7S4t8BSuM=
926926
github.com/coder/serpent v0.10.0/go.mod h1:cZFW6/fP+kE9nd/oRkEHJpG6sXCtQ+AX7WMMEHv0Y3Q=
927927
github.com/coder/ssh v0.0.0-20231128192721-70855dedb788 h1:YoUSJ19E8AtuUFVYBpXuOD6a/zVP3rcxezNsoDseTUw=
928928
github.com/coder/ssh v0.0.0-20231128192721-70855dedb788/go.mod h1:aGQbuCLyhRLMzZF067xc84Lh7JDs1FKwCmF1Crl9dxQ=
929-
github.com/coder/tailscale v1.1.1-0.20250611020837-f14d20d23d8c h1:d/qBIi3Ez7KkopRgNtfdvTMqvqBg47d36qVfkd3C5EQ=
930-
github.com/coder/tailscale v1.1.1-0.20250611020837-f14d20d23d8c/go.mod h1:l7ml5uu7lFh5hY28lGYM4b/oFSmuPHYX6uk4RAu23Lc=
929+
github.com/coder/tailscale v1.1.1-0.20250724015444-494197765996 h1:9x+ouDw9BKW1tdGzuQOWGMT2XkWLs+QQjeCrxYuU1lo=
930+
github.com/coder/tailscale v1.1.1-0.20250724015444-494197765996/go.mod h1:l7ml5uu7lFh5hY28lGYM4b/oFSmuPHYX6uk4RAu23Lc=
931931
github.com/coder/terraform-config-inspect v0.0.0-20250107175719-6d06d90c630e h1:JNLPDi2P73laR1oAclY6jWzAbucf70ASAvf5mh2cME0=
932932
github.com/coder/terraform-config-inspect v0.0.0-20250107175719-6d06d90c630e/go.mod h1:Gz/z9Hbn+4KSp8A2FBtNszfLSdT2Tn/uAKGuVqqWmDI=
933933
github.com/coder/terraform-provider-coder/v2 v2.7.1-0.20250623193313-e890833351e2 h1:vtGzECz5CyzuxMODexWdIRxhYLqyTcHafuJpH60PYhM=

tailnet/conn.go

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,9 @@ const EnvMagicsockDebugLogging = "CODER_MAGICSOCK_DEBUG_LOGGING"
6565

6666
func init() {
6767
// Globally disable network namespacing. All networking happens in
68-
// userspace.
68+
// userspace unless the connection is configured to use a TUN.
69+
// NOTE: this exists in init() so it affects all connections (incl. DERP)
70+
// made by tailscale packages by default.
6971
netns.SetEnabled(false)
7072
// Tailscale, by default, "trims" the set of peers down to ones that we are
7173
// "actively" communicating with in an effort to save memory. Since
@@ -100,6 +102,18 @@ type Options struct {
100102
BlockEndpoints bool
101103
Logger slog.Logger
102104
ListenPort uint16
105+
// UseSoftNetIsolation enables our homemade soft isolation feature in the
106+
// netns package. This option will only be considered if TUNDev is set.
107+
//
108+
// The Coder soft isolation mode is a workaround to allow Coder Connect to
109+
// connect to Coder servers behind corporate VPNs, and relaxes some of the
110+
// loop protections that come with Tailscale.
111+
//
112+
// When soft isolation is disabled, the netns package will function as
113+
// normal and route all traffic through the default interface (and block all
114+
// traffic to other VPN interfaces) on macOS and Windows.
115+
UseSoftNetIsolation bool
116+
103117
// CaptureHook is a callback that captures Disco packets and packets sent
104118
// into the tailnet tunnel.
105119
CaptureHook capture.Callback
@@ -154,7 +168,11 @@ func NewConn(options *Options) (conn *Conn, err error) {
154168
return nil, xerrors.New("At least one IP range must be provided")
155169
}
156170

157-
netns.SetEnabled(options.TUNDev != nil)
171+
useNetNS := options.TUNDev != nil
172+
useSoftIsolation := useNetNS && options.UseSoftNetIsolation
173+
options.Logger.Debug(context.Background(), "network isolation configuration", slog.F("use_netns", useNetNS), slog.F("use_soft_isolation", useSoftIsolation))
174+
netns.SetEnabled(useNetNS)
175+
netns.SetCoderSoftIsolation(useSoftIsolation)
158176

159177
var telemetryStore *TelemetryStore
160178
if options.TelemetrySink != nil {

vpn/client.go

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,14 @@ func NewClient() Client {
6969
}
7070

7171
type Options struct {
72-
Headers http.Header
73-
Logger slog.Logger
74-
DNSConfigurator dns.OSConfigurator
75-
Router router.Router
76-
TUNDevice tun.Device
77-
WireguardMonitor *netmon.Monitor
78-
UpdateHandler tailnet.UpdatesHandler
72+
Headers http.Header
73+
Logger slog.Logger
74+
UseSoftNetIsolation bool
75+
DNSConfigurator dns.OSConfigurator
76+
Router router.Router
77+
TUNDevice tun.Device
78+
WireguardMonitor *netmon.Monitor
79+
UpdateHandler tailnet.UpdatesHandler
7980
}
8081

8182
type derpMapRewriter struct {
@@ -163,6 +164,7 @@ func (*client) NewConn(initCtx context.Context, serverURL *url.URL, token string
163164
DERPForceWebSockets: connInfo.DERPForceWebSockets,
164165
Logger: options.Logger,
165166
BlockEndpoints: connInfo.DisableDirectConnections,
167+
UseSoftNetIsolation: options.UseSoftNetIsolation,
166168
DNSConfigurator: options.DNSConfigurator,
167169
Router: options.Router,
168170
TUNDev: options.TUNDevice,

vpn/speaker_internal_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ func TestMain(m *testing.M) {
2323
goleak.VerifyTestMain(m, testutil.GoleakOptions...)
2424
}
2525

26-
const expectedHandshake = "codervpn tunnel 1.2\n"
26+
const expectedHandshake = "codervpn tunnel 1.3\n"
2727

2828
// TestSpeaker_RawPeer tests the speaker with a peer that we simulate by directly making reads and
2929
// writes to the other end of the pipe. There should be at least one test that does this, rather

vpn/tunnel.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -271,13 +271,14 @@ func (t *Tunnel) start(req *StartRequest) error {
271271
svrURL,
272272
apiToken,
273273
&Options{
274-
Headers: header,
275-
Logger: t.clientLogger,
276-
DNSConfigurator: networkingStack.DNSConfigurator,
277-
Router: networkingStack.Router,
278-
TUNDevice: networkingStack.TUNDevice,
279-
WireguardMonitor: networkingStack.WireguardMonitor,
280-
UpdateHandler: t,
274+
Headers: header,
275+
Logger: t.clientLogger,
276+
UseSoftNetIsolation: req.GetTunnelUseSoftNetIsolation(),
277+
DNSConfigurator: networkingStack.DNSConfigurator,
278+
Router: networkingStack.Router,
279+
TUNDevice: networkingStack.TUNDevice,
280+
WireguardMonitor: networkingStack.WireguardMonitor,
281+
UpdateHandler: t,
281282
},
282283
)
283284
if err != nil {

vpn/tunnel_internal_test.go

Lines changed: 71 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ package vpn
22

33
import (
44
"context"
5+
"encoding/json"
56
"maps"
67
"net"
8+
"net/http"
79
"net/netip"
810
"net/url"
911
"slices"
@@ -22,32 +24,51 @@ import (
2224
"github.com/coder/quartz"
2325

2426
maputil "github.com/coder/coder/v2/coderd/util/maps"
27+
"github.com/coder/coder/v2/codersdk"
2528
"github.com/coder/coder/v2/tailnet"
2629
"github.com/coder/coder/v2/tailnet/proto"
2730
"github.com/coder/coder/v2/testutil"
2831
)
2932

3033
func newFakeClient(ctx context.Context, t *testing.T) *fakeClient {
3134
return &fakeClient{
32-
t: t,
33-
ctx: ctx,
34-
ch: make(chan *fakeConn, 1),
35+
t: t,
36+
ctx: ctx,
37+
connCh: make(chan *fakeConn, 1),
38+
}
39+
}
40+
41+
func newFakeClientWithOptsCh(ctx context.Context, t *testing.T) *fakeClient {
42+
return &fakeClient{
43+
t: t,
44+
ctx: ctx,
45+
connCh: make(chan *fakeConn, 1),
46+
optsCh: make(chan *Options, 1),
3547
}
3648
}
3749

3850
type fakeClient struct {
39-
t *testing.T
40-
ctx context.Context
41-
ch chan *fakeConn
51+
t *testing.T
52+
ctx context.Context
53+
connCh chan *fakeConn
54+
optsCh chan *Options // options will be written to this channel if it's not nil
4255
}
4356

4457
var _ Client = (*fakeClient)(nil)
4558

46-
func (f *fakeClient) NewConn(context.Context, *url.URL, string, *Options) (Conn, error) {
59+
func (f *fakeClient) NewConn(_ context.Context, _ *url.URL, _ string, opts *Options) (Conn, error) {
60+
if f.optsCh != nil {
61+
select {
62+
case <-f.ctx.Done():
63+
return nil, f.ctx.Err()
64+
case f.optsCh <- opts:
65+
}
66+
}
67+
4768
select {
4869
case <-f.ctx.Done():
4970
return nil, f.ctx.Err()
50-
case conn := <-f.ch:
71+
case conn := <-f.connCh:
5172
return conn, nil
5273
}
5374
}
@@ -134,37 +155,53 @@ func TestTunnel_StartStop(t *testing.T) {
134155
t.Parallel()
135156

136157
ctx := testutil.Context(t, testutil.WaitShort)
137-
client := newFakeClient(ctx, t)
158+
client := newFakeClientWithOptsCh(ctx, t)
138159
conn := newFakeConn(tailnet.WorkspaceUpdate{}, time.Time{})
139160

140161
_, mgr := setupTunnel(t, ctx, client, quartz.NewMock(t))
141162

142163
errCh := make(chan error, 1)
143164
var resp *TunnelMessage
144165
// When: we start the tunnel
166+
telemetry := codersdk.CoderDesktopTelemetry{
167+
DeviceID: "device001",
168+
DeviceOS: "macOS",
169+
CoderDesktopVersion: "0.24.8",
170+
}
171+
telemetryJSON, err := json.Marshal(telemetry)
172+
require.NoError(t, err)
145173
go func() {
146174
r, err := mgr.unaryRPC(ctx, &ManagerMessage{
147175
Msg: &ManagerMessage_Start{
148176
Start: &StartRequest{
149177
TunnelFileDescriptor: 2,
150-
CoderUrl: "https://coder.example.com",
151-
ApiToken: "fakeToken",
178+
// Use default value for TunnelUseSoftNetIsolation
179+
CoderUrl: "https://coder.example.com",
180+
ApiToken: "fakeToken",
152181
Headers: []*StartRequest_Header{
153182
{Name: "X-Test-Header", Value: "test"},
154183
},
155-
DeviceOs: "macOS",
156-
DeviceId: "device001",
157-
CoderDesktopVersion: "0.24.8",
184+
DeviceOs: telemetry.DeviceOS,
185+
DeviceId: telemetry.DeviceID,
186+
CoderDesktopVersion: telemetry.CoderDesktopVersion,
158187
},
159188
},
160189
})
161190
resp = r
162191
errCh <- err
163192
}()
164-
// Then: `NewConn` is called,
165-
testutil.RequireSend(ctx, t, client.ch, conn)
193+
194+
// Then: `NewConn` is called
195+
opts := testutil.RequireReceive(ctx, t, client.optsCh)
196+
require.Equal(t, http.Header{
197+
"X-Test-Header": {"test"},
198+
codersdk.CoderDesktopTelemetryHeader: {string(telemetryJSON)},
199+
}, opts.Headers)
200+
require.False(t, opts.UseSoftNetIsolation) // the default is false
201+
testutil.RequireSend(ctx, t, client.connCh, conn)
202+
166203
// And: a response is received
167-
err := testutil.TryReceive(ctx, t, errCh)
204+
err = testutil.TryReceive(ctx, t, errCh)
168205
require.NoError(t, err)
169206
_, ok := resp.Msg.(*TunnelMessage_Start)
170207
require.True(t, ok)
@@ -197,7 +234,7 @@ func TestTunnel_PeerUpdate(t *testing.T) {
197234
wsID1 := uuid.UUID{1}
198235
wsID2 := uuid.UUID{2}
199236

200-
client := newFakeClient(ctx, t)
237+
client := newFakeClientWithOptsCh(ctx, t)
201238
conn := newFakeConn(tailnet.WorkspaceUpdate{
202239
UpsertedWorkspaces: []*tailnet.Workspace{
203240
{
@@ -211,22 +248,28 @@ func TestTunnel_PeerUpdate(t *testing.T) {
211248

212249
tun, mgr := setupTunnel(t, ctx, client, quartz.NewMock(t))
213250

251+
// When: we start the tunnel
214252
errCh := make(chan error, 1)
215253
var resp *TunnelMessage
216254
go func() {
217255
r, err := mgr.unaryRPC(ctx, &ManagerMessage{
218256
Msg: &ManagerMessage_Start{
219257
Start: &StartRequest{
220-
TunnelFileDescriptor: 2,
221-
CoderUrl: "https://coder.example.com",
222-
ApiToken: "fakeToken",
258+
TunnelFileDescriptor: 2,
259+
TunnelUseSoftNetIsolation: true,
260+
CoderUrl: "https://coder.example.com",
261+
ApiToken: "fakeToken",
223262
},
224263
},
225264
})
226265
resp = r
227266
errCh <- err
228267
}()
229-
testutil.RequireSend(ctx, t, client.ch, conn)
268+
269+
// Then: `NewConn` is called
270+
opts := testutil.RequireReceive(ctx, t, client.optsCh)
271+
require.True(t, opts.UseSoftNetIsolation)
272+
testutil.RequireSend(ctx, t, client.connCh, conn)
230273
err := testutil.TryReceive(ctx, t, errCh)
231274
require.NoError(t, err)
232275
_, ok := resp.Msg.(*TunnelMessage_Start)
@@ -291,7 +334,7 @@ func TestTunnel_NetworkSettings(t *testing.T) {
291334
resp = r
292335
errCh <- err
293336
}()
294-
testutil.RequireSend(ctx, t, client.ch, conn)
337+
testutil.RequireSend(ctx, t, client.connCh, conn)
295338
err := testutil.TryReceive(ctx, t, errCh)
296339
require.NoError(t, err)
297340
_, ok := resp.Msg.(*TunnelMessage_Start)
@@ -432,7 +475,7 @@ func TestTunnel_sendAgentUpdate(t *testing.T) {
432475
resp = r
433476
errCh <- err
434477
}()
435-
testutil.RequireSend(ctx, t, client.ch, conn)
478+
testutil.RequireSend(ctx, t, client.connCh, conn)
436479
err := testutil.TryReceive(ctx, t, errCh)
437480
require.NoError(t, err)
438481
_, ok := resp.Msg.(*TunnelMessage_Start)
@@ -603,7 +646,7 @@ func TestTunnel_sendAgentUpdateReconnect(t *testing.T) {
603646
resp = r
604647
errCh <- err
605648
}()
606-
testutil.RequireSend(ctx, t, client.ch, conn)
649+
testutil.RequireSend(ctx, t, client.connCh, conn)
607650
err := testutil.TryReceive(ctx, t, errCh)
608651
require.NoError(t, err)
609652
_, ok := resp.Msg.(*TunnelMessage_Start)
@@ -703,7 +746,7 @@ func TestTunnel_sendAgentUpdateWorkspaceReconnect(t *testing.T) {
703746
resp = r
704747
errCh <- err
705748
}()
706-
testutil.RequireSend(ctx, t, client.ch, conn)
749+
testutil.RequireSend(ctx, t, client.connCh, conn)
707750
err := testutil.TryReceive(ctx, t, errCh)
708751
require.NoError(t, err)
709752
_, ok := resp.Msg.(*TunnelMessage_Start)
@@ -806,7 +849,7 @@ func TestTunnel_slowPing(t *testing.T) {
806849
resp = r
807850
errCh <- err
808851
}()
809-
testutil.RequireSend(ctx, t, client.ch, conn)
852+
testutil.RequireSend(ctx, t, client.connCh, conn)
810853
err := testutil.TryReceive(ctx, t, errCh)
811854
require.NoError(t, err)
812855
_, ok := resp.Msg.(*TunnelMessage_Start)
@@ -895,7 +938,7 @@ func TestTunnel_stopMidPing(t *testing.T) {
895938
resp = r
896939
errCh <- err
897940
}()
898-
testutil.RequireSend(ctx, t, client.ch, conn)
941+
testutil.RequireSend(ctx, t, client.connCh, conn)
899942
err := testutil.TryReceive(ctx, t, errCh)
900943
require.NoError(t, err)
901944
_, ok := resp.Msg.(*TunnelMessage_Start)

vpn/version.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ var CurrentSupportedVersions = RPCVersionList{
2323
// - preferred_derp: The server that DERP relayed connections are
2424
// using, if they're not using P2P.
2525
// - preferred_derp_latency: The latency to the preferred DERP
26-
{Major: 1, Minor: 2},
26+
// 1.3 adds:
27+
// - tunnel_use_soft_net_isolation to the StartRequest
28+
{Major: 1, Minor: 3},
2729
},
2830
}
2931

0 commit comments

Comments
 (0)