blob: 50f5ff83a1fe9d39a80064c924d94f590308f2c3 [file] [log] [blame]
# 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)}
'';
};
}