IPv6 address selection and cross-site ULAs

All I wanted was to update some SSH keys…
But for reasons that I’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 very basic context of this post clear, this is on FreeBSD, and it’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 Happy Eyeballs algorithm, basically whoever IPv4/IPv6 responds first gets used.

But it’s generally not implemented outside browsers. So that’s why your package manager decides to use either IPv6 or IPv4, but that selection is unreachable, so it’s just stuck there. That’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.

Chances are the app didn’t implement the aforementioned algorithm; it had a route to reach the destination, but for some reason it isn’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’t. They should slam their sysadmin/netadmin/ISP for doing a bad job. Those are the ones slowing down IPv6 adoption, not the users.

So back to the problem at hand.

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’s call him host A), IPv6 was preferred to reach the destination (good). However, on the second host (let’s call him host B), IPv4 was preferred instead (bad).

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?

See in FreeBSD system configuration file (/etc/rc.conf), there is ip6addrctl_policy that can be configured to ipv6_prefer. 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’t.

Under the ip6addrctl_policy setting is the ip6addrctl command that actually configures the address selection policy. Let’s see what it has to say:

$ ip6addrctl show
Prefix                          Prec Label      Use
::1/128                           50     0        0
::/0                              40     1     6131
::ffff:0.0.0.0/96                 35     4        0
2002::/16                         30     2        0
2001::/32                          5     5        0
fc00::/7                           3    13        0
::/96                              1     3        0
fec0::/10                          1    11        0
3ffe::/16                          1    12        0

That is the policy that is configured with ipv6_prefer. The selection goes as follows. Suppose the DNS resolution gave you two candidate IP addresses, and for the sake of our example, let’s suppose it’s 1.2.3.4 and 2001:aaaa:bbbb::1.

For each IP, as with a routing table, the longest prefix (i.e. the most specific match) wins. So for 1.2.3.4, it translates to the IPv4 mapped address ::ffff:1.2.3.4. Hence it selects the line ::ffff:0.0.0.0/96 with precedence 35.

Similarly for 2001:aaaa:bbbb::1, the longest matching prefix is ::/0 hence it is selected with precedence 40. Note that it’s not 2001::/32, which with zero expansion in the network prefix is really 2001:0000::/32.

Between those two:

::ffff:0.0.0.0/96 with precedence 35
::/0              with precedence 40

it will choose the candidate that matched the line with higher precedence, 40 > 35 so the address that matched ::/0 will be retained. So 2001:aaaa:bbbb::1 is selected to reach the destination.

That still doesn’t explain why IPv4 is preferred on host B.

Notice that there are several other lines below ::ffff:0.0.0.0/96. What are those?

  • 2002::/16: 6to4 (RFC 3056, deprecated by RFC 7526 in 2015). Embeds a public IPv4 address into an IPv6 prefix to tunnel IPv6 over IPv4 without explicit configuration. Seldom used nowadays.
  • 2001::/32: Teredo (RFC 4380). IPv6 tunnelling over IPv4 NAT via UDP encapsulation. Transitional and seldom used nowadays.
  • fc00::/7: Unique Local Addresses (ULA) (RFC 4193). The IPv6 counterpart to RFC 1918 private address space. In practice, only fd00::/8 is used. ULA addresses are not globally routable, but are perfectly valid for local routing.
  • ::/96: IPv4-compatible addresses (deprecated by RFC 4291). Obsolete dual-stack mechanism embedding an IPv4 address in the low 32 bits. Deprecated in 2006.
  • fec0::/10: Site-local addresses (deprecated by RFC 3879 in 2004).
  • 3ffe::/16: 6bone test addresses (RFC 2471, deprecated by RFC 3701 in 2006). Address space used by the now-defunct IPv6 testing network. Any traffic on this prefix is misconfigured or relic traffic.

In our cross-site networks, we use local addresses (fd00::/8) 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).

A quick read in /etc/rc.d/ip6addrctl shows that it will load a custom IP selection policy from /etc/ip6addrctl.conf when in /etc/rc.conf you have ip6addrctl_policy=AUTO and the configuration file is present, readable, and non-empty.

Hence the new configuration (note: ::ffff:0.0.0.0/96 and ::ffff:0:0/96 are equivalent notations for the same prefix):

::1/128		 50	 0
::/0		 40	 1
::ffff:0:0/96	 35	 4
2002::/16	 30	 2
2001::/32	 5	 5
fc00::/7	 37	13
::/96		 1	 3
fec0::/10	 1	11
3ffe::/16	 1	12

But that would only allow preferring ULA over IPv4 mapped addresses. In our case the destination address, 2001:aaaa:bbbb::1 is globally routable and totally not a ULA, so the default configuration should work. Or did we miss something?

