bgpwtf/machines: init edge01.waw

This configures our WAW edge router using NixOS. This replaces our
previous Ubuntu installation.

Change-Id: Ibd72bde66ec413164401da407c5b268ad83fd3af
diff --git a/bgpwtf/machines/modules/bootstrap.nix b/bgpwtf/machines/modules/bootstrap.nix
new file mode 100644
index 0000000..09f2555
--- /dev/null
+++ b/bgpwtf/machines/modules/bootstrap.nix
@@ -0,0 +1,77 @@
+# Functionality that used to live in bootstrap.hswaw.net, a VM.
+# PXE boot support has been removed and the functionality moved back to
+# edge01.waw.bgp.wtf.
+
+{ config, pkgs, ... }: {
+  networking.bridges.bootstrap.interfaces = [];
+  networking.interfaces.bootstrap.ipv4.addresses = [
+    { address = "185.236.240.18"; prefixLength = 32; }
+  ];
+  services.dhcpd4 = {
+    enable = true;
+    interfaces = [ "bootstrap" "vl-dcsw-l3" ];
+    extraConfig = ''
+      # ISC DHCP is trash. We only use it in relay mode, yet we have to do
+      # this.
+      subnet 185.236.240.18 netmask 255.255.255.255 {}
+
+      subnet 185.236.240.6 netmask 255.255.255.254 {}
+
+      subnet 185.236.240.24 netmask 255.255.255.248 {
+          option routers 185.236.240.25;
+          range 185.236.240.29 185.236.240.30;
+          option domain-name-servers 8.8.8.8;
+      }
+
+      subnet 185.236.240.32 netmask 255.255.255.240 {
+          range 185.236.240.45 185.236.240.46;
+          option routers 185.236.240.33;
+          option domain-name-servers 8.8.8.8;
+      }
+
+      host bc01n01 {
+          hardware ethernet 00:23:ae:fe:83:20;
+          fixed-address 185.236.240.35;
+          option host-name "bc01n01";
+      }
+      host bc01n02 {
+          hardware ethernet 00:23:ae:fe:83:c4;
+          fixed-address 185.236.240.36;
+          option host-name "bc01n02";
+      }
+      host bc01n03 {
+          hardware ethernet 00:23:ae:fe:42:80;
+          fixed-address 185.236.240.37;
+          option host-name "bc01n03";
+      }
+      host boston-packets {
+          hardware ethernet 00:23:ae:fe:45:8c;
+          fixed-address 185.236.240.38;
+          option host-name "boston-packets.hackerspace.pl";
+          #filename "ipxe.efi";
+      }
+      host dcr01s22 {
+          hardware ethernet 90:1b:0e:08:12:b8;
+          fixed-address 185.236.240.39;
+          option host-name "dcr01s22";
+          #filename "ipxe.efi";
+      }
+      host dcr01s24 {
+          hardware ethernet 90:1b:0e:31:bb:6a;
+          fixed-address 185.236.240.40;
+          option host-name "dcr01s24";
+          #filename "ipxe.efi";
+      }
+      host dsctf {
+          hardware ethernet 00:23:ae:fe:45:50;
+          fixed-address 185.236.240.41;
+          option host-name "dsctf";
+      }
+      host dcr03s32b1 {
+          hardware ethernet 02:01:87:4a:9a:b9;
+          fixed-address 185.236.240.26;
+          option host-name "dcr03s32b1";
+      }
+    '';
+  };
+}
diff --git a/bgpwtf/machines/modules/eoip.nix b/bgpwtf/machines/modules/eoip.nix
new file mode 100644
index 0000000..5ce04f3
--- /dev/null
+++ b/bgpwtf/machines/modules/eoip.nix
@@ -0,0 +1,75 @@
+# A small Ethernet-over-IP service implementation.
+# Yes, that's the Mikrotik EoIP implementation. This one is somewhat sketchy
+# (notably, it pumps huge zero-padded frames into tap), so doesn't use it for
+# production. We currently only use it in the edge01.waw test framework to
+# bring vlans across test VMs.
+
+{ config, pkgs, lib, ... }:
+
+with lib;
+
+let
+  eoip = pkgs.stdenv.mkDerivation {
+    pname = "eoip";
+    version = "20180119";
+    nativeBuildInputs = with pkgs; [ cmake ];
+    src = pkgs.fetchFromGitHub {
+      owner = "amphineko";
+      repo = "eoiptapd";
+      rev = "5573a905bcbc001b503308665f098e82f451dc33";
+      sha256 = "0np9dzcw5w6jarzdv2yh3mbzz0wgw10sjqyi6pxan4ipr75v1b8s";
+    };
+    installPhase = ''
+      mkdir -p $out/bin
+      cp eoiptapd $out/bin/eoiptapd
+    '';
+  };
+
+  cfg = config.hscloud.eoip;
+
+in {
+  options.hscloud.eoip = {
+    interfaces = mkOption {
+      type = with types; attrsOf (submodule {
+        options = {
+          localV4 = mkOption {
+            type = types.str;
+            description = "Local outer IPv4 address";
+          };
+          remoteV4 = mkOption {
+            type = types.str;
+            description = "Remote outer IPv4 address";
+          };
+          id = mkOption {
+            type = types.int;
+            description = "Tunnel ID";
+          };
+          parent = mkOption {
+            type = types.str;
+            description = "Parent/outer device";
+          };
+        };
+      });
+      description = ''
+        EoIP interfaces to create.
+      '';
+    };
+  };
+
+  config.systemd.services = mapAttrs' (name: value: nameValuePair "${name}-eoip" {
+    wantedBy = [ "network.target" ];
+    wants = [
+      "${name}-netdev.service"
+      "network-addresses-${value.parent}.service"
+    ];
+    after = [
+      "network-addresses-${value.parent}.service"
+    ];
+    serviceConfig = {
+      Type = "simple";
+      ExecStart = "${eoip}/bin/eoiptapd -i ${name} -l ${value.localV4} -r ${value.remoteV4} -t ${toString value.id}";
+      Restart = "always";
+      RestartSec = "1";
+    };
+  }) cfg.interfaces;
+}
diff --git a/bgpwtf/machines/modules/prometheus.nix b/bgpwtf/machines/modules/prometheus.nix
new file mode 100644
index 0000000..704c257
--- /dev/null
+++ b/bgpwtf/machines/modules/prometheus.nix
@@ -0,0 +1,57 @@
+# Prometheus configuration for a BIRD-enabled router.
+
+{ config, pkgs, lib, ... }:
+
+with lib;
+
+let
+  nodeExporterPort = 9100;
+  birdExporterPort = 9101;
+
+  birdExporter = pkgs.buildGoModule rec {
+    pname = "bird-exporter";
+    version = "1.2.5";
+    src = pkgs.fetchFromGitHub {
+      owner = "czerwonk";
+      repo = "bird_exporter";
+      rev = version;
+      sha256 = "1qrhncy1f119f5rfgn2d1l6nvapaqkld4zb9bxzdqmmw6kicc7bs";
+    };
+
+    vendorSha256 = null;
+  };
+
+in {
+  systemd.services.bird_exporter = {
+    wantedBy = [ "multi-user.target" ];
+    serviceConfig = {
+      Type = "simple";
+      ExecStart = "${birdExporter}/bin/bird_exporter -format.new=true -bird.v2=true -web.listen-address=127.0.0.1:${toString birdExporterPort}";
+      Restart = "always";
+      RestartSec = "60";
+    };
+  };
+
+  services.prometheus.exporters.node = {
+    enable = true;
+    listenAddress = "127.0.0.1";
+    port = nodeExporterPort;
+  };
+
+  services.nginx.enable = true;
+  services.nginx.virtualHosts."${config.networking.hostName}.${config.networking.domain}" = let
+    allowMonitoring = ''
+      allow 209.250.231.127; # monitoring.hackerspace.pl
+      deny all;
+    '';
+  in {
+    locations."/metrics-node" = {
+      proxyPass = "http://127.0.0.1:${toString nodeExporterPort}/metrics";
+      extraConfig = allowMonitoring;
+    };
+    locations."/metrics-bird" = {
+      proxyPass = "http://127.0.0.1:${toString birdExporterPort}/metrics";
+      extraConfig = allowMonitoring;
+    };
+  };
+}
diff --git a/bgpwtf/machines/modules/rename-interfaces.nix b/bgpwtf/machines/modules/rename-interfaces.nix
new file mode 100644
index 0000000..bbb5c81
--- /dev/null
+++ b/bgpwtf/machines/modules/rename-interfaces.nix
@@ -0,0 +1,29 @@
+# Sketchy little module to renamei interfaces by MAC.
+# This only works on startup.
+
+{ config, lib, pkgs, ... }:
+
+with lib;
+let
+  cfg = config.hscloud.renameInterfaces;
+in {
+  options.hscloud.renameInterfaces = mkOption {
+    type = with types; attrsOf (submodule {
+      options = {
+        mac = mkOption {
+          type = types.str;
+          description = ''
+            MAC address to match by, in hexadecimal form (ie. ac:1f:6b:1c:d7:ae).
+          '';
+        };
+      };
+    });
+    description = ''
+      Interfaces to rename by property (eg. MAC address).
+    '';
+  };
+
+  config.services.udev.extraRules = concatStringsSep "\n" (mapAttrsToList (n: v: ''
+    ACTION=="add", SUBSYSTEM=="net", ATTR{address}=="${v.mac}", ATTR{addr_assign_type}=="0", NAME="${n}"
+  '') cfg);
+}
diff --git a/bgpwtf/machines/modules/router.nix b/bgpwtf/machines/modules/router.nix
new file mode 100644
index 0000000..4999401
--- /dev/null
+++ b/bgpwtf/machines/modules/router.nix
@@ -0,0 +1,54 @@
+# Generic configuration for any bgpwtf router.
+
+{ config, pkgs, lib, ... }:
+
+with builtins;
+
+rec {
+  imports = [
+    ./routing.nix
+    ./rename-interfaces.nix
+    ./rsh-unbound.nix
+    ./bootstrap.nix
+    ./prometheus.nix
+  ];
+
+  environment.systemPackages = with pkgs; [
+    tcpdump htop dstat file strace gdb mtr
+    vim wget curl htop dstat whois bind
+    rxvt_unicode.terminfo dhcpcd efibootmgr
+  ];
+  networking.useDHCP = false;
+  networking.firewall.enable = false;
+  boot.kernel.sysctl."net.ipv4.ip_forward" = 1;
+  boot.kernel.sysctl."net.ipv4.conf.*.accept_redirects" = 0;
+  boot.kernel.sysctl."net.ipv4.conf.*.send_redirects" = 0;
+  boot.kernel.sysctl."net.ipv4.conf.*.accept_source_route" = 0;
+  boot.kernel.sysctl."net.ipv4.conf.*.proxy_arp" = 0;
+  boot.kernel.sysctl."net.ipv4.conf.*.secure_redirects" = 1;
+  boot.kernel.sysctl."net.ipv4.conf.*.bootp_relay" = 0;
+  boot.kernel.sysctl."net.ipv4.conf.*.arp_filter" = 1;
+  boot.kernel.sysctl."net.ipv4.conf.*.arp_ignore" = 1;
+  boot.kernel.sysctl."net.ipv4.conf.*.arp_announce" = 2;
+  boot.kernel.sysctl."net.ipv4.conf.*.rp_filter" = 0;
+  boot.kernel.sysctl."net.ipv6.conf.*.forwarding" = 1;
+  boot.kernel.sysctl."net.ipv6.conf.*.accept_ra" = 0;
+  boot.kernel.sysctl."net.ipv6.conf.*.autoconf" = 0;
+  boot.kernel.sysctl."net.ipv6.conf.*.router_solicitations" = 0;
+
+  # Use Chrony instead of systemd-timesyncd
+  time.timeZone = "Europe/Warsaw";
+  services.chrony.enable = true;
+  networking.nameservers = [ "8.8.8.8" ];
+
+  # Enable the OpenSSH daemon.
+  services.openssh.enable = true;
+  users.users.root.openssh.authorizedKeys.keys = [
+    "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDD4VJXAXEHEXZk2dxNwehneuJcEGkfXG/U7z4fO79vDVIENdedtXQUyLyhZJc5RTEfHhQj66FwIqzl7mzBHd9x9PuDp6QAYXrkVNMj48s6JXqZqBvF6H/weRqFMf4a2TZv+hG8D0kpvmLheCwWAVRls7Jofnp/My+yDd57GMdsbG/yFEf6WPMiOnA7hxdSJSVihCsCSw2p8PD4GhBe8CVt7xIuinhutjm9zYBjV78NT8acjDUfJh0B1ODTjs7nuW1CC4jybSe2j/OU3Yczj4AxRxBNWuFxUq+jBo9BfpbKLh+Tt7re+zBkaicM77KM/oV6943JJxgHNBBOsv9scZE7 q3k@amnesia"
+    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIG599UildOrAq+LIOQjKqtGMwjgjIxozI1jtQQRKHtCP q3k@mimeomia"
+    "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDQb3YQoiYFZLKwvHYKbu1bMqzNeDCAszQhAe1+QI5SLDOotclyY/vFmOReZOsmyMFl71G2d7d+FbYNusUnNNjTxRYQ021tVc+RkMdLJaORRURmQfEFEKbai6QSFTwErXzuoIzyEPK0lbsQuGgqT9WaVnRzHJ2Q/4+qQbxAS34PuR5NqEkmn4G6LMo3OyJ5mwPkCj9lsqz4BcxRaMWFO3mNcwGDfSW+sqgc3E8N6LKrTpZq3ke7xacpQmcG5DU9VO+2QVPdltl9jWbs3gXjmF92YRNOuKPVfAOZBBsp8JOznfx8s9wDgs7RwPmDpjIAJEyoABqW5hlXfqRbTnfnMvuR informatic@InformaticPC"
+    "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDGkMgEVwQM8yeuFUYL2TwlJIq9yUNBmHnwce46zeL2PK2CkMz7sxT/om7sp/K5XDiqeD05Nioe+Dr3drP6B8uI33S5NgxPIfaqQsRS+CBEgk6cqFlcdlKETU/DT+/WsdoO173n7mgGeafPInEuQuGDUID0Fl099kIxtqfAhdeZFMM6/szAZEZsElLJ8K6dp1Ni/jmnXCZhjivZH3AZUlnqrmtDG7FY1bgcOfDXAal45LItughGPtrdiigXe9DK2fW3+9DBZZduh5DMJTNlphAZ+nfSrbyHVKUg6WsgMSprur4KdU47q1QwzqqvEj75JcdP1jOWoZi4F6VJDte9Wb9lhD1jGgjxY9O6Gs4CH35bx15W7CN9hgNa0C8NbPJe/fZYIeMZmJ1m7O2xmnYwP8j+t7RNJWu7Pa3Em4mOEXvhBF07Zfq+Ye/4SluoRgADy5eII2x5fFo5EBhInxK0/X8wF6XZvysalVifoCh7T4Edejoi91oAxFgYAxbboXGlod0eEHIi2hla8SM9+IBHOChmgawKBYp2kzAJyAmHNBF+Pah9G4arVCj/axp/SJZDZbJQoI7UT/fJzEtvlb5RWrHXRq+y6IvjpUq4pzpDWW04+9UMqEEXRmhWOakHfEVM9rN8h3aJBflLUBBnh0Z/hVsKNh8bCRHaKtah8TrD9i+wMw== patryk.jakuszew@gmail.com"
+    "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQC33naG1ptCvUcRWX9cj9wXM1nW1lyQC4SvMJzWlr9aMD96O8hQ2JMkuIUgUJvorAY02QRplQ2BuoVoVkdkzwjMyi1bL3OdgcKo7Z1yByClGTTocqNJYY0lcUb6EJH8+6e6F9ydrQlSxNzL1uCaA7phZr+yPcmAmWbSfioXn98yXNkE0emHxzJv/nypJY56sDCMC2IXDRd8L2goDtPwgPEW7bWfAQdIFMJ75xOidZOTxJ8eqyXLw/kxY5UlyX66jdoYz1sE5XUHuoQl1AOG9UdlMo0aMhUvP4pX5l7r7EnA9OttKMFB3oWqkVK/R6ynZ52YNOU5BZ9V+Ppaj34W0xNu+p0mbHcCtXYCTrf/OU0hcZDbDaNTjs6Vtcm2wYw9iAKX7Tex+eOMwUwlrlcyPNRV5BTot7lGNYfauHCSIuWJKN4NhCLR/NtVNh4/94eKkPTwJsY6XqDcS7q49wPAs4DAH7BJgsbHPOqygVHrY0YYEfz3Pj0HTxJHQMCP/hQX4fXEGt0BjgoVJbXPAQtPyeg0JuxiUg+b4CgVVfQ6R060MlM1BZzhmh+FY5MJH6nJppS0aHYCvSg8Z68NUlCPKy0jpcyfuAIWQWwSGG1O010WShQG2ELsvNdg5/4HVdCGNl5mmoom6JOd72FOZyQlHDFfeQUQRn9HOeCq/c51rK99SQ== bartek@IHM"
+    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICTR292kx/2CNuWYIsZ6gykQ036aBGrmheIuZa6S1D2x implr@thonk"
+  ];
+}
diff --git a/bgpwtf/machines/modules/routing.nix b/bgpwtf/machines/modules/routing.nix
new file mode 100644
index 0000000..e87ab9d
--- /dev/null
+++ b/bgpwtf/machines/modules/routing.nix
@@ -0,0 +1,440 @@
+# 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;
+
+  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";
+    };
+    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 (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)}
+
+    '';
+  };
+}
diff --git a/bgpwtf/machines/modules/rsh-unbound.nix b/bgpwtf/machines/modules/rsh-unbound.nix
new file mode 100644
index 0000000..20442fc
--- /dev/null
+++ b/bgpwtf/machines/modules/rsh-unbound.nix
@@ -0,0 +1,70 @@
+# Run service that spits out an unbound-compatible blocklist of websites,
+# as mandated by polish telecommunications law.
+#
+# https://sip.lex.pl/akty-prawne/dzu-dziennik-ustaw/gry-hazardowe-17581037/art-15-f
+# Dz.U.2019.847 t.j.
+# Art. 15f. [Rejestr domen służących do oferowania gier hazardowych niezgodnie z ustawą]
+# 5.  Przedsiębiorca telekomunikacyjny świadczący usługi dostępu do sieci
+#     Internet jest obowiązany do:
+#  1) nieodpłatnego uniemożliwienia dostępu do stron internetowych
+#     wykorzystujących nazwy domen internetowych wpisanych do Rejestru
+#     poprzez ich usunięcie z systemów teleinformatycznych przedsiębiorców
+#     telekomunikacyjnych, służących do zamiany nazw domen internetowych na
+#     adresy IP, nie później niż w ciągu 48 godzin od dokonania wpisu do
+#     Rejestru;
+#  2) nieodpłatnego przekierowania połączeń odwołujących się do nazw domen
+#     internetowych wpisanych do Rejestru do strony internetowej prowadzonej
+#     przez ministra właściwego do spraw finansów publicznych, zawierającej
+#     komunikat, skierowany do odbiorców usługi dostępu do Internetu
+#     obejmujący w szczególności informacje o lokalizacji Rejestru, wpisaniu
+#     szukanej nazwy domeny internetowej do tego Rejestru, listę podmiotów
+#     legalnie oferujących gry hazardowe na terytorium Rzeczypospolitej
+#     Polskiej, a także powiadomienie o grożącej odpowiedzialności
+#     karno-skarbowej uczestnika gier urządzanych wbrew przepisom ustawy;
+#  3) nieodpłatnego umożliwienia dostępu do stron internetowych
+#     wykorzystujących nazwy domen wykreślonych z Rejestru, nie później niż w
+#     ciągu 48 godzin od wykreślenia nazwy domeny internetowej z Rejestru.
+
+{ config, pkgs, lib, ...  }:
+
+with lib;
+
+let
+  rshUnbound = pkgs.buildGoModule {
+    pname = "rsh-unbound";
+    version = "20200926";
+    src = pkgs.fetchFromGitHub {
+      owner = "q3k";
+      repo = "rsh-unbound";
+      rev = "3d98c754adadddfae59387d033aef531f47dee5d";
+      sha256 = "1ia33893m1dknw36vss97limlb1d28z5nkrkw6b4mp1igdgqsfcz";
+    };
+
+    vendorSha256 = "1w94g2dwhf47jmds95frb26ypjmis5zhyy85rmd124v0nz3axzhf";
+  };
+
+  cfg = config.hscloud.rsh;
+
+in {
+  options.hscloud.rsh = with types; {
+    enable = mkOption {
+      type = bool;
+      default = false;
+      description = "Enable the RSH-Unboudn service.";
+    };
+    out = mkOption {
+      type = str;
+      description = "Output file for generated unbound config.";
+    };
+  };
+
+  config.systemd.services.rsh = mkIf cfg.enable {
+    wantedBy = [ "multi-user.target" ];
+    serviceConfig = {
+      Type = "simple";
+      ExecStart = "${rshUnbound}/bin/rsh-unbound -output ${cfg.out} -register_endpoint https://hazard.mf.gov.pl/api/Register";
+      Restart = "always";
+      RestartSec = "60";
+    };
+  };
+}