Skip to content

Commit e3dbec9

Browse files
committed
WIP 8 vpn: add gateway support
1 parent 55f112e commit e3dbec9

4 files changed

Lines changed: 141 additions & 36 deletions

File tree

README.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,74 @@ On desktop you can also pick the active exit node from the web UI or the system
231231

232232
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`.
233233

234+
## VPN gateway (full-tunnel exit node)
235+
236+
In addition to the per-application SOCKS5 proxy, awl can route **all** of your IPv4 traffic through a peer at the IP layer — same model as Tailscale exit nodes or a classic full-tunnel WireGuard/OpenVPN. This is a separate feature from SOCKS5: a peer can allow being used for SOCKS5 without serving as a VPN gateway, and vice versa.
237+
238+
### Status
239+
240+
- **Linux:** supported (this is the platform we have tested on).
241+
- **Android:** client mode is supported via the awl Android app (uses `VpnService` for routing). Serving as an exit node from Android is **not** supported.
242+
- **macOS / Windows / other:** **not supported yet** — awl will refuse to start with VPN gateway enabled. Windows-side code exists but is unfinished; see `vpn/sockmark/sockmark_windows.go` and `vpn/routes/nat_windows.go` for the open work items.
243+
- IPv6 traffic is not tunnelled through the gateway in either direction (it is forwarded as a regular awl peer packet instead).
244+
- Both client and exit-node sides require **CAP_NET_ADMIN** on Linux. AWL needs that already to bring up the TUN, so there is no extra capability to grant.
245+
246+
### Use a friend as your exit node (client side)
247+
248+
1. Make sure you and the peer are friends and the peer has VPN gateway service enabled on their side (see "Serve as an exit node" below). The CLI exposes the candidates:
249+
```bash
250+
awl cli gateway list
251+
```
252+
2. Enable gateway client mode:
253+
```bash
254+
awl cli gateway client use --name="peer-name"
255+
# or: awl cli gateway client use --pid=<peer-id>
256+
```
257+
Calling `client use` again with a different peer atomically switches to the new gateway.
258+
3. **Restart `awl`** for the change to take effect. The OS-level routes and the libp2p socket marking that prevent the gateway from looping back through itself are wired up only at startup.
259+
4. Verify:
260+
```bash
261+
awl cli gateway status
262+
curl ifconfig.me # should show the exit node's public IP
263+
```
264+
265+
To turn the gateway off:
266+
267+
```bash
268+
awl cli gateway client stop
269+
# then restart awl to remove the OS-level routes
270+
```
271+
272+
You can also flip these settings from the web UI / system-tray menu, or by editing `config_awl.json` directly while awl is stopped (`gateway.enabled`, `gateway.exitNodePeerID`).
273+
274+
### Serve as an exit node
275+
276+
To allow others to route their internet traffic through this device:
277+
278+
1. Set `gateway.serveAsVPNGateway: true` in `config_awl.json` (it is **off by default** — see "Why off by default" below) and restart awl. Equivalent: `awl cli gateway server enable` (and `awl cli gateway server disable` to turn it back off).
279+
2. For each friend you want to permit, also tick *Allow as exit node* in their per-peer settings (or `awl cli peers allow_exit_node --name=… --allow=true`). The same flag governs SOCKS5; if you want different per-peer policies for SOCKS5 vs VPN gateway, file a request and we'll split it.
280+
3. Restart awl. On startup AWL will:
281+
- flip `net.ipv4.ip_forward` on,
282+
- install a dedicated `AWL-FORWARD` iptables chain that DROPs traffic to your LAN/CGNAT/link-local subnets and ACCEPTs the rest,
283+
- add a `MASQUERADE` rule for the awl subnet,
284+
- reverse all of the above on a clean shutdown.
285+
4. Connected friends can now pick you in their `gateway list` output once the next status exchange propagates the new state (≤ 5 minutes, or on next reconnect).
286+
287+
### Why off by default
288+
289+
Every mainstream VPN solution we surveyed — Tailscale, Headscale, ZeroTier, Nebula, WireGuard, OpenVPN, strongSwan — makes accepting other peers' internet traffic strictly opt-in, and AWL follows the same convention. The reasons are the same here:
290+
291+
- **Trust:** any friend in `KnownPeers` whom you have allowed will appear as your IP in destination logs, takedown notices, and abuse reports. That is a load-bearing trust position; the user should sign up for it explicitly, not inherit it from a default.
292+
- **Host side-effects:** flipping `net.ipv4.ip_forward` and installing iptables rules is global system state, not something AWL can sandbox. We don't want a routine awl install to silently change those.
293+
- **Conntrack/CPU cost:** the exit node sees every connection from every gateway client through `nf_conntrack`. On low-end hardware the load is non-trivial.
294+
295+
### Security and privacy notes
296+
297+
- **Your IP is exposed.** Once you serve as an exit node, the public IPs of your friends' destinations see your IP, not theirs.
298+
- **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.
299+
- **DNS:** in client gateway mode AWL forces upstream DNS to a public resolver to prevent the LAN resolver from leaking queries past the tunnel.
300+
- **No mutual auth at the IP layer.** Every gateway client's traffic is masqueraded out from the same NIC; you cannot per-flow attribute traffic back to a specific peer at the kernel level. The exit node only filters by `WeAllowUsingAsExitNode` at the awl protocol entry, so revoke aggressively if a peer abuses it.
301+
234302
## Configuration
235303

236304
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/vpn_gateway.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ func (h *Handler) SetVPNGatewayServerEnabled(c echo.Context) error {
7474
return c.JSON(http.StatusInternalServerError, ErrorMessage(err.Error()))
7575
}
7676

77+
go func() {
78+
h.authStatus.ExchangeStatusInfoWithAllKnownPeers(h.ctx)
79+
}()
80+
7781
return c.NoContent(http.StatusOK)
7882
}
7983

cmd/awl-tray/tray.go

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,13 @@ var (
2828
openBrowserMenu *systray.MenuItem
2929
peersMenu *systray.MenuItem
3030
proxyMenu *systray.MenuItem
31-
gatewayMenu *systray.MenuItem // nil when service.VPNGatewaySupported() != nil
31+
gatewayMenu *systray.MenuItem // nil when service.VPNGatewayClientSupported() != nil
3232
startStopMenu *systray.MenuItem
3333
restartMenu *systray.MenuItem
3434
updateMenu *systray.MenuItem
3535

3636
proxyRouting *routingMenu
37-
gatewayRouting *routingMenu // .root is nil when service.VPNGatewaySupported() != nil
37+
gatewayRouting *routingMenu // .root is nil when service.VPNGatewayClientSupported() != nil
3838
)
3939

4040
const updateMenuLabel = "Check for updates"
@@ -126,12 +126,11 @@ func initTray() {
126126
}
127127
}()
128128

129-
if service.VPNGatewaySupported() == nil {
129+
if service.VPNGatewayClientSupported() == nil {
130130
gatewayMenu = systray.AddMenuItem("VPN Gateway", "")
131-
gatewayRouting = newRoutingMenu(gatewayMenu, routingMenuConfig{
131+
cfg := routingMenuConfig{
132132
noneLabel: "None (disabled)",
133133
emptyLabel: `No VPN gateways available — set "Allow as exit node" on a peer`,
134-
serveLabel: "Serve as VPN gateway",
135134
listPeers: listGatewayPeers,
136135
currentSelection: currentGatewaySelection,
137136
selectPeer: func(peerIDStr string) error {
@@ -144,15 +143,22 @@ func initTray() {
144143
disable: func() {
145144
app.VPNGateway.DisableClient()
146145
},
147-
serveCurrent: func() bool {
146+
}
147+
// Only expose the "Serve as VPN gateway" toggle on platforms where
148+
// server mode actually runs. Empty serveLabel makes routingMenu skip
149+
// the toggle entirely.
150+
if service.VPNGatewayServerSupported() == nil {
151+
cfg.serveLabel = "Serve as VPN gateway"
152+
cfg.serveCurrent = func() bool {
148153
app.Conf.RLock()
149154
defer app.Conf.RUnlock()
150155
return app.Conf.VPNGateway.ServerEnabled
151-
},
152-
serveSet: func(b bool) error {
156+
}
157+
cfg.serveSet = func(b bool) error {
153158
return app.VPNGateway.SetServerEnabled(b)
154-
},
155-
})
159+
}
160+
}
161+
gatewayRouting = newRoutingMenu(gatewayMenu, cfg)
156162

157163
go func() {
158164
// On windows systray does not trigger clicked event on menus with submenus

service/vpn_gateway.go

Lines changed: 53 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -71,28 +71,43 @@ func NewVPNGateway(conf *config.Config, tunnel *Tunnel, device *vpn.Device, p2p
7171
}
7272
}
7373

74-
// VPNGatewaySupported reports whether VPN gateway mode can run on this OS/build.
75-
// Returns nil when supported, or a descriptive error explaining why not. The
76-
// check is OS-only and does not depend on any VPNGateway state, so it is safe
77-
// to call before NewVPNGateway (e.g. from awl-tray during menu construction).
78-
//
79-
// Currently only Linux is fully supported. macOS lacks NAT/route glue (the
80-
// vpn/routes/*_other.go implementations return errors), and Windows has
81-
// partially-written code (see TODOs in vpn/sockmark/sockmark_windows.go
82-
// and vpn/routes/nat_windows.go) but is not safe to enable until NAT and
83-
// DNS-leak protection are finished.
84-
func VPNGatewaySupported() error {
74+
// VPNGatewayClientSupported reports whether client-side VPN gateway mode can
75+
// run on this OS/build. Linux has the full implementation; Android can run as
76+
// a client but only via the Android host's VpnService.Builder, so runtime API
77+
// changes only mutate the persisted config and take effect on next startup
78+
// (see EnableClient / DisableClient).
79+
func VPNGatewayClientSupported() error {
8580
switch runtime.GOOS {
86-
case "linux":
81+
case "linux", "android":
8782
return nil
8883
case "windows":
89-
return fmt.Errorf("VPN gateway mode is not yet supported on Windows; " +
84+
return fmt.Errorf("VPN gateway client mode is not yet supported on Windows; " +
9085
"see vpn/sockmark/sockmark_windows.go for outstanding work")
9186
case "darwin":
92-
return fmt.Errorf("VPN gateway mode is not yet supported on macOS")
87+
return fmt.Errorf("VPN gateway client mode is not yet supported on macOS")
9388
default:
94-
return fmt.Errorf("VPN gateway mode is not supported on %s", runtime.GOOS)
89+
return fmt.Errorf("VPN gateway client mode is not supported on %s", runtime.GOOS)
90+
}
91+
}
92+
93+
// VPNGatewayServerSupported reports whether server-side VPN gateway mode can
94+
// run on this OS/build. Currently only Linux: Android exit-node support
95+
// requires root or special system config, macOS lacks NAT/route glue, and the
96+
// Windows path (vpn/routes/nat_windows.go) is not yet safe to enable.
97+
func VPNGatewayServerSupported() error {
98+
if runtime.GOOS == "linux" {
99+
return nil
95100
}
101+
return fmt.Errorf("VPN gateway server mode is not supported on %s", runtime.GOOS)
102+
}
103+
104+
// VPNGatewaySupported reports whether the full VPN gateway feature set (both
105+
// client and server) can run on this OS/build. Equivalent to
106+
// VPNGatewayServerSupported — server is the strictly stronger requirement.
107+
// Kept as a convenience for callers (awl-tray menu construction) that gate the
108+
// whole feature UI on full support.
109+
func VPNGatewaySupported() error {
110+
return VPNGatewayServerSupported()
96111
}
97112

98113
// ListAvailableVPNGateways returns peers that are currently a valid VPN
@@ -129,8 +144,18 @@ func (g *VPNGateway) ListAvailableVPNGateways() []entity.AvailableVPNGateway {
129144

130145
// EnableClient turns on VPN gateway client mode using the given peer as the
131146
// gateway, applying OS-level routes immediately. Atomic: rolls back the
132-
// tunnel binding (and the persisted config) on apply failure.
147+
// tunnel binding on apply failure.
148+
//
149+
// On android the OS-level apply (routes.SetupGatewayRoutes / sockmark) is a
150+
// no-op — routing is owned by the host's VpnService.Builder, which is only
151+
// rebuilt at daemon (re)start. Runtime calls still flip the in-memory tunnel
152+
// binding and persist config, but a restart is required for the change to
153+
// actually affect OS traffic; the UI is responsible for prompting that
154+
// restart.
133155
func (g *VPNGateway) EnableClient(gatewayPeerID peer.ID) error {
156+
if err := VPNGatewayClientSupported(); err != nil {
157+
return err
158+
}
134159
if g.tunnel == nil {
135160
return fmt.Errorf("VPN interface is disabled, cannot enable gateway")
136161
}
@@ -169,6 +194,9 @@ func (g *VPNGateway) DisableClient() {
169194
// the config directly.
170195
func (g *VPNGateway) SetServerEnabled(enabled bool) error {
171196
if enabled {
197+
if err := VPNGatewayServerSupported(); err != nil {
198+
return err
199+
}
172200
if err := g.applyServer(); err != nil {
173201
return err
174202
}
@@ -201,20 +229,19 @@ func (g *VPNGateway) SetupAtStartup() error {
201229
gw := g.conf.VPNGateway
202230
g.conf.RUnlock()
203231

204-
if err := VPNGatewaySupported(); err != nil {
205-
if gw.ClientEnabled || gw.ServerEnabled {
206-
g.logger.Errorf("VPN gateway not enabled at startup: %v", err)
207-
}
208-
return nil
209-
}
210-
211232
if gw.ServerEnabled {
212-
if err := g.SetServerEnabled(true); err != nil {
233+
if err := VPNGatewayServerSupported(); err != nil {
234+
g.logger.Errorf("VPN gateway server not enabled at startup: %v", err)
235+
} else if err := g.SetServerEnabled(true); err != nil {
213236
return fmt.Errorf("couldn't enable VPN gateway server at startup: %v", err)
214237
}
215238
}
216239

217240
if gw.ClientEnabled && gw.GatewayPeerID != "" {
241+
if err := VPNGatewayClientSupported(); err != nil {
242+
g.logger.Errorf("VPN gateway client not enabled at startup: %v", err)
243+
return nil
244+
}
218245
gatewayPeerID, err := peer.Decode(gw.GatewayPeerID)
219246
if err != nil {
220247
g.logger.Errorf("failed to decode gateway peer id, disabling VPN gateway client and continue startup: %v", err)
@@ -278,7 +305,7 @@ func (g *VPNGateway) applyServer() error {
278305
if g.serverNATState != nil {
279306
return nil
280307
}
281-
if err := VPNGatewaySupported(); err != nil {
308+
if err := VPNGatewayServerSupported(); err != nil {
282309
return err
283310
}
284311
if g.device == nil {
@@ -332,7 +359,7 @@ func (g *VPNGateway) applyClient() error {
332359
g.mu.Lock()
333360
defer g.mu.Unlock()
334361

335-
if err := VPNGatewaySupported(); err != nil {
362+
if err := VPNGatewayClientSupported(); err != nil {
336363
return err
337364
}
338365
if g.tunnel == nil {

0 commit comments

Comments
 (0)