Skip to content

Commit 018315a

Browse files
authored
Merge pull request #250 from anywherelan/windows-vpn-gateway
vpn gateway: add Windows support (client + exit node) and refactor into vpn/netstate
2 parents 0be76ef + 02786de commit 018315a

49 files changed

Lines changed: 3924 additions & 1020 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/test.yml

Lines changed: 155 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,24 @@ jobs:
4444
# `if: always()` cleanup step
4545
if: matrix.os == 'ubuntu-latest'
4646
run: |
47-
go test -c -tags vpn_hostnet -o gw-hostnet.test ./vpn/routes/
47+
go test -c -tags vpn_hostnet -o gw-hostnet.test ./vpn/netstate/
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/netstate/
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,8 +159,10 @@ 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"
163+
# Source IP of the physical uplink, used below to force a leak probe
164+
# out of the NIC (bypassing the fwmark policy route).
165+
NIC_IP=$(ip -4 route get 1.1.1.1 | grep -oP 'src \K\S+'); echo "uplink src IP: $NIC_IP"
148166
149167
./awl cli gateway client use --name awl-tester
150168
./awl cli gateway status
@@ -168,6 +186,26 @@ jobs:
168186
! echo "$RULE_OFF" | grep -q fwmark # policy route removed on disable
169187
! echo "$ROUTE_OFF" | grep -q 'default dev awl0' # TUN default removed on disable
170188
189+
# Second enable/disable cycle: a runtime re-enable after a disable is
190+
# the exact flow that looked broken in manual Windows testing (it was
191+
# not — browser keep-alive pools were lying). Assert the egress flips
192+
# again and reverts again. Also probe for a leak: a socket forced out
193+
# of the physical NIC (--interface) bypasses the fwmark policy route,
194+
# but on Linux those packets are still routed by destination into the
195+
# TUN and die there (their NIC source is not NATed by the exit node),
196+
# so a NIC-bound curl must fail while the gateway is on. This is the
197+
# weak-host-model counterpart of the Windows WFP leak fence.
198+
./awl cli gateway client use --name awl-tester
199+
IP_GW2=$(curl4 || true); echo "egress IP (via gateway, cycle 2): $IP_GW2"
200+
LEAK=$(curl -4 -s --max-time 8 --interface "$NIC_IP" https://ifconfig.me/ || true)
201+
echo "NIC-bound egress while gateway on (must be empty): '$LEAK'"
202+
./awl cli gateway client stop
203+
IP_REVERTED2=$(curl4); echo "egress IP (reverted, cycle 2): $IP_REVERTED2"
204+
test -n "$IP_GW2" # re-enable tunnelled again
205+
test "$IP_GW2" != "$IP_DIRECT" # egress changed on 2nd enable too
206+
test -z "$LEAK" # NIC-bound egress did NOT bypass the tunnel
207+
test "$IP_REVERTED2" = "$IP_DIRECT" # 2nd disable reverted egress
208+
171209
# Re-enable so the SIGINT shutdown path (teardownGatewayAtShutdown) is exercised.
172210
./awl cli gateway client use --name awl-tester
173211
@@ -209,11 +247,124 @@ jobs:
209247
sleep 10
210248
awl_pid=`jobs -l | grep './awl' | awk '{print $2}'`
211249
./awl.exe cli peers status
250+
212251
./librespeed-cli.exe --local-json config_librespeed.json --server 2 --json --share --telemetry-level disabled | python3 -m json.tool
213252
214253
ping -w 20000 -n 10 10.66.0.2
215254
ping -w 20000 -n 10 -a awl-tester.awl
216255
256+
# ---- VPN gateway server (exit-node) mode: runtime enable/disable round-trips OS state ----
257+
# Diagnostic first: what the runner already holds in WinNAT (a
258+
# Docker/HNS instance here would explain a New-NetNat conflict).
259+
powershell -NoProfile -Command 'Get-NetNat | Format-List'
260+
./awl.exe cli gateway server enable
261+
powershell -NoProfile -Command 'if (-not (Get-NetNat -Name awl-gateway -ErrorAction SilentlyContinue)) { exit 1 }'
262+
# WinNAT must not break p2p traffic inside the TUN
263+
ping -w 20000 -n 4 10.66.0.2
264+
./awl.exe cli gateway server disable
265+
powershell -NoProfile -Command 'if (Get-NetNat -Name awl-gateway -ErrorAction SilentlyContinue) { exit 1 }'
266+
267+
# ---- VPN gateway full-tunnel e2e: client = this runner, exit node = awl-tester ----
268+
# Requires awl-tester to permit this CI peer as an exit node
269+
# (WeAllowUsingAsExitNode) — the Windows CI peer id differs from the
270+
# Linux one.
271+
for i in $(seq 1 15); do
272+
if ./awl.exe cli gateway list | grep -E 'awl-tester.*\[connected\]'; then break; fi
273+
sleep 2
274+
done
275+
./awl.exe cli gateway list | grep -E 'awl-tester.*\[connected\]' # fail if never became available
276+
277+
curl4() { curl -4 -s --max-time 20 --retry 3 --retry-delay 2 https://ifconfig.me/; }
278+
IP_DIRECT=$(curl4); echo "egress IP (direct): $IP_DIRECT"
279+
# Source IP of the physical uplink (captured before enable, so the
280+
# best route still points at the NIC, not the TUN). Used to force a
281+
# leak probe out of the NIC past the /1 routes.
282+
NIC_IP=$(powershell -NoProfile -Command '(Find-NetRoute -RemoteIPAddress 1.1.1.1 | Select-Object -First 1).IPAddress' | tr -d '\r'); echo "uplink src IP: $NIC_IP"
283+
284+
# NRPT diagnostics before enable (split-DNS rules for the awl zone)
285+
powershell -NoProfile -Command 'Get-DnsClientNrptPolicy | Format-List Namespace,NameServers'
286+
287+
./awl.exe cli gateway client use --name awl-tester
288+
./awl.exe cli gateway status
289+
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')
290+
NRPT_ON=$(powershell -NoProfile -Command '(Get-DnsClientNrptPolicy | Where-Object { $_.Namespace -eq "." } | Measure-Object).Count' | tr -d '\r')
291+
powershell -NoProfile -Command 'Get-DnsClientNrptPolicy | Format-List Namespace,NameServers'
292+
# Route diagnostics incl. lifetimes/protocol: a route created without
293+
# InitializeIpForwardEntry shows zero lifetimes and is ignored by the
294+
# forwarding path while still being listed here.
295+
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'
296+
# Tolerate a failed curl here so we always reach `client stop` below
297+
# (a broken tunnel must not leave the runner black-holed); the
298+
# `test -n "$IP_GW"` assertion after teardown turns it into a clean failure.
299+
IP_GW=$(curl4 || true); echo "egress IP (via gateway): $IP_GW"
300+
# Leak probe: a socket forced out of the physical NIC (--interface)
301+
# bypasses the /1 routes via the strong host model — exactly the leak
302+
# the WFP fence exists to close. Must fail while the gateway is on.
303+
LEAK=$(curl.exe --interface "$NIC_IP" -4 -s --max-time 8 https://ifconfig.me/ || true)
304+
echo "NIC-bound egress while gateway on (must be empty): '$LEAK'"
305+
306+
./awl.exe cli gateway client stop
307+
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')
308+
NRPT_OFF=$(powershell -NoProfile -Command '(Get-DnsClientNrptPolicy | Where-Object { $_.Namespace -eq "." } | Measure-Object).Count' | tr -d '\r')
309+
IP_REVERTED=$(curl4); echo "egress IP (reverted): $IP_REVERTED"
310+
311+
# Assertions run with the gateway already disabled, so a failure here
312+
# can't leave the runner's egress black-holed. Values are echoed
313+
# first so a failed assertion is attributable from the log alone.
314+
echo "ROUTES_ON=$ROUTES_ON NRPT_ON=$NRPT_ON ROUTES_OFF=$ROUTES_OFF NRPT_OFF=$NRPT_OFF LEAK='$LEAK'"
315+
test "$ROUTES_ON" -eq 2 # /1 pair installed while gateway is on
316+
test "$NRPT_ON" -ge 1 # full-capture NRPT rule while gateway is on
317+
test -n "$IP_GW" # traffic really flowed through the exit node
318+
test "$IP_GW" != "$IP_DIRECT" # egress changed => full-tunnel works
319+
test "$IP_REVERTED" = "$IP_DIRECT" # runtime disable reverted egress
320+
test "$ROUTES_OFF" -eq 0 # /1 routes removed on disable
321+
test "$NRPT_OFF" -eq 0 # full-capture NRPT rule removed on disable
322+
test -z "$LEAK" # WFP fence blocked NIC-bound egress bypass
323+
324+
# Second enable/disable cycle: a runtime re-enable after a disable is
325+
# the exact flow that looked broken in manual testing (it was not —
326+
# browser keep-alive pools were lying). Assert egress flips again and
327+
# reverts again; the gateway is already off at each assertion so a
328+
# failure can't black-hole the runner.
329+
./awl.exe cli gateway client use --name awl-tester
330+
IP_GW2=$(curl4 || true); echo "egress IP (via gateway, cycle 2): $IP_GW2"
331+
./awl.exe cli gateway client stop
332+
IP_REVERTED2=$(curl4); echo "egress IP (reverted, cycle 2): $IP_REVERTED2"
333+
test -n "$IP_GW2" # re-enable tunnelled again
334+
test "$IP_GW2" != "$IP_DIRECT" # egress changed on 2nd enable too
335+
test "$IP_REVERTED2" = "$IP_DIRECT" # 2nd disable reverted egress
336+
337+
# Re-enable the gateway and hard-kill awl. A graceful-shutdown test is
338+
# not possible here: Git-Bash `kill -SIGINT` cannot deliver a console
339+
# ctrl event to a native Windows process on a console-less CI runner
340+
# (verified: awl kept logging after the kill). Graceful teardown is
341+
# already covered by the `client stop` assertions above and by the
342+
# Linux branch; what a hard kill lets us assert is the documented
343+
# CRASH semantics instead.
344+
./awl.exe cli gateway client use --name awl-tester
217345
sleep 1
218-
kill -SIGINT $awl_pid
219-
sleep 1
346+
taskkill //F //IM awl.exe
347+
348+
# Crash fail-open: the /1 routes (v4 + v6 fence) are bound to the
349+
# Wintun LUID and must disappear together with the adapter. Driver
350+
# cleanup after a hard kill is asynchronous — poll.
351+
DANGLING=-1
352+
for i in $(seq 1 30); do
353+
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')
354+
if [ "$DANGLING" -eq 0 ]; then break; fi
355+
sleep 1
356+
done
357+
echo "DANGLING=$DANGLING"
358+
test "$DANGLING" -eq 0
359+
360+
# The NRPT catch-all rule lives in the registry and must SURVIVE a
361+
# hard kill — that's the documented crash behaviour (the next awl
362+
# start reconfigures DNS and cleans it up).
363+
NRPT_CRASH=$(powershell -NoProfile -Command '(Get-DnsClientNrptPolicy | Where-Object { $_.Namespace -eq "." } | Measure-Object).Count' | tr -d '\r')
364+
echo "NRPT_CRASH=$NRPT_CRASH"
365+
test "$NRPT_CRASH" -ge 1
366+
367+
# Runner hygiene: with awl dead, the leftover catch-all rule points
368+
# all DNS at a resolver that no longer exists — remove it so the
369+
# runner's post-job steps keep working DNS.
370+
powershell -NoProfile -Command 'Get-DnsClientNrptRule | Where-Object { $_.Namespace -eq "." } | Remove-DnsClientNrptRule -Force'

README.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -250,18 +250,18 @@ In short: reach for SOCKS5 to send a single app through a peer, and for the VPN
250250
| --- | --- | --- | --- |
251251
| Linux ||| fully supported |
252252
| Android ||| exit-node role needs root — not planned |
253-
| Windows | | | coming next |
253+
| Windows | | | fully supported |
254254
| macOS ||| needs volunteers for testing |
255255

256-
On macOS and Windows awl currently refuses to start with VPN gateway enabled.
256+
On macOS awl currently refuses to start with VPN gateway enabled.
257257

258258
> ⚠️ **IPv6 is not tunnelled.** The gateway only carries IPv4. While it's on, all IPv6 traffic is dropped so that your real IPv6 address is never exposed past the exit node:
259259
> - **Dual-stack (IPv4 + IPv6):** everything automatically uses IPv4 through the tunnel.
260260
> - **IPv6-only network:** you'll have no internet connectivity until you turn the gateway off.
261261
262262
### Serve as an exit node
263263

264-
This lets your other devices route their internet traffic out through this one. It is **off by default** — see [Why serving as an exit node is opt-in](#why-serving-as-an-exit-node-is-opt-in) below. Two things need to be set: turn the gateway service on, then allow each specific device to use it. Serving as an exit node is Linux-only (see the status table above).
264+
This lets your other devices route their internet traffic out through this one. It is **off by default** — see [Why serving as an exit node is opt-in](#why-serving-as-an-exit-node-is-opt-in) below. Two things need to be set: turn the gateway service on, then allow each specific device to use it.
265265

266266
**Desktop (web UI):** open http://admin.awl, go to **Settings** (the gear icon, top-right) and turn on **Serve as VPN Gateway**. Then, for each device you want to permit, open its card on the Overview page, click **Settings**, and set **Allow as exit node** to *Allowed*. On `awl-tray` you can also toggle the service from the tray menu under **VPN Gateway → Serve as VPN Gateway**.
267267

@@ -305,7 +305,7 @@ awl cli gateway client stop
305305

306306
### Why serving as an exit node is opt-in
307307

308-
Unlike the SOCKS5 proxy, serving as a VPN gateway changes global system state on the host: awl turns on `net.ipv4.ip_forward` and installs iptables rules. That can interfere with the host's existing networking or firewall setup, and it isn't something awl can sandbox — so we don't enable it on a routine install. You opt in explicitly, the same way every mainstream VPN (ZeroTier, WireGuard, OpenVPN, ...) keeps exit-node mode opt-in.
308+
Unlike the SOCKS5 proxy, serving as a VPN gateway changes global system state on the host: on Linux awl turns on `net.ipv4.ip_forward` and installs iptables rules; on Windows it creates a WinNAT instance, enables per-interface IP forwarding and installs a WFP firewall filter. That can interfere with the host's existing networking or firewall setup, and it isn't something awl can sandbox — so we don't enable it on a routine install. You opt in explicitly, the same way every mainstream VPN (ZeroTier, WireGuard, OpenVPN, ...) keeps exit-node mode opt-in.
309309

310310
The privacy exposure — your IP appearing as the source of another device's traffic — is *not* what this toggle gates: it is the same for SOCKS5 and the VPN gateway, and it's controlled by the per-device **Use as exit** permission (see [Security and privacy notes](#security-and-privacy-notes) below). This toggle only governs the host-level networking changes above.
311311

@@ -314,12 +314,16 @@ The privacy exposure — your IP appearing as the source of another device's tra
314314
- **A device isn't available as an exit node.** It hasn't turned on **Serve as VPN Gateway**, or hasn't set **Allow as exit node** to *Allowed* for you, or the status exchange hasn't propagated yet — wait up to ~5 minutes or until the next reconnect.
315315
- **A "what's my IP" site still shows your own IP after enabling.** Check the gateway status (the **VPN Gateway** card, or `awl cli gateway status`): if it's not connected, awl can't reach the exit node, so nothing is being tunnelled.
316316
- **A site works over IPv6 but not through the gateway.** Expected — IPv6 isn't tunnelled (see the note above). Dual-stack hosts fall back to IPv4 automatically; anything IPv6-only won't work while the gateway is on.
317+
- **Turning on *Serve as VPN Gateway* fails on Windows.** Windows effectively allows one NAT instance per host, and it may already be taken by Docker (Windows containers), WSL2 or Internet Connection Sharing — the error message lists the current holders. Free it up, or share this device over SOCKS5 instead: the SOCKS5 exit node doesn't need NAT.
318+
- **No IPv6 connectivity after awl crashed (Linux).** If awl is killed (not shut down) with the gateway client on, its IPv6 block stays behind. It is removed automatically on the next awl start (and stop).
319+
- **Devices are reachable only via relay from a Windows machine with multiple network interfaces.** awl on Windows pins its peer-to-peer traffic to the interface that holds the default route, so peers reachable only through a secondary network card may fall back to relayed connections.
317320

318321
### Security and privacy notes
319322

320323
- **Your IP is exposed.** Once you serve as an exit node, the public IPs that those devices reach see your IP, not theirs.
321-
- **Your LAN is not.** awl drops forwarded traffic to RFC 1918 / RFC 6598 / RFC 3927 ranges (`10/8`, `172.16/12`, `192.168/16`, `100.64/10`, `169.254/16`) so a gateway client cannot reach the exit node's home network.
324+
- **Your LAN is not.** awl drops forwarded traffic to RFC 1918 / RFC 6598 / RFC 3927 ranges (`10/8`, `172.16/12`, `192.168/16`, `100.64/10`, `169.254/16`) so a gateway client cannot reach the exit node's home network. On Linux this is an iptables chain; on Windows, a WFP forward-layer BLOCK filter with the same subnet list.
322325
- **DNS:** in client gateway mode awl forces upstream DNS to a public resolver (`1.1.1.1` by default) so the LAN resolver can't leak queries past the tunnel. If you'd rather use a different resolver, you can change it by hand in the config file (`dns.upstreamDNSAddress`) while awl is stopped.
326+
- **Connections open before you enable the gateway are cut, not leaked.** When you turn client gateway mode on, existing outbound connections (a browser's keep-alive pools are the common case) are forced to break so applications re-establish them through the tunnel — otherwise, on Windows, they would keep flowing directly through your physical network card and expose your real IP. On Linux this happens naturally (the tunnel routing kills them); on Windows awl installs a WFP firewall to force it. One deliberate exception on Windows: if this device is *hosting* a service (e.g. an RDP or SMB server) and someone is already connected to it, that inbound connection keeps answering directly, so that enabling the gateway over a remote session does not disconnect you. Fresh inbound connections while the gateway is on are still limited to the tunnel path.
323327

324328
## Configuration
325329

0 commit comments

Comments
 (0)