# Declarative routing configuration. Usees BIRD2 underneath. # # The mapping from declarative configuration to BIRD is quite straightforward, # however, we take a few liberties: # - we introduce an 'originate' protocol for originating prefixes (using the # static protocol). # - routing tables in the configuration are referred to by a common name for # IPv4 and IPv4 - while in BIRD, two tables are created (suffixed by '4' and # '6', following the two default 'master4' and 'master6' tables). { config, lib, pkgs, ... }: with lib; let cfg = config.hscloud.routing; staticType = af: let v4 = af == "ipv4"; v6 = af == "ipv6"; pretty = if v4 then "IPv4" else "IPv6"; in with types; mkOption { type = attrsOf (submodule { options = { table = mkOption { type = nullOr str; description = "BIRD table to which session should be connected."; }; address = mkOption { type = str; description = "Address part of prefix to announce."; }; prefixLength = mkOption { type = int; description = "Prefix length to announce."; }; via = mkOption { type = str; description = "Target address for static route."; }; }; }); default = {}; description = "${pretty} static routes to inject into a table."; }; staticRender = af: n: v: let name = "static_static_${af}_${n}"; ip = if af == "ipv4" then "4" else "6"; in '' protocol static ${name} { ${af} { table ${v.table}${ip}; import all; export none; }; route ${v.address}/${toString v.prefixLength} via ${v.via}; } ''; originateType = af: let v4 = af == "ipv4"; v6 = af == "ipv6"; pretty = if v4 then "IPv4" else "IPv6"; in with types; mkOption { type = attrsOf (submodule { options = { table = mkOption { type = nullOr str; description = "BIRD table to which session should be connected."; }; address = mkOption { type = str; description = "Address part of prefix to announce."; }; prefixLength = mkOption { type = int; description = "Prefix length to announce."; }; }; }); default = {}; description = "${pretty} prefixes to unconditionally inject into a table."; }; originateRender = af: n: v: let name = "static_originate_${af}_${n}"; ip = if af == "ipv4" then "4" else "6"; in '' protocol static ${name} { ${af} { table ${v.table}${ip}; import all; export none; }; route ${v.address}/${toString v.prefixLength} blackhole; } ''; ospfType = af: let v4 = af == "ipv4"; v6 = af == "ipv6"; pretty = if v4 then "IPv4" else "IPv6"; ospf = if v4 then "OSPFv2" else "OSPFv3"; in with types; mkOption { type = attrsOf (submodule { options = { table = mkOption { type = nullOr str; description = "BIRD table to which session should be connected."; }; filterIn = mkOption { type = str; default = "accept;"; description = "BIRD filter definition for received routes."; }; filterOut = mkOption { type = str; default = "accept;"; description = "BIRD filter definition for sent routes."; }; area = mkOption { type = attrsOf (submodule { options = { interfaces = mkOption { type = attrsOf (submodule { options = { cost = mkOption { type = int; default = 10; # 1Gbps description = "Interface cost (10e9/iface_speed_in_bps)."; }; type = mkOption { type = enum ["bcast" "nbma" "ptp" "ptmp"]; description = "Interface type (dictates BIRD behaviour)."; }; stub = mkOption { type = bool; default = false; description = "Interface is stub (do not HELLO)."; }; }; }); description = "Interface configuration"; }; }; }); description = "Area configuration"; }; }; }); default = {}; description = "${ospf} configuration"; }; ospfRender = af: n: v: let v4 = af == "ipv4"; v6 = af == "ipv6"; ip = if v4 then "4" else "6"; name = "ospf_${af}_${n}"; interfaces = mapAttrsToList (iface: ifaceConfig: '' interface "${iface}" { type ${ifaceConfig.type}; cost ${toString ifaceConfig.cost}; ${if ifaceConfig.stub then "stub yes;" else ""} }; ''); areas = mapAttrsToList (area: areaConfig: '' area ${area} { ${concatStringsSep "\n" (interfaces areaConfig.interfaces)} }; '') v.area; in '' filter ${name}_in { ${v.filterIn} }; filter ${name}_out { ${v.filterOut} }; protocol ospf ${if v4 then "v2" else "v3"} ${name} { ${af} { table ${v.table}${ip}; import filter ${name}_in; export filter ${name}_out; }; ${concatStringsSep "\n" areas} } ''; pipeType = af: with types; mkOption { type = attrsOf (submodule { options = { table = mkOption { type = nullOr str; description = "BIRD table to which session should be connected."; }; peerTable = mkOption { type = nullOr str; description = "BIRD 'remote' table to which session should be connected."; }; filterIn = mkOption { type = str; default = "accept"; description = "BIRD filter definition for routes received from peerTable"; }; filterOut = mkOption { type = str; default = "reject;"; description = "BIRD filter definition for routes sent to peerTable"; }; }; }); default = {}; description = "${pretty} prefixes to pipe from one table to another."; }; pipeRender = af: n: v: let name = "pipe_${af}_${n}"; v4 = af == "ipv4"; v6 = af == "ipv6"; ip = if v4 then "4" else "6"; in '' filter ${name}_in { ${v.filterIn} }; filter ${name}_out { ${v.filterOut} }; protocol pipe ${name} { table ${v.table}${ip}; peer table ${v.peerTable}${ip}; import filter ${name}_in; export filter ${name}_out; } ''; bgpSessionsType = af: let v4 = af == "ipv4"; v6 = af == "ipv6"; pretty = if v4 then "IPv4" else "IPv6"; in with types; mkOption { type = attrsOf (submodule { options = { description = mkOption { type = str; description = "Session description (for BIRD)."; }; table = mkOption { type = nullOr str; description = "BIRD table to which session should be connected."; }; local = mkOption { type = str; description = "${pretty} address of this router."; }; asn = mkOption { type = int; description = "ASN of local router - will default to hscloud.routing.asn."; default = cfg.asn; }; prepend = mkOption { type = int; default = 0; description = "How many times to prepend this router's ASN on the link."; }; pref = mkOption { type = int; default = 100; description = "Preference (BGP local_pref) for routes from this session."; }; direct = mkOption { type = nullOr bool; default = null; }; filterIn = mkOption { type = str; default = "accept;"; description = "BIRD filter definition for received routes."; }; filterOut = mkOption { type = str; default = "accept;"; description = "BIRD filter definition for sent routes."; }; neighbors = mkOption { type = listOf (submodule { options = { address = mkOption { type = str; description = "${pretty} address of neighbor."; }; asn = mkOption { type = int; description = "ASN of neighbor."; }; password = mkOption { type = nullOr str; default = null; description = "BGP TCP MD5 secret."; }; }; }); description = "BGP Neighbor configuration"; }; }; }); default = {}; description = "BGP Sesions for ${pretty}"; }; bgpSessionRender = af: n: v: let name = "bgp_${af}_${n}"; ip = if af == "ipv4" then "4" else "6"; filters = '' filter ${name}_in { if bgp_path.len > 64 then reject; bgp_local_pref = ${toString v.pref}; ${v.filterIn} } filter ${name}_out { ${if v.prepend > 0 then (concatStringsSep "\n" (map (_: "bgp_path.prepend(${toString v.asn});") (range 0 (v.prepend - 1))) ) else ""} ${v.filterOut} } ''; peer = ix: peer: '' protocol bgp ${name}_${toString ix} { description "${v.description}"; ${af} { table ${v.table}${ip}; import filter ${name}_in; export filter ${name}_out; }; local ${v.local} as ${toString v.asn}; neighbor ${peer.address} as ${toString peer.asn}; ${if peer.password != null then "password \"${peer.password}\";" else ""} ${if v.direct == true then "direct;" else ""} } ''; in "${filters}\n${concatStringsSep "\n" (imap1 peer v.neighbors)}"; tablesFromProtoAF = af: p: filter (el: el != null) ( mapAttrsToList (_: v: "${af} table ${v.table}${if af == "ipv4" then "4" else "6"};") p); tablesFromProto = p: (tablesFromProtoAF "ipv4" p.v4) ++ (tablesFromProtoAF "ipv6" p.v6); tables = unique ( (tablesFromProto cfg.bgpSessions) ++ (tablesFromProto cfg.originate) ++ (tablesFromProto cfg.pipe) ++ (tablesFromProto cfg.ospf) # TODO(q3k): also slurp in peer tables from pipes. ); tablesRender = '' ${concatStringsSep "\n" tables} ''; tablesProgram = mapAttrsToList (n: _: n) (filterAttrs (n: v: v.program == true) cfg.tables); tableProgram = if (length tablesProgram) != 1 then (abort "exactly one table must be set to be programmed") else (head tablesProgram); in { options.hscloud.routing = { enable = mkEnableOption "declarative routing"; routerID = mkOption { type = types.str; description = '' Default Router ID for dynamic routing protocols, eg. IPv4 address from loopback interface. ''; }; asn = mkOption { type = types.int; description = "Default ASN for BGP."; }; extra = mkOption { type = types.lines; description = "Extra configuration lines."; }; bgpSessions = { v4 = bgpSessionsType "ipv4"; v6 = bgpSessionsType "ipv6"; }; originate = { v4 = originateType "ipv4"; v6 = originateType "ipv6"; }; static = { v4 = staticType "ipv4"; v6 = staticType "ipv6"; }; pipe = { v4 = pipeType "ipv4"; v6 = pipeType "ipv6"; }; ospf = { v4 = ospfType "ipv4"; v6 = ospfType "ipv6"; }; tables = mkOption { type = types.attrsOf (types.submodule { options = { program = mkOption { type = types.bool; default = false; description = "This is the primary table programmed in to the kernel."; }; programSourceV4 = mkOption { type = types.nullOr types.str; default = null; description = "If set, programmed routes will have source set to this address."; }; programSourceV6 = mkOption { type = types.nullOr types.str; default = null; description = "If set, programmed routes will have source set to this address."; }; }; }); description = "Routing table configuration."; }; }; config = mkIf cfg.enable { services.bird2.enable = true; services.bird2.config = '' log syslog all; debug protocols { states, interfaces, events } router id ${cfg.routerID}; ${cfg.extra} ${tablesRender} protocol device { scan time 10; }; protocol kernel kernel_v4 { scan time 60; ipv4 { table ${tableProgram}4; import none; export filter { ${let src = cfg.tables."${tableProgram}".programSourceV4; in if src != null then '' krt_prefsrc = ${src}; '' else ""} accept; }; }; } protocol kernel kernel_v6 { scan time 60; ipv6 { table ${tableProgram}6; import none; export filter { ${let src = cfg.tables."${tableProgram}".programSourceV6; in if src != null then '' krt_prefsrc = ${src}; '' else ""} accept; }; }; }; ${concatStringsSep "\n" (mapAttrsToList (bgpSessionRender "ipv4") cfg.bgpSessions.v4)} ${concatStringsSep "\n" (mapAttrsToList (bgpSessionRender "ipv6") cfg.bgpSessions.v6)} ${concatStringsSep "\n" (mapAttrsToList (originateRender "ipv4") cfg.originate.v4)} ${concatStringsSep "\n" (mapAttrsToList (originateRender "ipv6") cfg.originate.v6)} ${concatStringsSep "\n" (mapAttrsToList (staticRender "ipv4") cfg.static.v4)} ${concatStringsSep "\n" (mapAttrsToList (staticRender "ipv6") cfg.static.v6)} ${concatStringsSep "\n" (mapAttrsToList (pipeRender "ipv4") cfg.pipe.v4)} ${concatStringsSep "\n" (mapAttrsToList (pipeRender "ipv6") cfg.pipe.v6)} ${concatStringsSep "\n" (mapAttrsToList (ospfRender "ipv4") cfg.ospf.v4)} ${concatStringsSep "\n" (mapAttrsToList (ospfRender "ipv6") cfg.ospf.v6)} ''; }; }