Skip to content

Commit 7331dc1

Browse files
committed
vpn: add gateway support
1 parent 79d0ea9 commit 7331dc1

65 files changed

Lines changed: 6676 additions & 405 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: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,19 @@ jobs:
3333
run: go test -count=1 -v ./...
3434
- name: Test with -race
3535
run: go test -race -count=1 -v ./...
36+
- name: VPN gateway host-network integration test (Linux, root)
37+
# Hidden behind the `vpn_hostnet` build tag and excluded from `go test ./...`.
38+
# Exercises the real netlink/iptables plumbing (SetupNAT/SetupGatewayRoutes
39+
# + teardown/stale-recovery) against the runner's own network, so it needs
40+
# root and only runs on Linux. Compile as the normal user, then run the
41+
# binary under sudo so root never pollutes the Go build cache.
42+
# TODO: if a hard failure ever leaves the runner's egress black-holed
43+
# (default route via a dead awl0) and breaks log upload, add a separate
44+
# `if: always()` cleanup step
45+
if: matrix.os == 'ubuntu-latest'
46+
run: |
47+
go test -c -tags vpn_hostnet -o gw-hostnet.test ./vpn/routes/
48+
sudo ./gw-hostnet.test -test.run '^TestGatewayHostNet' -test.v
3649
- name: Build cmd/awl
3750
run: go build github.com/anywherelan/awl/cmd/awl
3851
- name: Upload cmd/awl build
@@ -103,9 +116,68 @@ jobs:
103116
# TODO: remove this temporal hack for linux
104117
ping awl-tester.awl -w 20 -c 10 || true
105118
119+
# ---- VPN gateway server (exit-node) mode: runtime enable/disable round-trips OS state ----
120+
# awl runs as root here, so enabling the server installs real NAT
121+
# (iptables MASQUERADE + AWL-FORWARD chain) in this netns. We assert the
122+
# commands succeed AND that they actually mutate then fully restore the
123+
# netfilter state. `iptables -S` (no packet/byte counters, unlike
124+
# iptables-save) makes the before/after comparison stable.
125+
nat_state() { sudo iptables -S; sudo iptables -t nat -S; }
126+
NAT_BEFORE=$(nat_state)
127+
./awl cli gateway server enable
128+
test "$(nat_state)" != "$NAT_BEFORE" # enable actually changed netfilter
129+
sudo iptables -S | grep -q AWL-FORWARD # exit-node chain installed
130+
./awl cli gateway server disable
131+
test "$(nat_state)" = "$NAT_BEFORE" # disable restored netfilter exactly
132+
133+
# ---- VPN gateway full-tunnel e2e: client = this runner, exit node = awl-tester ----
134+
# Requires the awl-tester peer to have VPN gateway server enabled AND to
135+
# permit this CI peer as an exit node (WeAllowUsingAsExitNode); both are
136+
# advertised to us via the status protocol. Wait until it shows up as a
137+
# connected, available gateway.
138+
for i in $(seq 1 15); do
139+
if ./awl cli gateway list | grep -E 'awl-tester.*\[connected\]'; then break; fi
140+
sleep 2
141+
done
142+
./awl cli gateway list | grep -E 'awl-tester.*\[connected\]' # fail if never became available
143+
144+
# Probe the egress IP via https://ifconfig.me
145+
curl4() { curl -4 -s --max-time 20 --retry 3 --retry-delay 2 https://ifconfig.me/; }
146+
147+
IP_DIRECT=$(curl4); echo "egress IP (direct): $IP_DIRECT"
148+
149+
./awl cli gateway client use --name awl-tester
150+
./awl cli gateway status
151+
RULE_ON=$(ip rule show); ROUTE_ON=$(ip route show)
152+
# Tolerate a failed curl here so we always reach `client stop` below
153+
# (a broken tunnel must not leave the runner black-holed); the
154+
# `test -n "$IP_GW"` assertion after teardown turns it into a clean failure.
155+
IP_GW=$(curl4 || true); echo "egress IP (via gateway): $IP_GW"
156+
157+
./awl cli gateway client stop
158+
RULE_OFF=$(ip rule show); ROUTE_OFF=$(ip route show)
159+
IP_REVERTED=$(curl4); echo "egress IP (reverted): $IP_REVERTED"
160+
161+
# Assertions run with the gateway already disabled, so a failure here
162+
# can't leave the runner's egress black-holed.
163+
echo "$RULE_ON" | grep -q fwmark # client policy route installed
164+
echo "$ROUTE_ON" | grep -q 'default dev awl0' # full-tunnel default via TUN
165+
test -n "$IP_GW" # traffic really flowed through the exit node
166+
test "$IP_GW" != "$IP_DIRECT" # egress changed => full-tunnel works
167+
test "$IP_REVERTED" = "$IP_DIRECT" # runtime disable reverted egress
168+
! echo "$RULE_OFF" | grep -q fwmark # policy route removed on disable
169+
! echo "$ROUTE_OFF" | grep -q 'default dev awl0' # TUN default removed on disable
170+
171+
# Re-enable so the SIGINT shutdown path (teardownGatewayAtShutdown) is exercised.
172+
./awl cli gateway client use --name awl-tester
173+
106174
sleep 1
107175
sudo kill -SIGINT $awl_pid
108-
sleep 1
176+
sleep 2
177+
# awl is gone: its TUN and the default route via it vanished with the
178+
# process, but the fwmark ip rule is interface-independent — its absence
179+
# proves the shutdown teardown ran. (NAT/ip_forward are server-side only.)
180+
! ip rule show | grep -q fwmark
109181
- name: Run librespeed-cli, awl, librespeed-cli through awl
110182
if: matrix.os == 'macos-latest'
111183
run: |

README.md

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
- [Linux server (`awl`)](#linux-server-awl)
2323
- [Connecting devices](#connecting-devices)
2424
- [Using devices as SOCKS5 proxy](#using-devices-as-socks5-proxy)
25+
- [VPN gateway (full-tunnel exit node)](#vpn-gateway-full-tunnel-exit-node)
2526
- [Configuration](#configuration)
2627
- [Config file location](#config-file-location)
2728
- [Example config](#example-config)
@@ -45,6 +46,7 @@ Some things people use it for:
4546
- SSH / RDP / VNC into your home or work laptop from anywhere, without port forwarding or exposing anything to the internet
4647
- reach selfhosted services (Nextcloud, Home Assistant, Bitwarden, ...) privately
4748
- route traffic through a remote device as a SOCKS5 proxy — useful for bypassing regional blocks
49+
- route *all* your traffic through a remote device at the IP layer — a full-tunnel VPN gateway / exit node
4850
- LAN-style multiplayer gaming across the internet
4951
- keep an old Android phone accessible for apps that only run there (e.g. with [scrcpy](https://github.com/Genymobile/scrcpy))
5052

@@ -64,6 +66,7 @@ Tradeoffs worth knowing about:
6466
## Features
6567

6668
- fully peer-to-peer, no coordination server — see [Why Anywherelan](#why-anywherelan) above
69+
- route **all** your traffic through a device — full-tunnel VPN gateway / exit node
6770
- route traffic through a device as a SOCKS5 proxy
6871
- automatic NAT traversal via libp2p; falls back to community relays when a direct path isn't possible
6972
- TLS 1.3 encryption (QUIC or TCP+TLS)
@@ -114,7 +117,7 @@ awl's transport security comes from [libp2p](https://docs.libp2p.io/).
114117

115118
awl ships in two desktop flavors:
116119

117-
- **`awl-tray`** — desktop build with a system-tray indicator: status at a glance, start/stop/restart, peer list. Use this for regular desktop usage.
120+
- **`awl-tray`** — desktop build with a system-tray indicator: status at a glance, start/stop/restart, peer list, and quick exit-node selection (SOCKS5 proxy / VPN gateway). Use this for regular desktop usage.
118121
- **`awl`** — headless server build, no GUI. Use this for servers and embedded devices.
119122

120123
Both share the same web UI and the same [CLI](#terminal-based-client).
@@ -231,6 +234,95 @@ On desktop you can also pick the active exit node from the web UI or the system
231234

232235
Traffic through a peer has no restrictions beyond the connection between the two of you — direct and relayed paths both work. You can reach the remote peer's LAN, but not the remote peer's `localhost`.
233236

237+
## VPN gateway (full-tunnel exit node)
238+
239+
awl can route **all** of your IPv4 traffic through a remote device at the IP layer — the same model as classic full-tunnel WireGuard/OpenVPN. The remote device becomes your exit node: your traffic reaches the internet from its IP, not yours.
240+
241+
### VPN gateway vs SOCKS5 proxy
242+
243+
awl has two independent ways to send your traffic through another device, and a device can offer either one without the other. The **SOCKS5 proxy** works per-application: you point a specific app (a browser, say) at awl's local proxy, and only that app's traffic goes through the peer — nothing on your system changes. The **VPN gateway** is system-wide: it routes *all* of your IPv4 traffic through the exit node at the IP layer, so every app is covered without configuring anything.
244+
245+
In short: reach for SOCKS5 to send a single app through a peer, and for the VPN gateway when you want the whole device to look like it's at the exit node.
246+
247+
### Status
248+
249+
| Platform | As client | As exit node | Notes |
250+
| --- | --- | --- | --- |
251+
| Linux ||| fully supported |
252+
| Android ||| exit-node role needs root — not planned |
253+
| Windows ||| coming next |
254+
| macOS ||| needs volunteers for testing |
255+
256+
On macOS and Windows awl currently refuses to start with VPN gateway enabled.
257+
258+
> ⚠️ **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:
259+
> - **Dual-stack (IPv4 + IPv6):** everything automatically uses IPv4 through the tunnel.
260+
> - **IPv6-only network:** you'll have no internet connectivity until you turn the gateway off.
261+
262+
> **Linux: host network changes mid-session aren't tracked.** If the host switches network (new Wi-Fi, Ethernet or cellular connection) while the gateway is on, restart awl. This will be fixed in a future release.
263+
264+
### Serve as an exit node
265+
266+
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).
267+
268+
**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**.
269+
270+
Turning the service on takes effect immediately — no restart. awl enables IP forwarding and sets up firewall rules that let that traffic out while keeping your own LAN private (see "Security and privacy notes" below); everything is reversed when you turn it off or shut awl down.
271+
272+
Once enabled, your permitted devices can pick this one as their exit node. It may take a few minutes to show up on their side — awl exchanges status with each device periodically (≤ 5 minutes) and on every reconnect.
273+
274+
> The "Allow as exit node" permission is shared with SOCKS5. If you need separate permissions for SOCKS5 vs the VPN gateway, [open an issue](https://github.com/anywherelan/awl/issues) and we'll split it.
275+
276+
#### Via the CLI
277+
278+
```bash
279+
# turn the gateway service on (server disable turns it off)
280+
awl cli gateway server enable
281+
# allow a specific device to use this one as an exit node
282+
awl cli peers allow_exit_node --name="peer-name" --allow=true
283+
```
284+
285+
### Use a remote device as your exit node (client side)
286+
287+
First make sure you've added the remote device and it has the gateway service enabled on its side (see above).
288+
289+
**Desktop (web UI):** open http://admin.awl; on the Overview page, find the **VPN Gateway** card on the right and pick the peer in the **Exit peer** dropdown. That's it — all your traffic now goes out through that device. Set it back to *None* to turn the gateway off. On `awl-tray` the same picker is in the tray menu, and on Android you use the gateway control in the app.
290+
291+
This takes effect immediately — no restart. Switching to a different peer in the dropdown atomically moves the gateway to the new device.
292+
293+
To confirm it's working, open a "what's my IP" site such as https://ifconfig.co — it should show the exit node's public IP, not yours.
294+
295+
#### Via the CLI
296+
297+
```bash
298+
# list peers available as exit nodes
299+
awl cli gateway list
300+
# route all traffic through a peer (or --pid=<peer-id>)
301+
awl cli gateway client use --name="peer-name"
302+
# show status: gateway peer, whether it's connected, its public IP and ping
303+
awl cli gateway status
304+
# stop using the gateway
305+
awl cli gateway client stop
306+
```
307+
308+
### Why serving as an exit node is opt-in
309+
310+
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 (Tailscale, ZeroTier, WireGuard, OpenVPN, …) keeps exit-node mode opt-in.
311+
312+
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" below). This toggle only governs the host-level networking changes above.
313+
314+
### Troubleshooting
315+
316+
- **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.
317+
- **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.
318+
- **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.
319+
320+
### Security and privacy notes
321+
322+
- **Your IP is exposed.** Once you serve as an exit node, the public IPs that those devices reach see your IP, not theirs.
323+
- **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+
- **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.
325+
234326
## Configuration
235327

236328
Awl stores all its state in a single JSON file called `config_awl.json`. The file is created automatically on the first launch and is rewritten by the application every time you change something through the web UI or CLI. You can also edit it by hand while awl is stopped.

api/api.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ type Handler struct {
3636
socks5 *service.SOCKS5
3737
dns DNSService
3838
logBuffer *ringbuffer.RingBuffer
39+
vpnGateway *service.VPNGateway
3940

4041
echo *echo.Echo
4142
echoAdmin *echo.Echo
@@ -45,7 +46,7 @@ type Handler struct {
4546
}
4647

4748
func NewHandler(conf *config.Config, p2p *p2p.P2p, authStatus *service.AuthStatus, tunnel *service.Tunnel, socks5 *service.SOCKS5,
48-
logBuffer *ringbuffer.RingBuffer, dns DNSService) *Handler {
49+
logBuffer *ringbuffer.RingBuffer, dns DNSService, vpnGateway *service.VPNGateway) *Handler {
4950
ctx, ctxCancel := context.WithCancel(context.Background())
5051
return &Handler{
5152
conf: conf,
@@ -55,6 +56,7 @@ func NewHandler(conf *config.Config, p2p *p2p.P2p, authStatus *service.AuthStatu
5556
socks5: socks5,
5657
dns: dns,
5758
logBuffer: logBuffer,
59+
vpnGateway: vpnGateway,
5860
logger: log.Logger("awl/api"),
5961
ctx: ctx,
6062
ctxCancel: ctxCancel,
@@ -137,6 +139,12 @@ func (h *Handler) setupRouter(address string) (*echo.Echo, error) {
137139
e.POST(UpdateProxySettingsPath, h.UpdateProxySettings)
138140
e.GET(ExportServerConfigPath, h.ExportServerConfiguration)
139141

142+
// VPN Gateway. Status comes from /settings/peer_info (PeerInfo.VPNGateway).
143+
e.POST(EnableVPNGatewayClientPath, h.EnableVPNGatewayClient)
144+
e.POST(DisableVPNGatewayClientPath, h.DisableVPNGatewayClient)
145+
e.POST(SetVPNGatewayServerEnabledPath, h.SetVPNGatewayServerEnabled)
146+
e.GET(ListAvailableVPNGatewaysPath, h.ListAvailableVPNGateways)
147+
140148
// Debug
141149
e.GET(GetP2pDebugInfoPath, h.GetP2pDebugInfo)
142150
e.GET(GetDebugLogPath, h.GetLog)
@@ -177,6 +185,9 @@ func (h *Handler) SetupFrontend(fsys fs.FS) {
177185
}
178186

179187
func (h *Handler) Shutdown(ctx context.Context) error {
188+
if h.ctxCancel != nil {
189+
h.ctxCancel()
190+
}
180191
if h.echoAdmin != nil {
181192
err := h.echoAdmin.Server.Shutdown(ctx)
182193
if err != nil {

api/apiclient/client.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,30 @@ func (c *Client) UpdateMySettings(name string) error {
152152
return c.sendPostRequest(api.UpdateMyInfoPath, request, nil)
153153
}
154154

155+
func (c *Client) EnableVPNGatewayClient(gatewayPeerID string) error {
156+
request := entity.EnableVPNGatewayClientRequest{
157+
GatewayPeerID: gatewayPeerID,
158+
}
159+
return c.sendPostRequest(api.EnableVPNGatewayClientPath, request, nil)
160+
}
161+
162+
func (c *Client) DisableVPNGatewayClient() error {
163+
return c.sendPostRequest(api.DisableVPNGatewayClientPath, nil, nil)
164+
}
165+
166+
func (c *Client) SetVPNGatewayServerEnabled(enabled bool) error {
167+
return c.sendPostRequest(api.SetVPNGatewayServerEnabledPath, entity.SetVPNGatewayServerEnabledRequest{Enabled: enabled}, nil)
168+
}
169+
170+
func (c *Client) ListAvailableVPNGateways() ([]entity.AvailableVPNGateway, error) {
171+
resp := entity.ListAvailableVPNGatewaysResponse{}
172+
err := c.sendGetRequest(api.ListAvailableVPNGatewaysPath, &resp)
173+
if err != nil {
174+
return nil, err
175+
}
176+
return resp.VPNGateways, nil
177+
}
178+
155179
func (c *Client) P2pDebugInfo() (*entity.P2pDebugInfo, error) {
156180
debugInfo := new(entity.P2pDebugInfo)
157181
err := c.sendGetRequest(api.GetP2pDebugInfoPath, debugInfo)

api/const.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ const (
2222
UpdateProxySettingsPath = V0Prefix + "settings/set_proxy"
2323
ExportServerConfigPath = V0Prefix + "settings/export_server_config"
2424

25+
// VPN Gateway
26+
EnableVPNGatewayClientPath = V0Prefix + "vpn_gateway/client/enable"
27+
DisableVPNGatewayClientPath = V0Prefix + "vpn_gateway/client/disable"
28+
ListAvailableVPNGatewaysPath = V0Prefix + "vpn_gateway/client/list_available"
29+
SetVPNGatewayServerEnabledPath = V0Prefix + "vpn_gateway/server/set_enabled"
30+
2531
// Debug
2632
GetP2pDebugInfoPath = V0Prefix + "debug/p2p_info"
2733
GetDebugLogPath = V0Prefix + "debug/log"

0 commit comments

Comments
 (0)