This was a huge hassle to figure out, so I wrote up a little guide in hopes that others would find it helpful:
How to convince macOS to do IPv6 DNS lookups when your only IPv6 address is via a VPN or tunnel of some sort
macOS's domain name resolver will only return IPv6 addresses (from AAAA records) when it thinks that you have a valid routable IPv6 address. For physical interfaces like Ethernet or Wi-Fi it's enough to set or be assigned an IPv6 address, but for tunnels (such as those using utun
interfaces) there are some extra annoying steps that need to be taken to convince the system that yes, you indeed have an IPv6 address, and yes, you'd like to get IPv6 addresses back for DNS lookups.
I use wg-quick
to establish a WireGuard tunnel between my laptop and a Linode virtual server. WireGuard uses a utun
user-space tunnel device to make the connection. Here's how that device gets configured:
utun1: flags=8051<UP,POINTOPOINT,RUNNING,MULTICAST> mtu 1420
inet 10.75.131.2 --> 10.75.131.2 netmask 0xffffff00
inet6 fe80::a65e:60ff:fee1:b1bf%utun1 prefixlen 64 scopeid 0xc
inet6 2600:3c03::de:d002 prefixlen 116
nd6 options=201<PERFORMNUD,DAD>
And here's a few relevant lines from my routing table:
Internet:
Destination Gateway Flags Refs Use Netif Expire
0/1 utun1 USc 0 0 utun1
default 10.20.4.4 UGSc 0 0 en3
10.20.4/24 link#14 UCS 3 0 en3 !
10.75.131.2 10.75.131.2 UH 0 0 utun1
50.116.51.30 10.20.4.4 UGHS 7 2629464 en3
128.0/1 utun1 USc 5 0 utun1
Internet6:
Destination Gateway Flags Netif Expire
::/1 utun1 USc utun1
2600:3c03::de:d000/116 fe80::a65e:60ff:fee1:b1bf%utun1 Uc utun1
8000::/1 utun1 USc utun1
10.20.4/24
is my local ethernet network.10.20.4.5
is my laptop's LAN IP address.10.20.4.4
is my gateway's LAN IP address.10.75.131.2
is the IPv4 address of my end of the WireGuard point-to-point tunnel.2600:3c03::de:d002
is the IPv6 address of my end of the WireGuard point-to-point tunnel.50.116.51.30
is the public address of my Linode server.
This should be enough to have IPv6 connectivity, right? Well, name resolution works when host
talks directly to my name server:
sam@shiny ~> host ipv6.whatismyv6.com
ipv6.whatismyv6.com has IPv6 address 2607:f0d0:3802:84::128
Pinging by IPv6 address works:
sam@shiny ~> ping6 -c1 2607:f0d0:3802:84::128
PING6(56=40+8+8 bytes) 2600:3c03::de:d002 --> 2607:f0d0:3802:84::128
16 bytes from 2607:f0d0:3802:84::128, icmp_seq=0 hlim=55 time=80.991 ms
--- 2607:f0d0:3802:84::128 ping6 statistics ---
1 packets transmitted, 1 packets received, 0.0% packet loss
round-trip min/avg/max/std-dev = 80.991/80.991/80.991/0.000 ms
And HTTP connections by IPv6 address work:
sam@shiny ~> curl -s 'http://[2607:f0d0:3802:84::128]' -H 'Host: ipv6.whatismyv6.com' | html2text | head -3
This page shows your IPv6 and/or IPv4 address
You are connecting with an IPv6 Address of:
2600:3c03::de:d002
However, HTTP connections by IPv6-only hostname don't work:
sam@shiny ~> curl 'http://ipv6.whatismyv6.com'
curl: (6) Could not resolve host: ipv6.whatismyv6.com
The result is the same in wget
as well as in GUI apps like Firefox: connecting by a literal IPv6 address works fine, but connecting by a hostname that only has an AAAA record (and no A record) associated with it does not.
Interestingly, ping6
is able to do a DNS lookup and get an IPv6 address back:
sam@shiny ~ [6]> ping6 -c1 ipv6.whatismyv6.com
PING6(56=40+8+8 bytes) 2600:3c03::de:d002 --> 2607:f0d0:3802:84::128
16 bytes from 2607:f0d0:3802:84::128, icmp_seq=0 hlim=55 time=49.513 ms
--- ipv6.whatismyv6.com ping6 statistics ---
1 packets transmitted, 1 packets received, 0.0% packet loss
round-trip min/avg/max/std-dev = 49.513/49.513/49.513/0.000 ms
Why can ping6
do this when nothing else can? It turns out that when ping6
calls getaddrinfo
it overwrites the default flags. One of the default flags is AI_ADDRCONFIG
, which tells the resolver to only return addresses in address families that the system has an IP address for. (That is, don't return IPv6 addresses unless the system has a (not link-local) IPv6 address.) Most other programs add to the default flags rather than clobbering them, which I suppose is sensible.
If you run scutil --dns
it will tell you how the resolver is set up. Here's the output on my system (minus a bunch of mdns stuff that doesn't matter):
DNS configuration
resolver #1
search domain[0] : home.munkynet.org
nameserver[0] : 10.20.4.4
if_index : 14 (en3)
flags : Request A records
reach : 0x00020002 (Reachable,Directly Reachable Address)
DNS configuration (for scoped queries)
resolver #1
search domain[0] : home.munkynet.org
nameserver[0] : 10.20.4.4
if_index : 14 (en3)
flags : Scoped, Request A records
reach : 0x00020002 (Reachable,Directly Reachable Address)
Note that under flags
, it says Request A records
but not Request AAAA records
. So it's left to us to try to convince macOS's resolver that we do in fact have a valid IPv6 address, even though it's on a tunnel interface.
The "right" way for this to happen is for whatever program sets up the tunnel to use the bizarre and largely undocumented SystemConfiguration
API to register the network "service" and its IPv6 properties. The Viscosity app does this. Tunnelblick does not, the official OpenVPN Client does not, and wg-quick
sure as hell doesn't.
We can create the same SystemConfiguration "service" strucures manually using the scutil
command:
First we create the IPv4 part of the service:
sam@shiny ~> sudo scutil
> d.init
> d.add Addresses * 10.75.131.2
> d.add DestAddresses * 10.75.131.2
> d.add InterfaceName utun1
> set State:/Network/Service/my_ipv6_tunnel_service/IPv4
> set Setup:/Network/Service/my_ipv6_tunnel_service/IPv4
And then we create the IPv6 part:
> d.init
> d.add Addresses * fe80::a65e:60ff:fee1:b1bf 2600:3c03::de:d002
> d.add DestAddresses * ::ffff:ffff:ffff:ffff:0:0 ::
> d.add Flags * 0 0
> d.add InterfaceName utun1
> d.add PrefixLength * 64 116
> set State:/Network/Service/my_ipv6_tunnel_service/IPv6
> set Setup:/Network/Service/my_ipv6_tunnel_service/IPv6
> quit
Once this is done, the output of scutil --dns
(again modulo mdns stuff) changes:
DNS configuration
resolver #1
search domain[0] : home.munkynet.org
nameserver[0] : 10.20.4.4
if_index : 14 (en3)
flags : Request A records, Request AAAA records
reach : 0x00020002 (Reachable,Directly Reachable Address)
DNS configuration (for scoped queries)
resolver #1
search domain[0] : home.munkynet.org
nameserver[0] : 10.20.4.4
if_index : 14 (en3)
flags : Scoped, Request A records
reach : 0x00020002 (Reachable,Directly Reachable Address)
Now we see Request AAAA records
in the flags! I'm not really sure what "scoped queries" are or why the DNS configuration for them didn't change, but things seem to work now so whatever:
sam@shiny ~> curl -s 'http://ipv6.whatismyv6.com' | html2text | head -3
This page shows your IPv6 and/or IPv4 address
You are connecting with an IPv6 Address of:
2600:3c03::de:d002
When disconnecting from the tunnel, all you have to do is remove the SystemConfiguration keys you added:
sam@shiny ~> sudo scutil
> remove State:/Network/Service/my_ipv6_tunnel_service/IPv4
> remove Setup:/Network/Service/my_ipv6_tunnel_service/IPv4
> remove State:/Network/Service/my_ipv6_tunnel_service/IPv6
> remove Setup:/Network/Service/my_ipv6_tunnel_service/IPv6
> quit
A couple things to note:
- The name
my_ipv6_tunnel_service
is totally arbitrary. - According to information I gleaned from the up/down scripts in the Mullvad
.ovpn
profile, you have to create both theSetup:
andState:
keys. I didn't verify this because I am lazy. - I have no clue where the IPv6
DestAddresses
come from. I copied these from Viscosity because they seemed to work there.::ffff:ffff:ffff:ffff:0:0
for the link-local address and::
for the public - I don't even really know what
DestAddresses
means or what it's used for.
I wrote a python script that gleans addresses and prefix lengths from ifconfig
output. It requires Python 3.6 or later so make sure you've got that in your path. It's called wg-updown
and calls its SystemConfiguration service wg-updown-utun#
, but it's not really WireGuard-specific. You could call it as a post-up/pre-down script for any old VPN tunnel or run it manually. Call it like this:
# After tunnel comes up
wg-updown up IFACE
# Before tunnel goes down
wg-updown down IFACE
replace IFACE
with the name of the interface that your tunnel/VPN client is using, e.g. utun1
. It will print the commands that it's sending to scutil
so you can see what it's doing in detail.
#!/usr/bin/env python3
import re
import subprocess
import sys
def service_name_for_interface(interface):
return 'wg-updown-' + interface
v4pat = re.compile(r'^\s*inet\s+(\S+)\s+-->\s+(\S+)\s+netmask\s+\S+')
v6pat = re.compile(r'^\s*inet6\s+(\S+?)(?:%\S+)?\s+prefixlen\s+(\S+)')
def get_tunnel_info(interface):
ipv4s = dict(Addresses=[], DestAddresses=[])
ipv6s = dict(Addresses=[], DestAddresses=[], Flags=[], PrefixLength=[])
ifconfig = subprocess.run(["ifconfig", interface], capture_output=True,
check=True, text=True)
for line in ifconfig.stdout.splitlines():
v6match = v6pat.match(line)
if v6match:
ipv6s['Addresses'].append(v6match[1])
# This is cribbed from Viscosity and probably wrong.
if v6match[1].startswith('fe80'):
ipv6s['DestAddresses'].append('::ffff:ffff:ffff:ffff:0:0')
else:
ipv6s['DestAddresses'].append('::')
ipv6s['Flags'].append('0')
ipv6s['PrefixLength'].append(v6match[2])
continue
v4match = v4pat.match(line)
if v4match:
ipv4s['Addresses'].append(v4match[1])
ipv4s['DestAddresses'].append(v4match[2])
continue
return (ipv4s, ipv6s)
def run_scutil(commands):
print(commands)
subprocess.run(['scutil'], input=commands, check=True, text=True)
def up(interface):
service_name = service_name_for_interface(interface)
(ipv4s, ipv6s) = get_tunnel_info(interface)
run_scutil('\n'.join([
f"d.init",
f"d.add Addresses * {' '.join(ipv4s['Addresses'])}",
f"d.add DestAddresses * {' '.join(ipv4s['DestAddresses'])}",
f"d.add InterfaceName {interface}",
f"set State:/Network/Service/{service_name}/IPv4",
f"set Setup:/Network/Service/{service_name}/IPv4",
f"d.init",
f"d.add Addresses * {' '.join(ipv6s['Addresses'])}",
f"d.add DestAddresses * {' '.join(ipv6s['DestAddresses'])}",
f"d.add Flags * {' '.join(ipv6s['Flags'])}",
f"d.add InterfaceName {interface}",
f"d.add PrefixLength * {' '.join(ipv6s['PrefixLength'])}",
f"set State:/Network/Service/{service_name}/IPv6",
f"set Setup:/Network/Service/{service_name}/IPv6",
]))
def down(interface):
service_name = service_name_for_interface(interface)
run_scutil('\n'.join([
f"remove State:/Network/Service/{service_name}/IPv4",
f"remove Setup:/Network/Service/{service_name}/IPv4",
f"remove State:/Network/Service/{service_name}/IPv6",
f"remove Setup:/Network/Service/{service_name}/IPv6",
]))
def main():
operation = sys.argv[1]
interface = sys.argv[2]
if operation == 'up':
up(interface)
elif operation == 'down':
down(interface)
else:
raise NotImplementedError()
if __name__ == "__main__":
main()
Nothing seems to work anymore under Ventura :( At least in my case