- Nix 100%
Internet scale routing with NixOS #
This is mostly derived from my NixCon 2025 talk with RFC 0189 twist.
Summary #
Introduce a contracts.routing contract following the NixOS RFC 0189 pattern to
decouple routing protocol configuration from routing daemon implementation. The
contract defines fully typed Nix options for 14 standard routing protocols. A
Bird2 provider compiles these typed inputs into bird.conf. No raw config
strings in the contract layer -- the provider is the only place where
daemon-specific syntax exists.
Architecture #
Actors #
Consumer Contract Provider
(host config) --> contracts.routing --> (Bird2 compiler)
| | ^ |
| typed inputs bird.conf
| | | |
+-- reads output (all inputs mirrored) --+
Four roles per RFC 0189:
- Contract team -- maintains
contracts.routing(types, behavior tests) - Provider team -- maintains Bird2 provider (compiler, systemd unit)
- Consumer team -- maintains modules that need routing (currently just the host config)
- End user -- wires consumer to provider, sets provider-specific options
File layout #
lib/
router.nix # lib.router constants + custom types
modules/
contracts/
routing/
default.nix # contract definition (input/output)
types/
common.nix # shared: blockOptions, prefix list, address family
filter.nix # filter/policy language types
bgp.nix # BGP session type
ospf.nix # OSPF instance type
rip.nix # RIP instance type
babel.nix # Babel instance type
radv.nix # RAdv instance type
rpki.nix # RPKI validator type
bfd.nix # BFD session type
bmp.nix # BMP station type
mrt.nix # MRT dump type
static.nix # static route type
aggregator.nix # route aggregation type
device.nix # interface discovery type
direct.nix # connected routes type
kernel.nix # kernel FIB sync type
providers/
bird2/
default.nix # Bird2 provider module (wiring + systemd)
compiler.nix # top-level compiler: typed inputs -> bird.conf
compiler/
bgp.nix
ospf.nix
rip.nix
babel.nix
radv.nix
rpki.nix
bfd.nix
bmp.nix
mrt.nix
static.nix
aggregator.nix
device.nix
direct.nix
kernel.nix
filter.nix # filter AST -> Bird2 filter syntax
tests/
default.nix # NixOS VM behavior tests
Module injection via importApply #
All modules receive std (builtins // nixpkgs.lib) and router (constants +
types) via importApply:
# flake.nix
let
std = builtins // nixpkgs.lib;
router = import ./lib/router.nix { inherit std; };
in
{
nixosModules.default = std.modules.importApply ./modules {
inherit std router;
};
}
Module files:
{ std, router, ... }: # injected by importApply
{ config, pkgs, ... }: # standard NixOS module args
{ ... }
lib.router namespace #
Semantic constants replacing string enums. Provides tab completion in nix-repl and type safety.
# lib/router.nix
{ std, ... }:
{
# -- constants --
peering = {
disabled = { type = "disabled"; };
direct = { type = "direct"; };
multihop = { type = "multihop"; };
};
policy = {
accept = { decision = "accept"; };
reject = { decision = "reject"; };
};
roa = {
valid = { status = "valid"; };
invalid = { status = "invalid"; };
unknown = { status = "unknown"; };
notFound = { status = "not-found"; };
};
addPaths = {
off = { mode = "off"; };
rx = { mode = "rx"; };
tx = { mode = "tx"; };
switch = { mode = "switch"; };
};
route = {
blackhole = { action = "blackhole"; };
unreachable = { action = "unreachable"; };
prohibit = { action = "prohibit"; };
via = addr: { action = "via"; gateway = addr; };
};
multiprotocol = {
v4overV6 = { mode = "v4-over-v6"; };
v6overV4 = { mode = "v6-over-v4"; };
};
ospf = {
v2 = { version = "v2"; };
v3 = { version = "v3"; };
};
area = {
normal = { type = "normal"; };
stub = { type = "stub"; };
nssa = { type = "nssa"; };
};
cmp = {
eq = { op = "="; };
ne = { op = "!="; };
lt = { op = "<"; };
gt = { op = ">"; };
le = { op = "<="; };
ge = { op = ">="; };
};
af = {
ipv4 = { family = "ipv4"; };
ipv6 = { family = "ipv6"; };
};
babel = {
wired = { type = "wired"; };
wireless = { type = "wireless"; };
};
bmp = {
prePolicy = { mode = "pre-policy"; };
postPolicy = { mode = "post-policy"; };
};
# -- custom option types --
types = {
# helper: create an option type that validates an attrset has
# a specific key with a value from an allowed set
mkEnumAttrType = name: attrName: validValues:
std.types.mkOptionType {
inherit name;
description = "${name} (one of: ${std.concatStringsSep ", " validValues})";
check = v: std.isAttrs v && v ? ${attrName}
&& std.elem v.${attrName} validValues;
merge = std.options.mergeEqualOption;
};
peeringType = /* mkEnumAttrType "peeringType" "type" [...] */;
policyType = /* mkEnumAttrType "policyDecision" "decision" [...] */;
roaStatusType = /* mkEnumAttrType "roaStatus" "status" [...] */;
addPathsType = /* mkEnumAttrType "addPathsMode" "mode" [...] */;
routeActionType = /* mkEnumAttrType "routeAction" "action" [...] */;
cmpType = /* mkEnumAttrType "cmpOp" "op" [...] */;
# submodule types for protocols
filterType = /* std.types.submodule { ... } */;
bgpSessionType = /* std.types.submodule { ... } */;
ospfInstanceType = /* std.types.submodule { ... } */;
# ... one per protocol
};
}
Contract definition #
Input options #
All protocol options default to empty/disabled. Consumer only populates what it
needs. Every protocol block inherits blockOptions (order, extraConfigBefore,
extraConfigAfter).
# modules/contracts/routing/default.nix
contracts.routing = {
input = { config, ... }: {
options = {
routerId = std.mkOption { type = std.types.str; };
asn = std.mkOption { type = std.types.int; };
source = {
ipv4 = std.mkOption { type = std.types.nullOr std.types.str; default = null; };
ipv6 = std.mkOption { type = std.types.nullOr std.types.str; default = null; };
};
prefixLists = std.mkOption {
type = std.types.attrsOf router.types.prefixListType;
default = {};
};
bgp.sessions = std.mkOption {
type = std.types.attrsOf router.types.bgpSessionType;
default = {};
};
ospf.instances = std.mkOption {
type = std.types.attrsOf router.types.ospfInstanceType;
default = {};
};
rip.instances = std.mkOption {
type = std.types.attrsOf router.types.ripInstanceType;
default = {};
};
babel.instances = std.mkOption {
type = std.types.attrsOf router.types.babelInstanceType;
default = {};
};
radv.instances = std.mkOption {
type = std.types.attrsOf router.types.radvInstanceType;
default = {};
};
rpki = std.mkOption {
type = router.types.rpkiType;
default = {};
};
bfd.sessions = std.mkOption {
type = std.types.attrsOf router.types.bfdSessionType;
default = {};
};
bmp.stations = std.mkOption {
type = std.types.attrsOf router.types.bmpStationType;
default = {};
};
mrt.dumps = std.mkOption {
type = std.types.attrsOf router.types.mrtDumpType;
default = {};
};
static = std.mkOption {
type = router.types.staticType;
default = {};
};
aggregator.instances = std.mkOption {
type = std.types.attrsOf router.types.aggregatorType;
default = {};
};
device = std.mkOption {
type = router.types.deviceType;
default = {};
};
direct = std.mkOption {
type = router.types.directType;
default = {};
};
kernel = std.mkOption {
type = router.types.kernelType;
default = {};
};
};
};
output = { ... }: {
# all input options mirrored as read-only
# downstream modules (nftables, tailscale, prometheus) consume these
};
behaviorTest = { /* NixOS VM test */ };
};
Output options #
All user-specified inputs are exposed back as read-only outputs. This allows downstream modules to reference routing data without depending on the provider.
Example downstream usage:
let
announced = config.services.bird2.routing.output;
in
{
networking.nftables.tables.outbound4.content = ''
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
ip saddr { ${std.concatMapStringsSep ", " (r: r.prefix) announced.static.ipv4.routes} } oifname "eth0" masquerade
}
'';
services.tailscale.extraSetFlags =
let
v4 = map (r: r.prefix) announced.static.ipv4.routes;
v6 = map (r: r.prefix) announced.static.ipv6.routes;
in
[ "--advertise-routes=${std.concatStringsSep "," (v4 ++ v6)}" ];
}
Block ordering #
Numeric priority via mkOrder. Default ordering encodes Bird2's dependency
requirements:
| Priority | Category | Rationale |
|---|---|---|
| 10 | ROA table declarations | Must exist before filters reference them |
| 20 | RPKI protocol instances | Populate the ROA tables |
| 30 | Device protocol | Must discover interfaces before other protocols use them |
| 40 | Direct protocol | Connected routes depend on device |
| 50 | Kernel protocol | FIB sync |
| 60 | Static routes | |
| 70 | Aggregator | Operates on existing routes |
| 80 | BFD | Must be ready before BGP sessions reference it |
| 90 | OSPF, RIP, Babel, RAdv | IGP protocols |
| 100 | BGP | EGP sessions |
| 110 | BMP, MRT | Monitoring/dump protocols |
Users override with order on any block. The Bird2 compiler collects all
blocks, sorts by order, joins with newlines.
Injection points #
Every block supports:
extraConfigBefore: raw string prepended before the block (e.g.,include "/run/secrets/bgp-passwords")extraConfigAfter: raw string appended after the block
The Bird2 provider module also has a top-level extraConfig for content that
doesn't belong to any specific block.
Filter/policy language #
Filters are inline values of filterType, not named references. Users compose
them with standard Nix (let/in, functions, //).
Filter type structure #
A filter is a list of ordered rules. Each rule has a match condition (AND of all non-null fields) and an action.
Match conditions:
| Field | Type | Bird2 compilation |
|---|---|---|
roaStatus |
router.roa.* |
roa_check(table, net, bgp_path.last) = ROA_* |
prefixIn |
prefixListType |
net ~ [ prefix/len+, ... ] |
bgpAsn |
int |
bgp_path.last = ASN |
bgpPathLength |
{ op; value; } |
bgp_path.len OP VALUE |
communityHas |
{ asn; value; } |
(ASN, VALUE) ~ bgp_community |
Empty match (all fields null) means match all -- used for default rules.
Actions:
| Field | Type | Bird2 compilation |
|---|---|---|
decision |
router.policy.* |
accept; / reject; |
setLocalPref |
int |
bgp_local_pref = VALUE; |
setMed |
int |
bgp_med = VALUE; |
prependPath |
{ asn; count; } |
bgp_path.prepend(ASN); repeated count times |
addCommunity |
{ asn; value; } |
bgp_community.add((ASN, VALUE)); |
deleteCommunity |
{ asn; value; } |
bgp_community.delete((ASN, VALUE)); |
Multiple action fields can be set on one rule (e.g., set local pref AND accept). The compiler emits them in order.
Usage example #
let
inherit (lib) router;
mkRpkiFilter = bogons: {
rules = [
{ match.roaStatus = router.roa.invalid; action = router.policy.reject; }
{ match.prefixIn = bogons; action = router.policy.reject; }
{ action = router.policy.accept; }
];
};
bogons4 = {
prefixes = [
{ prefix = "0.0.0.0/8"; le = 32; }
{ prefix = "10.0.0.0/8"; le = 32; }
{ prefix = "127.0.0.0/8"; le = 32; }
];
};
in
{
routing.bgp.sessions.upstream1.ipv4.import.filter = mkRpkiFilter bogons4;
}
Bird2 compiler output:
protocol bgp upstream1_ipv4 {
...
ipv4 {
import filter {
if (roa_check(roa4, net, bgp_path.last) = ROA_INVALID) then { reject; }
if (net ~ [ 0.0.0.0/8+, 10.0.0.0/8+, 127.0.0.0/8+ ]) then { reject; }
accept;
};
...
};
}
Bird2 provider #
Compiler #
Pure function: contract input attrset -> bird.conf string.
compiler.nix: top-level orchestrator. Collects all protocol compilers, sorts blocks by order, concatenates.compiler/<protocol>.nix: each returns{ order = int; text = string; }or a list thereof.compiler/filter.nix: translates filter AST to Bird2 filter body syntax.
The compiler handles Bird2-specific decisions:
- Splitting BGP sessions into separate v4/v6 protocol blocks
- Deriving ROA table declarations from RPKI config
- Mapping
router.peering.directto Bird2'sdirect;keyword - Mapping
router.peering.multihopto Bird2'smultihop;keyword - Generating
protocol device,protocol direct,protocol kernelblocks
Provider-specific options #
Options outside the contract, set directly on the Bird2 provider by the end user:
pipe: Bird2 inter-table routing (attrsOf pipe config)l3vpn: Bird2 MPLS L3VPN config (attrsOf l3vpn config)extraConfig: raw string appended to the generated config
Systemd unit #
Equivalent to current module:
Type = "forking"- Capabilities:
CAP_NET_ADMIN,CAP_NET_BIND_SERVICE,CAP_NET_RAW ExecStart = bird -c /etc/bird/bird.confExecReload = birdc configure- Auto-reload on config change via
reloadTriggers - Hardened:
ProtectSystem,ProtectHome,PrivateTmp,MemoryDenyWriteExecute
Protocol split #
| Protocol | In contract? | Rationale |
|---|---|---|
| BGP | Yes | RFC 4271, universal |
| OSPF | Yes | RFC 2328/5340, universal |
| RIP | Yes | RFC 2453, universal |
| Babel | Yes | RFC 8966, universal |
| RAdv | Yes | RFC 4861, universal |
| RPKI | Yes | RFC 6810, universal |
| BFD | Yes | RFC 5880, universal |
| BMP | Yes | RFC 7854, universal |
| MRT | Yes | RFC 6396, universal |
| Static | Yes | Every daemon supports static routes |
| Aggregator | Yes | Route summarization is a standard concept |
| Device | Yes | Interface discovery (explicit in Bird2, implicit in FRR/OpenBGPD) |
| Direct | Yes | Connected route generation, universal concept |
| Kernel | Yes | Kernel FIB sync, universal concept |
| Pipe | No (provider) | Bird2-specific inter-table routing |
| L3VPN | No (provider) | Maturity varies too much across daemons |
Behavior tests #
NixOS VM tests following the NuschtOS/bird.nix pattern:
- Two hosts on a shared VLAN
- Each runs the Bird2 provider with contract config (static routes, OSPF, BGP)
- Verify route propagation via
ip --json r | jq - Verify BGP session establishment via
birdc show protocols - Verify RPKI table population (when validators are reachable)
The test is generic on the provider -- parameterized so future FRR/OpenBGPD providers run the same assertions.
End-user wiring #
{
# consumer: declare routing intent
routing = {
routerId = "198.51.100.10";
asn = 65535;
# ... protocols ...
};
# wire consumer to bird2 provider
services.bird2.routing = {
consumer = config.routing;
# provider-specific
extraConfig = "";
};
}
Scope exclusions #
The following remain separate modules that consume contract outputs:
- systemd-networkd / dummy interface setup
- nftables SNAT rules
- Tailscale advertised routes / exit node config
- Prometheus bird exporter
- IP forwarding sysctl settings
Unresolved questions #
- Dual-link: RFC 0189 requires linking consumer-to-provider and provider-to-consumer. Whether we can simplify to single-direction depends on module system experiments.
- Filter completeness: The initial filter language covers the common 80% of
BGP policy. Advanced Bird2 filter features (variables, functions, custom
attributes) may need an
extraFilterescape hatch or future type extensions. - OSPF interface options: OSPF has many per-interface knobs (cost, priority, authentication, network type). Initial implementation covers the common ones; extend as needed.
- Behavior test scope: Which protocols to test in the initial VM test. BGP + OSPF + static route propagation is the minimum viable set.
License #
Licensed under the MIT License.