This service enables incoming IPv4 traffic on designated ports to be forwarded to a designated IPv6 address.
{
pkgs,
config,
lib,
...
}:
let
cfg = config.mjm.ipv4-proxy;
<<ynl>>
in
{
options.mjm.ipv4-proxy = {
enable = lib.mkEnableOption "IPv4 to IPv6 proxy";
<<options>>
};
config = lib.mkIf cfg.enable {
<<config>>
};
_class = "nixos";
}My thanks go to my friend emilazy for patiently helping me figure out how to do this.
ports = lib.mkOption {
type = with lib.types; listOf port;
default = [
80
443
1965
];
};This option defines the list of ports that will be forwarded. Each port will get a routing policy rule that will be covered later.
destinationIp = lib.mkOption {
type = lib.types.str;
default = config.mjm.ingress.virtualIP;
};boot.extraModulePackages = [
(pkgs.ipxlat.override {
linuxPackages = config.boot.kernelPackages;
})
];The ipxlat support I'm using is not merged into the Linux kernel yet. To avoid having to build an entire kernel just for that, I'm building it as a module. ipxlat isn't in nixpkgs, so I've got a local package definition for it:
{
lib,
stdenv,
fetchFromCodeberg,
linuxPackages,
}:
let
inherit (linuxPackages) kernel kernelModuleMakeFlags;
in
stdenv.mkDerivation {
pname = "ipxlat";
version = "0-unstable-2026-04-16";
src = fetchFromCodeberg {
owner = "IPv6-Monostack";
repo = "ipxlat-net-next";
rev = "0c8505408b8dfc4a3e92758d2fca86ea503a25d4";
sparseCheckout = [
"drivers/net/ipxlat"
"include/uapi"
# for ynl…
"tools"
"Documentation/netlink"
];
hash = "sha256-XUw7kx3UdZep2sYcMR6tScVWHJ53CIZU4U/jGw94pKU=";
};
# patches = [
# ./skip-checksum.patch
# ];
nativeBuildInputs = kernel.moduleBuildDependencies;
makeFlags = kernelModuleMakeFlags ++ [
"-C${kernel.dev}/lib/modules/${kernel.modDirVersion}/build"
"M=$(PWD)/drivers/net/ipxlat"
"CONFIG_IPXLAT=m"
"ccflags-y:=-I$(PWD)/include"
"INSTALL_MOD_PATH=${placeholder "out"}"
];
buildFlags = [ "modules" ];
installTargets = [ "modules_install" ];
enableParallelBuilding = true;
inherit (kernel) hardeningDisable;
strictDeps = true;
__structuredAttrs = true;
meta = {
description = "Stateless IP/ICMP Translation (SIIT) virtual device driver";
homepage = "https://codeberg.org/IPv6-Monostack/ipxlat";
license = lib.licenses.gpl2Only;
platforms = lib.platforms.linux;
maintainers = [ lib.maintainers.emily ];
};
}I'm not going to try to explain this, as I don't really know much about it. As you can guess by the maintainers listed, this was given to me by Emily.
boot.kernelPackages = pkgs.linuxPackages_latest;
This module won't build against the default LTS version of the kernel, but it works with the latest, so the machine will need to use that.
systemd.network.config.networkConfig = {
IPv4Forwarding = true;
IPv6Forwarding = true;
};There will be plenty of forwarding happening for both versions of IP, so I enable those globally.
systemd.services.siit-if-setup = {
wantedBy = [ "multi-user.target" ];
path = with pkgs; [
ynl
iproute2
kmod
];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = pkgs.execline.writeScript "siit-if-setup-start" "-P" ''
if { modprobe ipxlat }
foreground { ip link delete siit0 }
if { ip link add name siit0 type ipxlat }
if { ip link set siit0 up }
backtick -E iid { cat /sys/class/net/siit0/ifindex }
define json {\"ifindex\":''${iid},\"config\":{\"xlat-prefix6\":{\"prefix\":\"2a0104ff01f0879b0064471000000000\",\"prefix-len\":96},\"lowest-ipv6-mtu\":1500}}
ynl --family ipxlat --do dev-set --json $json
'';
ExecStopPost = pkgs.execline.writeScript "siit-if-setup-stop" "-P" ''
foreground { ip link delete siit0 }
exit
'';
};
};I'm going to need a network interface with type "ipxlat" that can be configured to do the IPv4<->IPv6 translation. Ideally, I would set that up with networkd and it would be nice and declarative, but networkd doesn't support that type, given that it's not actually in the kernel yet. So I'm left to define a systemd service to do it instead.
The setup process for the interface is not too bad. It's created and immediately brought up. Then it needs to be configured by sending a Netlink request. The ynl command is a Python tool that does that:
ynl = pkgs.ynl.override { src = pkgs.ipxlat.src; };The command is built from the same source tree as ipxlat. The definition of the package is:
{
lib,
python3Packages,
linuxPackages_latest,
src ? linuxPackages_latest.kernel.src,
}:
let
inherit (python3Packages)
setuptools
pyyaml
jsonschema
;
in
python3Packages.buildPythonApplication {
pname = "ynl";
inherit (linuxPackages_latest.kernel) version;
inherit src;
sourceRoot = "${src.name}/tools/net/ynl";
format = "pyproject";
build-system = [ setuptools ];
dependencies = [
pyyaml
jsonschema
];
postPatch = ''
substituteInPlace Makefile \
--replace-fail '@pip install --prefix=$(DESTDIR)$(prefix) .' ""
substituteInPlace pyynl/cli.py \
--replace-fail "SYS_SCHEMA_DIR='/usr/share/ynl'" "SYS_SCHEMA_DIR='$out/share/ynl'"
patchShebangs --build \
pyynl/ynl_gen_c.py \
pyynl/ynl_gen_rst.py
'';
preBuild = ''
make
'';
preInstall = ''
mkdir -p $out/bin
make install prefix=${placeholder "out"} bindir=${placeholder "out"}/bin
'';
strictDeps = true;
__structuredAttrs = true;
meta = {
description = "Simple CLI tool for issuing Netlink requests to the kernel";
homepage = "https://docs.kernel.org/userspace-api/netlink/intro-specs.html";
license = lib.licenses.gpl2Only;
platforms = lib.platforms.linux;
maintainers = [ lib.maintainers.emily ];
};
}As with ipxlat, I didn't write this package; don't ask me too many questions about it.
The quoting on the JSON kind of obscures it, so here's a prettier version:
{
"ifindex": ${iid},
"config": {
"xlat-prefix6": {
"prefix": "2a0104ff01f0879b0064471000000000",
"prefix-len": 96
},
"lowest-ipv6-mtu": 1500
}
}It needs the interface ID of the network interface being configured, so that is read from sysfs in the command shown above. Then it needs to be told the translation prefix to use on the IPv6 side. So IPv4 addresses will become IPv6 addresses with this 96-bit prefix followed by the 32-bit IPv4 address, making a full 128-bit IPv6 address. The first 64 bits are the /64 Hetzner allocated for me on the VPS where this runs, which is important so that return traffic actually gets routed back to the VPS.
The MTU setting is an optimization to avoid unnecessary packet fragmentation.
systemd.network.networks."10-siit" = {
matchConfig.Name = "siit0";
routes = [
{ Destination = "2a01:4ff:1f0:879b:64:4710::/96"; }
{
Destination = "0.0.0.0/0";
Table = 100;
Scope = "link";
}
];
<<routing-policy-rules>>
};The siit0 interface created above needs some routes set up, and thankfully that can be done with networkd even if creating the interface cannot.
First, the interface is designated to receive all traffic for the IPv6 prefix that was set up above. IPv6 traffic intended for such an address needs to be translated to equivalent IPv4 traffic, which is what this interface is for.
The second route is a little more interesting. It's there to get IPv4 traffic to go this device, but in that case, I only want that to happen for certain ports. I believe there's more than one way to do that, but here's the way I'm doing it. The route is placed in a designated table (100), and then routing policy rules will direct the traffic from those ports to that table.
routingPolicyRules =
(lib.zipListsWith (prio: port: {
Priority = prio;
Table = 100;
IncomingInterface = "lan0";
Family = "ipv4";
DestinationPort = port;
}) (lib.range 200 (199 + (lib.length cfg.ports))) cfg.ports)
++ [
{
Priority = 500;
Table = "local";
}
];How fun? This mess generates the necessary rules. It's not as scary as it looks, as most of the complication is there to generate distinct priorities for each rule, counting up from 200. Each rule handles IPv4 traffic coming in via the default network interface lan0 going to one of the configured ports. If the rule matches, then it will use table 100, and therefore the route created above, and send the traffic to siit0.
The last rule created is intended as a replacement for the default rule for the local routing table. Normally that rule sits at priority 0, which means it would have priority over these rules and they would have no effect. To fix that, I'm adding the replacement at priority 500, well above the other rules I've added. This alone just adds another rule though, it doesn't get rid of the one I don't want. networkd won't touch rules added by the kernel, so once again a service that sidesteps networkd is needed.
systemd.services.local-rule-priority = {
wantedBy = [ "multi-user.target" ];
path = with pkgs; [ iproute2 ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = pkgs.execline.writeScript "local-rule-priority-start" "-P" ''
foreground { ip rule delete priority 0 }
exit
'';
ExecStop = pkgs.execline.writeScript "local-rule-priority-stop" "-P" ''
ip rule add priority 0 lookup local
'';
};
};Very simple: it just deletes the rule on start, and adds it back on stop. networkd takes care of creating the replacement rule, so it doesn't need to manage that. In fact, trying to create the priority 500 rule in this service would not work: networkd would delete it, since it's expecting to manage all the rules that are not managed by the kernel.
This will leave me with a rule table that looks like this:
# ip rule 200: from all iif lan0 dport 80 lookup 100 proto static 201: from all iif lan0 dport 443 lookup 100 proto static 202: from all iif lan0 dport 1965 lookup 100 proto static 500: from all lookup local proto static 32766: from all lookup main 32767: from all lookup default
At this point, it's almost done, but you may have noticed that while the intention is for this traffic to all get forwarded to a particular IPv6 address, that address has not been mentioned anywhere. So far, incoming traffic on the specified ports will come in, get routed to the siit0 interface, translated from IPv4 to IPv6, and then just die there. There's nothing listening on those IPs, and nowhere external to route them.
What's needed for the last piece is destination NAT, so it's time to reach for nftables.
networking.nftables = {
enable = true;
tables.nat-it-up = {
family = "ip6";
content = ''
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
iifname "siit0" dnat to ${cfg.destinationIp}
}
'';
};
};This rule is what will send the incoming traffic that makes it into siit0 out to the destination. Normally when using dnat like this, you would possibly need to also use source NAT to change the source IP to the machine's IP. That's not necessary here, because the source IP has already been translated into an IPv6 address that is set up to route to this machine. The translation prefix was chosen that way on purpose. And on this machine, there's a route to send traffic going to that prefix to the siit0 interface, where it will get translated back to IPv4 so it can end up where it's supposed to go.
networking.firewall.checkReversePath = false;
The NixOS firewall does conspire a bit here to try to sabotage things. It takes precautions to ensure that replies to packets are going to get sent out on the same interface the packets came in on. I don't think that check is smart enough to understand the IP translation that is happening here, so even though replies are going to go back out the way the original packet came in, this check needs to be disabled, as it will reject the traffic.
text/gemini;lang=en-USThis content has been proxied by September (UNKNO).