Skip to content

Commit 8879f1e

Browse files
committed
vpn/netstate: add WFP leak fence for the Windows gateway client
Windows' strong host send model keeps connections established before the gateway was enabled flowing out of the physical NIC for their whole lifetime — the /1 routes never apply to a socket with an already-fixed source. Browser HTTP/2/HTTP/3 keep-alive pools are the visible case: the egress IP appears unchanged after enabling. On Linux the weak host model routes such flows into the tunnel where they die and get re-dialled; on Windows nothing kills them — a real leak routing cannot fix. Install a dynamic WFP session alongside the client routes with a BLOCK on ALE_AUTH_CONNECT_V4/V6 and permits for our own process (app ID), tunnel egress (TUN LUID) and local destinations. Adding the filter reauthorizes established flows so pre-gateway connections break and re-establish through the tunnel, matching Linux. Inbound is intentionally not blocked so enabling the gateway over an RDP session does not cut it.
1 parent 79e660c commit 8879f1e

7 files changed

Lines changed: 599 additions & 61 deletions

File tree

.github/workflows/test.yml

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,9 @@ jobs:
160160
# Probe the egress IP via https://ifconfig.me
161161
curl4() { curl -4 -s --max-time 20 --retry 3 --retry-delay 2 https://ifconfig.me/; }
162162
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"
163166
164167
./awl cli gateway client use --name awl-tester
165168
./awl cli gateway status
@@ -183,6 +186,26 @@ jobs:
183186
! echo "$RULE_OFF" | grep -q fwmark # policy route removed on disable
184187
! echo "$ROUTE_OFF" | grep -q 'default dev awl0' # TUN default removed on disable
185188
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+
186209
# Re-enable so the SIGINT shutdown path (teardownGatewayAtShutdown) is exercised.
187210
./awl cli gateway client use --name awl-tester
188211
@@ -253,6 +276,10 @@ jobs:
253276
254277
curl4() { curl -4 -s --max-time 20 --retry 3 --retry-delay 2 https://ifconfig.me/; }
255278
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"
256283
257284
# NRPT diagnostics before enable (split-DNS rules for the awl zone)
258285
powershell -NoProfile -Command 'Get-DnsClientNrptPolicy | Format-List Namespace,NameServers'
@@ -270,6 +297,11 @@ jobs:
270297
# (a broken tunnel must not leave the runner black-holed); the
271298
# `test -n "$IP_GW"` assertion after teardown turns it into a clean failure.
272299
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'"
273305
274306
./awl.exe cli gateway client stop
275307
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')
@@ -279,14 +311,28 @@ jobs:
279311
# Assertions run with the gateway already disabled, so a failure here
280312
# can't leave the runner's egress black-holed. Values are echoed
281313
# 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"
314+
echo "ROUTES_ON=$ROUTES_ON NRPT_ON=$NRPT_ON ROUTES_OFF=$ROUTES_OFF NRPT_OFF=$NRPT_OFF LEAK='$LEAK'"
283315
test "$ROUTES_ON" -eq 2 # /1 pair installed while gateway is on
284316
test "$NRPT_ON" -ge 1 # full-capture NRPT rule while gateway is on
285317
test -n "$IP_GW" # traffic really flowed through the exit node
286318
test "$IP_GW" != "$IP_DIRECT" # egress changed => full-tunnel works
287319
test "$IP_REVERTED" = "$IP_DIRECT" # runtime disable reverted egress
288320
test "$ROUTES_OFF" -eq 0 # /1 routes removed on disable
289321
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
290336
291337
# Re-enable the gateway and hard-kill awl. A graceful-shutdown test is
292338
# not possible here: Git-Bash `kill -SIGINT` cannot deliver a console

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

vpn/netstate/nat_windows.go

Lines changed: 54 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,22 @@ import (
2020
)
2121

2222
const (
23-
wfpSublayerName = "awl-gateway"
24-
wfpRuleName = "awl-gateway: block forwarded LAN/CGNAT egress from TUN"
23+
wfpSublayerName = "awl-gateway-server"
24+
wfpRuleName = "awl-gateway-server: block forwarded LAN/CGNAT egress from TUN"
2525

2626
// winNATName is the name of the WinNAT instance owned by awl.
2727
winNATName = "awl-gateway"
28+
29+
// wfpSublayerWeight is the weight of both awl WFP sublayers (the server
30+
// forward-layer filter and the client leak fence). 0x8000 is the midpoint
31+
// of the uint16 range: sublayers arbitrate high-to-low, and our BLOCK
32+
// rules lose only to a hard permit (FWPM_FILTER_FLAG_CLEAR_ACTION_RIGHT)
33+
// from a higher-weight sublayer. The midpoint deliberately does NOT claim
34+
// the top: these filters fence awl's own traffic, they are not a host
35+
// kill-switch, so a security product that outranks us on purpose (EDR,
36+
// corporate firewall) is allowed to win. Contrast wireguard-windows,
37+
// whose firewall takes 0xFFFF exactly because it IS a kill-switch.
38+
wfpSublayerWeight = 0x8000
2839
)
2940

3041
// natState holds the state needed to teardown NAT on Windows.
@@ -266,41 +277,20 @@ func resyncForwarding(state *natState, route uplinkRoute, ok bool, enable func(w
266277
// when the session closes or the process dies, so there is no stale-WFP
267278
// recovery path.
268279
func setupWFP(state *natState, tunIfIndex uint32, awlPrefix netip.Prefix) error {
269-
session, err := wf.New(&wf.Options{
270-
Name: "Anywherelan VPN gateway",
271-
Description: "Blocks forwarded traffic from awl devices to the exit node's private networks",
272-
Dynamic: true,
273-
})
280+
session, sublayerID, err := openDynamicWFPSession(
281+
"Anywherelan VPN gateway",
282+
"Blocks forwarded traffic from awl devices to the exit node's private networks",
283+
wfpSublayerName,
284+
)
274285
if err != nil {
275-
return fmt.Errorf("open WFP session: %w", err)
286+
return err
276287
}
277288
// Parked in state immediately, so on any later failure the caller's
278289
// rollback (teardownNAT) closes the session — the same idiom as the
279290
// forwarding and WinNAT steps. A dangling dynamic session would otherwise
280291
// outlive its guarantees until process exit.
281292
state.wfpSession = session
282293

283-
sublayerID, err := newWFPGUID()
284-
if err != nil {
285-
return err
286-
}
287-
// Weight 0x8000 = the midpoint of the uint16 sublayer range. Sublayers
288-
// arbitrate high-to-low, and our BLOCK loses only to a hard permit
289-
// (CLEAR_ACTION_RIGHT) from a higher-weight sublayer — see the rule
290-
// comment below. The midpoint deliberately does NOT claim the top:
291-
// this filter fences awl's forwarded transit, it is not a host
292-
// kill-switch, so a security product that outranks us on purpose (EDR,
293-
// corporate firewall) is allowed to win. Contrast wireguard-windows,
294-
// whose firewall takes 0xFFFF exactly because it IS a kill-switch.
295-
err = session.AddSublayer(&wf.Sublayer{
296-
ID: wf.SublayerID(sublayerID),
297-
Name: wfpSublayerName,
298-
Weight: 0x8000,
299-
})
300-
if err != nil {
301-
return fmt.Errorf("add WFP sublayer: %w", err)
302-
}
303-
304294
ruleID, err := newWFPGUID()
305295
if err != nil {
306296
return err
@@ -321,7 +311,7 @@ func setupWFP(state *natState, tunIfIndex uint32, awlPrefix netip.Prefix) error
321311
ID: wf.RuleID(ruleID),
322312
Name: wfpRuleName,
323313
Layer: wf.LayerIPForwardV4,
324-
Sublayer: wf.SublayerID(sublayerID),
314+
Sublayer: sublayerID,
325315
Weight: 1000,
326316
Conditions: conditions,
327317
Action: wf.ActionBlock,
@@ -333,6 +323,40 @@ func setupWFP(state *natState, tunIfIndex uint32, awlPrefix netip.Prefix) error
333323
return nil
334324
}
335325

326+
// openDynamicWFPSession opens a dynamic WFP session and creates one sublayer
327+
// in it at wfpSublayerWeight, returning both. Shared by the server
328+
// forward-layer filter (setupWFP) and the client leak fence
329+
// (setupClientFence). Dynamic: the kernel removes the sublayer and every rule
330+
// under it when the session closes or the process dies, so there is no
331+
// stale-WFP recovery path on either side. On failure the session is closed
332+
// here; on success the caller must park it where its teardown can close it.
333+
func openDynamicWFPSession(name, description, sublayerName string) (*wf.Session, wf.SublayerID, error) {
334+
session, err := wf.New(&wf.Options{
335+
Name: name,
336+
Description: description,
337+
Dynamic: true,
338+
})
339+
if err != nil {
340+
return nil, wf.SublayerID{}, fmt.Errorf("open WFP session: %w", err)
341+
}
342+
343+
sublayerGUID, err := newWFPGUID()
344+
if err != nil {
345+
_ = session.Close()
346+
return nil, wf.SublayerID{}, err
347+
}
348+
sublayerID := wf.SublayerID(sublayerGUID)
349+
if err := session.AddSublayer(&wf.Sublayer{
350+
ID: sublayerID,
351+
Name: sublayerName,
352+
Weight: wfpSublayerWeight,
353+
}); err != nil {
354+
_ = session.Close()
355+
return nil, wf.SublayerID{}, fmt.Errorf("add WFP sublayer: %w", err)
356+
}
357+
return session, sublayerID, nil
358+
}
359+
336360
func newWFPGUID() (windows.GUID, error) {
337361
guid, err := windows.GenerateGUID()
338362
if err != nil {

0 commit comments

Comments
 (0)