Skip to content

Commit 27cc996

Browse files
committed
WIP 13 vpn: add gateway support
1 parent 3f6a676 commit 27cc996

6 files changed

Lines changed: 251 additions & 41 deletions

File tree

application.go

Lines changed: 117 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"os"
1111
"runtime"
1212
"strings"
13+
"sync"
1314
"time"
1415

1516
"github.com/anywherelan/ts-dns/control/controlknobs"
@@ -161,7 +162,7 @@ func (a *Application) Init(ctx context.Context, tunDevice tun.Device) error {
161162
}, a.Eventbus, new(awlevent.KnownPeerChanged))
162163
}
163164

164-
a.VPNGateway = service.NewVPNGateway(a.Conf, a.Tunnel, a.vpnDevice, a.P2p, a.SockMarker, a.DisableGatewayOSSetup)
165+
a.VPNGateway = service.NewVPNGateway(a.Conf, a.Tunnel, a.vpnDevice, a.P2p, a.SockMarker, a.Dns, a.DisableGatewayOSSetup)
165166

166167
handler := api.NewHandler(a.Conf, a.P2p, a.AuthStatus, a.Tunnel, a.SOCKS5, a.LogBuffer, a.Dns, a.VPNGateway)
167168
a.Api = handler
@@ -353,33 +354,60 @@ type DNSService struct {
353354
ctx context.Context
354355
logger *log.ZapEventLogger
355356

357+
mu sync.Mutex
358+
dnsHost string
359+
dnsFQDN dnsname.FQDN
356360
dnsOsConfigurator dns.OSConfigurator
357361
dnsResolver *awldns.Resolver
358362
upstreamDNS string
359363
isAwlDNSSetAsSystem bool
364+
// forceUpstream forces the awl resolver to capture all queries
365+
// (MatchDomains=nil) and forward them to the configured public upstream so
366+
// DNS traverses the tunnel instead of leaking to the system resolver. Set
367+
// in VPN gateway client mode.
368+
forceUpstream bool
360369
}
361370

362371
func NewDNSService(conf *config.Config, eventbus awlevent.Bus, ctx context.Context, logger *log.ZapEventLogger) *DNSService {
363372
return &DNSService{conf: conf, eventbus: eventbus, ctx: ctx, logger: logger}
364373
}
365374