The astute reader might have noticed there is also a Label column. Also, the more knowledgeable reader might point out that all this IPv6 selection mechanism is described by RFC 6724. Here is what it has to say about the policy table, precedence, label, and selection algorithm:

   The policy table is a longest-matching-prefix lookup table, much like
   a routing table.  Given an address A, a lookup in the policy table
   produces two values: a precedence value denoted Precedence(A) and a
   classification or label denoted Label(A).

   The precedence value Precedence(A) is used for sorting destination
   addresses.  If Precedence(A) > Precedence(B), we say that address A
   has higher precedence than address B, meaning that our algorithm will
   prefer to sort destination address A before destination address B.

   The label value Label(A) allows for policies that prefer a particular
   source address prefix for use with a destination address prefix.  The
   algorithms prefer to use a source address S with a destination
   address D if Label(S) = Label(D).

and again with more details in Section 6. Destination Address Selection:

   Rule 5: Prefer matching label.
   If Label(Source(DA)) = Label(DA) and Label(Source(DB)) <> Label(DB),
   then prefer DA.  Similarly, if Label(Source(DA)) <> Label(DA) and
   Label(Source(DB)) = Label(DB), then prefer DB.

   Rule 6: Prefer higher precedence.
   If Precedence(DA) > Precedence(DB), then prefer DA.  Similarly, if
   Precedence(DA) < Precedence(DB), then prefer DB.

In our case, it's Rule 5 that was causing IPv4 to be preferred. See, on host B, the route table states that in order to reach 2001:aaaa:bbbb::1, 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.

At the input of the destination address selection algorithm, we have those two candidates:

# source-address     destination-address

## IPv4 mapped candidate (from the A record)
::ffff:192.168.1.1   ::ffff:1.2.3.4

## IPv6 candidate (from the AAAA record)
fd08:aaaa:bbbb::1    2001:aaaa:bbbb::1

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.

So the solution is to ensure both the ULA and globally routable lines of the policy share the same label:

::1/128		 50	 0
::/0		 40	 1
::ffff:0:0/96	 35	 4
2002::/16	 30	 2
2001::/32	 5	 5
fc00::/7	 37	 1
::/96		 1	 3
fec0::/10	 1	11
3ffe::/16	 1	12

Note that the problem of ULA being disfavored is explicitly acknowledged in Section 10.6. Configuring ULA Preference of the RFC. I quote:

   [...] By default, global IPv6 destinations are preferred over
   ULA destinations, since an arbitrary ULA is not necessarily
   reachable.

   [...]

   However, a site-specific policy entry can be used to cause ULAs
   within a site to be preferred over global addresses [...].

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:

# custom policies for our network
fd08:aaaa:bbbb::/48  42	 14
2001:aaaa:bbbb::/64  41  14
2001:aaaa:cccc::/64  41  14

# default automatic policy with IPv6 prefer
::1/128		     50	 0
::/0		     40	 1
::ffff:0:0/96	     35	 4
2002::/16	     30	 2
2001::/32	     5	 5
fc00::/7	     3	13
::/96		     1	 3
fec0::/10	     1	11
3ffe::/16	     1	12

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.

This was for FreeBSD, though. What about Linux, I hear you ask? There, this is configured in /etc/gai.conf. The syntax changes slightly, but surely with the explanation above you will figure it out.

Json config files

Status

— We use a simple JSON configuration file
— Where is the doc?
— We provided a sample configuration file
— You can’t add doc to a JSON file
— Yeah so we didn’t document our sample configuration file
— WTF did you use JSON as a configuration file? ¯\〳 •̀ o •́ 〵/¯

That unreadable thing would vomit on you for an extra comma.

awscli v2.34.3 on FreeBSD 15.0: _awscrt shenanigans

I had been running AWS CLI v2 on FreeBSD for a while, and it stopped working after I updated to the latest release. I checked out v2.34.3 from the awscli v2 repo, installed the dependencies from requirements.txt, making sure there were no conflicts with locally installed packages — in particular awscrt, where I used the latest available version as specified in the requirements. The aws command itself ran fine:

» aws --version
aws-cli/2.34.3 Python/3.11.14 FreeBSD/15.0-RELEASE-p2 source/amd64

But as soon as it needed to do anything real, it fell apart:

» aws configure sso

aws: [ERROR]: module '_awscrt' has no attribute 'set_log_level'

A bit of digging showed awscrt/io.py calling _awscrt.set_log_level(log_level).

For some context, the awscrt package is the AWS Common Runtime binding for Python, and it’s split into two parts. The pure Python layer lives in the awscrt package and handles the high-level API. The actual work is done by _awscrt, a C extension compiled as a library (a .so file) which on a typical FreeBSD + Python 3.11 setup would live at /usr/local/lib/python3.11/site-packages/_awscrt.abi3.so.

