Dynamic host configuration, please.

This commit is contained in:
Florian Obser 2023-03-05 18:56:22 +01:00
parent 024073d10e
commit 595f8ee2ff

View File

@ -0,0 +1,668 @@
#+TITLE: Dynamic host configuration, please
#+DATE: 2023-03-03
* Prologue
The minimal viable product for an OpenBSD laptop has the following
features:
1. It has a real time clock (RTC).
2. It runs Emacs.
3. It can suspend *and* resume.
4. It has working Wi-Fi.
With those things available we can start to improve the user
experience.
A smart phone is basically always online in urban areas and even in
rural areas[fn:: My phone automatically connected to the Wi-Fi at Elk
Lakes Cabin. Never mind that we had to drag the satellite dish over
the pass.]. Nearly seven years ago at a hackathon in Cambridge, UK, we
set out to have a similar experience for our laptops. We will look at
how OpenBSD configures Wi-Fi networks, deals with network
auto-configuration for IPv4 and IPv6, and DNS resolution. We will show
how it does this in a reasonably secure way with minimal manual
configuration.
* Join the Wi-Fi.
The reader might recognize this conversation when arriving at a new
location and taking out their phone:
#+begin_quote
Me: Hey, what's the Wi-Fi password?
Them: We are in the middle of nowhere, there is no Wi-Fi.
Me: All lower-case, one word?
#+end_quote
On the phone, we need to select the Wi-Fi and enter the password only
once. The phone then remembers it indefinitely and auto-connects to
it whenever the Wi-Fi is in range.
On OpenBSD, network interfaces are configured by [[https://man.openbsd.org/ifconfig.8][ifconfig(8)]], or
persistently in [[https://man.openbsd.org/hostname.if.5][/etc/hostname.IF]][fn::IF denotes a specific network
interface. For example for iwm0 the file is =/etc/hostname.iwm0=],
which is read by [[https://man.openbsd.org/netstart.8][netstart(8)]] during boot. netstart(8) calls ifconfig(8)
internally to handle the network configuration.
For a long time, we could only configure one SSID:
#+begin_src shell
$ cat /etc/hostname.iwm0
nwid home wpakey "trivial password"
inet autoconf
inet6 autoconf
up
#+end_src
This configures a Wi-Fi network named "home" and a password "trivial
password". IPv4 and IPv6 auto-configuration are enabled. Whenever the
network is in range the kernel automatically connects to it.
That is not a good user experience (UX). We typically take our laptops
with us and connect to different Wi-Fi networks, like our phones. We
have a Wi-Fi at home, at work, there are open Wi-Fis at hotels, and so
on.
People came up with all kinds of weird shell scripts that would run in
the background or triggered by [[https://man.openbsd.org/cron.8][cron(8)]] to notice when the laptop moved
to a different Wi-Fi. The script would then call ifconfig(8) to
reconfigure Wi-Fi from a list of networks it new about. This was all
incredibly fragile and not the OpenBSD way.
Peter Hessler (phessler@), with the help of Stefan Sperling (stsp@)
went ahead and tackled this problem: What if we could pass multiple
=(name, password)= tuples to the kernel and the kernel would chose the
right one?
#+begin_src shell
$ cat /etc/hostname.iwm0
join home wpakey "trivial password"
join work wpakey zUDciIezevfySqam
join "Airport Wi-Fi"
join ""
inet autoconf
inet6 autoconf
up
#+end_src
=join= implements exactly this. The argument to =join= is the name of
the network and the following =wpakey= is the password for that
network. If we leave out the =wpakey=, the Wi-Fi is open and does not
require a password. Using =join= with the empty string (~join ""~)
means the kernel will try to connect to any open Wi-Fi if no Wi-Fi
from the join list is found first.
We still need to configure the name and password by editing a file
in =/etc/= and run netstart(8) when we encounter a new Wi-Fi. This is
probably not the best UI[fn::As far as I am concerned ed(1) is the
pinnacle of UI design, but YMMV.] but the UX is pretty good and on par
with a smart phone. Once the Wi-Fi is configured by adding a =join=
line, the kernel will automatically re-connect to a known Wi-Fi
whenever it comes into range.
* Stop slacking.
Now that we are connected to the Wi-Fi, we need to configure IP
addresses.
We started our efforts to improve the network configuration user
experience with IPv6 for two reasons. Even in this day and age
IPv6 is a technology for early adopters[fn::Which is quite sad.], they
are used to pain. When we break IPv4, people tend to complain. With
IPv6 they are eager to help debug the problem.
The other reason was, OpenBSD got IPv6 support from the KAME project
in the late 1990s and early 2000s and then there was not a lot of work
done afterwards. The network configuration was handled mostly in the
kernel, so there was no isolation from malicious input. For the most
part it assumed a stationary work station that tried to acquire an
IPv6 prefix for stateless address auto-configuration during boot by
sending three router solicitations and then listened for router
advertisements to create auto-configuration addresses and renewed
their lifetimes when a new advertisement flew by. There was some
rudimentary code in rtsold(8) to handle movement between networks, but
nobody was using it because it was optional. rtsold(8) was used in
one-shot mode where it would sent at most three router solicitations
when an interface connected to the network and then it would exit.
We started to write [[https://man.openbsd.org/slaacd.8][slaacd(8)]][fn:name_things:I should not be allowed
to name things.] and once that was working we could delete rtsold(8)
and remove a lot of code from the kernel.
slaacd(8) is a privilege separated network daemon that build previous
experience with privilege separation in OpenBSD. It uses three
processes, the /parent/ process to configure the system, the
/frontend/ process to talk to the outside world and the /engine/
process to handle untrusted data and run a state machine for the
stateless address auto-configuration protocol.
pledge(2) restricts what a process is allowed to do and this is
enforced by the kernel. Enforcement means that the kernel will
terminate processes that violate what they pledged they would do. The
pledges themselves are in broad strokes, we do not concern ourselves
with single system calls but with groups of system calls. For example,
the process is allowed to interact with open file descriptors
(="stdio"=), it is allowed to open connections to hosts on the
Internet (="inet"=), or it is allowed to open files for reading
(="rpath"=).
The /parent/ process pledges that it will only open new network
sockets, send those to other processes and reconfigure the routing
table (="stdio inet sendfd wroute"=). The /frontend/ process pledges
to only receive file descriptors, open unix domain sockets and check
the state of the routing table (="stdio unix recvfd route"=). Checking
the routing table includes seeing which flags are configured per
interface. The /engine/ process pledges to only read and write to
already open file-descriptors (="stdio"=). The /engine/ process is
very restricted what it is allowed to do. This is important because it
handles untrusted data coming from the network. While the /frontend/
process talks to the network, it never looks at the data. An attacker
will not be able to confuse the /frontend/ process with data they
sent. They can and did [[https://ftp.openbsd.org/pub/OpenBSD/patches/7.0/common/014_slaacd.patch.sig][confuse]] the /engine/ process.
For more details see [[file:privsep.org]["Privilege drop, privilege separation, and
restricted-service operating mode in OpenBSD"]].
slaacd(8) is enabled per default on all OpenBSD installations.
IPv6 stateless address auto-configuration is enabled on an interface
by setting the =AUTCONF6= flag using [[file:/man.openbsd.org/ifconfig.8][ifconfig(8)]]: =ifconfig iwm0 inet6
autoconf=. The kernel announces this changed interface flag to the
whole system using a broadcasted route message. slaacd(8) reads those
messages using a [[https://man.openbsd.org/route.4][route(4)]] socket.
slaacd(8) handles all aspects of stateless address
auto-configuration. It sends router solicitations when needed, either
multi-cast or uni-cast, depending on which is appropriate. It waits
for router advertisements, parses them, and configures default routes,
global and temporary IPv6 addresses, and passes name server
information via a route message to the rest of the system. It takes
care of the lifetimes of addresses, default routes, and name server
information expiring and removing those from the system when no router
advertisements are received to extend the lifetime.
slaacd(8) also monitors when network interfaces regain their
connection to a network. For example because the laptop woke up from
suspend or it got moved out of range of a Wi-Fi network and moved back
into range. It then needs to find out if it connected to the same
network as before or if it is now in a new network. If it is a new
network we need to replace the old addresses, default route, and name
servers. If there is no IPv6 available it needs to remove the old
information.
The stateless address auto-configuration specification allows multiple
default routers being present on the same layer two network,
announcing the same or different network information. slaacd(8) tries
to handle this, but this has not been extensively tested in all
possible cases. There are still open questions being discussed at the
IETF on how to run networks with different network prefixes in the
same layer two network. Hic sunt dracones...
slaacd(8) does handle multiple interfaces just fine and we will show
later how we pick the right source address when multiple are available
to chose from.
* Dynamic host configuration, please.
With IPv6 address configuration mostly solved, it was time to look at
IPv4 again. We used a fork of ISC's dhclient(8). Henning Brauer
(henning@) added privilege separation to it and in recent years
Kenneth Westerback (krw@) heroically maintained it. It was showing its
age though. The privilege separation was never quite right. This
became more visible with the integration of pledge(2) and it would be
difficult to integrate some of the features we developed in slaacd(8).
It was time to write a new daemon. Otto Moerbeek (otto@) solved the
most pressing problem by suggesting a name for it: dhcpleased(8). We
try to be polite towards the computer. It is pronounced "dynamic host
configuration, please". The "d" is silent.
On a very high level IPv4 DHCP and IPv6 stateless address
auto-configuration are very similar. We request some information from
the router[fn::In IPv6 we might not need to request the information,
it might just show up unannounced.], we use it to configure the system
and we make sure that information does not expire. When we move
networks we need to probe if our information is still up to date and
if not, reconfigure the system.
The obvious solution is to copy =sbin/slaacd= to =sbin/dhcpleased= and
replace the IPv6 specific bits with IPv4 specific bits. And that is
exactly what we did.
On paper DHCP looks more complicated than IPv6 stateless address
auto-configuration because it negotiates with the server and there is
a complicated state machine to implement.
In practice it is the other way around. The "stateless" part in IPv6
does not apply to the client. The client must keep state and implement
a state machine to keep track of which routers are available and when
various information expires. In IPv4 we talk to one server and all
information expires at the same time.
We will talk about a few differences between slaacd(8) and
dhcpleased(8) in a moment, but from the user perspective both behave
the same. They make sure that the address configuration and default
gateway are always up to date and they pay attention when the machine
moves between networks, either while awake or while sleeping.
Because dhcpleased(8) has to use [[https://man.openbsd.org/bpf.4][bpf(4)]] instead of regular sockets for
some of the network packets it needs to sent, the /parent/ process
cannot use pledge(2). There is nothing it could pledge that would
allow the usage of bpf(4) at the moment. To protect the system and
prevent exfiltration of sensitive data we use [[https://man.openbsd.org/unveil.2][unveil(2)]] to restrict
the /parent/ process' view of the file system. dhcpleased(8) can only
read its configuration file, read and write =/dev/bpf=, and read,
write and create files underneath =/var/db/dhcpleased/= to store
information about received leases.
While we could get away with not implementing a config file for
slaacd(8), we were not this lucky with dhcpleased(8). Some systems out
there will only give us a DHCP lease if we sent the correct /client
id/ for example.
There are a lot of DHCP options specified in RFC 2132. We only
implement the bare minimum, only the options we need and can
handle. We do not need a swap server or a cookie server to get the
quote of the day.
Like slaacd(8), dhcpleased(8) is enabled on all OpenBSD
installations.
* Route priorities.
dhcpleased(8) and slaacd(8) can handle multiple interfaces at the same
time. The routing table might look like this:
#+begin_src shell
$ netstat -nrf inet
Routing tables
Internet:
Destination Gateway Flags Refs Use Mtu Prio Iface
default 192.168.1.1 UGS 4 110 - 8 em0
default 192.168.178.1 UGS 0 0 - 12 iwm0
[...]
#+end_src
We end up with two default routes, one gateway is reachable via the
/em0/ interface with priority value 8 and the other gateway is
reachable via the /iwm0/ interface with priority value 12. A route has
higher priority when its priority value is lower. /em0/ is an Ethernet
interface and it gets higher priority over the Wi-Fi interface
/iwm0/. All things being equal, the kernel will pick the address from
/em0/ as source address when making a new connection to the internet
and route traffic over the Ethernet interface, which is presumably
faster.
If we pick up the laptop and unplug the Ethernet interface, all things
are no longer equal, the route over /em0/ is no longer usable and
existing connections using it will stall and time out. New connections
will instead use /iwm0/.
If we plug /em0/ back in again, session might come alive again and new
connections will use /em0/. Connections that are running over /iwm0/
will continue working, because the interface is still connected to
the Wi-Fi.
Applications like web browsers, email clients or even video
conferencing systems will automatically establish a new connection
when they notice the old one is dead.
Unfortunately [[https://man.openbsd.org/ssh.1][ssh(1)]] is not one of them. If switching between wired
and wireless happens seldomly [[https://man.openbsd.org/tmux.1][tmux(1)]] on the remote system might help
with ssh(1) disconnects. Or maybe a [[https://man.openbsd.org/wg.4][wg(4)]] tunnel can be used so that
the source address does not change when switching between wired and
wireless.
* Cellular networks.
In addition to Ethernet and Wi-Fi networks, OpenBSD supports "Mobile
Broadband Interface Model" devices using the [[https://man.openbsd.org/umb.4][umb(4)]] driver. These can
be used to connect to UMTS or LTE networks. They require a sim card
and after being configured using a PIN they will connect to cellular
networks and automatically configure an IP address and default
route. The default route has an even lower route priority than Wi-Fi
so it will only be used when Ethernet and Wi-Fi are not connected.
* It is always DNS.[fn::In my line of work that is certainly true, but that is just sample bias.]
We need to talk about DNS next. Humans are not particularly good at
remembering =2606:2800:220:1:248:1893:25c8:1946=, we are much better
with names like /example.com/. When we run ~ping6 example.com~ we
sooner or later end up in [[https://man.openbsd.org/asr_run.3][libc's stub resolver]]. It will open
=/etc/resolv.conf=, and look for /nameserver/ lines to use for DNS
resolution.
We can learn name servers from dhcpleased(8), slaacd(8), umb(4),
and [[https://man.openbsd.org/iked.8][iked(8)]]. Historically dhclient(8) owned =/etc/resolv.conf=, which
means that no other process could add name servers to it. dhclient(8)
would just overwrite whatever was in there whenever it renewed its
lease. This made it impossible to sometimes move to an IPv6-only
network. slaacd(8) could not configure name servers and the left-over
IPv4 name servers were not reachable.
We can either teach all name server sources to somehow cooperate and
to not scribble over each other and share responsibility of
=/etc/resolv.conf= or we can run an arbitrator that collects name
servers from diverse sources and handles the contents of
=/etc/resolv.conf=.
[[https://man.openbsd.org/resolvd.8][resolvd(8)]] is such an arbitrator. It is another always enabled
daemon. It collects name servers from all the mentioned sources and
adds them to =/etc/resolv.conf=.
It also monitors if =/etc/resolv.conf= gets edited in which case it
re-reads the file and makes sure that the learned name servers are at
the beginning of the file. This is useful when the administrator of
the machine decides to add options to =/etc/resolv.conf=. For example,
we can edit the file and add =family inet6 inet= to prefer IPv6 over
IPv4 and resolvd(8) will cope. There is no need for an extra
configuration file, =/etc/resolv.conf= is the configuration file.
Name servers are announced using route messages and resolvd(8) listens
for them using a route(4) socket. They can also be observed using the
[[https://man.openbsd.org/route.8][route(8)]] tool: ~$ route monitor~.
resolvd(8) can also request that name servers are re-announced by their
sources. This is useful when resolvd(8) gets restarted.
* Let us unwind[fn:: See [fn:name_things].] a bit.
Good old plain DNS is not a secure protocol. It exchanges
un-authenticated UDP packets without any integrity protection. This
makes it easy for an attacker to spoof answer packets.
DNS answer packets are untrusted data, they come from the
network. However, the process that sends DNS queries and parses the
answer using the libc functions is almost always the single main
process of the tool. When we run ~ping example.com~, DNS packets are
parsed using our user. An attacker who can spoof a DNS answer might be
able to trigger a bug in libc and gain code execution that way.
On OpenBSD ping(8) pledges ="stdio DNS"=, so the attacker will not get
very far, but there are many more programs in ports that are not
pledged that might want to resolve names.
It would be worthwhile to have some sort of proxy running on localhost
so that DNS packets from the outside need to traverse a well locked
down process running in a different address-space and as a different
user than the program that needs to resolve a name.
An early experiment was rebound(8), written by Ted Unangst (tedu@). It
was simplistic and did not understand DNS at all, it would just
forward packets, but it would sit between the Internet and the
program.
An alternative is to run a full recursive resolver like [[https://man.openbsd.org/unbound.8][unbound(8)]] on
the laptop, but this leads to problems, too. unbound(8) expects a well
working network where nobody interferes with DNS, this is true in data
centres and can be achieved in well maintained home networks, but it
is not something we find when moving laptops to arbitrary networks
like free Wi-Fi in a hotel or airport.
We can either give up and move to a different hotel[fn::Which is not
realistic.], or we need to adjust our expectations, figure out what we
have and work with that.
It turns out that often the quality of the network changes over
time. When we first connect to a hotel Wi-Fi we may find ourselves in
what is referred to as a /captive portal/. Everything is blocked, DNS
gets intercepted, and we are redirected to a web site where we need to
agree to the terms and conditions. Maybe provide our name and room
number. Once we are past that, network quality improves considerably
and we are mostly free to talk to the outside world.
This is where [[https://man.openbsd.org/unwind.8][unwind(8)]] comes in. It is another privilege separated
network daemon that provides a recursive name server for the local
machine. resolvd(8) detects when it is running and automatically
rewrites =/etc/resolv.conf= to have only =nameserver 127.0.0.1= listed
as name server.
With that we have the first problem solved, or at least improved on
the situation. Programs that need DNS resolution are insulated from
the Internet. An attacker needs to get past unwind(8) first before
they can try to attack the libc stub resolver.
unwind(8) understands and speaks DNS and it actively observes the
network quality.
We did not write our own recursive name server. That would be
difficult, it would be unlikely we would get it right on first
try[fn:: Or second or third try for that matter.], and DNS is
constantly evolving, so it is a lot of effort to keep up. Instead we
are standing on the shoulders of giants and use libunbound, which is
part of [[https://man.openbsd.org/unbound.8][unbound(8)]]. It is developed under a BSD license by [[https://www.nlnetlabs.nl/][NLnet Labs]].
The resolver process pledges ="stdio inet dns rpath"= and
restricts access to the file system using unveil(2) to
=/etc/ssl/cert.pem=. This is the process that is exposed to the
Internet and handles untrusted data. It would be preferable to have
one process exposed to the Internet and another to parse untrusted
data but that is not possible to do with libunbound.
Since we are using a real recursive name server, that gives us a lot
of options on how we can resolve names:
+ We can do our own recursion, walk down from the root zone using
qname minimization to improve privacy.
+ We can use the name server we learned from dhcpleased(8) and
slaacd(8) as forwarders, so we do not need to do our own recursion,
which might be faster.
+ We can try to opportunistically speak DNS over TLS (DoT) to the
learned name servers to prevent eavesdroppers from listening in.
+ We can configure forwarders manually to not depend on the network
provided name servers. Those might be more trustworthy. They can
also be DoT forwarders to prevent eavesdropping.
+ As a last resort, unwind(8) can behave exactly like the libc stub
resolver[fn::I call this the Dutch train problem. The free Wi-Fi on
Dutch trains do not like DNS queries with an /EDNS0/ option, they
intercept them, do not understand them, and answer /NXDOMAIN/. There
are other free Wi-Fi networks that are similarly broken.].
We call these resolving strategies and unwind(8) actively probes if
they are usable by sending test queries when it notices that the
network changed, for example because we moved to a different Wi-Fi
network or woke up from suspend. It then orders them by quality and
picks the best one.
There is an implicit skew in the strategies for finding the best one:
A manually configured DoT name server is always considered better than
a name server provided by the local network. As long as its available
and not atrociously slow.
unwind(8) is not too concerned about preserving privacy, it is
pragmatic and tries to resolve names the best way it can, if that
means using the local name servers provided by the network because
they are the only ones available it will use them.
Since unwind(8) uses libunbound it also supports DNSSEC. DNSSEC
provides data integrity and cryptographic authenticity, it does not
provide confidentiality.
unwind(8) is pragmatic about DNSSEC. When it tests the quality of a
resolving strategy it also tries to find out if DNSSEC is
available. There are many reasons why DNSSEC is not available: The
network is misconfigured, DNSSEC is flat out blocked or the laptop
does not (yet) have the correct time. If DNSSEC does not work
unwind(8) does not insist on using it.
Of course this makes it susceptible to a downgrade attack. To mitigate
this, unwind(8) will insist on DNSSEC working after it discovered once
that DNSSEC is working in the local network. This means that an
attacker needs to be able to block DNSSEC from the moment we connect
to a network. They cannot show up later and try to downgrade
us. unwind(8) will only become lenient again when we connect to a new
network.
This is not a strong mitigation of course, but DNSSEC is not a silver
bullet that fixes everything at the resolver. Applications also need
to do their part and decide how much they are willing to trust
DNS. For example ssh(1)'s /VerifyHostKeyDNS/ feature will only trust
host key fingerprints it obtained from DNS if they were validated
using DNSSEC and the validator runs on the local
machine[fn::Technically not entirely true, ssh(1) trusts what libc
indicates and libc automatically trusts localhost. See /trust-ad/ in
[[https://man.openbsd.org/resolv.conf.5][resolv.conf(5)]].]. Otherwise it will ask the user what to do.
A worst case scenario when joining a somewhat broken Wi-Fi network
with captive portal and a manually configured DoT name server might
look like this:
1. We connect to the network, we cannot reach the DoT name server and
cannot do our own recursion.
2. unwind(8) will chose the name server provided by the
network. It also notes that we just connected to a new network so
it is lenient with respect to DNSSEC validation. In effect it will
ignore validation errors.
3. We try to access a web site and the captive portal
detection in the browser triggers. We click the buttons and fill in
the forms until we are allowed on the internet.
4. unwind(8) notices that it can do its own recursion.
5. At the same time, unwind(8) notices that the DoT name server is
also reachable now and starts using it.
unwind(8) does not natively support DNS over HTTPS (DoH) and we
sometimes find ourselves in networks that block everything except for
TCP port 443. One way around this is to use dnscrypt-proxy from ports
which does support DoH. We can point unwind(8) at it by manually
configuring a plain DNS forwarder in addition to a DoT forwarder:
#+begin_src shell
$ cat /etc/unwind.conf
forwarder "9.9.9.9" port 853 authentication name "dns.quad9.net" DoT
forwarder "2620:fe::9" port 853 authentication name "dns.quad9.net" DoT
forwarder "127.0.0.1" port 5353 # dnscrypt-proxy for DoH
#+end_src
* Time for gelato.[fn:: Again, see [fn:name_things].]
People from the future might encounter networks without any IPv4. If
they are not too far in the future they might still need to talk to
IPv4 hosts on the Internet.
There are various transition technologies that get us from an IPv4
only Internet to an IPv6 only Internet. We will only look at /NAT64/,
/DNS64/, and /464XLAT/.
/NAT64/ allows us to reach IPv4 hosts from an IPv6 only network by
pretending that the hosts are IPv6 enabled. IPv6 addresses are so big
that we can easily encode all of IPv4 in an IPv6 /64 prefix, which is
the usual size of on IPv6 prefix we see per layer two network. In fact
we don't need the whole /64, a /96 is enough to encode the whole IPv4
Internet.
Let us pretend we know the /96 prefix used for /NAT64/ and the IPv4
address we want to reach. Forming an IPv6 address for the host is then
simply a bitwise-or operation of the IPv4 address with the /96 prefix,
the IPv4 address fills in the lower bits of the IPv6 prefix. This is
called address synthesis.
We can then use this address to connect to the IPv4-only
host. Somewhere on the network path is the /NAT64/ gateway that is
dual stacked. It knows that our packets are using /NAT64/ because it
is configured with the /96 prefix. It intercepts the packets and forms
IPv4 packets and sends them on their way. The gateway needs to be
stateful to be able to /NAT/ the return traffic back to us.
To find out the IPv4 address we want to connect to we of course use
DNS. The local name servers that slaacd(8) learned about would know
about the /NAT64/ prefix used in the network and do the address
synthesis for us. This is called /DNS64/. The problem with this is that
the name servers spoof DNS answers, something that DNSSEC tries very
hard to prevent. unwind(8) will detect this and generate an error, or
unwind(8) might not even talk to the designated name servers at all.
To get around this unwind(8) can itself detect the presence of /DNS64/
on a network by asking the local name servers for the /AAAA/ record,
i.e. the IPv6 address, for something that is guaranteed to never have
one: /ipv4only.arpa/. If it gets an answer, it can reverse the address
synthesis and learn the /NAT64/ prefix. With that information it can
do /DNS64/ itself and there is no longer a problem with DNSSEC.
The downsides of this mechanism are that it is quite complicated, it
messes around with DNS, and it does not work with IPv4 address
literals. It also does not work with programs that are fundamentally
IPv4 only: =ping example.com= will never work in an IPv6 only network
with only /NAT64 / DNS64/.
Instead of pretending the IPv4 host we want to reach has IPv6, we can
pretend to have working IPv4 if a /NAT64/ gateway is present. We ask
the kernel via the [[https://man.openbsd.org/pf.4][pf(4)]] firewall to do the IPv4 to IPv6 translation
for us. The /NAT64/ gateway will then do the reverse translation and
send an IPv4 packet on its way. This is called /464XLAT/.
We first need an IPv4 address, RFC 7335 reserved =192.0.0.0/29= for
this purpose:
#+begin_src shell
ifconfig pair1 inet 192.0.0.4/29
#+end_src
We then need a default gateway:
#+begin_src shell
ifconfig pair2 rdomain 1
ifconfig pair2 inet 192.0.0.1/29
#+end_src
Because pf(4) will only do address family translation on inbound rules
we need a different /rdomain/ and use [[https://man.openbsd.org/pair.4][pair(4)]] interfaces. We need to
connect them:
#+begin_src shell
ifconfig pair1 patch pair2
#+end_src
And then we can configure our default route:
#+begin_src
route add -host -inet default 192.0.0.1 -priority 48
#+end_src
We set it to a very low priority[fn:: Remember, a high priority
*value* means low priority.] so that it does not interfere with routes
dhcpleased(8) configures when we move to an IPv4 enabled network.
We then need to configure address family translation in pf(4) when we
detect /NAT64/ being present. This is were [[https://github.com/fobser/gelatod/][gelatod(8)]] comes in. It is
a Customer-side transLATor (/CLAT/) configuration daemon[fn::If you
squint just right, gelato kinda sounds like clat[fn::Again, I really
really should be prohibited from naming things.].]. /CLAT/ is what
/464XLAT/ calls the address translation happening on the laptop.
gelatod(8) is yet another privilege separated daemon[fn::At this point
you should believe me that that is a good thing and I will not go into
pledge details.] that checks for the presence of a /NAT64/
gateway whenever we change networks. It does so either via the
/ipv4only.arpa/ trick or explicitly via router advertisements. RFC
8781 specifies how a network can signal the presence of a /NAT64/
gateway.
gelatod(8) needs a pf(4) anchor into which it adds rules that are
similar to this example:
#+begin_src
pass in log quick on pair2 inet af-to inet6 \
from 2001:db8::da68:f613:4573:4ed0 to 64:ff9b::/96 \
rtable 0
#+end_src
The rule is doing address family translation to IPv6 on incoming
packets on =pair2=. In this example it uses
=2001:db8::da68:f613:4573:4ed0= as the IPv6 source address, gelatod(8)
learned this from the system when slaacd(8) configured
it. =64:ff9b::/96= is the learned /NAT64/ prefix and we are moving
traffic back to =rtable 0=. Remember =pair2= is in rdomain 1[fn::Do
not ask me about the difference between an rdomain and an rtable, I do
not know either.].
While this is all cute and works rather well, it is also completely
horribly complicated to set up. And that is why gelatod(8) is not in
OpenBSD base but lives in ports. We believe in good defaults in
OpenBSD and try to keep the buttons a user has to push to get
something working to an absolute minimum.
* Future work.
Which brings us to future work.
We want the functionality of gelatod(8) in OpenBSD base. gelatod(8)
was mostly a proof of concept. We imagine that a new network device
like clat(4) take over the role of client side address family
translation. It could be always present and gelatod(8) just enables
and disables it. At that point we can move the functionality into
slaacd(8) and delete gelatod(8). /CLAT/ is defined as a stateless
mechanism so it does not need the full pf(4) machinery for address
family translation.
It would be nice to have DNS over HTTPS (DoH) and DNS over Quic (DoQ)
natively in unwind(8). We are mostly waiting on upstream to implement
support in unbound(8).
And then there is some ongoing maintenance, little things that could
be improved:
+ The captive portal detection in unwind(8) is not perfect and it will
probably never be.
+ dhcpleased(8) and slaacd(8) should remember IP addresses from
networks they have been connected to before to be able to quickly
re-establish connectivity by probing if we are connecting to a
previous network while the lifetime of our addresses did not expire
yet. RFC 4436 "Detecting Network Attachment in IPv4 (DNAv4)" and RFC
6059 "Simple Procedures for Detecting Network Attachment in IPv6"
have the details.
+ It would be nice if the dhcpleased(8) parent process could be
pledged. This is not currently possible because of bpf(4). Things to
investigate here are changes to the network stack that would allow
us to use raw sockets instead of bpf(4) sockets or the ability to
[[https://man.openbsd.org/dup.2][dup(2)]] an existing bpf(4) socket and re-program the interface it is
using.
* Epilogue
Writing all this software over the last six to seven years was a lot
of fun. And combined with all the other features OpenBSD has to offer
like the /join/ feature, working suspend and resume and accelerated
video on /amd/ and /intel/ graphic cards makes it a pleasure to use
OpenBSD on a laptop as a daily driver. Things just work. Mostly. And
if they do not you have something to fix!