Dynamic host configuration, please.
This commit is contained in:
parent
024073d10e
commit
595f8ee2ff
668
dynamic_host_configuration_please.org
Normal file
668
dynamic_host_configuration_please.org
Normal 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!
|
Loading…
Reference in New Issue
Block a user