Skip to content

Commit 7045a25

Browse files
committed
vpn gateway: add initial Windows support (client + exit node)
Client: always-on IP_UNICAST_IF/IPV6_UNICAST_IF marking bound to the detected uplink (per address family), with a route/interface-change watcher that re-binds the registry of long-lived UDP sockets on roaming or late network appearance; /1 routes + fail-closed IPv6 fence; Ready() gate refuses enabling without an uplink. Exit node: WFP forward-layer BLOCK for private/CGNAT destinations (dynamic session, dies with the process), per-interface IPv4 forwarding via winipcfg, WinNAT instance with stale-recovery and rollback on partial failure. Tests: unit (uplink pick, setsockopt families, socket registry, NetNat JSON), hostnet suite for Windows (NAT lifecycle/stale/rollback on a real NIC, /1 routes on a real Wintun, crash semantics), CI hostnet step and full Windows e2e (server round-trip, full-tunnel egress, NRPT, dangling routes check). Known limitations: crash is fail-open for the client, WinNAT single-instance conflicts, uplink change while serving requires re-enable, inbound direct TCP degrades in gateway mode — cross-platform.
1 parent 0be76ef commit 7045a25

32 files changed

Lines changed: 1898 additions & 179 deletions

.github/workflows/test.yml

Lines changed: 108 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,22 @@ jobs:
4646
run: |
4747
go test -c -tags vpn_hostnet -o gw-hostnet.test ./vpn/routes/
4848
sudo ./gw-hostnet.test -test.run '^TestGatewayHostNet' -test.v
49+
- name: VPN gateway host-network integration test (Windows, admin)
50+
# Windows counterpart of the step above: exercises WinNAT + WFP +
51+
# per-interface forwarding (SetupNAT/TeardownNAT) against a real NIC
52+
# and the /1 client routes against a real Wintun adapter. GitHub
53+
# Windows runners execute as Administrator, so no sudo equivalent is
54+
# needed. The diagnostic Get-NetNat shows what the runner image holds
55+
# in WinNAT (a Docker/HNS instance would conflict — decide about
56+
# pre-cleaning based on what this prints).
57+
if: matrix.os == 'windows-latest'
58+
shell: pwsh
59+
run: |
60+
Get-NetNat | Format-List
61+
go test -c -tags vpn_hostnet -o gw-hostnet.test.exe ./vpn/routes/
62+
# Flags are quoted: pwsh splits an unquoted `-test.run` at the dot
63+
# into `-test` + `.run`, which the test binary rejects.
64+
./gw-hostnet.test.exe '-test.run' '^TestGatewayHostNet' '-test.v'
4965
- name: Build cmd/awl
5066
run: go build github.com/anywherelan/awl/cmd/awl
5167
- name: Upload cmd/awl build
@@ -143,7 +159,6 @@ jobs:
143159
144160
# Probe the egress IP via https://ifconfig.me
145161
curl4() { curl -4 -s --max-time 20 --retry 3 --retry-delay 2 https://ifconfig.me/; }
146-
147162
IP_DIRECT=$(curl4); echo "egress IP (direct): $IP_DIRECT"
148163
149164
./awl cli gateway client use --name awl-tester
@@ -209,11 +224,101 @@ jobs:
209224
sleep 10
210225
awl_pid=`jobs -l | grep './awl' | awk '{print $2}'`
211226
./awl.exe cli peers status
227+
212228
./librespeed-cli.exe --local-json config_librespeed.json --server 2 --json --share --telemetry-level disabled | python3 -m json.tool
213229
214230
ping -w 20000 -n 10 10.66.0.2
215231
ping -w 20000 -n 10 -a awl-tester.awl
216232
233+
# ---- VPN gateway server (exit-node) mode: runtime enable/disable round-trips OS state ----
234+
# Diagnostic first: what the runner already holds in WinNAT (a
235+
# Docker/HNS instance here would explain a New-NetNat conflict).
236+
powershell -NoProfile -Command 'Get-NetNat | Format-List'
237+
./awl.exe cli gateway server enable
238+
powershell -NoProfile -Command 'if (-not (Get-NetNat -Name awl-gateway -ErrorAction SilentlyContinue)) { exit 1 }'
239+
# WinNAT must not break p2p traffic inside the TUN
240+
ping -w 20000 -n 4 10.66.0.2
241+
./awl.exe cli gateway server disable
242+
powershell -NoProfile -Command 'if (Get-NetNat -Name awl-gateway -ErrorAction SilentlyContinue) { exit 1 }'
243+
244+
# ---- VPN gateway full-tunnel e2e: client = this runner, exit node = awl-tester ----
245+
# Requires awl-tester to permit this CI peer as an exit node
246+
# (WeAllowUsingAsExitNode) — the Windows CI peer id differs from the
247+
# Linux one.
248+
for i in $(seq 1 15); do
249+
if ./awl.exe cli gateway list | grep -E 'awl-tester.*\[connected\]'; then break; fi
250+
sleep 2
251+
done
252+
./awl.exe cli gateway list | grep -E 'awl-tester.*\[connected\]' # fail if never became available
253+
254+
curl4() { curl -4 -s --max-time 20 --retry 3 --retry-delay 2 https://ifconfig.me/; }
255+
IP_DIRECT=$(curl4); echo "egress IP (direct): $IP_DIRECT"
256+
257+
# NRPT diagnostics before enable (split-DNS rules for the awl zone)
258+
powershell -NoProfile -Command 'Get-DnsClientNrptPolicy | Format-List Namespace,NameServers'
259+
260+
./awl.exe cli gateway client use --name awl-tester
261+
./awl.exe cli gateway status
262+
ROUTES_ON=$(powershell -NoProfile -Command '(Get-NetRoute -DestinationPrefix 0.0.0.0/1,128.0.0.0/1 -ErrorAction SilentlyContinue | Measure-Object).Count' | tr -d '\r')
263+
NRPT_ON=$(powershell -NoProfile -Command '(Get-DnsClientNrptPolicy | Where-Object { $_.Namespace -eq "." } | Measure-Object).Count' | tr -d '\r')
264+
powershell -NoProfile -Command 'Get-DnsClientNrptPolicy | Format-List Namespace,NameServers'
265+
# Route diagnostics incl. lifetimes/protocol: a route created without
266+
# InitializeIpForwardEntry shows zero lifetimes and is ignored by the
267+
# forwarding path while still being listed here.
268+
powershell -NoProfile -Command 'Get-NetRoute -DestinationPrefix 0.0.0.0/1,128.0.0.0/1,::/1,8000::/1 -ErrorAction SilentlyContinue | Format-List DestinationPrefix,InterfaceAlias,RouteMetric,Protocol,ValidLifetime,PreferredLifetime'
269+
# Tolerate a failed curl here so we always reach `client stop` below
270+
# (a broken tunnel must not leave the runner black-holed); the
271+
# `test -n "$IP_GW"` assertion after teardown turns it into a clean failure.
272+
IP_GW=$(curl4 || true); echo "egress IP (via gateway): $IP_GW"
273+
274+
./awl.exe cli gateway client stop
275+
ROUTES_OFF=$(powershell -NoProfile -Command '(Get-NetRoute -DestinationPrefix 0.0.0.0/1,128.0.0.0/1 -ErrorAction SilentlyContinue | Measure-Object).Count' | tr -d '\r')
276+
NRPT_OFF=$(powershell -NoProfile -Command '(Get-DnsClientNrptPolicy | Where-Object { $_.Namespace -eq "." } | Measure-Object).Count' | tr -d '\r')
277+
IP_REVERTED=$(curl4); echo "egress IP (reverted): $IP_REVERTED"
278+
279+
# Assertions run with the gateway already disabled, so a failure here
280+
# can't leave the runner's egress black-holed. Values are echoed
281+
# first so a failed assertion is attributable from the log alone.
282+
echo "ROUTES_ON=$ROUTES_ON NRPT_ON=$NRPT_ON ROUTES_OFF=$ROUTES_OFF NRPT_OFF=$NRPT_OFF"
283+
test "$ROUTES_ON" -eq 2 # /1 pair installed while gateway is on
284+
test "$NRPT_ON" -ge 1 # full-capture NRPT rule while gateway is on
285+
test -n "$IP_GW" # traffic really flowed through the exit node
286+
test "$IP_GW" != "$IP_DIRECT" # egress changed => full-tunnel works
287+
test "$IP_REVERTED" = "$IP_DIRECT" # runtime disable reverted egress
288+
test "$ROUTES_OFF" -eq 0 # /1 routes removed on disable
289+
test "$NRPT_OFF" -eq 0 # full-capture NRPT rule removed on disable
290+
291+
# Re-enable the gateway and hard-kill awl. A graceful-shutdown test is
292+
# not possible here: Git-Bash `kill -SIGINT` cannot deliver a console
293+
# ctrl event to a native Windows process on a console-less CI runner
294+
# (verified: awl kept logging after the kill). Graceful teardown is
295+
# already covered by the `client stop` assertions above and by the
296+
# Linux branch; what a hard kill lets us assert is the documented
297+
# CRASH semantics instead.
298+
./awl.exe cli gateway client use --name awl-tester
217299
sleep 1
218-
kill -SIGINT $awl_pid
219-
sleep 1
300+
taskkill //F //IM awl.exe
301+
302+
# Crash fail-open: the /1 routes (v4 + v6 fence) are bound to the
303+
# Wintun LUID and must disappear together with the adapter. Driver
304+
# cleanup after a hard kill is asynchronous — poll.
305+
DANGLING=-1
306+
for i in $(seq 1 30); do
307+
DANGLING=$(powershell -NoProfile -Command '(Get-NetRoute -DestinationPrefix 0.0.0.0/1,128.0.0.0/1,::/1,8000::/1 -ErrorAction SilentlyContinue | Measure-Object).Count' | tr -d '\r')
308+
if [ "$DANGLING" -eq 0 ]; then break; fi
309+
sleep 1
310+
done
311+
echo "DANGLING=$DANGLING"
312+
test "$DANGLING" -eq 0
313+
314+
# The NRPT catch-all rule lives in the registry and must SURVIVE a
315+
# hard kill — that's the documented crash behaviour (the next awl
316+
# start reconfigures DNS and cleans it up).
317+
NRPT_CRASH=$(powershell -NoProfile -Command '(Get-DnsClientNrptPolicy | Where-Object { $_.Namespace -eq "." } | Measure-Object).Count' | tr -d '\r')
318+
echo "NRPT_CRASH=$NRPT_CRASH"
319+
test "$NRPT_CRASH" -ge 1
320+
321+
# Runner hygiene: with awl dead, the leftover catch-all rule points
322+
# all DNS at a resolver that no longer exists — remove it so the
323+
# runner's post-job steps keep working DNS.
324+
powershell -NoProfile -Command 'Get-DnsClientNrptRule | Where-Object { $_.Namespace -eq "." } | Remove-DnsClientNrptRule -Force'

application.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,12 @@ func (a *Application) Init(ctx context.Context, tunDevice tun.Device) error {
113113
if a.SockMarker == nil {
114114
a.SockMarker = sockmark.New()
115115
}
116+
// Start before InitHost so the very first libp2p sockets are already
117+
// marked (on Windows: bound to the detected uplink). An offline start is
118+
// not an error — see Marker.Start; only hard failures abort Init.
119+
if err := a.SockMarker.Start(a.ctx); err != nil {
120+
return fmt.Errorf("start socket marker: %v", err)
121+
}
116122
a.P2p = p2p.NewP2p(a.ctx)
117123
p2pHost, err := a.P2p.InitHost(a.makeP2pHostConfig())
118124
if err != nil {

cmd/awl-tray/go.mod

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ require (
2727
filippo.io/bigmod v0.1.1-0.20260103110540-f8a47775ebe5 // indirect
2828
filippo.io/keygen v0.0.0-20260114151900-8e2790ea4c5b // indirect
2929
git.sr.ht/~jackmordaunt/go-toast v1.1.2 // indirect
30+
github.com/BurntSushi/toml v1.5.0 // indirect
3031
github.com/akavel/rsrc v0.10.2 // indirect
3132
github.com/anywherelan/ts-dns v0.0.0-20240721135326-6d6b7b811853 // indirect
3233
github.com/benbjohnson/clock v1.3.5 // indirect
@@ -140,6 +141,7 @@ require (
140141
github.com/sergeymakinen/go-ico v1.0.0-beta.0 // indirect
141142
github.com/spaolacci/murmur3 v1.1.0 // indirect
142143
github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect
144+
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 // indirect
143145
github.com/urfave/cli/v2 v2.27.7 // indirect
144146
github.com/valyala/bytebufferpool v1.0.0 // indirect
145147
github.com/valyala/fasttemplate v1.2.2 // indirect
@@ -162,6 +164,7 @@ require (
162164
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
163165
golang.org/x/crypto v0.53.0 // indirect
164166
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
167+
golang.org/x/exp/typeparams v0.0.0-20221208152030-732eee02a75a // indirect
165168
golang.org/x/image v0.39.0 // indirect
166169
golang.org/x/mod v0.36.0 // indirect
167170
golang.org/x/net v0.56.0 // indirect
@@ -177,6 +180,7 @@ require (
177180
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
178181
gonum.org/v1/gonum v0.17.0 // indirect
179182
google.golang.org/protobuf v1.36.11 // indirect
183+
honnef.co/go/tools v0.4.5 // indirect
180184
lukechampine.com/blake3 v1.4.1 // indirect
181185
rsc.io/qr v0.2.0 // indirect
182186
)

cmd/awl-tray/go.sum

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ filippo.io/keygen v0.0.0-20260114151900-8e2790ea4c5b/go.mod h1:9nnw1SlYHYuPSo/3w
4444
git.sr.ht/~jackmordaunt/go-toast v1.1.2 h1:/yrfI55LRt1M7H1vkaw+NaH1+L1CDxrqDltwm5euVuE=
4545
git.sr.ht/~jackmordaunt/go-toast v1.1.2/go.mod h1:jA4OqHKTQ4AFBdwrSnwnskUIIS3HYzlJSgdzCKqfavo=
4646
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
47+
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
48+
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
4749
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
4850
github.com/GrigoryKrasnochub/updaterini v0.1.0 h1:csl3bqQIShuI8RnXzKprTgEhqi3I59XbfZn5+0LGYR8=
4951
github.com/GrigoryKrasnochub/updaterini v0.1.0/go.mod h1:w+8blQZCZzGmJGXqmXbeSOsUOF6LqyeqckxSiZah7RI=
@@ -531,6 +533,8 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD
531533
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
532534
github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk=
533535
github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o=
536+
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M=
537+
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y=
534538
github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
535539
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
536540
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
@@ -626,6 +630,8 @@ golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EH
626630
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
627631
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
628632
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
633+
golang.org/x/exp/typeparams v0.0.0-20221208152030-732eee02a75a h1:Jw5wfR+h9mnIYH+OtGT2im5wV1YGGDora5vTv/aa5bE=
634+
golang.org/x/exp/typeparams v0.0.0-20221208152030-732eee02a75a/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
629635
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
630636
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
631637
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
@@ -863,6 +869,8 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
863869
golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s=
864870
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
865871
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
872+
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
873+
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
866874
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
867875
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
868876
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -1005,6 +1013,8 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
10051013
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
10061014
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
10071015
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
1016+
honnef.co/go/tools v0.4.5 h1:YGD4H+SuIOOqsyoLOpZDWcieM28W47/zRO7f+9V3nvo=
1017+
honnef.co/go/tools v0.4.5/go.mod h1:GUV+uIBCLpdf0/v6UhHHG/yzI/z6qPskBeQCjcNB96k=
10081018
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
10091019
lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=
10101020
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=

go.mod

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ require (
3131
github.com/prometheus/client_golang v1.23.2
3232
github.com/quic-go/quic-go v0.59.1
3333
github.com/stretchr/testify v1.11.1
34+
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6
3435
github.com/urfave/cli/v2 v2.27.7
3536
github.com/vishvananda/netlink v1.3.1
3637
go.uber.org/goleak v1.3.0
@@ -45,6 +46,7 @@ require (
4546
require (
4647
filippo.io/bigmod v0.1.1-0.20260103110540-f8a47775ebe5 // indirect
4748
filippo.io/keygen v0.0.0-20260114151900-8e2790ea4c5b // indirect
49+
github.com/BurntSushi/toml v1.5.0 // indirect
4850
github.com/benbjohnson/clock v1.3.5 // indirect
4951
github.com/beorn7/perks v1.0.1 // indirect
5052
github.com/blang/semver/v4 v4.0.0 // indirect
@@ -151,6 +153,7 @@ require (
151153
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
152154
golang.org/x/crypto v0.53.0 // indirect
153155
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
156+
golang.org/x/exp/typeparams v0.0.0-20221208152030-732eee02a75a // indirect
154157
golang.org/x/mod v0.36.0 // indirect
155158
golang.org/x/sync v0.21.0 // indirect
156159
golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 // indirect
@@ -162,6 +165,7 @@ require (
162165
gonum.org/v1/gonum v0.17.0 // indirect
163166
google.golang.org/protobuf v1.36.11 // indirect
164167
gopkg.in/yaml.v3 v3.0.1 // indirect
168+
honnef.co/go/tools v0.4.5 // indirect
165169
lukechampine.com/blake3 v1.4.1 // indirect
166170
rsc.io/qr v0.2.0 // indirect
167171
)

0 commit comments

Comments
 (0)