forked from anywherelan/awl
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapplication_gateway_test.go
More file actions
1177 lines (982 loc) · 46.7 KB
/
Copy pathapplication_gateway_test.go
File metadata and controls
1177 lines (982 loc) · 46.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package awl
import (
"context"
"testing"
"time"
"github.com/anywherelan/awl/config"
"github.com/anywherelan/awl/entity"
"github.com/anywherelan/awl/service"
)
const (
gatewayTestPacketSize = 500
internetIP = "8.8.8.8"
)
// skipIfVPNGatewayUnsupported skips tests that drive the VPN gateway runtime API
// (client/server enable) or its startup wiring. The feature is implemented only
// on Linux; on Windows and macOS the API returns an error and startup wiring is
// a no-op, so these tests don't apply there. service.VPNGatewaySupported is the
// single source of truth for platform support.
func skipIfVPNGatewayUnsupported(t *testing.T) {
t.Helper()
if err := service.VPNGatewaySupported(); err != nil {
t.Skipf("VPN gateway is only supported on Linux: %v", err)
}
}
// setupGatewayPeers creates two peers that are friends and configures them for gateway mode.
// peer1 = gateway client, peer2 = exit node.
// Returns the peers and peer1's assigned IP in peer2's config.
func setupGatewayPeers(ts *TestSuite) (client, exitNode TestPeer, clientAssignedIP string) {
client = ts.NewTestPeer(true)
exitNode = ts.NewTestPeer(true)
// Configure exit node directly on tunnel (bypasses OS-level NAT setup)
exitNode.app.Tunnel.SetVPNGatewayServerEnabled(true)
ts.makeFriends(client, exitNode)
clientAssignedIP = grantExitNodePermission(ts, exitNode, client)
// Enable gateway on client tunnel
ts.NoError(client.app.Tunnel.SetVPNGatewayPeer(exitNode.app.P2p.PeerID()))
return client, exitNode, clientAssignedIP
}
// grantExitNodePermission has host grant client AllowUsingAsExitNode through the
// API and waits for the flag to propagate to client's KnownPeer entry.
// Returns client's assigned IP in host's config.
func grantExitNodePermission(ts *TestSuite, host, client TestPeer) string {
clientCfg, err := host.api.KnownPeerConfig(client.PeerID())
ts.NoError(err)
ts.NoError(host.api.UpdatePeerSettings(entity.UpdatePeerSettingsRequest{
PeerID: client.PeerID(),
Alias: clientCfg.Alias,
DomainName: clientCfg.DomainName,
IPAddr: clientCfg.IPAddr,
AllowUsingAsExitNode: true,
}))
ts.Eventually(func() bool {
c, err := client.api.KnownPeerConfig(host.PeerID())
ts.NoError(err)
return c.AllowedUsingAsExitNode
}, 15*time.Second, 100*time.Millisecond)
return clientCfg.IPAddr
}
// captureInbound prepares a peer's TestTUN to record inbound packets of the
// standard gateway test size into a freshly-allocated channel, and resets the
// inbound counter. Returns the channel.
func captureInbound(p TestPeer, bufSize int) chan []byte {
ch := make(chan []byte, bufSize)
p.tun.SetInboundCapture(gatewayTestPacketSize, ch)
p.tun.ClearInboundCount()
return ch
}
// resetInboundCounter installs a counter-only capture (no packet channel) and
// resets the counter, for tests that assert "no packet arrived".
func resetInboundCounter(p TestPeer) {
p.tun.SetInboundCapture(gatewayTestPacketSize, nil)
p.tun.ClearInboundCount()
}
// expectNoInbound asserts that the peer's TestTUN does not receive any
// inbound packet within the given duration.
func expectNoInbound(ts *TestSuite, p TestPeer, dur time.Duration, msgAndArgs ...interface{}) {
ts.Never(func() bool { return p.tun.InboundCount() > 0 }, dur, 50*time.Millisecond, msgAndArgs...)
}
// recvPacketWithTimeout reads from the InboundPackets channel with a timeout.
func recvPacketWithTimeout(ch chan []byte) ([]byte, bool) {
select {
case pkt := <-ch:
return pkt, true
case <-time.After(5 * time.Second):
return nil, false
}
}
// TestNormalVPNWithGateway verifies that regular peer-to-peer VPN traffic
// still works correctly when gateway mode is enabled.
func TestNormalVPNWithGateway(t *testing.T) {
ts := NewTestSuite(t)
client, exitNode, _ := setupGatewayPeers(ts)
// Get exit node's IP as seen by client
exitNodeConfig, err := client.api.KnownPeerConfig(exitNode.PeerID())
ts.NoError(err)
exitNodeIP := exitNodeConfig.IPAddr
// Send normal VPN packet (to exit node's awl IP)
exitInbound := captureInbound(exitNode, 10)
packet := testPacketWithDest(gatewayTestPacketSize, exitNodeIP)
client.tun.Outbound <- [][]byte{packet}
// Verify exit node receives it with normal full IP rewrite
rawPkt, ok := recvPacketWithTimeout(exitInbound)
ts.True(ok, "exit node should receive normal VPN packet")
src, dst := parsePacketIPs(rawPkt)
// Normal VPN: src = client's assigned IP in exit node's config,
// dst = exit node's local IP (10.66.0.1)
clientConfig, err := exitNode.api.KnownPeerConfig(client.PeerID())
ts.NoError(err)
ts.Equal(clientConfig.IPAddr, src.String(), "src should be client's assigned IP (normal VPN)")
ts.Equal("10.66.0.1", dst.String(), "dst should be local IP (normal VPN)")
}
// TestGatewayBidirectional verifies the full round-trip: client sends to internet,
// exit node receives it, then exit node sends back a return packet that reaches
// the client with the correct IP rewrites.
func TestGatewayBidirectional(t *testing.T) {
ts := NewTestSuite(t)
client, exitNode, clientAssignedIP := setupGatewayPeers(ts)
exitInbound := captureInbound(exitNode, 10)
clientInbound := captureInbound(client, 10)
// 1. Client sends to internet
outPacket := testPacketWithSrcDest(gatewayTestPacketSize, "10.66.0.1", internetIP)
client.tun.Outbound <- [][]byte{outPacket}
// Verify exit node receives it
rawPkt, ok := recvPacketWithTimeout(exitInbound)
ts.True(ok, "exit node should receive outbound gateway packet")
src, dst := parsePacketIPs(rawPkt)
ts.Equal(clientAssignedIP, src.String())
ts.Equal(internetIP, dst.String())
// 2. Simulate internet reply: inject return packet at exit node
returnPacket := testPacketWithSrcDest(gatewayTestPacketSize, internetIP, clientAssignedIP)
exitNode.tun.Outbound <- [][]byte{returnPacket}
// Verify client receives the reply
rawPkt, ok = recvPacketWithTimeout(clientInbound)
ts.True(ok, "client should receive return gateway packet")
src, dst = parsePacketIPs(rawPkt)
ts.Equal(internetIP, src.String())
ts.Equal("10.66.0.1", dst.String())
}
// TestGatewayPermissionDenied covers two complementary revocation paths:
//
// 1. Defence-in-depth on the exit-node side: even if a client somehow
// bypasses the SetGatewayPeer validation and sends gateway-style packets
// at us, writeInboundBatch must drop them when the per-peer
// WeAllowUsingAsExitNode flag is false. We exercise this by directly
// flipping the flag in the exit-node config without going through the
// API, so the change does not propagate back to the client yet.
//
// 2. Runtime API revocation propagating to the client.
// The exit node calls UpdatePeerSettings with
// AllowUsingAsExitNode=false; ExchangeNewStatusInfo runs in the
// background, the client's KnownPeer.CanUseAsVPNGateway() flips to
// false, and any new EnableGateway() call is rejected. The in-memory
// gateway pointer on the client is intentionally NOT auto-cleared by
// status propagation — running sessions keep their channel routing.
func TestGatewayPermissionDenied(t *testing.T) {
ts := NewTestSuite(t)
client, exitNode, _ := setupGatewayPeers(ts)
// Defence-in-depth on the exit-node side: silently flip WeAllow without
// going through the API (so the change does not propagate back to the
// client yet) and verify writeInboundBatch drops gateway-style packets.
// The hot-path permission check reads VpnPeer.weAllowUsingAsExitNode, an
// atomic kept in sync by RefreshPeersList; in production a settings change
// emits KnownPeerChanged which triggers it, so we call it directly here to
// mirror that local sync (the revocation still does not reach the client).
t.Run("DefenceInDepthDropsAtExitNode", func(t *testing.T) {
exitNode.app.Conf.Lock()
clientPeer := exitNode.app.Conf.KnownPeers[client.PeerID()]
clientPeer.WeAllowUsingAsExitNode = false
exitNode.app.Conf.KnownPeers[client.PeerID()] = clientPeer
exitNode.app.Conf.Unlock()
exitNode.app.Tunnel.RefreshPeersList()
captureInbound(exitNode, 10)
packet := testPacketWithSrcDest(gatewayTestPacketSize, "10.66.0.1", internetIP)
client.tun.Outbound <- [][]byte{packet}
expectNoInbound(ts, exitNode, 2*time.Second,
"exit node should NOT forward packets when WeAllowUsingAsExitNode is false")
})
// Case #5: revoke through the proper API.
// UpdatePeerSettings triggers ExchangeNewStatusInfo in a goroutine,
// propagating the new AllowUsingAsExitNode value to the client; future
// EnableGateway calls are rejected. The in-memory gateway pointer on the
// client is intentionally NOT auto-cleared by status propagation —
// running sessions keep their channel routing.
t.Run("APIRevocationPropagatesToClient", func(t *testing.T) {
clientCfg, err := exitNode.api.KnownPeerConfig(client.PeerID())
ts.NoError(err)
err = exitNode.api.UpdatePeerSettings(entity.UpdatePeerSettingsRequest{
PeerID: client.PeerID(),
Alias: clientCfg.Alias,
DomainName: clientCfg.DomainName,
IPAddr: clientCfg.IPAddr,
AllowUsingAsExitNode: false,
})
ts.NoError(err)
ts.Eventually(func() bool {
kp, ok := client.app.Conf.GetPeer(exitNode.PeerID())
return ok && !kp.CanUseAsVPNGateway()
}, 15*time.Second, 100*time.Millisecond,
"client's AllowUsingAsVPNGateway() must flip to false once revocation propagates")
// New EnableGateway calls must be rejected.
err = client.api.EnableVPNGatewayClient(exitNode.PeerID())
ts.Error(err, "EnableGateway must be rejected after permission is revoked")
// The in-memory gateway pointer is *not* auto-cleared on status
// updates: ClientEnabled stays true until the user explicitly
// disables it. This pins the documented behavior.
client.app.Conf.RLock()
ts.True(client.app.Conf.VPNGateway.ClientEnabled,
"propagation must NOT auto-clear the client-side gateway pointer")
client.app.Conf.RUnlock()
})
}
// TestGatewayServerAllowsNormalAwlWithoutExitPermission is the regression
// test for a bug where a peer with serveAsVPNGateway=true would refuse
// *all* inbound tunnel packets from any peer that did not have
// WeAllowUsingAsExitNode set — including normal awl peer-to-peer traffic
// destined for the gateway machine itself. The fix moves the permission
// check inside writeInboundBatch to apply only to gateway-bound packets
// (dst outside the awl subnet); awl peer-to-peer traffic must always
// pass regardless of the exit-node permission flag.
func TestGatewayServerAllowsNormalAwlWithoutExitPermission(t *testing.T) {
ts := NewTestSuite(t)
client := ts.NewTestPeer(true)
exitNode := ts.NewTestPeer(true)
// Exit node serves as VPN gateway, but we deliberately do NOT grant the
// client AllowUsingAsExitNode — setupGatewayPeers grants it; here we
// inline a stripped-down version that omits that step.
exitNode.app.Tunnel.SetVPNGatewayServerEnabled(true)
ts.makeFriends(client, exitNode)
clientCfg, err := exitNode.api.KnownPeerConfig(client.PeerID())
ts.NoError(err)
clientAssignedIP := clientCfg.IPAddr
exitCfg, err := client.api.KnownPeerConfig(exitNode.PeerID())
ts.NoError(err)
exitNodeIP := exitCfg.IPAddr
exitInbound := captureInbound(exitNode, 10)
// One batch with both kinds of packets:
// awlPkt — peer-to-peer awl traffic addressed to the gateway machine.
// gwPkt — gateway-bound packet (dst is on the public internet).
// The first must be delivered, the second must be dropped silently.
awlPkt := testPacketWithSrcDest(gatewayTestPacketSize, "10.66.0.1", exitNodeIP)
gwPkt := testPacketWithSrcDest(gatewayTestPacketSize, "10.66.0.1", internetIP)
client.tun.Outbound <- [][]byte{awlPkt, gwPkt}
rawPkt, ok := recvPacketWithTimeout(exitInbound)
ts.True(ok, "exit node must deliver normal awl traffic even without exit-node permission")
src, dst := parsePacketIPs(rawPkt)
ts.Equal(clientAssignedIP, src.String(), "awl packet src should be client's assigned IP")
ts.Equal("10.66.0.1", dst.String(), "awl packet dst should be local IP")
// No second packet — the gateway-bound one must have been dropped.
select {
case extra := <-exitInbound:
s, d := parsePacketIPs(extra)
t.Fatalf("unexpected gateway packet leaked through: src=%s dst=%s", s, d)
case <-time.After(1 * time.Second):
}
}
// TestGatewayNonRoutableIPsNotForwarded verifies that packets to loopback,
// multicast, and link-local addresses are NOT forwarded through the gateway.
func TestGatewayNonRoutableIPsNotForwarded(t *testing.T) {
ts := NewTestSuite(t)
client, exitNode, _ := setupGatewayPeers(ts)
resetInboundCounter(exitNode)
nonRoutableIPs := []struct {
name string
ip string
}{
{"loopback", "127.0.0.1"},
{"multicast", "224.0.0.1"},
{"link-local", "169.254.1.1"},
}
for _, tc := range nonRoutableIPs {
t.Run(tc.name, func(t *testing.T) {
exitNode.tun.ClearInboundCount()
packet := testPacketWithSrcDest(gatewayTestPacketSize, "10.66.0.1", tc.ip)
client.tun.Outbound <- [][]byte{packet}
expectNoInbound(ts, exitNode, 1*time.Second,
"packet to %s should NOT be forwarded via gateway", tc.ip)
})
}
}
// TestGatewayAwlSubnetNotForwarded verifies that packets to AWL subnet addresses
// (10.66.0.x) that don't match any known peer are NOT forwarded through the gateway.
func TestGatewayAwlSubnetNotForwarded(t *testing.T) {
ts := NewTestSuite(t)
client, exitNode, _ := setupGatewayPeers(ts)
resetInboundCounter(exitNode)
// Send to an AWL subnet IP that doesn't belong to any known peer
packet := testPacketWithSrcDest(gatewayTestPacketSize, "10.66.0.1", "10.66.0.250")
client.tun.Outbound <- [][]byte{packet}
expectNoInbound(ts, exitNode, 1*time.Second,
"packet to awl subnet should NOT be forwarded via gateway")
}
// TestGatewayPeerLifecycle verifies that gateway can be enabled, disabled,
// and re-enabled, with correct packet routing at each stage.
func TestGatewayPeerLifecycle(t *testing.T) {
ts := NewTestSuite(t)
client, exitNode, _ := setupGatewayPeers(ts)
exitInbound := captureInbound(exitNode, 10)
packet := testPacketWithSrcDest(gatewayTestPacketSize, "10.66.0.1", internetIP)
// 1. Gateway enabled — packet should arrive at exit node
client.tun.Outbound <- [][]byte{packet}
_, ok := recvPacketWithTimeout(exitInbound)
ts.True(ok, "with gateway enabled, packet should arrive at exit node")
// 2. Disable gateway: multiple packets must all be dropped.
client.app.Tunnel.ClearVPNGatewayPeer()
exitNode.tun.ClearInboundCount()
for i := 0; i < 5; i++ {
client.tun.Outbound <- [][]byte{packet}
}
expectNoInbound(ts, exitNode, 1*time.Second,
"with gateway disabled, internet packets should NOT arrive at exit node")
// 3. Re-enable gateway
ts.NoError(client.app.Tunnel.SetVPNGatewayPeer(exitNode.app.P2p.PeerID()))
exitNode.tun.ClearInboundCount()
client.tun.Outbound <- [][]byte{packet}
_, ok = recvPacketWithTimeout(exitInbound)
ts.True(ok, "after re-enabling gateway, packet should arrive at exit node")
}
// TestGatewayWithThreePeers verifies that gateway mode works correctly alongside
// normal VPN when three peers are involved: client, exit node, and a regular peer.
func TestGatewayWithThreePeers(t *testing.T) {
ts := NewTestSuite(t)
client, exitNode, _ := setupGatewayPeers(ts)
// Create a third peer and make it friends with client. Distinct aliases
// are required because the default "peer_1"/"peer_2" would collide with
// the existing client↔exitNode pairing.
regularPeer := ts.NewTestPeer(true)
ts.makeFriendsWithAliases(client, regularPeer, "gw_client", "regular_peer")
// Get IPs
exitNodeConfig, err := client.api.KnownPeerConfig(exitNode.PeerID())
ts.NoError(err)
exitNodeIP := exitNodeConfig.IPAddr
regularPeerConfig, err := client.api.KnownPeerConfig(regularPeer.PeerID())
ts.NoError(err)
regularPeerIP := regularPeerConfig.IPAddr
// Test 1: Normal VPN to regular peer works.
//
// Also covers case #8: when gateway is enabled,
// traffic to another awl peer must take the per-peer awl path, not the
// gateway path — the exit node must NOT see the packet.
t.Run("NormalVPNToRegularPeer", func(t *testing.T) {
regularInbound := captureInbound(regularPeer, 10)
// Counter-only capture on the exit node so we can assert it sees nothing.
resetInboundCounter(exitNode)
packet := testPacketWithDest(gatewayTestPacketSize, regularPeerIP)
client.tun.Outbound <- [][]byte{packet}
rawPkt, ok := recvPacketWithTimeout(regularInbound)
ts.True(ok, "regular peer should receive normal VPN packet")
_, dst := parsePacketIPs(rawPkt)
ts.Equal("10.66.0.1", dst.String(), "dst should be local IP (normal VPN)")
// Case #8: even with gateway on, awl-subnet traffic must not be
// duplicated through the exit node.
expectNoInbound(ts, exitNode, 500*time.Millisecond,
"exit node TUN must NOT receive a packet destined for another awl peer")
})
// Test 2: Normal VPN to exit node works (as awl peer, not gateway)
t.Run("NormalVPNToExitNode", func(t *testing.T) {
exitInbound := captureInbound(exitNode, 10)
packet := testPacketWithDest(gatewayTestPacketSize, exitNodeIP)
client.tun.Outbound <- [][]byte{packet}
rawPkt, ok := recvPacketWithTimeout(exitInbound)
ts.True(ok, "exit node should receive normal VPN packet")
_, dst := parsePacketIPs(rawPkt)
ts.Equal("10.66.0.1", dst.String(), "dst should be local IP (normal VPN)")
})
// Test 3: Gateway traffic goes to exit node, not regular peer
t.Run("GatewayTrafficToExitNode", func(t *testing.T) {
exitInbound := captureInbound(exitNode, 10)
resetInboundCounter(regularPeer)
packet := testPacketWithSrcDest(gatewayTestPacketSize, "10.66.0.1", internetIP)
client.tun.Outbound <- [][]byte{packet}
_, ok := recvPacketWithTimeout(exitInbound)
ts.True(ok, "exit node should receive gateway packet")
ts.EqualValues(0, regularPeer.tun.InboundCount(),
"regular peer should NOT receive gateway traffic")
})
// Test 4: Regular peer can still send to client
t.Run("RegularPeerToClient", func(t *testing.T) {
// Get client's IP from regular peer's perspective
clientConfig, err := regularPeer.api.KnownPeerConfig(client.PeerID())
ts.NoError(err)
clientInbound := captureInbound(client, 10)
packet := testPacketWithDest(gatewayTestPacketSize, clientConfig.IPAddr)
regularPeer.tun.Outbound <- [][]byte{packet}
rawPkt, ok := recvPacketWithTimeout(clientInbound)
ts.True(ok, "client should receive packet from regular peer")
_, dst := parsePacketIPs(rawPkt)
ts.Equal("10.66.0.1", dst.String(), "dst should be client's local IP")
})
}
// TestGatewayMultipleDestinations verifies that packets to multiple distinct
// internet IPs (in both directions) are all correctly forwarded through the
// gateway with the right rewrite applied per-direction.
func TestGatewayMultipleDestinations(t *testing.T) {
ts := NewTestSuite(t)
client, exitNode, clientAssignedIP := setupGatewayPeers(ts)
ips := []string{"8.8.8.8", "1.1.1.1", "93.184.216.34", "142.250.80.46"}
t.Run("Outbound", func(t *testing.T) {
exitInbound := captureInbound(exitNode, 20)
for _, destIP := range ips {
packet := testPacketWithSrcDest(gatewayTestPacketSize, "10.66.0.1", destIP)
client.tun.Outbound <- [][]byte{packet}
}
for _, expectedDst := range ips {
rawPkt, ok := recvPacketWithTimeout(exitInbound)
ts.True(ok, "exit node should receive packet for %s", expectedDst)
src, dst := parsePacketIPs(rawPkt)
ts.Equal(clientAssignedIP, src.String())
ts.Equal(expectedDst, dst.String())
}
})
t.Run("Return", func(t *testing.T) {
clientInbound := captureInbound(client, 20)
for _, srcIP := range ips {
pkt := testPacketWithSrcDest(gatewayTestPacketSize, srcIP, clientAssignedIP)
exitNode.tun.Outbound <- [][]byte{pkt}
}
for _, expectedSrc := range ips {
rawPkt, ok := recvPacketWithTimeout(clientInbound)
ts.True(ok, "client should receive return packet from %s", expectedSrc)
src, dst := parsePacketIPs(rawPkt)
ts.Equal(expectedSrc, src.String(), "src should be preserved")
ts.Equal("10.66.0.1", dst.String(), "dst should be rewritten to local IP")
}
})
}
// TestGatewayExitNodeNotServing verifies that when an exit node turns its
// serveAsVPNGateway runtime flag off, Forward-tagged packets are dropped on
// receive (metric: gateway_server_disabled) instead of being silently
// rewritten as normal awl traffic. The client still has its gateway pointer
// set and stamps Forward on outbound packets; the exit-node-side switch in
// writeInboundBatch refuses to handle them without the server role.
func TestGatewayExitNodeNotServing(t *testing.T) {
ts := NewTestSuite(t)
client, exitNode, _ := setupGatewayPeers(ts)
exitNode.app.Tunnel.SetVPNGatewayServerEnabled(false)
resetInboundCounter(exitNode)
packet := testPacketWithSrcDest(gatewayTestPacketSize, "10.66.0.1", internetIP)
client.tun.Outbound <- [][]byte{packet}
expectNoInbound(ts, exitNode, 500*time.Millisecond,
"Forward packet must be dropped on exit node when server mode is off")
}
// TestGatewayMixedBatch verifies that a batch combining awl-subnet packets
// and gateway (non-awl) packets gets the correct per-packet rewrite in both
// directions: client → exit node (Outbound) and exit node → client (Return).
func TestGatewayMixedBatch(t *testing.T) {
ts := NewTestSuite(t)
client, exitNode, clientAssignedIP := setupGatewayPeers(ts)
t.Run("Outbound", func(t *testing.T) {
// Get exit node's IP from client's perspective
exitNodeConfig, err := client.api.KnownPeerConfig(exitNode.PeerID())
ts.NoError(err)
exitNodeIP := exitNodeConfig.IPAddr
exitInbound := captureInbound(exitNode, 20)
// Send a normal awl packet and a gateway packet in one batch.
awlPacket := testPacketWithSrcDest(gatewayTestPacketSize, "10.66.0.1", exitNodeIP)
gwPacket := testPacketWithSrcDest(gatewayTestPacketSize, "10.66.0.1", internetIP)
client.tun.Outbound <- [][]byte{awlPacket, gwPacket}
rawPkt1, ok := recvPacketWithTimeout(exitInbound)
ts.True(ok, "exit node should receive first packet")
rawPkt2, ok := recvPacketWithTimeout(exitInbound)
ts.True(ok, "exit node should receive second packet")
// Classify by dst: awl-subnet (full rewrite) vs internet (src-only).
var normalPkt, gatewayPkt []byte
for _, pkt := range [][]byte{rawPkt1, rawPkt2} {
_, dst := parsePacketIPs(pkt)
if dst.String() == "10.66.0.1" {
normalPkt = pkt
} else {
gatewayPkt = pkt
}
}
ts.NotNil(normalPkt, "should have a normal awl packet")
ts.NotNil(gatewayPkt, "should have a gateway packet")
// Normal packet: full rewrite (src=clientAssignedIP, dst=10.66.0.1)
src, dst := parsePacketIPs(normalPkt)
ts.Equal(clientAssignedIP, src.String(), "normal packet src should be client's assigned IP")
ts.Equal("10.66.0.1", dst.String(), "normal packet dst should be local IP")
// Gateway packet: src-only rewrite (src=clientAssignedIP, dst preserved)
src, dst = parsePacketIPs(gatewayPkt)
ts.Equal(clientAssignedIP, src.String(), "gateway packet src should be client's assigned IP")
ts.Equal(internetIP, dst.String(), "gateway packet dst should be preserved")
})
t.Run("Return", func(t *testing.T) {
clientInbound := captureInbound(client, 20)
// Get exit node's assigned IP in client's config
exitNodeConfig, err := client.api.KnownPeerConfig(exitNode.PeerID())
ts.NoError(err)
exitNodeIPInClient := exitNodeConfig.IPAddr
// Inject at exit node's TUN one gateway-return and one normal awl packet,
// both addressed to the client's assigned IP.
gwReturnPkt := testPacketWithSrcDest(gatewayTestPacketSize, internetIP, clientAssignedIP)
normalPkt := testPacketWithSrcDest(gatewayTestPacketSize, "10.66.0.1", clientAssignedIP)
exitNode.tun.Outbound <- [][]byte{gwReturnPkt, normalPkt}
rawPkt1, ok := recvPacketWithTimeout(clientInbound)
ts.True(ok, "client should receive first packet")
rawPkt2, ok := recvPacketWithTimeout(clientInbound)
ts.True(ok, "client should receive second packet")
// Classify by src: internet (dst-only rewrite) vs awl (full rewrite).
var returnPktData, normalPktData []byte
for _, pkt := range [][]byte{rawPkt1, rawPkt2} {
src, _ := parsePacketIPs(pkt)
if src.String() == internetIP {
returnPktData = pkt
} else {
normalPktData = pkt
}
}
ts.NotNil(returnPktData, "should have a gateway return packet")
ts.NotNil(normalPktData, "should have a normal awl packet")
// Gateway return: dst-only rewrite (src preserved, dst=10.66.0.1)
src, dst := parsePacketIPs(returnPktData)
ts.Equal(internetIP, src.String(), "gateway return src should be preserved")
ts.Equal("10.66.0.1", dst.String(), "gateway return dst should be local IP")
// Normal awl: full rewrite (src=exitNodeIPInClient, dst=10.66.0.1)
src, dst = parsePacketIPs(normalPktData)
ts.Equal(exitNodeIPInClient, src.String(), "normal packet src should be exit node's assigned IP")
ts.Equal("10.66.0.1", dst.String(), "normal packet dst should be local IP")
})
}
// TestGatewayAPIEnableUnknownPeer verifies that enabling gateway with an
// unknown peer ID returns an error.
func TestGatewayAPIEnableUnknownPeer(t *testing.T) {
ts := NewTestSuite(t)
client := ts.NewTestPeer(true)
err := client.api.EnableVPNGatewayClient("QmYyQSo1c1Ym7orWxLYvCrM2EmxFTANf8wXmmE7DWjhx5N")
ts.Error(err)
}
// TestGatewayAPIEnableNotAllowed verifies that enabling gateway with a peer
// that doesn't allow exit node usage returns an error.
func TestGatewayAPIEnableNotAllowed(t *testing.T) {
ts := NewTestSuite(t)
client := ts.NewTestPeer(true)
peer2 := ts.NewTestPeer(true)
ts.makeFriends(client, peer2)
// peer2 does NOT have AllowedUsingAsExitNode set
err := client.api.EnableVPNGatewayClient(peer2.PeerID())
ts.Error(err)
}
// TestGatewayPeerInfoStatus verifies that gateway state is reflected in
// /settings/peer_info (the canonical status surface — there is no dedicated
// /gateway/status endpoint).
func TestGatewayPeerInfoStatus(t *testing.T) {
skipIfVPNGatewayUnsupported(t)
ts := NewTestSuite(t)
client, exitNode, _ := setupGatewayPeers(ts)
err := client.api.EnableVPNGatewayClient(exitNode.PeerID())
ts.NoError(err)
info, err := client.api.PeerInfo()
ts.NoError(err)
ts.True(info.VPNGateway.ClientEnabled)
ts.Equal(exitNode.PeerID(), info.VPNGateway.GatewayPeerID)
ts.True(info.VPNGateway.Connected, "exit node should be connected")
}
// TestGatewayAPIListAvailableGateways verifies the list available gateways API endpoint.
func TestGatewayAPIListAvailableGateways(t *testing.T) {
ts := NewTestSuite(t)
client, exitNode, _ := setupGatewayPeers(ts)
gateways, err := client.api.ListAvailableVPNGateways()
ts.NoError(err)
// exitNode has AllowedUsingAsExitNode set from setupGatewayPeers
ts.Len(gateways, 1)
ts.Equal(exitNode.PeerID(), gateways[0].PeerID)
ts.True(gateways[0].Connected)
}
// TestGatewayAPIRuntimeToggle verifies that EnableGateway and DisableGateway
// take effect immediately (no restart): each call must update Tunnel state
// and flip Application gateway-route bookkeeping. It also covers the
// atomic-switch case: an enable with a different exit-node ID while already
// enabled must rebind the tunnel without tearing routes down and back up.
func TestGatewayAPIRuntimeToggle(t *testing.T) {
skipIfVPNGatewayUnsupported(t)
ts := NewTestSuite(t)
client, exitNode, _ := setupGatewayPeers(ts)
checkClientBound := func(want bool) {
t.Helper()
ts.Equal(want, client.app.VPNGateway.IsClientActive(), "client gateway routes installed")
}
// setupGatewayPeers calls Tunnel.SetVPNGatewayPeer directly without going
// through the API — at this point routes are NOT applied yet.
checkClientBound(false)
ts.NoError(client.api.EnableVPNGatewayClient(exitNode.PeerID()))
checkClientBound(true)
// Idempotent: a second enable with the same peer must not double-apply —
// the OS-level install count must not grow.
netManager := client.app.NetManager.(*testNetManager)
enablesBefore := netManager.ClientEnables()
ts.NoError(client.api.EnableVPNGatewayClient(exitNode.PeerID()))
ts.Equal(enablesBefore, netManager.ClientEnables(), "routes must be reused on re-enable with same peer")
client.app.Conf.RLock()
ts.Equal(exitNode.PeerID(), client.app.Conf.VPNGateway.GatewayPeerID, "config still points to original peer")
client.app.Conf.RUnlock()
ts.NoError(client.api.DisableVPNGatewayClient())
checkClientBound(false)
client.app.Conf.RLock()
ts.False(client.app.Conf.VPNGateway.ClientEnabled)
client.app.Conf.RUnlock()
// Re-enable works on the same Application instance.
ts.NoError(client.api.EnableVPNGatewayClient(exitNode.PeerID()))
checkClientBound(true)
// Atomic switch to a second exit node: spin up a third peer, register
// it as a valid exit node for the client, then call EnableGateway with
// the new peer ID *while gateway is already on*. The installed routes
// must survive (no teardown/reinstall) and the tunnel/config bindings
// must rebind to the new peer.
exitNode2 := ts.NewTestPeer(true)
exitNode2.app.Tunnel.SetVPNGatewayServerEnabled(true)
// Custom aliases: ts.makeFriends defaults to "peer_1"/"peer_2" which
// would collide with the first exitNode pairing.
ts.makeFriendsWithAliases(client, exitNode2, "client_alt", "peer_3")
grantExitNodePermission(ts, exitNode2, client)
enablesPreSwitch := netManager.ClientEnables()
ts.NoError(client.api.EnableVPNGatewayClient(exitNode2.PeerID()))
ts.Equal(enablesPreSwitch, netManager.ClientEnables(), "atomic switch must not reinstall routes")
client.app.Conf.RLock()
ts.Equal(exitNode2.PeerID(), client.app.Conf.VPNGateway.GatewayPeerID, "config must point to new exit node")
client.app.Conf.RUnlock()
}
// TestGatewayAPIExitNodeMode verifies that the /gateway/exit_node endpoint
// applies and tears down the server-side state at runtime, persisting the
// flag and propagating it to peers via the next status exchange.
func TestGatewayAPIExitNodeMode(t *testing.T) {
skipIfVPNGatewayUnsupported(t)
ts := NewTestSuite(t)
peer1 := ts.NewTestPeer(true)
peer2 := ts.NewTestPeer(true)
ts.makeFriends(peer1, peer2)
// Initially both have ServeAsVPNGateway=false.
peer1.app.Conf.RLock()
ts.False(peer1.app.Conf.VPNGateway.ServerEnabled)
peer1.app.Conf.RUnlock()
ts.False(peer1.app.VPNGateway.IsServerActive())
// Turn it on via API.
ts.NoError(peer1.api.SetVPNGatewayServerEnabled(true))
peer1.app.Conf.RLock()
ts.True(peer1.app.Conf.VPNGateway.ServerEnabled)
peer1.app.Conf.RUnlock()
ts.True(peer1.app.VPNGateway.IsServerActive(), "NAT state must be tracked after enable")
// Idempotent.
ts.NoError(peer1.api.SetVPNGatewayServerEnabled(true))
ts.True(peer1.app.VPNGateway.IsServerActive())
// peer2 should learn that peer1 serves as VPN gateway. The background
// status exchange runs every 5 minutes, which is too slow for a test —
// trigger it manually to force the next exchange immediately.
peer1.app.AuthStatus.ExchangeStatusInfoWithAllKnownPeers(peer1.app.Ctx())
ts.Eventually(func() bool {
cfg, err := peer2.api.KnownPeerConfig(peer1.PeerID())
ts.NoError(err)
return cfg.RemoteVPNGatewayServerEnabled
}, 15*time.Second, 100*time.Millisecond, "peer2 must observe peer1 advertising VPN gateway")
// Turn it off; NAT teardown + flag clears.
ts.NoError(peer1.api.SetVPNGatewayServerEnabled(false))
peer1.app.Conf.RLock()
ts.False(peer1.app.Conf.VPNGateway.ServerEnabled)
peer1.app.Conf.RUnlock()
ts.False(peer1.app.VPNGateway.IsServerActive(), "NAT state must be cleared after disable")
}
// TestGatewayBroadcastHandledViaBroadcastPath verifies that broadcast packets
// (255.255.255.255) go through the existing broadcast code path, not the
// gateway path. On the exit node side, the broadcast arrives via the peer's
// normal inbound channel and the src is rewritten to the client's assigned IP.
func TestGatewayBroadcastHandledViaBroadcastPath(t *testing.T) {
ts := NewTestSuite(t)
client, exitNode, clientAssignedIP := setupGatewayPeers(ts)
exitInbound := captureInbound(exitNode, 10)
packet := testPacketWithSrcDest(gatewayTestPacketSize, "10.66.0.1", "255.255.255.255")
client.tun.Outbound <- [][]byte{packet}
// Exit node receives it — the broadcast code path sends it to all peers
rawPkt, ok := recvPacketWithTimeout(exitInbound)
ts.True(ok, "exit node should receive broadcast packet")
src, _ := parsePacketIPs(rawPkt)
ts.Equal(clientAssignedIP, src.String(), "broadcast src should be rewritten to client's assigned IP")
}
// TestGatewaySetGatewayPeerNotAllowed verifies that SetGatewayPeer rejects
// peers whose AllowUsingAsVPNGateway() is false (i.e. the peer either does
// not advertise VPN gateway service, or has not granted us exit-node use).
// Each missing flag is exercised independently.
func TestGatewaySetGatewayPeerNotAllowed(t *testing.T) {
ts := NewTestSuite(t)
client := ts.NewTestPeer(true)
other := ts.NewTestPeer(true)
ts.makeFriends(client, other)
cases := []struct {
name string
serveAsGateway bool
allowAsExit bool
expectErrContains string
}{
{
name: "AllowedExitButNotServing",
serveAsGateway: false,
allowAsExit: true,
expectErrContains: "RemoteVPNGatewayServerEnabled=false",
},
{
name: "ServingButExitNotAllowed",
serveAsGateway: true,
allowAsExit: false,
expectErrContains: "AllowedUsingAsExitNode=false",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// Inject the desired KnownPeer flags directly so we don't have to
// orchestrate full status propagation for each case.
client.app.Conf.Lock()
kp := client.app.Conf.KnownPeers[other.PeerID()]
kp.AllowedUsingAsExitNode = tc.allowAsExit
kp.RemoteVPNGatewayServerEnabled = tc.serveAsGateway
client.app.Conf.KnownPeers[other.PeerID()] = kp
client.app.Conf.Unlock()
err := client.app.Tunnel.SetVPNGatewayPeer(other.app.P2p.PeerID())
ts.Error(err)
ts.Contains(err.Error(), tc.expectErrContains)
})
}
}
// TestGatewayPeerLifecycleConfigPersisted verifies that SetGatewayPeer and
// ClearGatewayPeer write the choice to the config under a single critical
// section, not just runtime state. This is what allows the API/CLI handlers
// to delegate to the service layer instead of maintaining their own writes.
func TestGatewayPeerLifecycleConfigPersisted(t *testing.T) {
ts := NewTestSuite(t)
client, exitNode, _ := setupGatewayPeers(ts)
// setupGatewayPeers already enabled gateway via SetGatewayPeer.
client.app.Conf.RLock()
ts.True(client.app.Conf.VPNGateway.ClientEnabled, "SetGatewayPeer must persist Enabled=true")
ts.Equal(exitNode.PeerID(), client.app.Conf.VPNGateway.GatewayPeerID)
client.app.Conf.RUnlock()
client.app.Tunnel.ClearVPNGatewayPeer()
client.app.Conf.RLock()
ts.False(client.app.Conf.VPNGateway.ClientEnabled, "ClearGatewayPeer must persist Enabled=false")
ts.Equal("", client.app.Conf.VPNGateway.GatewayPeerID)
client.app.Conf.RUnlock()
}
// TestGatewayServeAsVPNGatewayConfigPersisted verifies that
// SetServeAsVPNGateway writes the choice to the config so it survives a
// restart and propagates on the next status exchange.
func TestGatewayServeAsVPNGatewayConfigPersisted(t *testing.T) {
ts := NewTestSuite(t)
exitNode := ts.NewTestPeer(true)
exitNode.app.Tunnel.SetVPNGatewayServerEnabled(true)
exitNode.app.Conf.RLock()
ts.True(exitNode.app.Conf.VPNGateway.ServerEnabled,
"SetServeAsVPNGateway(true) must persist ServeAsVPNGateway=true")
exitNode.app.Conf.RUnlock()
exitNode.app.Tunnel.SetVPNGatewayServerEnabled(false)
exitNode.app.Conf.RLock()
ts.False(exitNode.app.Conf.VPNGateway.ServerEnabled,
"SetServeAsVPNGateway(false) must persist ServeAsVPNGateway=false")
exitNode.app.Conf.RUnlock()
}
// TestGatewayServeAsVPNGatewayPropagatesViaStatus verifies the new propagated
// status field: when the exit node enables VPN gateway service, the client's
// KnownPeer entry for that peer eventually has RemoteVPNGatewayServerEnabled=true.
// AllowUsingAsVPNGateway() then becomes the AND of this with the existing
// AllowedUsingAsExitNode (shared with SOCKS5).
func TestGatewayServeAsVPNGatewayPropagatesViaStatus(t *testing.T) {
ts := NewTestSuite(t)
client := ts.NewTestPeer(true)
exitNode := ts.NewTestPeer(true)
ts.makeFriends(client, exitNode)
// Initially exit node is not serving — propagation should set false.
ts.Eventually(func() bool {
kp, _ := client.app.Conf.GetPeer(exitNode.PeerID())
return !kp.RemoteVPNGatewayServerEnabled
}, 15*time.Second, 100*time.Millisecond,
"client should initially see RemoteVPNGatewayServerEnabled=false")
// Flip it on and trigger a fresh status exchange so the new value
// propagates without waiting for the 5-minute background ticker.
exitNode.app.Tunnel.SetVPNGatewayServerEnabled(true)
exitKnownByExit, _ := exitNode.app.Conf.GetPeer(client.PeerID())
ts.NoError(exitNode.app.AuthStatus.ExchangeNewStatusInfo(
context.Background(), client.app.P2p.PeerID(), exitKnownByExit))
ts.Eventually(func() bool {
kp, _ := client.app.Conf.GetPeer(exitNode.PeerID())
return kp.RemoteVPNGatewayServerEnabled
}, 15*time.Second, 100*time.Millisecond,
"client should see RemoteVPNGatewayServerEnabled=true after propagation")
// And the field is exposed via /peers/get_known so the Flutter
// exit-node picker can classify peers without per-peer fan-out.
peers, err := client.api.KnownPeers()
ts.NoError(err)
ts.Len(peers, 1)
ts.Equal(exitNode.PeerID(), peers[0].PeerID)
ts.True(peers[0].RemoteVPNGatewayServerEnabled, "list endpoint must surface RemoteVPNGatewayServerEnabled")
}
// TestGatewayRebindOnPeerReadd covers the full rebind cycle: removing the
// configured exit node from KnownPeers must clear in-memory gateway state
// (and reject subsequent SetGatewayPeer for the now-unknown peer); re-adding
// the same peer must let SetGatewayPeer succeed again and resume packet flow.
// HandleReadPackets must not panic on packets sent during the gap.
func TestGatewayRebindOnPeerReadd(t *testing.T) {
skipIfVPNGatewayUnsupported(t)
ts := NewTestSuite(t)
client, exitNode, _ := setupGatewayPeers(ts)
// Snapshot the KnownPeer entry so we can re-add it later with the same
// permissions intact.
originalPeer, ok := client.app.Conf.GetPeer(exitNode.PeerID())
ts.True(ok)
ts.True(originalPeer.CanUseAsVPNGateway(),
"precondition: setupGatewayPeers must leave the peer as a valid VPN gateway target")
// Sanity: gateway packets flow before we touch anything.
exitInbound := captureInbound(exitNode, 10)
packet := testPacketWithSrcDest(gatewayTestPacketSize, "10.66.0.1", internetIP)
client.tun.Outbound <- [][]byte{packet}
_, ok = recvPacketWithTimeout(exitInbound)
ts.True(ok, "gateway should flow before peer removal")
// Remove the exit node from KnownPeers. UpsertPeer/RemovePeer emit
// KnownPeerChanged which triggers an async RefreshPeersList; we also
// call it explicitly for synchronous determinism.
client.app.Conf.RemovePeer(exitNode.PeerID())
client.app.Tunnel.RefreshPeersList()
err := client.app.Tunnel.SetVPNGatewayPeer(exitNode.app.P2p.PeerID())
ts.Error(err, "SetGatewayPeer must reject a peer that is no longer in KnownPeers")
ts.Contains(err.Error(), "is not in known peers")
// Re-add the peer with the original (gateway-eligible) flags.
// RefreshPeersList rebuilds the VpnPeer; SetGatewayPeer must succeed.
client.app.Conf.UpsertPeer(originalPeer)
client.app.Tunnel.RefreshPeersList()
ts.NoError(client.app.Tunnel.SetVPNGatewayPeer(exitNode.app.P2p.PeerID()),
"SetGatewayPeer must succeed after the peer is re-added")
// Verify gateway packets flow again. ClearInboundCount + a fresh
// capture in case the previous run left leftover state.
exitInbound = captureInbound(exitNode, 10)
client.tun.Outbound <- [][]byte{packet}
_, ok = recvPacketWithTimeout(exitInbound)
ts.True(ok, "gateway should flow again after the peer is re-added")
}
// TestGatewayPeerRemovalRefusedWhileActive verifies the API refuses to remove
// the configured gateway peer while client mode is ACTIVE. Allowing the removal
// would leave the OS routes/DNS installed with nothing bound and black-hole all