Skip to content

IPv6 on a real server

The previous article made the case for IPv6 — the address space ran out, and this is the replacement. This one is about what actually changes when you log into a machine that has it, because the day-to-day surprises have very little to do with the size of the address space.

Most of them come from one thing: a dual-stack host has two independent networks, and they can fail independently.

What a dual-stack interface looks like

ip -br addr show eth0
eth0             UP             203.0.113.44/24 2001:db8:5c2::a1/64 fe80::5054:ff:fe12:3456/64

Three addresses on one interface, and the third one is where the questions start.

fe80::/10 is link-local. Every IPv6 interface has one, always, generated automatically, and it works only on the directly attached link — no router will forward it. It isn't optional or decorative: neighbour discovery and router advertisements run over link-local addresses, so it's the address IPv6's equivalent of ARP depends on.

The consequence that trips people is that a link-local address is ambiguous on a host with several interfaces — fe80::1 could mean a neighbour on any of them. So it has to be qualified with the interface, using a % suffix:

ping -c1 fe80::5054:ff:fe12:3456%eth0

Omit the %eth0 and you get Invalid argument, which is an unhelpfully generic message for "you didn't say which link."

2001:db8:5c2::a1/64 is the global address — the routable one, the equivalent of a public IPv4 address. Note the prefix: /64 is effectively universal for a LAN in IPv6, not because of any address shortage but because autoconfiguration is specified to work with 64 host bits. Subnetting within a /64 is possible and almost always a mistake.

2001:db8::/32 is the documentation prefix, reserved by RFC 3849 — the IPv6 equivalent of 192.0.2.0/24, which is why every example you'll ever read uses it.

Nobody assigns these by hand

IPv4 hosts get addresses from DHCP. IPv6 has a second mechanism that runs without any server at all: SLAAC, stateless address autoconfiguration.

A host joining a network sends a Router Solicitation to a multicast address. Routers on the link answer with a Router Advertisement carrying the network's prefix — say 2001:db8:5c2::/64 — and the host builds its own address by appending 64 bits of its own choosing. No server, no lease, no state anywhere.

ip -6 route show
2001:db8:5c2::/64 dev eth0 proto ra metric 100 pref medium
default via fe80::1 dev eth0 proto ra metric 100 pref medium

proto ra says both routes came from a Router Advertisement. And look at the default route's gateway: fe80::1, a link-local address. That's normal and correct in IPv6 — the router is identified by its link-local address, not a global one — and it looks alarming the first time.

Two practical consequences follow. A rogue Router Advertisement can hijack a network's default route, so a managed switch's RA Guard feature is a real security control rather than a checkbox. And the addresses hosts pick can change: privacy extensions (RFC 8981) generate temporary addresses that rotate, which is good for client privacy and useless for a server. Servers should have a stable address, from DHCPv6 or static configuration, or your firewall rules and logs will refer to addresses that no longer exist.

The listening socket that surprises everyone

sudo ss -tlpn
State  Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0      511               [::]:80         [::]:*   users:(("nginx",pid=1204,fd=6))

One row, IPv6 only, and yet the service answers over IPv4 perfectly well.

That's IPv4-mapped addressing. A socket bound to [::] — the IPv6 "any address" — also accepts IPv4 connections on most Linux systems, with the client's IPv4 address presented to the application as ::ffff:203.0.113.9. One socket, both protocols.

Which produces two things worth knowing. Grepping ss output for 0.0.0.0 to find exposed services will miss every dual-stack listener, so the audit has to cover [::] too. And applications that log or filter by client address see that ::ffff: prefix, which breaks naive string comparisons and regexes written against IPv4 — a bug that only appears once IPv6 is enabled, in code that was correct for years.

The behaviour is controlled by a kernel setting:

cat /proc/sys/net/ipv6/bindv6only
0

0 means dual-stack sockets are allowed, which is the default. Set to 1, a service would need to bind both protocols separately.

Firewall rules are separate unless you make them otherwise

A ruleset written only for IPv4 filters only IPv4. The service still listens on [::], IPv6 traffic still reaches it, and the machine you believe is locked down is open on every port over the second protocol.

This is a common finding in real audits and the reason the nftables article uses an inet table — one ruleset covering both families, with no possibility of drift. With legacy iptables, every rule needs a matching ip6tables rule, maintained by hand.

