Something went wrong. Try again.
simple and declarative nixos router
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642{ lib, ... }:
{ config, pkgs, ... }:
let cfg = config.router;
routeType = lib.types.submodule { options = { prefix = lib.mkOption { type = lib.types.str; description = "prefix to announce"; }; option = lib.mkOption { type = lib.types.str; description = "option"; }; }; };
decisionType = lib.types.submodule { options = { hostname = lib.mkOption { type = lib.types.str; description = "hostname"; };
interface = { local = lib.mkOption { type = lib.types.str; description = "local interface name that will be used to assign addresses within the announced prefixes"; }; primary = lib.mkOption { type = lib.types.str; description = "the primary interface (assigned by hosting provider)"; }; };
ipv4.addresses = lib.mkOption { type = with lib.types; listOf str; description = "IPv4 addresses to use on local interface"; };
ipv6.addresses = lib.mkOption { type = with lib.types; listOf str; description = "IPv6 addresses to use on local interface"; }; }; };in{ options.router = { enable = lib.mkEnableOption "whether to enable the router module";
asn = lib.mkOption { type = lib.types.int; description = "ASN"; };
local = lib.mkOption { type = decisionType; description = "local routing decision"; };
router = { id = lib.mkOption { type = lib.types.str; description = "router ID"; };
exit = lib.mkOption { type = lib.types.bool; default = true; description = "is this node capable of sending outboud traffic (i.e. have BGP session or not)"; };
outboundGateway = { ipv4 = lib.mkOption { type = with lib.types; nullOr str; default = null; description = '' IPv4 address of outbound gateway if different from main interface's default gateway only set this if upstream provider requires it ''; }; ipv6 = lib.mkOption { type = with lib.types; nullOr str; default = null; description = '' IPv6 address of outbound gateway if different from main interface's default gateway only set this if upstream provider requires it ''; }; };
scantime = lib.mkOption { type = lib.types.int; default = 10; description = "scan time"; };
secret = lib.mkOption { type = lib.types.path; description = "path to secret (imported via `include`)"; };
source = { ipv4 = lib.mkOption { type = with lib.types; nullOr str; default = null; description = "Default IPv4 source address"; }; ipv6 = lib.mkOption { type = with lib.types; nullOr str; default = null; description = "Default IPv6 source address"; }; };
rpki = { ipv4 = { table = lib.mkOption { type = lib.types.str; default = "trpki4"; description = "IPv4 RPKI table name"; }; filter = lib.mkOption { type = lib.types.str; default = "validated4"; description = "IPv4 RPKI filter name"; }; }; ipv6 = { table = lib.mkOption { type = lib.types.str; default = "trpki6"; description = "IPv6 RPKI table name"; }; filter = lib.mkOption { type = lib.types.str; default = "validated6"; description = "IPv6 RPKI filter name"; }; }; retry = lib.mkOption { type = lib.types.int; default = 600; description = "retry time"; }; refresh = lib.mkOption { type = lib.types.int; default = 3600; description = "refresh time"; }; expire = lib.mkOption { type = lib.types.int; default = 7200; description = "expire time"; }; validators = lib.mkOption { type = lib.types.listOf (lib.types.submodule { options = { id = lib.mkOption { type = lib.types.int; description = "id of RPKI validator (only for generating unique names)"; }; remote = lib.mkOption { type = lib.types.str; description = "remote address"; }; port = lib.mkOption { type = lib.types.int; description = "port"; }; }; }); default = [ { id = 0; remote = "rtr.rpki.cloudflare.com"; port = 8282; } { id = 1; remote = "r3k.zrh2.v.rpki.daknob.net"; port = 3323; } ]; description = "RPKI validators"; }; };
device.name = lib.mkOption { type = lib.types.str; default = "device0"; description = "name of device protocol"; };
direct.name = lib.mkOption { type = lib.types.str; default = "direct0"; description = "name of direct protocol"; };
kernel = { ipv4 = { name = lib.mkOption { type = lib.types.str; default = "kernel4"; description = "name of IPv4 kernel protocol"; }; import = lib.mkOption { type = lib.types.str; default = "import none;"; description = "import option"; }; export = lib.mkOption { type = lib.types.str; default = "export none;"; description = "export option"; }; }; ipv6 = { name = lib.mkOption { type = lib.types.str; default = "kernel6"; description = "name of IPv6 kernel protocol"; }; import = lib.mkOption { type = lib.types.str; default = "import none;"; description = "import option"; }; export = lib.mkOption { type = lib.types.str; default = "export none;"; description = "export option"; }; }; };
static = { ipv4 = { name = lib.mkOption { type = lib.types.str; default = "static4"; description = "name of IPv4 static protocol"; }; routes = lib.mkOption { type = with lib.types; listOf routeType; description = "IPv4 prefixes to announce and their corresponding options"; }; }; ipv6 = { name = lib.mkOption { type = lib.types.str; default = "static6"; description = "name of IPv6 static protocol"; }; routes = lib.mkOption { type = with lib.types; listOf routeType; description = "IPv6 prefixes to announce and their corresponding options"; }; }; };
sessions = lib.mkOption { description = "list of BGP sessions";
type = lib.types.listOf (lib.types.submodule { options = { name = lib.mkOption { type = lib.types.str; description = "name of BGP neighbor"; };
password = lib.mkOption { type = with lib.types; nullOr str; description = "key of BGP session password in the environment file"; };
type = { ipv4 = lib.mkOption { type = lib.types.enum [ "disabled" "direct" "multihop" ]; description = "IPv4 peer connection type"; }; ipv6 = lib.mkOption { type = lib.types.enum [ "disabled" "direct" "multihop" ]; description = "IPv6 peer connection type"; }; };
mp = lib.mkOption { type = lib.types.nullOr (lib.types.enum [ "v4 over v6" "v6 over v4" ]); default = null; description = "BGP multi-protocol extension"; };
source = { ipv4 = lib.mkOption { type = with lib.types; nullOr str; default = cfg.router.source.ipv4; description = "IPv4 source address if different from router's default outbound IPv4"; }; ipv6 = lib.mkOption { type = with lib.types; nullOr str; default = cfg.router.source.ipv6; description = "IPv6 source address if different from router's default outbound IPv6"; }; };
neighbor = { asn = lib.mkOption { type = lib.types.int; description = "ASN of BGP neighbor"; }; ipv4 = lib.mkOption { type = with lib.types; nullOr str; default = null; description = "IPv4 of BGP neighbor"; }; ipv6 = lib.mkOption { type = with lib.types; nullOr str; default = null; description = "IPv6 of BGP neighbor"; }; };
addpath = lib.mkOption { type = lib.types.enum [ "switch" "rx" "tx" "off" ]; default = "off"; description = "BGP Add-Path extension"; };
import = { ipv4 = lib.mkOption { type = lib.types.str; default = "import none;"; description = "IPv4 import option"; }; ipv6 = lib.mkOption { type = lib.types.str; default = "import none;"; description = "IPv6 import option"; }; };
export = { ipv4 = lib.mkOption { type = lib.types.str; default = "export all;"; description = "IPv4 export option"; }; ipv6 = lib.mkOption { type = lib.types.str; default = "export all;"; description = "IPv6 export option"; }; }; }; }); }; }; };
# FIXME: parameterize # require nftables and tailscale # already imported in minimal # imports = with inputs.self.nixosModules; [ nftables tailscale ];
config = lib.mkIf cfg.enable (lib.mkMerge [ { networking.firewall.allowedTCPPorts = lib.optional cfg.router.exit 179; services.bird.enable = cfg.router.exit; services.bird.checkConfig = false; services.bird.package = pkgs.bird2; services.bird.config = '' include "${cfg.router.secret}";
router id ${cfg.router.id};
roa4 table ${cfg.router.rpki.ipv4.table}; roa6 table ${cfg.router.rpki.ipv6.table};
${lib.concatMapStringsSep "\n\n" (validator: '' protocol rpki rpki${lib.toString validator.id} { roa4 { table ${cfg.router.rpki.ipv4.table}; }; roa6 { table ${cfg.router.rpki.ipv6.table}; };
remote "${validator.remote}" port ${lib.toString validator.port};
retry keep ${lib.toString cfg.router.rpki.retry}; refresh keep ${lib.toString cfg.router.rpki.refresh}; expire ${lib.toString cfg.router.rpki.expire}; }'') cfg.router.rpki.validators}
filter ${cfg.router.rpki.ipv4.filter} { if (roa_check(${cfg.router.rpki.ipv4.table}, net, bgp_path.last) = ROA_INVALID) then { print "Ignore RPKI invalid ", net, " for ASN ", bgp_path.last; reject; } accept; }
filter ${cfg.router.rpki.ipv6.filter} { if (roa_check(${cfg.router.rpki.ipv6.table}, net, bgp_path.last) = ROA_INVALID) then { print "Ignore RPKI invalid ", net, " for ASN ", bgp_path.last; reject; } accept; }
protocol device ${cfg.router.device.name} { scan time ${lib.toString cfg.router.scantime}; }
protocol direct ${cfg.router.direct.name} { interface "${cfg.local.interface.local}"; ipv4; ipv6; }
protocol kernel ${cfg.router.kernel.ipv4.name} { scan time ${lib.toString cfg.router.scantime};
learn; persist;
ipv4 { ${cfg.router.kernel.ipv4.import} ${cfg.router.kernel.ipv4.export} }; }
protocol kernel ${cfg.router.kernel.ipv6.name} { scan time ${lib.toString cfg.router.scantime};
learn; persist;
ipv6 { ${cfg.router.kernel.ipv6.import} ${cfg.router.kernel.ipv6.export} }; }
protocol static ${cfg.router.static.ipv4.name} { ipv4;
${lib.concatMapStringsSep "\n " (r: ''route ${r.prefix} ${r.option};'') cfg.router.static.ipv4.routes} }
protocol static ${cfg.router.static.ipv6.name} { ipv6;
${lib.concatMapStringsSep "\n " (r: ''route ${r.prefix} ${r.option};'') cfg.router.static.ipv6.routes} }
${lib.concatMapStringsSep "\n\n" (session: (lib.optionalString (session.type.ipv4 != "disabled") '' protocol bgp ${session.name}4 { graceful restart on;
${session.type.ipv4}; ${if (lib.isNull session.source.ipv4) then "" else ''source address ${session.source.ipv4};'' } local as ${lib.toString cfg.asn}; neighbor ${session.neighbor.ipv4} as ${lib.toString session.neighbor.asn};${ if lib.isNull session.password then "" else "\n password ${session.password};" }
ipv4 { add paths ${session.addpath}; ${session.import.ipv4} ${session.export.ipv4} }; ${lib.optionalString (session.mp == "v6 over v4") '' ipv6 { add paths ${session.addpath}; ${session.import.ipv6} ${session.export.ipv6} };''} } '') + "\n" + (lib.optionalString (session.type.ipv6 != "disabled") '' protocol bgp ${session.name}6 { graceful restart on;
${session.type.ipv6}; ${if (lib.isNull session.source.ipv6) then "" else ''source address ${session.source.ipv6};'' } local as ${lib.toString cfg.asn}; neighbor ${session.neighbor.ipv6} as ${lib.toString session.neighbor.asn};${ if lib.isNull session.password then "" else "\n password ${session.password};" }
ipv6 { add paths ${session.addpath}; ${session.import.ipv6} ${session.export.ipv6} }; ${lib.optionalString (session.mp == "v4 over v6") '' ipv4 { add paths ${session.addpath}; ${session.import.ipv4} ${session.export.ipv4} };''} }'')) cfg.router.sessions}''; } { boot.kernelModules = [ "dummy" ]; systemd.network.config.networkConfig.ManageForeignRoutes = false;
systemd.network.netdevs."40-${cfg.local.interface.local}".netdevConfig = { Kind = "dummy"; Name = cfg.local.interface.local; };
systemd.network.networks."40-${cfg.local.interface.local}" = { name = cfg.local.interface.local; address = with cfg.local; ipv4.addresses ++ ipv6.addresses; # only needed when announced prefixes' outbound gateway is # different from the default gateway of the main interface routingPolicyRules = lib.remove { } (lib.flatten [ (lib.optionalAttrs (lib.isString cfg.router.outboundGateway.ipv4) (lib.map (r: { From = r.prefix; Table = cfg.asn; Priority = 10000; }) cfg.router.static.ipv4.routes)) (lib.optionalAttrs (lib.isString cfg.router.outboundGateway.ipv6) (lib.map (r: { From = r.prefix; Table = cfg.asn; Priority = 10000; }) cfg.router.static.ipv6.routes)) ]); };
networking.nftables.tables = { outbound4 = { name = "nat"; family = "ip"; content = '' chain postrouting { type nat hook postrouting priority srcnat; policy accept; ${if cfg.router.exit then # if node have BGP session, SNAT Tailscale exit node traffic to announced IP # '' # meta mark & 0x0000ff00 == 0x00000400 oifname "${cfg.local.interface.primary}" snat to ${cfg.local.ipv4.address} # '' "" else # if no BGP session, outbound traffic will be SNATed to the primary interface address '' ip saddr { ${lib.concatMapStringsSep ", " (r: r.prefix) cfg.router.static.ipv4.routes} } oifname "${cfg.local.interface.primary}" masquerade ''} ip saddr != { ${lib.concatMapStringsSep ", " (r: r.prefix) cfg.router.static.ipv4.routes} } oifname { "${cfg.local.interface.primary}", "${config.services.tailscale.interfaceName}" } masquerade } ''; }; outbound6 = { name = "nat"; family = "ip6"; content = '' chain postrouting { type nat hook postrouting priority srcnat; policy accept; ${if cfg.router.exit then # if node have BGP session, SNAT Tailscale exit node traffic to announced IP # '' # meta mark & 0x0000ff00 == 0x00000400 oifname "${cfg.local.interface.primary}" snat to ${cfg.local.ipv6.address} # '' "" else # if no BGP session, outbound traffic will be SNATed to the primary interface address '' ip6 saddr { ${lib.concatMapStringsSep ", " (r: r.prefix) cfg.router.static.ipv6.routes} } oifname "${cfg.local.interface.primary}" masquerade ''} ip6 saddr != { ${lib.concatMapStringsSep ", " (r: r.prefix) cfg.router.static.ipv6.routes} } oifname { "${cfg.local.interface.primary}", "${config.services.tailscale.interfaceName}" } masquerade } ''; }; };
services.tailscale.extraSetFlags = let v4s = lib.concatStringsSep "," cfg.local.ipv4.addresses; v6s = lib.concatStringsSep "," cfg.local.ipv6.addresses; addresses = if v4s == "" then v6s else if v6s == "" then v4s else v4s + "," + v6s; in [ "--accept-routes" "--advertise-exit-node" "--advertise-routes=${addresses}" "--snat-subnet-routes=false" "--ssh" ];
networking.localCommands = (lib.optionalString (lib.isString cfg.router.outboundGateway.ipv4) '' ip -4 route replace default via ${cfg.router.outboundGateway.ipv4} table ${lib.toString cfg.asn} '') + (lib.optionalString (lib.isString cfg.router.outboundGateway.ipv6) '' ip -6 route replace default via ${cfg.router.outboundGateway.ipv6} table ${lib.toString cfg.asn} ''); } { networking.tempAddresses = "disabled"; boot.kernel.sysctl = { "net.ipv4.conf.all.forwarding" = 1; "net.ipv4.conf.default.forwarding" = 1; "net.ipv6.conf.all.forwarding" = 1; "net.ipv6.conf.default.forwarding" = 1; }; } { services.prometheus = { exporters.bird = { enable = with config.services; bird.enable && prometheus.enable; listenAddress = "127.0.0.1"; port = 9324; }; scrapeConfigs = [{ job_name = "prometheus-bird-exporter"; static_configs = [ { targets = [ "${with config.services.prometheus.exporters.bird; lib.toString listenAddress + ":" + lib.toString port}" ]; } ]; }]; }; } ]);}