Skip to content

Commit eada93f

Browse files
committed
vpn gateway: track routes changes on Linux
Route-staleness monitor (routes_linux.go): subscribe to netlink route changes and re-sync the tableID exemption copies (v4 and v6) with the live host default(s) on DHCP renew / Wi-Fi<->Ethernet roam / new RA. Debounced (500ms) reconcile diffs by route key; it never touches the TUN default or the IPv6 unreachable fence, so there is no leak window even mid-uplink-change. The monitor re-subscribes with backoff on a mid-session netlink socket death, using a per-subscription cancel channel so a dead socket + its watcher goroutine are released immediately rather than leaked. RouteState gains a mutex + monitor lifecycle; teardown stops the monitor first. Adds vpn_hostnet integration tests (v4 + v6 reconcile) with goleak checks. Drop the conf lock on the inbound hot path: writeInboundBatch takes *VpnPeer and reads vp.weAllowUsingAsExitNode (atomic, synced by RefreshPeersList) instead of a per-batch conf.GetPeer RLock + peer.ID.String() alloc. Forward-without-permission is still dropped before the TUN write. Gateway connectivity event: new awlevent.VPNGatewayConnectivityChanged, emitted from Tunnel on gateway-peer connect/disconnect with edge dedup and an initial state on enable. awl-tray shows a desktop notification on up/down. Refuse removing a peer that is the active VPN gateway (409) instead of black-holing all non-local traffic; the user must disable the gateway first.
1 parent a489caa commit eada93f

11 files changed

Lines changed: 647 additions & 84 deletions

File tree

README.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -259,8 +259,6 @@ On macOS and Windows awl currently refuses to start with VPN gateway enabled.
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
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-
264262
### Serve as an exit node
265263

266264
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).