sudo nft list ruleset | head -3
table inet filter {
    chain input {
        type filter hook input priority filter; policy drop;

table inet is the word doing the work. Anything else — table ip for IPv4 alone, or table ip6 — leaves half the machine unprotected unless you deliberately wrote both.

Happy Eyeballs, and the failure it hides

A dual-stack client resolving a dual-stack name gets both an A and an AAAA record. Which does it use?

Modern clients try both, nearly at once. The Happy Eyeballs algorithm starts an IPv6 connection, waits a short interval — RFC 8305 recommends around 250 ms — and if it hasn't completed, starts an IPv4 connection in parallel, using whichever succeeds first.

The intent is that broken IPv6 never leaves a user staring at a blank page. The side effect is that broken IPv6 becomes invisible, because everything still works, just marginally slower, and nobody investigates a quarter-second.

Which is why you test each protocol explicitly rather than trusting that a working browser means a working network:

curl -4 -sS -o /dev/null -w 'IPv4 %{time_total}s\n' https://example.com
curl -6 -sS -o /dev/null -w 'IPv6 %{time_total}s\n' https://example.com
IPv4 0.082s
IPv6 0.079s

Both working, comparable latency. When the IPv6 line reports an error and the IPv4 line doesn't, you've found something Happy Eyeballs was cheerfully concealing.

dig +short AAAA example.com
ping -6 -c2 example.com
ip -6 route get 2606:4700:4700::1111

Those three cover the layers in order: does the name have an IPv6 address, is it reachable, and does this host even have a route for IPv6 traffic. The failure modes are the same as IPv4's — Network is unreachable from ping -6 means no IPv6 default route, and it's the most common answer.

ICMPv6 is not optional the way ICMP was

Blocking all ICMP on an IPv4 firewall is heavy-handed but survivable. Doing the equivalent on IPv6 breaks the network.

IPv6 uses ICMPv6 for neighbour discovery — its replacement for ARP — and for Path MTU Discovery, since routers no longer fragment packets. Block ICMPv6 wholesale and hosts can't resolve each other's link-layer addresses, and large packets vanish silently while small ones work, producing the classic "the page starts loading and then hangs" signature.

Permit at minimum neighbour solicitation and advertisement, router solicitation and advertisement, echo request and reply, packet-too-big, and time-exceeded. RFC 4890 documents exactly what a firewall may filter and what it must not.

Getting the addresses right

Writing IPv6 addresses correctly matters more than it seems, because the shortening rules cause real bugs:

  • Leading zeros in a group are dropped: 2001:0db8:0000:0000:0000:0000:0000:00012001:db8:0:0:0:0:0:1.
  • One run of consecutive zero groups collapses to ::one run only, since two would be ambiguous: 2001:db8::1.
  • In a URL, the address goes in square brackets so the colons don't collide with the port separator: http://[2001:db8::1]:8080/. Forgetting the brackets is the single most common IPv6 configuration error, and it appears in nginx listen directives, connection strings, and monitoring configuration alike.

Two ways of writing the same address are equal for routing and unequal for string comparison. Any allowlist, cache key, or log-matching rule that compares IPv6 addresses as text needs them normalised first.

Practice

  1. On a host with IPv6, identify its link-local, global, and (if present) temporary privacy addresses. Determine which one outbound connections actually use, with ip -6 route get.
  2. Ping a neighbour's link-local address with and without the %<interface> suffix, and explain the error.
  3. Audit a server's listening sockets for both 0.0.0.0 and [::] bindings. Then check whether its firewall ruleset covers both families.
  4. Run the curl -4 / curl -6 pair against five sites. Note which have no AAAA record at all, and which are slower over IPv6.
  5. Configure a service to listen on an IPv6 address and connect to it by URL, deliberately omitting the square brackets first so you see what the failure looks like.

Exercise 3 is the one with a security finding waiting in it. A machine whose IPv4 rules are careful and whose IPv6 rules don't exist is common, and the gap doesn't appear in any test that only checks IPv4 — which is most tests.

That finding is a good note to end this module on. Everything across it has been about making a network work — resolving names, leasing addresses, sharing one public address, tunneling privately, distributing content close to its audience. None of it has asked who's allowed to do any of it, or how to stop someone doing it maliciously. That's the next module's entire subject.

Sources