{"id":2835,"date":"2026-04-10T17:23:42","date_gmt":"2026-04-10T17:23:42","guid":{"rendered":"https:\/\/hauweele.net\/~gawen\/blog\/?p=2835"},"modified":"2026-04-10T20:26:23","modified_gmt":"2026-04-10T20:26:23","slug":"ipv6-address-selection-and-cross-site-ulas","status":"publish","type":"post","link":"https:\/\/hauweele.net\/~gawen\/blog\/?p=2835","title":{"rendered":"IPv6 address selection and cross-site ULAs"},"content":{"rendered":"<p>All I wanted was to update some SSH keys&#8230;<br \/>\nBut for reasons that I&#8217;ll spare you, it turned out to be a tad more complicated.<br \/>\nAlso, in what follows, the context will be simplified for clarity.<\/p>\n<p>As to make the very basic context of this post clear, this is on FreeBSD, and it&#8217;s all about IPv4 vs IPv6 address selection. What address should I use to reach xyz.com? Chances are that if you are just mainly using your web browser, that never appeared to be a problem. See, browsers use what is called the <a href=\"https:\/\/www.rfc-editor.org\/rfc\/rfc6555\">Happy Eyeballs algorithm<\/a>, basically whoever IPv4\/IPv6 responds first gets used. <\/p>\n<p>But it&#8217;s generally not implemented outside browsers. So that&#8217;s why your package manager decides to use either IPv6 or IPv4, but that selection is unreachable, so it&#8217;s just stuck there. That&#8217;s also why sometimes web apps seem to work perfectly fine in your browser, but on your phone, they seem very sluggish at best, and most of the time just clicking on any button or link just makes the app hang in there forever.<\/p>\n<p>Chances are the app didn&#8217;t implement the aforementioned algorithm; it had a route to reach the destination, but for some reason it isn&#8217;t reachable. So it tries IPv6 and just has to wait for some timeout. Hence why I guess most people just disable IPv6. But they really shouldn&#8217;t. They should slam their sysadmin\/netadmin\/ISP for doing a bad job. Those are the ones slowing down IPv6 adoption, not the users. <\/p>\n<p>So back to the problem at hand.<\/p>\n<p>At some point, I had to reach over the same destination (read the same fqdn) via SSH from two different hosts. That destination DNS resolution presented both a A (IPv4) record, and a AAAA (IPv6) record. However on the first host (let&#8217;s call him host A), IPv6 was preferred to reach the destination (good). However, on the second host (let&#8217;s call him host B), IPv4 was preferred instead (bad).<\/p>\n<p>Both hosts received the exact same DNS resolution (with IPv4+IPv6 resolve), both have a globally routable IPv6 and an IPv6 route to the destination. Both hosts can ping6 to the actual destination. In other words, the destination is perfectly reachable via IPv6. Why then would A prefer IPv6 and B prefer IPv4?<\/p>\n<p>See in FreeBSD system configuration file (<code>\/etc\/rc.conf<\/code>), there is <code>ip6addrctl_policy<\/code> that can be configured to <code>ipv6_prefer<\/code>. As the name suggests, with this configuration and when presented with both a IPv4 and IPv6 to reach the destination, the network should choose the later by default. And it was configured to this value on both host A and host B, so in both case it should have chosen IPv6, but it didn&#8217;t.<\/p>\n<p>Under the <code>ip6addrctl_policy<\/code> setting is the <code>ip6addrctl<\/code> command that actually configures the address selection policy. Let&#8217;s see what it has to say:<\/p>\n<pre>\r\n$ ip6addrctl show\r\nPrefix                          Prec Label      Use\r\n::1\/128                           50     0        0\r\n::\/0                              40     1     6131\r\n::ffff:0.0.0.0\/96                 35     4        0\r\n2002::\/16                         30     2        0\r\n2001::\/32                          5     5        0\r\nfc00::\/7                           3    13        0\r\n::\/96                              1     3        0\r\nfec0::\/10                          1    11        0\r\n3ffe::\/16                          1    12        0\r\n<\/pre>\n<p>That is the policy that is configured with <code>ipv6_prefer<\/code>. The selection goes as follows. Suppose the DNS resolution gave you two candidate IP addresses, and for the sake of our example, let&#8217;s suppose it&#8217;s <em>1.2.3.4<\/em> and <em>2001:aaaa:bbbb::1<\/em>.<\/p>\n<p>For each IP, as with a routing table, the longest prefix (i.e. the most specific match) wins. So for <em>1.2.3.4<\/em>, it translates to the IPv4 mapped address <em>::ffff:1.2.3.4<\/em>. Hence it selects the line <em>::ffff:0.0.0.0\/96<\/em> with precedence 35.<\/p>\n<p>Similarly for <em>2001:aaaa:bbbb::1<\/em>, the longest matching prefix is <em>::\/0<\/em> hence it is selected with precedence 40. Note that it&#8217;s not <em>2001::\/32<\/em>, which with zero expansion in the network prefix is really <em>2001:0000::\/32<\/em>.<\/p>\n<p>Between those two:<\/p>\n<pre>\r\n::ffff:0.0.0.0\/96 with precedence 35\r\n::\/0              with precedence 40\r\n<\/pre>\n<p>it will choose the candidate that matched the line with higher precedence, 40 > 35 so the address that matched <em>::\/0<\/em> will be retained. So <em>2001:aaaa:bbbb::1<\/em> is selected to reach the destination.<\/p>\n<p>That still doesn&#8217;t explain why IPv4 is preferred on host B.<\/p>\n<p>Notice that there are several other lines below <em>::ffff:0.0.0.0\/96<\/em>. What are those?<\/p>\n<ul>\n<li><strong><em>2002::\/16<\/em><\/strong>: 6to4 (<a href=\"https:\/\/www.rfc-editor.org\/rfc\/rfc3056\">RFC 3056<\/a>, deprecated by <a href=\"https:\/\/www.rfc-editor.org\/rfc\/rfc7526\">RFC 7526<\/a> in 2015). Embeds a public IPv4 address into an IPv6 prefix to tunnel IPv6 over IPv4 without explicit configuration. Seldom used nowadays.<\/li>\n<li><strong><em>2001::\/32<\/em><\/strong>: Teredo (<a href=\"https:\/\/www.rfc-editor.org\/rfc\/rfc4380\">RFC 4380<\/a>). IPv6 tunnelling over IPv4 NAT via UDP encapsulation. Transitional and seldom used nowadays.<\/li>\n<li><strong><em>fc00::\/7<\/em><\/strong>: Unique Local Addresses (ULA) (<a href=\"https:\/\/www.rfc-editor.org\/rfc\/rfc4193\">RFC 4193<\/a>). The IPv6 counterpart to <a href=\"https:\/\/www.rfc-editor.org\/rfc\/rfc1918\">RFC 1918<\/a> private address space. In practice, only <em>fd00::\/8<\/em> is used. ULA addresses are not globally routable, but are perfectly valid for local routing.<\/li>\n<li><strong><em>::\/96<\/em><\/strong>: IPv4-compatible addresses (deprecated by <a href=\"https:\/\/www.rfc-editor.org\/rfc\/rfc4291\">RFC 4291<\/a>). Obsolete dual-stack mechanism embedding an IPv4 address in the low 32 bits. Deprecated in 2006.<\/li>\n<li><strong><em>fec0::\/10<\/em><\/strong>: Site-local addresses (deprecated by <a href=\"https:\/\/www.rfc-editor.org\/rfc\/rfc3879\">RFC 3879<\/a> in 2004).<\/li>\n<li><strong><em>3ffe::\/16<\/em><\/strong>: 6bone test addresses (<a href=\"https:\/\/www.rfc-editor.org\/rfc\/rfc2471\">RFC 2471<\/a>, deprecated by <a href=\"https:\/\/www.rfc-editor.org\/rfc\/rfc3701\">RFC 3701<\/a> in 2006). Address space used by the now-defunct IPv6 testing network. Any traffic on this prefix is misconfigured or relic traffic.<\/li>\n<\/ul>\n<p>In our cross-site networks, we use local addresses (<em>fd00::\/8<\/em>) a lot. So if one of the candidates is a ULA, it must be preferred over IPv4. This is not the case if you look at the precedence for IPv4 mapped addresses (35) vs ULA addresses (3).<\/p>\n<p>A quick read in <code>\/etc\/rc.d\/ip6addrctl<\/code> shows that it will load a custom IP selection policy from <code>\/etc\/ip6addrctl.conf<\/code> when in <code>\/etc\/rc.conf<\/code> you have <code>ip6addrctl_policy=AUTO<\/code> and the configuration file is present, readable, and non-empty.<\/p>\n<p>Hence the new configuration (note: <em>::ffff:0.0.0.0\/96<\/em> and <em>::ffff:0:0\/96<\/em> are equivalent notations for the same prefix):<\/p>\n<pre>\r\n::1\/128\t\t 50\t 0\r\n::\/0\t\t 40\t 1\r\n::ffff:0:0\/96\t 35\t 4\r\n2002::\/16\t 30\t 2\r\n2001::\/32\t 5\t 5\r\nfc00::\/7\t 37\t13\r\n::\/96\t\t 1\t 3\r\nfec0::\/10\t 1\t11\r\n3ffe::\/16\t 1\t12\r\n<\/pre>\n<p>But that would only allow preferring ULA over IPv4 mapped addresses. In our case the destination address, <em>2001:aaaa:bbbb::1<\/em> is globally routable and totally not a ULA, so the default configuration should work. Or did we miss something?<\/p>\n<p>The astute reader might have noticed there is also a <i>Label<\/i> column. Also, the more knowledgeable reader might point out that all this IPv6 selection mechanism is described by <a href=\"https:\/\/www.rfc-editor.org\/rfc\/rfc6724\">RFC 6724<\/a>. Here is what it has to say about the policy table, precedence, label, and selection algorithm:<\/p>\n<p><quote><\/p>\n<pre>\r\n   The policy table is a longest-matching-prefix lookup table, much like\r\n   a routing table.  Given an address A, a lookup in the policy table\r\n   produces two values: a precedence value denoted Precedence(A) and a\r\n   classification or label denoted Label(A).\r\n\r\n   The precedence value Precedence(A) is used for sorting destination\r\n   addresses.  If Precedence(A) > Precedence(B), we say that address A\r\n   has higher precedence than address B, meaning that our algorithm will\r\n   prefer to sort destination address A before destination address B.\r\n\r\n   The label value Label(A) allows for policies that prefer a particular\r\n   source address prefix for use with a destination address prefix.  The\r\n   algorithms prefer to use a source address S with a destination\r\n   address D if Label(S) = Label(D).\r\n<\/pre>\n<p><\/quote><\/p>\n<p>and again with more details in Section <em>6.  Destination Address Selection<\/em>:<\/p>\n<p><quote><\/p>\n<pre>\r\n   Rule 5: Prefer matching label.\r\n   If Label(Source(DA)) = Label(DA) and Label(Source(DB)) <> Label(DB),\r\n   then prefer DA.  Similarly, if Label(Source(DA)) <> Label(DA) and\r\n   Label(Source(DB)) = Label(DB), then prefer DB.\r\n\r\n   Rule 6: Prefer higher precedence.\r\n   If Precedence(DA) > Precedence(DB), then prefer DA.  Similarly, if\r\n   Precedence(DA) < Precedence(DB), then prefer DB.\r\n<\/pre>\n<p><\/quote><\/p>\n<p>In our case, it's <em>Rule 5<\/em> that was causing IPv4 to be preferred. See, on host B, the route table states that in order to reach <em>2001:aaaa:bbbb::1<\/em>, you must go via a specific interface. That interface only has one ULA configured, so it is selected as the source address to reach that destination.<\/p>\n<p>At the input of the destination address selection algorithm, we have those two candidates:<\/p>\n<pre>\r\n# source-address     destination-address\r\n\r\n## IPv4 mapped candidate (from the A record)\r\n::ffff:192.168.1.1   ::ffff:1.2.3.4\r\n\r\n## IPv6 candidate (from the AAAA record)\r\nfd08:aaaa:bbbb::1    2001:aaaa:bbbb::1\r\n<\/pre>\n<p>For the IPv4 mapped candidate, the source and destination label match (4). For the IPv6 candidate, the source and destination label don't match (13 != 1). Hence, by rule 5, the first candidate is preferred.<\/p>\n<p>So the solution is to ensure both the ULA and globally routable lines of the policy share the same label:<\/p>\n<pre>\r\n::1\/128\t\t 50\t 0\r\n::\/0\t\t 40\t 1\r\n::ffff:0:0\/96\t 35\t 4\r\n2002::\/16\t 30\t 2\r\n2001::\/32\t 5\t 5\r\nfc00::\/7\t 37\t 1\r\n::\/96\t\t 1\t 3\r\nfec0::\/10\t 1\t11\r\n3ffe::\/16\t 1\t12\r\n<\/pre>\n<p>Note that the problem of ULA being disfavored is explicitly acknowledged in Section <em>10.6.  Configuring ULA Preference<\/em> of the RFC. I quote:<\/p>\n<p><quote><\/p>\n<pre>\r\n   [...] By default, global IPv6 destinations are preferred over\r\n   ULA destinations, since an arbitrary ULA is not necessarily\r\n   reachable.\r\n\r\n   [...]\r\n\r\n   However, a site-specific policy entry can be used to cause ULAs\r\n   within a site to be preferred over global addresses [...].\r\n<\/pre>\n<p><\/quote><\/p>\n<p>When you work with ULAs and globally routable addresses in cross-site networks, the prefixes used are generally known in advance and static. The recommended way is to add dedicated policies for those prefixes with higher precedence and ensure that the label matches if those ULAs can also be used as source addresses to reach your own globally routable IPs:<\/p>\n<pre>\r\n# custom policies for our network\r\nfd08:aaaa:bbbb::\/48  42\t 14\r\n2001:aaaa:bbbb::\/64  41  14\r\n2001:aaaa:cccc::\/64  41  14\r\n\r\n# default automatic policy with IPv6 prefer\r\n::1\/128\t\t     50\t 0\r\n::\/0\t\t     40\t 1\r\n::ffff:0:0\/96\t     35\t 4\r\n2002::\/16\t     30\t 2\r\n2001::\/32\t     5\t 5\r\nfc00::\/7\t     3\t13\r\n::\/96\t\t     1\t 3\r\nfec0::\/10\t     1\t11\r\n3ffe::\/16\t     1\t12\r\n<\/pre>\n<p>Here, our ULA prefix and our globally routable prefixes are all assigned the same label (14), ensuring that rule 5 (prefer matching label) never penalizes a ULA source address when reaching one of our own globally routable destinations, and vice versa. They are also given higher precedence than the default ::\/0 and ::ffff:0.0.0.0\/96 entries, so our known prefixes are always preferred over the generic fallback behavior. For any address outside these explicitly listed prefixes, the default policy applies unchanged.<\/p>\n<p>This was for FreeBSD, though. What about Linux, I hear you ask? There, this is configured in <code>\/etc\/gai.conf<\/code>. The syntax changes slightly, but surely with the explanation above you will figure it out.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>All I wanted was to update some SSH keys&#8230; But for reasons that I&#8217;ll spare you, it turned out to be a tad more complicated. Also, in what follows, the context will be simplified for clarity. As to make the &hellip; <a href=\"https:\/\/hauweele.net\/~gawen\/blog\/?p=2835\">Continue reading <span class=\"meta-nav\">&rarr;<\/span><\/a><\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_jetpack_memberships_contains_paid_content":false,"footnotes":""},"categories":[1],"tags":[1222,1223,389,1220,1216,164,1218,1215,1219,1217,1221],"class_list":["post-2835","post","type-post","status-publish","format-standard","hentry","category-uncategorized","tag-address-selection","tag-dual-stack","tag-freebsd","tag-ip6addrctl","tag-ipv4","tag-ipv6","tag-netadmin","tag-networking","tag-rfc6724","tag-sysadmin","tag-ula"],"jetpack_featured_media_url":"","jetpack_sharing_enabled":true,"_links":{"self":[{"href":"https:\/\/hauweele.net\/~gawen\/blog\/index.php?rest_route=\/wp\/v2\/posts\/2835","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/hauweele.net\/~gawen\/blog\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/hauweele.net\/~gawen\/blog\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/hauweele.net\/~gawen\/blog\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/hauweele.net\/~gawen\/blog\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=2835"}],"version-history":[{"count":0,"href":"https:\/\/hauweele.net\/~gawen\/blog\/index.php?rest_route=\/wp\/v2\/posts\/2835\/revisions"}],"wp:attachment":[{"href":"https:\/\/hauweele.net\/~gawen\/blog\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=2835"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/hauweele.net\/~gawen\/blog\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=2835"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/hauweele.net\/~gawen\/blog\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=2835"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}