I had manually installed awscrt==0.31.2, the latest release at the time of writing, and also exactly the version pinned in awscli’s requirements.txt. Still, the error persisted. The Python wrapper was clearly calling something the .so didn’t expose.

The clue came from checking where Python was actually loading things from:

» python3 -c "import awscrt; print(awscrt.__version__, awscrt.__file__)"
0.31.2 /usr/local/lib/python3.11/site-packages/awscrt/__init__.py
» python3 -c "import _awscrt; print(_awscrt.__file__)"
/home/myuser/.local/lib/python3.11/site-packages/_awscrt.abi3.so

There it is. The Python part of awscrt was correctly loading from the system-wide site-packages. But _awscrt, the compiled extension, was being pulled from my user’s local site-packages. Probably a leftover from a previous install that I hadn’t properly cleaned up.

After removing the dangling file from ~/.local/lib/python3.11/site-packages/, the check came back clean:

» python3 -c "import _awscrt; print(_awscrt.__file__)"
/usr/local/lib/python3.11/site-packages/_awscrt.abi3.so

And with that, aws configure sso ran without complaint.

Error 500 in nextcloud polls

Today we found the poll application on one of our nextcloud instance to be completely broken. Attempting to create a new poll or add date options to an existing one resulted in HTTP 500 errors. In nextcloud logs, there were errors like:

duplicate key value violates unique constraint "oc_polls_polls_pkey"
DETAIL: Key (id)=(9) already exists.

or when adding options to an existing poll:

OCP\AppFramework\Db\DoesNotExistException: Did expect one result but found none

As a clue, this happened after a migration of the nextcloud DB from MySQL to PostgreSQL. My guess is that the PostgreSQL sequences were not updated correctly during the migration. So it was not possible to create either new rows for options in a poll or even a poll itself after a certain point, when the auto generated ID conflicted with an existing ID in the tables, which is the unique constraint violation you see in the error.

So the behavior was, we could add some options and some polls, but as soon as the sequence catches up to an existing ID, any INSERT fails with a primary key conflict. In our case only the polls tables seemed to be affected, but this could happen on other tables too because of the migration.

The fix was to reset the sequences to the current maximum ID for each table of the polls application:

SELECT setval('oc_polls_polls_id_seq',    (SELECT MAX(id) FROM oc_polls_polls));
SELECT setval('oc_polls_options_id_seq',  (SELECT MAX(id) FROM oc_polls_options));
SELECT setval('oc_polls_votes_id_seq',    (SELECT MAX(id) FROM oc_polls_votes));
SELECT setval('oc_polls_share_id_seq',    (SELECT MAX(id) FROM oc_polls_share));
SELECT setval('oc_polls_comments_id_seq', (SELECT MAX(id) FROM oc_polls_comments));
SELECT setval('oc_polls_log_id_seq',      (SELECT MAX(id) FROM oc_polls_log));

Open with and external programs on Claws-Mail

I was integrating rrr with claws-mail and use it to open external files, so I could centralize file opening configuration there. I created a mail profile in ~/.config/rrr.conf with a catch-all rule * echo "'%s'" >> /tmp/mail-rrr.log to debug which files and extensions were being passed. The plan was to set rrr -p mail -F '%s' as the default external application, but the command kept failing.

I wrote a small test.sh script to display each argument individually as passed by claws-mail. That’s when I discovered the issue: claws-mail treats each space in the command as a separate argument delimiter. So rrr -p  mail -F '%s' (note the double space between -p and mail) resulted in the argument list: ["rrr", "-p", "", "mail", "-F", "the-actual-file"]. The empty string broke everything.

So the fix was simple, just had to make sure there were no extra space everywhere.

runrunrun v0.3.0: I would use that

Another week, another release, runrunrun v0.3.0 is here. This release adds all the features I actually needed to start using this project myself on a daily basis.

What’s New

Fallback: The new -f/--fallback option (or RRR_FALLBACK=true) enables automatic fallback to previous matching rules when commands fail. This is useful when some commands aren’t found on the system:

https://* lynx
https://* chromium
https://* firefox

With fallback enabled, rrr -f https://example.com will try firefox first, then chromium, then lynx until one succeeds.

Alias redefinition: Redefining an alias now updates all rules using it, including previous ones:

[video] vlc
*.mkv [video]
*.mp4 [video]

# Now .mkv and .mp4 both use mplayer
[video] mplayer
*.avi [video]

Fix in desktop file support: Added support for more format specifiers (%f, %F, %u, %U) in desktop files (previously we only supported %U).

