making captive portals work with dns over tls in systemd-resolved

published on 19th December 2023

I’m currently using systemd-networkd, systemd-resolved and iwd together with my ad blocking DNS-over-TLS server for my networking, and it works quite well for most networks, even the pesky 802.1x types such as eduroam. But now picture this: you’re at a hotel, a public place or in a train and you want to use the free Wi-Fi but you can’t seem to get online because your custom config using some minimal network configuration doesn’t work, and you’re on the brink of just installing NetworkManager and rolling with the defaults. Fear not! This is how to make an exception for your DNS-over-TLS config for certain networks.

But first, let’s talk about why we need to do this.

how do captive portals work?

First you need to know that Wi-Fi access points always provide connected devices with a default DNS server over DHCP, which is usually your access point itself. Using this default DNS server, the AP can block all traffic to external sites by just making every domain name resolve to the captive portal login page (it then keeps track of your login status using your device’s MAC address). Note that there are also captive portals using HTTP traffic interception instead of DNS redirection, however, I found that these aren’t that common anymore, because they have issues with HTTPS traffic, which is most traffic nowadays.

current config

Your config files probably look something like this if your setup is similar to mine:

/etc/systemd/network/20-wlan0.network copy
 1[Match]
 2Name=wlan0
 3
 4[DHCPv4]
 5# dont use dns servers advertised by dhcp server (router)
 6UseDNS=no
 7
 8[DHCPv6]
 9# dont use dns servers advertised by dhcp server (router)
10UseDNS=no
/etc/systemd/resolved.conf copy
 1[Resolve]
 2# insert your dns over tls server ip and domain name below, keep the '#'
 3DNS=1.2.3.4#dnsovertls.resolver.tld
 4# only use provided dns server, no fallback
 5FallbackDNS=
 6# use dns for all domains
 7Domains=~.
 8DNSOverTLS=yes
 9ReadEtcHosts=yes
10Cache=yes
11LLMNR=no
12MulticastDNS=no
13# hacky way to only make resolved listen on udp 127.0.0.1:53
14DNSStubListener=no
15DNSStubListenerExtra=udp:127.0.0.1:53

Note that by default iwd does network configuration such as handling DHCP itself, but I chose to let systemd-networkd do this, because I’m not sure if it’s possible to do such high-level config distinctions (Wi-Fi access point names, etc.) in the iwd config. So here’s my iwd config, too:

/etc/iwd/main.conf copy
 1[General]
 2# dhcp is done by systemd-networkd
 3EnableNetworkConfiguration=false
 4
 5# random mac address
 6AddressRandomization=once
 7AddressRandomizationRange=full
 8
 9[Network]
10NameResolvingService=systemd

needed config changes

We need to create a config file that overrides the default interface settings shown above. To do this, just create the following file:

/etc/systemd/resolved.conf copy
 1[Match]
 2Name=wlan0
 3SSID="free_wifi"
 4
 5[Network]
 6DHCP=yes
 7# because the dns advertised by routers is not encrypted
 8DNSOverTLS=no
 9# i dont expect hotspots to support dnssec
10DNSSEC=no
11# reset options set in global systemd-resolved conf
12DNS=
13Domains=~.
14
15[DHCPv4]
16UseDNS=yes
17
18[DHCPv6]
19UseDNS=yes

And exclude the SSID in your default config file:

/etc/systemd/network/20-default.network copy
1[Match]
2Name=wlan0
3SSID=! "free_wifi"

If you want to connect to a new open Wi-Fi, just add its SSID to the two config files, explicitly including it in one file and explicitly excluding it in the other, like so:

/etc/systemd/network/20-default.network copy
1[Match]
2Name=wlan0
3SSID=! "free_wifi_2"
/etc/systemd/network/30-captiveportal.network copy
1[Match]
2Name=wlan0
3SSID="free_wifi_2"

Then restart all the relevant services:

1sudo systemctl restart iwd systemd-resolved systemd-networkd

And go to http://captive.apple.com, which should be intercepted by the captive portal and redirect to its login page.

You should now also be able to see that you’re using a different DNS server when you run resolvectl status.

switching back dns servers

Now that you’re authenticated to the captive portal, you should be able to change back your DNS server using this command:

1sudo systemd-resolve --interface wlan0 --set-dns="1.2.3.4#dnsovertls.resolver.tld" --set-domain "~." --set-dnsovertls=true

To verify this, run sudo ngrep port 853 (853 is the port used for DNS-over-TLS) and see the encrypted DNS traffic to the server address you used.