api/peers.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,7 @@ func (h *Handler) GetAuthRequests(c echo.Context) (err error) {
280280
// @Success 200 "OK"
281281
// @Failure 400 {object} api.Error
282282
// @Failure 404 {object} api.Error
283+
// @Failure 409 {object} api.Error
283284
// @Router /peers/remove [POST]
284285
func (h *Handler) RemovePeer(c echo.Context) (err error) {
285286
req := entity.PeerIDRequest{}
@@ -296,6 +297,18 @@ func (h *Handler) RemovePeer(c echo.Context) (err error) {
296297
ErrorMessage("Invalid hex-encoded multihash representing of a peer ID"))
297298
}
298299

300+
// Refuse to remove the peer while it is the active VPN gateway: doing so
301+
// would leave the TUN default route / DNS installed with nothing bound and
302+
// black-hole all non-local traffic. The user must disable gateway client
303+
// mode first (an explicit, reversible action).
304+
h.conf.RLock()
305+
isActiveGateway := h.conf.VPNGateway.ClientEnabled && h.conf.VPNGateway.GatewayPeerID == req.PeerID
306+
h.conf.RUnlock()
307+
if isActiveGateway {
308+
return c.JSON(http.StatusConflict,
309+
ErrorMessage("disable VPN gateway before removing this peer"))
310+
}
311+
299312
knownPeer, exists := h.conf.RemovePeer(req.PeerID)
300313
if !exists {
301314
return c.JSON(http.StatusNotFound, ErrorMessage("peer not found"))

application.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ func (a *Application) Init(ctx context.Context, tunDevice tun.Device) error {
135135
}
136136
a.logger.Infof("VPN interface created. Name: %s CIDR: %s", interfaceName, &net.IPNet{IP: localIP, Mask: netMask})
137137

138-
a.Tunnel = service.NewTunnel(a.P2p, a.vpnDevice, a.Conf)
138+
a.Tunnel = service.NewTunnel(a.P2p, a.vpnDevice, a.Conf, a.Eventbus)
139139
go a.vpnDevice.ReadTUNPackets(a.Tunnel.HandleReadPackets)
140140
}
141141

application_gateway_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,12 +187,17 @@ func TestGatewayPermissionDenied(t *testing.T) {
187187
// Defence-in-depth on the exit-node side: silently flip WeAllow without
188188
// going through the API (so the change does not propagate back to the
189189
// client yet) and verify writeInboundBatch drops gateway-style packets.
190+
// The hot-path permission check reads VpnPeer.weAllowUsingAsExitNode, an
191+
// atomic kept in sync by RefreshPeersList; in production a settings change
192+
// emits KnownPeerChanged which triggers it, so we call it directly here to
193+
// mirror that local sync (the revocation still does not reach the client).
190194
t.Run("DefenceInDepthDropsAtExitNode", func(t *testing.T) {
191195
exitNode.app.Conf.Lock()
192196
clientPeer := exitNode.app.Conf.KnownPeers[client.PeerID()]
193197
clientPeer.WeAllowUsingAsExitNode = false
194198
exitNode.app.Conf.KnownPeers[client.PeerID()] = clientPeer
195199
exitNode.app.Conf.Unlock()
200+
exitNode.app.Tunnel.RefreshPeersList()
196201

197202
captureInbound(exitNode, 10)
198203

@@ -988,6 +993,41 @@ func TestGatewayRebindOnPeerReadd(t *testing.T) {
988993
ts.True(ok, "gateway should flow again after the peer is re-added")
989994
}
990995

996+
// TestGatewayPeerRemovalRefusedWhileActive verifies the API refuses to remove
997+
// the configured gateway peer while client mode is ACTIVE. Allowing the removal
998+
// would leave the OS routes/DNS installed with nothing bound and black-hole all
999+
// non-local traffic until restart, so RemovePeer returns 409 and the user must
1000+
// disable the gateway first. The peer and its gateway binding stay intact.
1001+
func TestGatewayPeerRemovalRefusedWhileActive(t *testing.T) {
1002+
skipIfVPNGatewayUnsupported(t)
1003+
ts := NewTestSuite(t)
1004+
client, exitNode, _ := setupGatewayPeers(ts)
1005+
1006+
// Enable via the API so VPNGateway applies (stubbed) routes + config.
1007+
ts.NoError(client.api.EnableVPNGatewayClient(exitNode.PeerID()))
1008+
ts.True(client.app.VPNGateway.IsClientActive(), "precondition: client routes installed")
1009+
1010+
// Removing the active gateway peer must be rejected.
1011+
err := client.api.RemovePeer(exitNode.PeerID())
1012+
ts.Error(err, "removing the active gateway peer must be refused")
1013+
ts.Contains(err.Error(), "disable VPN gateway")
1014+
1015+
// The peer is still known and the gateway is still active and persisted.
1016+
_, ok := client.app.Conf.GetPeer(exitNode.PeerID())
1017+
ts.True(ok, "the gateway peer must remain in KnownPeers after a refused removal")
1018+
ts.True(client.app.VPNGateway.IsClientActive(), "gateway client mode must stay active")
1019+
client.app.Conf.RLock()
1020+
ts.True(client.app.Conf.VPNGateway.ClientEnabled, "ClientEnabled must stay persisted")
1021+
client.app.Conf.RUnlock()
1022+
1023+
// After disabling the gateway, removal succeeds.
1024+
ts.NoError(client.api.DisableVPNGatewayClient())
1025+
ts.NoError(client.api.RemovePeer(exitNode.PeerID()),
1026+
"removal must succeed once the gateway is disabled")
1027+
_, ok = client.app.Conf.GetPeer(exitNode.PeerID())
1028+
ts.False(ok, "the peer must be gone after removal")
1029+
}
1030+
9911031
// TestGatewayListAvailableGatewaysFiltersToVPNGatewayOnly verifies that
9921032
// /gateway/list_available returns only peers where
9931033
// KnownPeer.CanUseAsVPNGateway() is true. SOCKS5-only exit nodes

awlevent/awlevent.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,16 @@ type ReceivedAuthRequest struct {
1818
PeerID string
1919
}
2020

21+
// VPNGatewayConnectivityChanged is emitted (client mode only) when the
22+
// connection to the configured VPN gateway peer goes up or down, so the UI /
23+
// tray can reflect gateway reachability immediately instead of waiting for the
24+
// next GatewayInfo poll. GatewayInfo.Connected stays the canonical state; this
25+
// event is a low-latency edge signal, deduplicated to fire only on transitions.
26+
type VPNGatewayConnectivityChanged struct {
27+
Connected bool
28+
PeerID string
29+
}
30+
2131
func WrapSubscriptionToCallback(ctx context.Context, callback func(interface{}), bus Bus,
2232
eventType interface{}, opts ...event.SubscriptionOpt) {
2333
sub, err := bus.Subscribe(eventType, opts...)

cmd/awl-tray/main.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,28 @@ func subscribeToNotifications(app *awl.Application) {
169169
logger.Errorf("show notification: incoming friend request: %v", notifyErr)
170170
}
171171
}, app.Eventbus, new(awlevent.ReceivedAuthRequest))
172+
173+
// VPN gateway reachability: notify the user when the connection to their
174+
// selected VPN gateway peer goes up or down.
175+
awlevent.WrapSubscriptionToCallback(app.Ctx(), func(evt interface{}) {
176+
change := evt.(awlevent.VPNGatewayConnectivityChanged)
177+
peerName := change.PeerID
178+
if kp, ok := app.Conf.GetPeer(change.PeerID); ok {
179+
peerName = kp.DisplayName()
180+
}
181+
var title, body string
182+
if change.Connected {
183+
title = "Anywherelan: VPN gateway connected"
184+
body = fmt.Sprintf("Connected to: %s", peerName)
185+
} else {
186+
title = "Anywherelan: VPN gateway disconnected"
187+
body = fmt.Sprintf("Lost connection to: %s", peerName)
188+
}
189+
notifyErr := beeep.Notify(title, body, embeds.GetIconPath())
190+
if notifyErr != nil {
191+
logger.Errorf("show notification: vpn gateway connectivity: %v", notifyErr)
192+
}
193+
}, app.Eventbus, new(awlevent.VPNGatewayConnectivityChanged))
172194
}
173195

174196
func openWebGUI(a *awl.Application) error {

docs/swagger.yaml

Lines changed: 41 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,7 @@ definitions:
270270
gatewayPeerID:
271271
type: string
272272
required:
273-
- gatewayPeerID
273+
- gatewayPeerID
274274
type: object
275275
entity.FriendRequest:
276276
properties:
@@ -814,6 +814,10 @@ paths:
814814
description: Not Found
815815
schema:
816816
$ref: '#/definitions/api.Error'
817+
"409":
818+
description: Conflict
819+
schema:
820+
$ref: '#/definitions/api.Error'
817821
summary: Remove known peer
818822
tags:
819823
- Peers
@@ -857,13 +861,13 @@ paths:
857861
$ref: '#/definitions/github_com_anywherelan_awl_config.Config'
858862
summary: Export server configuration
859863
tags:
860-
- Settings
864+
- Settings
861865
/settings/list_proxies:
862866
get:
863867
consumes:
864-
- application/json
868+
- application/json
865869
produces:
866-
- application/json
870+
- application/json
867871
responses:
868872
"200":
869873
description: OK
@@ -885,20 +889,20 @@ paths:
885889
$ref: '#/definitions/entity.PeerInfo'
886890
summary: Get my peer info
887891
tags:
888-
- Settings
892+
- Settings
889893
/settings/set_proxy:
890894
post:
891895
consumes:
892-
- application/json
896+
- application/json
893897
parameters:
894-
- description: Params
895-
in: body
896-
name: body
897-
required: true
898-
schema:
899-
$ref: '#/definitions/entity.UpdateProxySettingsRequest'
898+
- description: Params
899+
in: body
900+
name: body
901+
required: true
902+
schema:
903+
$ref: '#/definitions/entity.UpdateProxySettingsRequest'
900904
produces:
901-
- application/json
905+
- application/json
902906
responses:
903907
"200":
904908
description: OK
@@ -927,65 +931,65 @@ paths:
927931
/vpn_gateway/client/disable:
928932
post:
929933
consumes:
930-
- application/json
934+
- application/json
931935
produces:
932-
- application/json
936+
- application/json
933937
responses:
934938
"200":
935939
description: OK
936940
summary: Disable VPN gateway client mode
937941
tags:
938-
- VPN Gateway
942+
- VPN Gateway
939943
/vpn_gateway/client/enable:
940944
post:
941945
consumes:
942-
- application/json
946+
- application/json
943947
parameters:
944-
- description: Params
945-
in: body
946-
name: body
947-
required: true
948-
schema:
949-
$ref: '#/definitions/entity.EnableVPNGatewayClientRequest'
948+
- description: Params
949+
in: body
950+
name: body
951+
required: true
952+
schema:
953+
$ref: '#/definitions/entity.EnableVPNGatewayClientRequest'
950954
produces:
951-
- application/json
955+
- application/json
952956
responses:
953957
"200":
954958
description: OK
955959
summary: Enable VPN gateway client mode
956960
tags:
957-
- VPN Gateway
961+
- VPN Gateway
958962
/vpn_gateway/client/list_available:
959963
get:
960964
consumes:
961-
- application/json
965+
- application/json
962966
produces:
963-
- application/json
967+
- application/json
964968
responses:
965969
"200":
966970
description: OK
967971
schema:
968972
$ref: '#/definitions/entity.ListAvailableVPNGatewaysResponse'
969973
summary: List available VPN gateways
970974
tags:
971-
- VPN Gateway
975+
- VPN Gateway
972976
/vpn_gateway/server/set_enabled:
973977
post:
974978
consumes:
975-
- application/json
979+
- application/json
976980
parameters:
977-
- description: Params
978-
in: body
979-
name: body
980-
required: true
981-
schema:
982-
$ref: '#/definitions/entity.SetVPNGatewayServerEnabledRequest'
981+
- description: Params
982+
in: body
983+
name: body
984+
required: true
985+
schema:
986+
$ref: '#/definitions/entity.SetVPNGatewayServerEnabledRequest'
983987
produces:
984-
- application/json
988+
- application/json
985989
responses:
986990
"200":
987991
description: OK
988992
summary: Toggle VPN gateway server mode
989993
tags:
990-
- VPN Gateway
994+
- VPN Gateway
991995
swagger: "2.0"

service/auth_status.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,10 @@ func (s *AuthStatus) createPeerInfo(peer config.KnownPeer, myPeerName string, de
180180
// the peer is no longer known.
181181
func (s *AuthStatus) processPeerStatusInfo(peerID string, peerInfo protocol.PeerStatusInfo) {
182182
if peerInfo.Declined {
183+
// TODO: emit an awlevent (analogous to VPNGatewayConnectivityChanged) when
184+
// a peer explicitly declines us, so the UI can surface it immediately. The
185+
// peer stays in KnownPeers as not-confirmed (including if it was our VPN
186+
// gateway — we keep the binding even though it will no longer forward).
183187
s.conf.UpdatePeerFields(peerID, func(peer *config.KnownPeer) {
184188
peer.LastSeen = time.Now()
185189
peer.Declined = true
@@ -305,11 +309,9 @@ func (s *AuthStatus) SendAuthRequest(ctx context.Context, peerID peer.ID, req pr
305309
s.authsLock.Unlock()
306310
}
307311
if authResponse.Declined {
308-
knownPeer, exists := s.conf.GetPeer(peerID.String())
309-
if exists {
310-
knownPeer.Declined = true
311-
s.conf.UpsertPeer(knownPeer)
312-
}
312+
s.conf.UpdatePeerFields(peerID.String(), func(peer *config.KnownPeer) {
313+
peer.Declined = true
314+
})
313315
}
314316

315317
s.logger.Infof("Successfully send auth to %s", peerID)

0 commit comments

Comments
 (0)