366375
func (a *DNSService) initDNS(interfaceName string) {
367-
var err error
376+
a.mu.Lock()
377+
defer a.mu.Unlock()
378+
368379
dnsAddr := a.conf.DNS.ListenAddress
369380
dnsHost, _, err := net.SplitHostPort(dnsAddr)
370381
if err != nil {
371382
a.logger.Errorf("invalid dns listen address %s: %v", dnsAddr, err)
372383
return
373384
}
385+
a.dnsHost = dnsHost
374386

387+
fqdn, err := dnsname.ToFQDN(awldns.LocalDomain)
388+
if err != nil {
389+
panic(err)
390+
}
391+
a.dnsFQDN = fqdn
392+
393+
// TODO(android awldns): on Android this NewResolver cannot bind :53 (needs
394+
// root) and dnsOsConfigurator.SetDNS below fails (no writable resolv.conf),
395+
// so awldns is effectively inert there and .awl names do not resolve. The
396+
// Android host instead points VpnService at DNS.UpstreamDNSAddress directly
397+
// (see awl-flutter MainActivity.establishTun), which prevents leaks but
398+
// gives no .awl resolution. A full fix would intercept :53 to a magic awl
399+
// IP inside the tunnel read-path (userspace netstack),
400+
// rather than binding an OS socket.
375401
a.dnsResolver = awldns.NewResolver(dnsAddr)
376-
a.upstreamDNS = awldns.DefaultUpstreamDNSAddress
377-
a.refreshDNSConfig()
402+
a.upstreamDNS = a.conf.DNS.UpstreamDNSAddress
403+
a.forceUpstream = a.conf.VPNGateway.ClientEnabled
404+
a.refreshDNSConfigLocked()
378405

379406
awlevent.WrapSubscriptionToCallback(a.ctx, func(_ interface{}) {
380-
a.refreshDNSConfig()
407+
a.mu.Lock()
408+
defer a.mu.Unlock()
409+
a.refreshDNSConfigLocked()
381410
}, a.eventbus, new(awlevent.KnownPeerChanged))
382-
defer a.refreshDNSConfig()
383411

384412
tsLogger := log.Logger("ts/dnsconf")
385413
a.dnsOsConfigurator, err = dns.NewOSConfigurator(func(format string, args ...interface{}) {
@@ -390,52 +418,98 @@ func (a *DNSService) initDNS(interfaceName string) {
390418
return
391419
}
392420

393-
fqdn, err := dnsname.ToFQDN(awldns.LocalDomain)
394-
if err != nil {
395-
panic(err)
396-
}
397-
newOSConfig := dns.OSConfig{
398-
Nameservers: []netip.Addr{netip.MustParseAddr(dnsHost)},
399-
MatchDomains: []dnsname.FQDN{fqdn},
400-
}
421+
a.applyOSDNSConfigLocked()
422+
}
423+
424+
// applyOSDNSConfigLocked (re)computes the OS DNS takeover config from the
425+
// current state (split-DNS support, base config, forceUpstream) and pushes it
426+
// to the OS, then refreshes the awl resolver. Caller must hold a.mu and have a
427+
// non-nil dnsOsConfigurator. Safe to call repeatedly.
428+
func (a *DNSService) applyOSDNSConfigLocked() {
429+
supportsSplitDNS := a.dnsOsConfigurator.SupportsSplitDNS()
401430

402-
if !a.dnsOsConfigurator.SupportsSplitDNS() {
403-
newOSConfig.MatchDomains = nil
431+
var baseNameservers []netip.Addr
432+
if !supportsSplitDNS {
404433
baseOSConfig, err := a.dnsOsConfigurator.GetBaseConfig()
405434
if err != nil {
406435
a.logger.Errorf("get base config from os configurator, abort setting os dns: %v", err)
407436
return
408437
}
409-
410438
a.logger.Infof("os does not support split dns. base config: %v", baseOSConfig)
411-
if len(baseOSConfig.Nameservers) == 0 {
412-
a.logger.Errorf("got zero nameservers from os configurator, use %s as default", awldns.DefaultUpstreamDNSAddress)
413-
a.upstreamDNS = awldns.DefaultUpstreamDNSAddress
414-
} else {
415-
// TODO: use all nameservers in awldns resolver proxy
416-
a.upstreamDNS = net.JoinHostPort(baseOSConfig.Nameservers[0].String(), awldns.DefaultDNSPort)
417-
}
439+
baseNameservers = baseOSConfig.Nameservers
418440
}
419441

420-
// In VPN gateway client mode, force upstream DNS to a public resolver to
421-
// prevent DNS leaks. All traffic (including DNS) routes through the TUN
422-
// and the gateway peer. This must come after the split-DNS logic so it
423-
// takes final precedence.
424-
if a.conf.VPNGateway.ClientEnabled {
425-
a.upstreamDNS = awldns.DefaultUpstreamDNSAddress
426-
a.logger.Infof("VPN gateway client mode: using public upstream DNS %s to prevent DNS leaks", a.upstreamDNS)
442+
matchDomains, upstream := chooseDNSPolicy(a.forceUpstream, supportsSplitDNS, baseNameservers, a.conf.DNS.UpstreamDNSAddress)
443+
a.upstreamDNS = upstream
444+
a.refreshDNSConfigLocked()
445+
446+
newOSConfig := dns.OSConfig{
447+
Nameservers: []netip.Addr{netip.MustParseAddr(a.dnsHost)},
448+
MatchDomains: matchDomains,
427449
}
450+
if err := a.dnsOsConfigurator.SetDNS(newOSConfig); err != nil {
451+
a.logger.Errorf("set dns config to os configurator: %v", err)
452+
return
453+
}
454+
a.logger.Infof("successfully set dns config to os (forceUpstream=%v, upstream=%s, matchDomains=%v)",
455+
a.forceUpstream, a.upstreamDNS, matchDomains)
456+
a.isAwlDNSSetAsSystem = true
457+
}
428458

429-
err = a.dnsOsConfigurator.SetDNS(newOSConfig)
459+
// chooseDNSPolicy decides which domains the awl resolver should capture and
460+
// which upstream it forwards non-.awl queries to.
461+
//
462+
// - forceUpstream (VPN gateway client mode): capture everything
463+
// (MatchDomains=nil) and forward to the configured public upstream so DNS
464+
// goes through the tunnel — no leak.
465+
// - split-DNS supported: capture only .awl; other queries are handled by the
466+
// OS resolver directly, so the awl upstream is unused (kept as the
467+
// configured default for completeness).
468+
// - split-DNS unsupported: capture everything and forward to the system's
469+
// first base nameserver, falling back to the configured default when the OS
470+
// reports none.
471+
func chooseDNSPolicy(forceUpstream, supportsSplitDNS bool, base []netip.Addr, upstreamCfg string) (matchDomains []dnsname.FQDN, upstream string) {
472+
awlFQDN, err := dnsname.ToFQDN(awldns.LocalDomain)
430473
if err != nil {
431-
a.logger.Errorf("set dns config to os configurator: %v", err)
432-
} else {
433-
a.logger.Info("successfully set dns config to os")
434-
a.isAwlDNSSetAsSystem = true
474+
panic(err)
475+
}
476+
477+
if forceUpstream {
478+
return nil, upstreamCfg
479+
}
480+
if supportsSplitDNS {
481+
return []dnsname.FQDN{awlFQDN}, upstreamCfg
482+
}
483+
// no split DNS: capture everything, forward to the system's base resolver
484+
if len(base) == 0 {
485+
return nil, upstreamCfg
435486
}
487+
// TODO: use all nameservers in awldns resolver proxy
488+
return nil, net.JoinHostPort(base[0].String(), awldns.DefaultDNSPort)
489+
}
490+
491+
// ForceUpstreamDNS toggles full-capture mode where the awl resolver intercepts
492+
// all DNS (not just .awl) and forwards it to the configured public upstream, so
493+
// queries traverse the tunnel and do not leak. Driven by the VPN gateway client
494+
// apply/teardown. No-op (returns nil) when DNS was never set up as the system
495+
// resolver (DNS disabled, or Android where the OS DNS takeover does not apply).
496+
// Idempotent.
497+
func (a *DNSService) ForceUpstreamDNS(enabled bool) error {
498+
a.mu.Lock()
499+
defer a.mu.Unlock()
500+
501+
if a.dnsOsConfigurator == nil || !a.isAwlDNSSetAsSystem {
502+
return nil
503+
}
504+
if a.forceUpstream == enabled {
505+
return nil
506+
}
507+
a.forceUpstream = enabled
508+
a.applyOSDNSConfigLocked()
509+
return nil
436510
}
437511

438-
func (a *DNSService) refreshDNSConfig() {
512+
func (a *DNSService) refreshDNSConfigLocked() {
439513
if a.dnsResolver == nil {
440514
a.logger.DPanicf("called refreshDNSConfig with nil resolver %v", a.dnsResolver)
441515
return
@@ -446,6 +520,8 @@ func (a *DNSService) refreshDNSConfig() {
446520
}
447521

448522
func (a *DNSService) Close() {
523+
a.mu.Lock()
524+
defer a.mu.Unlock()
449525
if a.dnsOsConfigurator != nil {
450526
err := a.dnsOsConfigurator.Close()
451527
if err != nil {
@@ -458,13 +534,17 @@ func (a *DNSService) Close() {
458534
}
459535

460536
func (a *DNSService) AwlDNSAddress() string {
537+
a.mu.Lock()
538+
defer a.mu.Unlock()
461539
if a.dnsResolver != nil {
462540
return a.dnsResolver.DNSAddress()
463541
}
464542
return ""
465543
}
466544

467545
func (a *DNSService) IsAwlDNSSetAsSystem() bool {
546+
a.mu.Lock()
547+
defer a.mu.Unlock()
468548
return a.isAwlDNSSetAsSystem
469549
}
470550

application_test.go

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"io"
77
"net"
88
"net/http"
9+
"net/netip"
910
"net/url"
1011
"runtime"
1112
"sort"
@@ -18,6 +19,7 @@ import (
1819
"golang.org/x/net/proxy"
1920

2021
"github.com/anywherelan/awl/api"
22+
"github.com/anywherelan/awl/awldns"
2123
"github.com/anywherelan/awl/config"
2224
"github.com/anywherelan/awl/entity"
2325
"github.com/anywherelan/awl/protocol"
@@ -82,9 +84,13 @@ func TestRemovePeer(t *testing.T) {
8284
ts.Len(peer1.app.AuthStatus.GetIngoingAuthRequests(), 0)
8385
ts.Len(peer2.app.AuthStatus.GetIngoingAuthRequests(), 0)
8486

85-
// test ping
86-
p1Ping := peer1.app.P2p.GetPeerLatency(peer2.app.P2p.PeerID())
87-
ts.NotEmpty(p1Ping)
87+
// test ping. Latency is recorded asynchronously by the status/auth exchange
88+
// the re-add above kicks off in a goroutine (RecordPeerLatency); on slow CI
89+
// the fixed sleeps aren't always enough for it to land, so poll for it rather
90+
// than asserting once.
91+
ts.Eventually(func() bool {
92+
return peer1.app.P2p.GetPeerLatency(peer2.app.P2p.PeerID()) != 0
93+
}, 15*time.Second, 100*time.Millisecond, "peer latency should be recorded after the status exchange")
8894
}
8995

9096
func TestDeclinePeerFriendRequest(t *testing.T) {
@@ -992,3 +998,78 @@ func TestMetricsEndpoint(t *testing.T) {
992998
// Verify libp2p built-in metrics are present
993999
ts.Contains(metricsOutput, "libp2p_")
9941000
}
1001+
1002+
func TestChooseDNSPolicy(t *testing.T) {
1003+
const upstreamCfg = "9.9.9.9:53"
1004+
base := []netip.Addr{netip.MustParseAddr("192.168.0.1"), netip.MustParseAddr("8.8.8.8")}
1005+
1006+
tests := []struct {
1007+
name string
1008+
forceUpstream bool
1009+
supportsSplitDNS bool
1010+
base []netip.Addr
1011+
wantCapturesAll bool // MatchDomains == nil
1012+
wantUpstream string
1013+
}{
1014+
{
1015+
name: "gateway on captures everything to configured upstream",
1016+
forceUpstream: true,
1017+
supportsSplitDNS: true,
1018+
base: base,
1019+
wantCapturesAll: true,
1020+
wantUpstream: upstreamCfg,
1021+
},
1022+
{
1023+
name: "gateway on ignores split dns support",
1024+
forceUpstream: true,
1025+
supportsSplitDNS: false,
1026+
base: base,
1027+
wantCapturesAll: true,
1028+
wantUpstream: upstreamCfg,
1029+
},
1030+
{
1031+
name: "split dns captures only .awl",
1032+
forceUpstream: false,
1033+
supportsSplitDNS: true,
1034+
base: base,
1035+
wantCapturesAll: false,
1036+
wantUpstream: upstreamCfg,
1037+
},
1038+
{
1039+
name: "no split dns forwards to system base resolver",
1040+
forceUpstream: false,
1041+
supportsSplitDNS: false,
1042+
base: base,
1043+
wantCapturesAll: true,
1044+
wantUpstream: "192.168.0.1:" + awldns.DefaultDNSPort,
1045+
},
1046+
{
1047+
name: "no split dns with empty base falls back to configured upstream",
1048+
forceUpstream: false,
1049+
supportsSplitDNS: false,
1050+
base: nil,
1051+
wantCapturesAll: true,
1052+
wantUpstream: upstreamCfg,
1053+
},
1054+
}
1055+
1056+
for _, tt := range tests {
1057+
t.Run(tt.name, func(t *testing.T) {
1058+
matchDomains, upstream := chooseDNSPolicy(tt.forceUpstream, tt.supportsSplitDNS, tt.base, upstreamCfg)
1059+
1060+
gotCapturesAll := matchDomains == nil
1061+
if gotCapturesAll != tt.wantCapturesAll {
1062+
t.Errorf("captures all queries = %v, want %v (matchDomains=%v)", gotCapturesAll, tt.wantCapturesAll, matchDomains)
1063+
}
1064+
if !tt.wantCapturesAll {
1065+
// split-DNS: must capture exactly the .awl zone
1066+
if len(matchDomains) != 1 || matchDomains[0].WithoutTrailingDot() != awldns.LocalDomain {
1067+
t.Errorf("split-DNS match domains = %v, want [%s]", matchDomains, awldns.LocalDomain)
1068+
}
1069+
}
1070+
if upstream != tt.wantUpstream {
1071+
t.Errorf("upstream = %q, want %q", upstream, tt.wantUpstream)
1072+
}
1073+
})
1074+
}
1075+
}

config/config.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,12 @@ type (
108108
DNSConfig struct {
109109
DisableDNS bool `json:"disableDNS"`
110110
ListenAddress string `json:"listenAddress"`
111+
// UpstreamDNSAddress is the public resolver (host:port) that the awl
112+
// DNS resolver forwards non-.awl queries to. Used as a fallback when
113+
// the OS exposes no base nameserver, and forced as the sole upstream in
114+
// VPN gateway client mode so DNS traverses the tunnel and does not leak.
115+
// On Android the host reads this value to configure VpnService DNS.
116+
UpstreamDNSAddress string `json:"upstreamDNSAddress"`
111117
}
112118
KnownPeer struct {
113119
// Hex-encoded multihash representing a peer ID

config/other.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,9 @@ func setDefaults(conf *Config, bus awlevent.Bus) {
195195
if conf.DNS.ListenAddress == "" {
196196
conf.DNS.ListenAddress = awldns.DefaultDNSAddress
197197
}
198+
if conf.DNS.UpstreamDNSAddress == "" {
199+
conf.DNS.UpstreamDNSAddress = awldns.DefaultUpstreamDNSAddress
200+
}
198201

199202
uniqAliases := make(map[string]struct{}, len(conf.KnownPeers))
200203
if conf.KnownPeers == nil {

docs/swagger.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@ definitions:
2626
type: boolean
2727
listenAddress:
2828
type: string
29+
upstreamDNSAddress:
30+
description: |-
31+
UpstreamDNSAddress is the public resolver (host:port) that the awl
32+
DNS resolver forwards non-.awl queries to. Used as a fallback when
33+
the OS exposes no base nameserver, and forced as the sole upstream in
34+
VPN gateway client mode so DNS traverses the tunnel and does not leak.
35+
On Android the host reads this value to configure VpnService DNS.
36+
type: string
2937
type: object
3038
config.HttpBasicAuthConfig:
3139
properties:

0 commit comments

Comments
 (0)