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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy