Inspiration

When I was configuring the fairly complicated network environment in my own home, I chose Debian as a software router to control all of my WAN connections.

I originally thought about using a dedicated router system such as OPNsense, but I found that Debian could also do the job perfectly well. Since I do not need any web UI, OPNsense became much less attractive to me. More importantly, I am already used to Debian, and I even have a dedicated bootstrap script that can automatically configure a fresh system into the setup I want.

Then I ran into a problem when I started configuring IPv6.

My home connection does not have any fixed address. It is just a normal residential Telekom broadband connection. Telekom is actually quite generous with IPv6 Prefix Delegation, but the delegated prefix changes very frequently.

nftables supports NAT66, but it has no built-in dynamic support for this situation. If you want to do prefix translation, you have to configure both your ULA prefix and the current public prefix.

In my setup, this is basically used like NPTv6, but technically it is still NAT66, because nftables SNAT and DNAT are stateful and still depend on conntrack.

If you want to ask why I do not simply use the Router Advertisement provided by the ISP, you need to notice that I said Debian controls all of my WAN connections.

Yes, I also plan to do outbound load balancing across multiple network links. In that setup, using the provider-assigned address directly would make it very difficult to dynamically change the egress path. Using ULA internally and translating it to the prefix of the selected WAN is much easier to control.

After that, I looked at the existing solutions and found that there was basically nothing available for this exact problem.

Hooks provided by systemd-networkd or other software are not reliable enough, and even if I used them, I would still need to write an even less reliable script around them.

So I decided to build a real application instead.

I already have quite a lot of experience building small tools like this. More examples can be found in my tools repositories, and many of them were built with Codex. As long as you can describe the problem clearly, Codex can usually give you a stable and usable tool within one or two days.

I used Go simply because I am a loyal Go fan.

I also want to thank all of the libraries used by this project. They helped me a lot and greatly reduced the amount of low-level code I had to write.

What it does

The application basically combines network event handling, configuration loading, prefix calculation, and kernel-level nftables configuration into one program.

It reads the configuration and determines what mappings should exist. It then registers netlink watchers for relevant route events.

When an event arrives, it checks whether the current delegated prefix or desired nftables state has actually changed. If an update is needed, it generates the new nftables configuration and atomically replaces the table managed by the daemon.

The process is very clear and pipeline-oriented:

  1. Read and validate the configuration.
  2. Read the current network state from the kernel.
  3. Find the current DHCPv6 delegated prefix.
  4. Generate the public /64 mappings.
  5. Build the desired nftables state.
  6. Compare it with the current managed table.
  7. Do nothing if there is no change.
  8. Atomically replace the table if an update is required.

The whole process does not require any external program.

It does not call ip, nft, or shell scripts. It communicates directly with the Linux kernel through netlink and the nftables API.

How we built it

I used ChatGPT during almost the entire development process.

At first, I briefly described my goal: build a Go program that automatically reads the current IPv6 prefix and updates nftables.

I asked ChatGPT to help me find several existing Go libraries and design an initial solution.

After that, I discussed the advantages and disadvantages of different implementation approaches with it over multiple rounds. Eventually, we settled on the current clear pipeline model.

Once all of the details had been decided, I handed the implementation to Codex.

I placed the design document we had already discussed into the local workspace, then asked Codex to implement it using GPT-5.6 with high reasoning.

At that point, almost every important detail had already been decided, and there were also many other local repositories available as references.

After I finished writing the prompt and sent it, I went to sleep.

When I checked the result the next morning, the implementation was already finished. Codex took around 20 minutes to complete the core functionality and the tests.

Of course, the first result still had problems.

At that time, I had not yet used the right skills or other controls to fight AI slop, so the project contained too much defensive programming and some very obvious AI-generated patterns.

There were unnecessary guards, helpers, abstractions, duplicated tests, and code that looked careful but did not actually protect any meaningful boundary.

After I found the correct review skills and discussed the details with Codex several times, these problems were mostly solved.

We reviewed the code based on a simple question: does this check, helper, abstraction, error path, or test have a real purpose?

If the answer was no, it was removed.

Challenges we ran into

One challenge was deciding what should happen when the delegated prefix disappears temporarily or when multiple prefixes overlap during renewal.

The daemon cannot simply trust the event payload, because route events only mean that something may have changed.

So the event is only used as a trigger. After receiving it, the program reads a completely fresh route snapshot from the kernel.

If there is no acceptable prefix, or if there is more than one acceptable candidate, the program does not modify nftables.

Another challenge was making sure that nftables updates were safe.

The program must not partially update the managed rules.

All configuration validation, route reading, prefix selection, mapping calculation, desired-state construction, and comparison happen before any nftables modification is created.

Only after everything is valid does the program build one replacement transaction and commit it atomically.

There was also a correctness issue in the prefix mapping.

At first, the generated nftables map did not represent the full target /64 range correctly. The fix was to provide both ends of the target interval, so nftables could replace only the high 64-bit prefix while preserving the low 64-bit interface identifier.

The repository structure was another problem.

The project path layout became too messy, partly because the reference repositories I provided were also messy in similar ways.

So after v0.1.0, I had to reorganize the repository, move files into the correct locations, and publish v0.2.0.

After that, I continued with smaller fixes and additional features until the current v0.2.2 release.

Accomplishments that we're proud of

The thing I am most proud of is that the program worked immediately in the real environment it was built for.

When v0.1.0 was successfully released, I directly deployed it to my Debian software router.

It ran without any problems.

The project has now been running on my gateway for several days, and there have still been no issues.

IPv6 has remained available, and the various internal services in my home network are still publishing their functions over IPv6 normally.

I am also happy that the application stayed focused.

It does not try to become a full firewall manager, DHCPv6 client, router platform, or general network automation framework.

It only does one job: keep the dedicated nftables IPv6 prefix NAT table synchronized with the current DHCPv6 delegated prefix.

The current project includes:

  • Direct rtnetlink route monitoring.
  • Fresh kernel-state reads after relevant events.
  • Strict configuration validation.
  • Deterministic public /64 generation.
  • A no-op path when nothing changed.
  • Atomic nftables table replacement.
  • No dependency on external networking commands.
  • Static Linux binaries.
  • Debian and RPM packages.
  • Behavior-focused tests.
  • A read-only operational status file under /run.

What we learned

From my point of view, I at least learned that OPNsense definitely exists for a reason.

A dedicated router system gives you a lot of integration, testing, user experience, and ready-made behavior that you otherwise have to build yourself.

But I still do not regret choosing Debian.

I am already familiar with it, it matches the rest of my infrastructure, and it gives me full control over how the system works.

I also learned again that Codex has never disappointed me.

The important part is not simply asking it to build something without preparation.

The effective workflow is to describe the problem clearly, discuss the architecture and tradeoffs first, decide the important constraints, and then let Codex implement the result.

I also learned that AI-generated code still needs proper review.

Without clear control, it can easily contain too much defensive programming, unnecessary abstraction, and obvious AI slop.

Once the review criteria became more concrete, the quality improved a lot.

What's next for v6pfxnatd

The next steps are mostly user-experience and stability improvements.

I have already asked Codex to review the project several times, and for now it has not found any obvious problem.

At this point, I may need to wait until my server or network breaks in some interesting way before I discover the next real issue.

Possible future improvements include:

  • Better documentation.
  • A simpler demonstration environment.
  • More diagnostics.
  • More testing across different Linux distributions and nftables versions.
  • Better support material for multi-WAN setups.
  • More integration tests using isolated network namespaces.

If someone is willing to use the project and provide feedback, updates may become more frequent.

But for now, the current functionality is already completely enough for my own use.

Built With

Share this project:

Updates