Skip to content

Commit 26b8cd0

Browse files
authored
Merge pull request #264 from anywherelan/awldns-android
awldns: resolve .awl names on Android via a netstack DNS interceptor
2 parents 9588f92 + 8a7b0ae commit 26b8cd0

22 files changed

Lines changed: 1082 additions & 84 deletions

README.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
- [Terminal-based client](#terminal-based-client)
3333
- [Common examples](#common-examples)
3434
- [Upgrading](#upgrading)
35+
- [Platform notes & known limitations](#platform-notes--known-limitations)
3536
- [Contributing](#contributing)
3637
- [License](#license)
3738

@@ -196,8 +197,6 @@ Open the web UI at http://admin.awl (or the Android app). On the Status / Overvi
196197

197198
To try the public tester: enter `12D3KooWJMUjt9b5T1umzgzjLv5yG2ViuuF4qjmN65tsRXZGS1p8` as peer id, name it `awl-tester`, save. After a few seconds it will appear in your peer list. Open http://awl-tester.awl/ — you should see a network speed-test page.
198199

199-
> `.awl` DNS is not yet available on Android ([#17](https://github.com/anywherelan/awl/issues/17)); on Android you access peers by IP.
200-
201200
When someone invites you, a notification will appear; accept or block in the admin UI.
202201

203202
### Server
@@ -496,6 +495,24 @@ systemctl restart awl
496495

497496
As an alternative on desktop or server: download the new build from the [releases page](https://github.com/anywherelan/awl/releases) and replace the files manually.
498497

498+
## Platform notes & known limitations
499+
500+
### `.awl` name resolution
501+
502+
`.awl` names resolve on every platform, but the mechanism differs:
503+
504+
- **Desktop (Linux / Windows / macOS):** awl runs a local resolver and registers it with the OS. Where the OS supports split-DNS only the `.awl` zone is captured; the rest of your DNS is left untouched, so LAN names keep working.
505+
- **Android:** the app resolves `.awl` inside the tunnel — `.awl` is answered locally, everything else is forwarded to the configured upstream resolver (`1.1.1.1` by default, `dns.upstreamDNSAddress` in the config). This has a few consequences:
506+
- **Private DNS in strict mode** (a hostname set under Android's *Private DNS* setting) bypasses the VPN's DNS entirely, so `.awl` names won't resolve. The default *Automatic* mode works fine.
507+
- While DNS is enabled, all queries go to the configured upstream instead of your network's own resolver, so LAN-only names handed out by your router (e.g. `printer.lan`) won't resolve.
508+
- `admin.awl` is not reachable on Android — use the app's own UI instead.
509+
- `dns.disableDNS: true` in the config turns awl's DNS handling off entirely: `.awl` stops resolving and queries go straight to the system resolver again.
510+
511+
### Other limitations
512+
513+
- **IPv6 inside the tunnel is not supported** — IPv6 packets are dropped, only IPv4 is carried.
514+
- **Serving as a VPN gateway / exit node** is not available on every platform — see the [support table](#vpn-gateway-full-tunnel-exit-node).
515+
499516
# Contributing
500517

501518
Contributions to this repository are very welcome.

application.go

Lines changed: 100 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import (
3232

3333
"github.com/anywherelan/awl/api"
3434
"github.com/anywherelan/awl/awldns"
35+
"github.com/anywherelan/awl/awldns/dnsbridge"
3536
"github.com/anywherelan/awl/awlevent"
3637
"github.com/anywherelan/awl/config"
3738
"github.com/anywherelan/awl/metrics"
@@ -194,12 +195,7 @@ func (a *Application) Init(ctx context.Context, tunDevice tun.Device) error {
194195
go a.SOCKS5.ServeConns(a.ctx)
195196

196197
if !a.Conf.DNS.DisableDNS && !a.Conf.VPNConfig.DisableVPNInterface {
197-
interfaceName, err := a.vpnDevice.InterfaceName()
198-
if err != nil {
199-
a.logger.Errorf("failed to get TUN interface name: %v", err)
200-
} else {
201-
a.Dns.initDNS(interfaceName)
202-
}
198+
a.setupDNS()
203199
}
204200

205201
// Metrics
@@ -218,6 +214,23 @@ func (a *Application) Init(ctx context.Context, tunDevice tun.Device) error {
218214
return nil
219215
}
220216

217+
// setupDNS picks the platform DNS path. On Android there are no OS sockets
218+
// and no OS DNS configurator: DNS packets are intercepted from the TUN read
219+
// path into a netstack bridge instead.
220+
func (a *Application) setupDNS() {
221+
if runtime.GOOS == "android" {
222+
a.Dns.initDNSAndroid(a.vpnDevice, a.Tunnel)
223+
return
224+
}
225+
226+
interfaceName, err := a.vpnDevice.InterfaceName()
227+
if err != nil {
228+
a.logger.Errorf("failed to get TUN interface name: %v", err)
229+
return
230+
}
231+
a.Dns.initDNS(interfaceName)
232+
}
233+
221234
func (a *Application) SetupLoggerAndConfig(appType config.AppType) *log.ZapEventLogger {
222235
a.Eventbus = eventbus.NewBus()
223236
// Config
@@ -373,15 +386,17 @@ type DNSService struct {
373386

374387
mu sync.Mutex
375388
dnsHost string
376-
dnsFQDN dnsname.FQDN
377389
dnsOsConfigurator dns.OSConfigurator
378390
dnsResolver *awldns.Resolver
391+
dnsBridge *dnsbridge.Bridge
379392
upstreamDNS string
380393
isAwlDNSSetAsSystem bool
381394
// forceUpstream forces the awl resolver to capture all queries
382395
// (MatchDomains=nil) and forward them to the configured public upstream so
383396
// DNS traverses the tunnel instead of leaking to the system resolver. Set
384-
// in VPN gateway client mode.
397+
// in VPN gateway client mode. Desktop only: the Android netstack bridge has
398+
// no split-DNS choice to make (it already captures all device DNS), so it
399+
// leaves this at zero and ForceUpstreamDNS is a no-op there.
385400
forceUpstream bool
386401
}
387402

@@ -401,20 +416,6 @@ func (a *DNSService) initDNS(interfaceName string) {
401416
}
402417
a.dnsHost = dnsHost
403418

404-
fqdn, err := dnsname.ToFQDN(awldns.LocalDomain)
405-
if err != nil {
406-
panic(err)
407-
}
408-
a.dnsFQDN = fqdn
409-
410-
// TODO(android awldns): on Android this NewResolver cannot bind :53 (needs
411-
// root) and dnsOsConfigurator.SetDNS below fails (no writable resolv.conf),
412-
// so awldns is effectively inert there and .awl names do not resolve. The
413-
// Android host instead points VpnService at DNS.UpstreamDNSAddress directly
414-
// (see awl-flutter MainActivity.establishTun), which prevents leaks but
415-
// gives no .awl resolution. A full fix would intercept :53 to a magic awl
416-
// IP inside the tunnel read-path (userspace netstack),
417-
// rather than binding an OS socket.
418419
a.dnsResolver = awldns.NewResolver(dnsAddr)
419420
a.upstreamDNS = a.conf.DNS.UpstreamDNSAddress
420421
a.forceUpstream = a.conf.VPNGateway.ClientEnabled
@@ -438,6 +439,60 @@ func (a *DNSService) initDNS(interfaceName string) {
438439
a.applyOSDNSConfigLocked()
439440
}
440441

442+
// initDNSAndroid sets up the Android DNS path: no OS sockets and no OS DNS
443+
// configurator. A netstack bridge (awldns/dnsbridge) owns the in-subnet DNS
444+
// IP, the Tunnel feeds it packets intercepted from the TUN read path, and the
445+
// resolver serves on the bridge's listeners. The Android host passes the same
446+
// IP to VpnService.Builder.addDnsServer, so all device DNS arrives there. On
447+
// any failure DNS stays off with a log; VPN keeps working.
448+
func (a *DNSService) initDNSAndroid(vpnDevice *vpn.Device, tunnel *service.Tunnel) {
449+
a.mu.Lock()
450+
defer a.mu.Unlock()
451+
452+
dnsIP := a.conf.NetstackDNSIP()
453+
if dnsIP == nil {
454+
a.logger.Errorf("no free IP for the DNS server in VPN subnet %s, DNS is disabled", a.conf.VPNConfig.IPNet)
455+
return
456+
}
457+
dnsAddr, ok := netip.AddrFromSlice(dnsIP.To4())
458+
if !ok {
459+
a.logger.Errorf("invalid DNS server IP %v, DNS is disabled", dnsIP)
460+
return
461+
}
462+
463+
bridge, err := dnsbridge.New(dnsAddr, vpn.InterfaceMTU, vpnDevice.WriteRawPacket)
464+
if err != nil {
465+
a.logger.Errorf("create DNS netstack bridge, DNS is disabled: %v", err)
466+
return
467+
}
468+
a.dnsBridge = bridge
469+
a.dnsResolver = awldns.NewResolverFromListeners(bridge.UDPConn(), bridge.TCPListener(),
470+
net.JoinHostPort(dnsIP.String(), awldns.DefaultDNSPort))
471+
472+
// TODO(android awldns): use the system DNS servers reported by the Android
473+
// layer (ConnectivityManager network callback) as upstream, so LAN names
474+
// keep resolving; for now non-.awl queries always go to the configured
475+
// public upstream. In VPN gateway client mode the resolver's upstream
476+
// socket is deliberately unprotected: its traffic loops back into the TUN
477+
// and leaves through the gateway peer — no DNS leak.
478+
a.upstreamDNS = a.conf.DNS.UpstreamDNSAddress
479+
a.refreshDNSConfigLocked()
480+
481+
awlevent.WrapSubscriptionToCallback(a.ctx, func(_ interface{}) {
482+
a.mu.Lock()
483+
defer a.mu.Unlock()
484+
a.refreshDNSConfigLocked()
485+
}, a.eventbus, new(awlevent.KnownPeerChanged))
486+
487+
tunnel.SetDNSHandler(dnsIP, bridge)
488+
// The interceptor now handles all device DNS — the host sets the same IP
489+
// via addDnsServer — which is exactly what this flag means to the UI.
490+
// (dnsIP is config.NetstackDNSIP, reserved from peers since setDefaults.)
491+
a.isAwlDNSSetAsSystem = true
492+
493+
a.logger.Infof("DNS interceptor is set up on %s (upstream %s)", dnsAddr, a.upstreamDNS)
494+
}
495+
441496
// applyOSDNSConfigLocked (re)computes the OS DNS takeover config from the
442497
// current state (split-DNS support, base config, forceUpstream) and pushes it
443498
// to the OS, then refreshes the awl resolver. Caller must hold a.mu and have a
@@ -544,7 +599,12 @@ func (a *DNSService) refreshDNSConfigLocked() {
544599
return
545600
}
546601
dnsNamesMapping := a.conf.DNSNamesMapping()
547-
dnsNamesMapping[config.AdminHttpServerDomainName] = config.AdminHttpServerIP
602+
// TODO(android awldns): make admin.awl work on Android. The admin server is
603+
// not reachable on AdminHttpServerIP there (the API listens elsewhere and
604+
// port 80 cannot be bound), so don't advertise a dead name.
605+
if runtime.GOOS != "android" {
606+
dnsNamesMapping[config.AdminHttpServerDomainName] = config.AdminHttpServerIP
607+
}
548608
a.dnsResolver.ReceiveConfiguration(a.upstreamDNS, dnsNamesMapping)
549609
}
550610

@@ -560,8 +620,14 @@ func (a *DNSService) Close() {
560620
if a.dnsResolver != nil {
561621
a.dnsResolver.Close()
562622
}
623+
if a.dnsBridge != nil {
624+
a.dnsBridge.Close()
625+
}
563626
}
564627

628+
// AwlDNSAddress returns the resolver address (ip:port) to display in the
629+
// status API/UI/CLI, empty until both resolver servers are up. Not the
630+
// host-wiring IP — for that see NetstackDNSServerIP.
565631
func (a *DNSService) AwlDNSAddress() string {
566632
a.mu.Lock()
567633
defer a.mu.Unlock()
@@ -577,6 +643,17 @@ func (a *DNSService) IsAwlDNSSetAsSystem() bool {
577643
return a.isAwlDNSSetAsSystem
578644
}
579645

646+
// NetstackDNSServerIP returns the in-tunnel IP owned by the running DNS
647+
// interceptor (Android), nil when the interceptor is not set up.
648+
func (a *DNSService) NetstackDNSServerIP() net.IP {
649+
a.mu.Lock()
650+
defer a.mu.Unlock()
651+
if a.dnsBridge == nil {
652+
return nil
653+
}
654+
return a.dnsBridge.DNSIP().AsSlice()
655+
}
656+
580657
// configMetricsAdapter implements metrics.ConfigMetrics by combining Config and AuthStatus.
581658
type configMetricsAdapter struct {
582659
conf *config.Config

application_gateway_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1145,3 +1145,32 @@ func TestGatewayUnknownPeerIDAtStartupFailsBoot(t *testing.T) {
11451145
ts.Equal(unknownPeerID, tp.app.Conf.VPNGateway.GatewayPeerID,
11461146
"unknown peer at startup must NOT auto-wipe GatewayPeerID")
11471147
}
1148+
1149+
// TestGatewayDNSInterception verifies the DNS interceptor filter runs before
1150+
// the gateway-client branch in HandleReadPackets: a packet to the in-tunnel
1151+
// DNS IP is diverted to the handler instead of being dropped as in-subnet
1152+
// traffic (or forwarded to the exit node).
1153+
func TestGatewayDNSInterception(t *testing.T) {
1154+
skipIfVPNGatewayUnsupported(t)
1155+
ts := NewTestSuite(t)
1156+
1157+
client, _, _ := setupGatewayPeers(ts)
1158+
1159+
dnsIP := client.app.Conf.NetstackDNSIP()
1160+
ts.NotNil(dnsIP)
1161+
1162+
intercepted := make(chan []byte, 16)
1163+
client.app.Tunnel.SetDNSHandler(dnsIP, dnsHandlerFunc(func(packet []byte) {
1164+
intercepted <- append([]byte{}, packet...)
1165+
}))
1166+
1167+
dnsPacket := testPacketWithDest(0, dnsIP.String())
1168+
client.tun.Outbound <- [][]byte{dnsPacket}
1169+
1170+
select {
1171+
case got := <-intercepted:
1172+
ts.Equal(dnsPacket, got)
1173+
case <-time.After(5 * time.Second):
1174+
ts.FailNow("dns packet was not intercepted in gateway client mode")
1175+
}
1176+
}

0 commit comments

Comments
 (0)