As always, feedback and contributions are welcome!


runrunrun is available at https://github.com/gawen947/runrunrun

runrunrun v0.2.0: desktop file import and more

Just one week after the initial release, here comes runrunrun v0.2.0 with a key feature from the roadmap: desktop file import.

What’s New

Desktop File Import: The :import directive can now read .desktop files and automatically generate rules from their MIME types:

:import /usr/local/share/applications/gimp.desktop
:import /usr/share/applications

This extracts the Exec and MimeTypes attributes, infers file extensions, and creates the appropriate glob patterns automatically. It’s a great bridge for migrating from existing desktop configurations.

Path Expansion: Configuration files now support tilde (~) and environment variable expansion:

:include ~/.config/rrr/work.conf
:include ~/$DOTFILES/rrr/common.conf

Include Loop Protection: Fixed a bug where circular includes would cause infinite loops. Now if a file has already been included, it’s simply skipped.

Performance: The config parser now only loads rules for the requested profile, improving startup time for large configurations.

Getting Started with Import

If you want to import your existing desktop file associations:

# Import a specific application
:import /usr/share/applications/firefox.desktop

# Import everything
:import /usr/share/applications
:import ~/.local/share/applications

Desktop files without Exec or MimeTypes are silently skipped, so you can safely import entire directories.

Next Steps

With desktop file import now available, migrating from traditional file association systems is much easier. As always, feedback and contributions are welcome!


runrunrun is available at https://github.com/gawen947/runrunrun

runrunrun v0.1.0 (first release)

I’m happy to announce the first release of runrunrun (v0.1.0), a new file and URL opener that runs the right thing—no surprises, no guesswork.

The Problem It Solves

If you’ve ever clicked on a text file only to have it open in Wine’s Notepad, or watched your carefully configured file associations get overridden by yet another desktop environment, you’ll understand the frustration. Every time I need to configure file associations with xdg-open and its cousins, something isn’t quite right.

Traditional desktop openers work by making assumptions—and that might be exactly what some users want. But for those who prefer explicit control, the current tools fall short. They guess your preferred browser based on your desktop environment, scatter configuration across countless .desktop files, and make debugging file associations unnecessarily complex.

The goal of runrunrun is simple: make opening files and URL schemes straightforward and consistent.

What Makes runrunrun Different

runrunrun (or rrr for short) takes a radically simple approach: explicit configuration through pattern matching. No MIME type cascades, no desktop file archaeology—just straightforward rules that you control.

Here’s what opening a PDF looks like in your config:

*.pdf    qpdf

That’s it. When you run rrr document.pdf, it opens in qpdf. No surprises.

Core Features in v0.1.0

Simple Pattern Matching: Define what opens what using glob patterns for files and URLs:

*.jpg        feh
https://*    firefox
mailto:*     thunderbird

Predictable Overrides: Later rules win, making customization straightforward:

*.txt    mousepad
*.txt    vim    # This one wins

Regex Support with Capture Groups: For more advanced matching, use regex patterns (prefixed with ~) that can even extract and reuse parts of the match:

~^IMG_[0-9]+\.png$  darktable
~mailto:([^?]+)\?subject=([^&]+)  "true %s; thunderbird --compose \"to=%1,subject=%2\""

Profiles for Different Contexts: Switch between configurations easily:

:profile work
https://*    firefox-work-profile

:profile personal
https://*    brave

Terminal-Friendly: Unlike desktop-centric tools, rrr works just as well in terminal environments as it does on a full desktop, making it suitable for servers and minimal setups.

Transparent Operation: Query mode (rrr -q file.ext) shows you exactly what would run, and dry-run mode (rrr -n) lets you test configurations safely.

Getting Started

The project is written in Rust and available on GitHub. Configuration lives in simple text files at /etc/rrr.conf or ~/.config/rrr.conf.

What’s Next

This first release includes the core functionality along with regex patterns and aliases for common applications. Future versions will add the ability to import existing .desktop files for those who want a migration path from their current setup.

As with any first release, there are likely bugs to catch and behaviors that might need adjustment. Your feedback will help shape the direction of future releases.

If you’re tired of fighting with file associations and want something that just works the way you configure it, give runrunrun a try. Your feedback and contributions are welcome!


runrunrun is available at https://github.com/gawen947/runrunrun

Disable RustAnalyzer warnings

Currently, I mainly use Neovim to code in Rust (and pretty much any other language). The problem is that when you start a new Rust project and begin creating structs and functions all over the place, you get flooded with warnings about “unused this” and “unused that”.

Maybe many of you probably already know this, but a quick trick to avoid those warnings is to simply run:
export RUSTFLAGS="-Awarnings".

This will disable all warnings, but it can be very convenient during early development.