Add MVP router flake with VM test and tmux lab
router.* schema (wan, lans, dns.forwarders) compiled to systemd-networkd, a generated nftables ruleset with NAT, Kea DHCPv4 and Unbound, plus build-time assertions against lock-out and interface conflicts. tests/basic.nix is a three-node VM test (upstream/router/client) covering WAN DHCP, LAN DHCP and DNS, NAT, WAN-deny and LAN SSH/ping. `nix run .#lab` boots the same topology in tmux with one console pane per VM and the driver REPL. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
{ config, lib, ... }:
|
||||
let
|
||||
cfg = config.router;
|
||||
lans = lib.attrValues cfg.lans;
|
||||
in
|
||||
{
|
||||
config = lib.mkIf cfg.enable {
|
||||
assertions = [
|
||||
{
|
||||
assertion = lans != [ ];
|
||||
message = "router: at least one LAN must be defined.";
|
||||
}
|
||||
{
|
||||
assertion = lib.any (l: l.allowManagement) lans;
|
||||
message = "router: no LAN has allowManagement = true; you would be locked out of the router.";
|
||||
}
|
||||
{
|
||||
assertion = !(lib.elem cfg.wan.interface (map (l: l.interface) lans));
|
||||
message = "router: the WAN interface `${cfg.wan.interface}` is also used by a LAN.";
|
||||
}
|
||||
{
|
||||
assertion =
|
||||
let
|
||||
ifaces = map (l: l.interface) lans;
|
||||
in
|
||||
lib.length ifaces == lib.length (lib.unique ifaces);
|
||||
message = "router: two LANs share the same interface.";
|
||||
}
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
imports = [
|
||||
./options.nix
|
||||
./assertions.nix
|
||||
./networking.nix
|
||||
./firewall.nix
|
||||
./dhcp.nix
|
||||
./dns.nix
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{ config, lib, ... }:
|
||||
let
|
||||
cfg = config.router;
|
||||
dhcpLans = lib.filter (l: l.dhcp.enable) (lib.attrValues cfg.lans);
|
||||
in
|
||||
{
|
||||
config = lib.mkIf (cfg.enable && dhcpLans != [ ]) {
|
||||
services.kea.dhcp4 = {
|
||||
enable = true;
|
||||
settings = {
|
||||
interfaces-config = {
|
||||
interfaces = map (l: l.interface) dhcpLans;
|
||||
# Interfaces are configured asynchronously by networkd.
|
||||
re-detect = true;
|
||||
};
|
||||
lease-database = {
|
||||
type = "memfile";
|
||||
persist = true;
|
||||
name = "/var/lib/kea/dhcp4.leases";
|
||||
};
|
||||
valid-lifetime = 3600;
|
||||
subnet4 = lib.imap1 (id: l: {
|
||||
inherit id;
|
||||
inherit (l.ipv4) subnet;
|
||||
interface = l.interface;
|
||||
pools = [ { pool = "${l.dhcp.pool.start} - ${l.dhcp.pool.end}"; } ];
|
||||
option-data = [
|
||||
{ name = "routers"; data = l.ipv4.address; }
|
||||
{ name = "domain-name-servers"; data = l.ipv4.address; }
|
||||
];
|
||||
}) dhcpLans;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{ config, lib, ... }:
|
||||
let
|
||||
cfg = config.router;
|
||||
lans = lib.attrValues cfg.lans;
|
||||
in
|
||||
{
|
||||
config = lib.mkIf cfg.enable {
|
||||
services.unbound = {
|
||||
enable = true;
|
||||
settings = {
|
||||
server = {
|
||||
interface = [ "127.0.0.1" ] ++ map (l: l.ipv4.address) lans;
|
||||
# LAN addresses are assigned by networkd, possibly after Unbound starts.
|
||||
ip-freebind = true;
|
||||
access-control = [
|
||||
"127.0.0.0/8 allow"
|
||||
] ++ map (l: "${l.ipv4.subnet} allow") lans;
|
||||
};
|
||||
} // lib.optionalAttrs (cfg.dns.forwarders != [ ]) {
|
||||
forward-zone = [
|
||||
{
|
||||
name = ".";
|
||||
forward-addr = cfg.dns.forwarders;
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
{ config, lib, ... }:
|
||||
let
|
||||
cfg = config.router;
|
||||
lans = lib.attrValues cfg.lans;
|
||||
|
||||
set = ifaces: "{ ${lib.concatMapStringsSep ", " (i: ''"${i}"'') ifaces} }";
|
||||
lanIfaces = map (l: l.interface) lans;
|
||||
mgmtIfaces = map (l: l.interface) (lib.filter (l: l.allowManagement) lans);
|
||||
dhcpIfaces = map (l: l.interface) (lib.filter (l: l.dhcp.enable) lans);
|
||||
in
|
||||
{
|
||||
config = lib.mkIf cfg.enable {
|
||||
# The generated ruleset replaces networking.firewall entirely.
|
||||
networking.firewall.enable = false;
|
||||
networking.nftables.enable = true;
|
||||
|
||||
networking.nftables.tables.router = {
|
||||
family = "inet";
|
||||
content = ''
|
||||
chain input {
|
||||
type filter hook input priority filter; policy drop;
|
||||
|
||||
iifname "lo" accept
|
||||
ct state established,related accept
|
||||
ct state invalid drop
|
||||
|
||||
# Error types and ND are required on every interface (WAN included);
|
||||
# echo is only answered on LANs.
|
||||
icmp type { destination-unreachable, time-exceeded, parameter-problem } accept
|
||||
icmpv6 type {
|
||||
destination-unreachable, packet-too-big, time-exceeded, parameter-problem,
|
||||
nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert
|
||||
} accept
|
||||
iifname ${set lanIfaces} icmp type echo-request accept
|
||||
iifname ${set lanIfaces} icmpv6 type echo-request accept
|
||||
|
||||
iifname "${cfg.wan.interface}" udp dport 68 accept
|
||||
|
||||
iifname ${set lanIfaces} udp dport 53 accept
|
||||
iifname ${set lanIfaces} tcp dport 53 accept
|
||||
${lib.optionalString (dhcpIfaces != [ ]) ''iifname ${set dhcpIfaces} udp dport 67 accept''}
|
||||
${lib.optionalString (mgmtIfaces != [ ]) ''iifname ${set mgmtIfaces} tcp dport 22 accept''}
|
||||
}
|
||||
|
||||
chain forward {
|
||||
type filter hook forward priority filter; policy drop;
|
||||
|
||||
ct state established,related accept
|
||||
ct state invalid drop
|
||||
|
||||
icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem } accept
|
||||
|
||||
iifname ${set lanIfaces} oifname "${cfg.wan.interface}" accept
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
networking.nftables.tables.router-nat = {
|
||||
family = "ip";
|
||||
content = ''
|
||||
chain postrouting {
|
||||
type nat hook postrouting priority srcnat; policy accept;
|
||||
oifname "${cfg.wan.interface}" masquerade
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
services.openssh = {
|
||||
enable = true;
|
||||
openFirewall = false;
|
||||
settings = {
|
||||
PasswordAuthentication = false;
|
||||
KbdInteractiveAuthentication = false;
|
||||
PermitRootLogin = "prohibit-password";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{ config, lib, ... }:
|
||||
let
|
||||
cfg = config.router;
|
||||
in
|
||||
{
|
||||
config = lib.mkIf cfg.enable {
|
||||
networking.useNetworkd = true;
|
||||
networking.useDHCP = false;
|
||||
|
||||
boot.kernel.sysctl = {
|
||||
"net.ipv4.conf.all.forwarding" = 1;
|
||||
};
|
||||
|
||||
systemd.network.networks = {
|
||||
"10-wan" = {
|
||||
matchConfig.Name = cfg.wan.interface;
|
||||
networkConfig.DHCP = "ipv4";
|
||||
# The router runs its own resolver; never adopt the ISP's DNS for the host.
|
||||
dhcpV4Config.UseDNS = false;
|
||||
};
|
||||
} // lib.mapAttrs' (
|
||||
name: lan:
|
||||
lib.nameValuePair "20-lan-${name}" {
|
||||
matchConfig.Name = lan.interface;
|
||||
address = [ "${lan.ipv4.address}/${toString lan.ipv4.prefixLength}" ];
|
||||
networkConfig.ConfigureWithoutCarrier = true;
|
||||
}
|
||||
) cfg.lans;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
{ lib, ... }:
|
||||
let
|
||||
inherit (lib) mkOption mkEnableOption types;
|
||||
in
|
||||
{
|
||||
options.router = {
|
||||
enable = mkEnableOption "the router role";
|
||||
|
||||
wan = {
|
||||
interface = mkOption {
|
||||
type = types.str;
|
||||
example = "eth0";
|
||||
description = "Name of the uplink interface.";
|
||||
};
|
||||
};
|
||||
|
||||
lans = mkOption {
|
||||
default = { };
|
||||
description = "LAN segments served by this router, keyed by name.";
|
||||
type = types.attrsOf (
|
||||
types.submodule {
|
||||
options = {
|
||||
interface = mkOption {
|
||||
type = types.str;
|
||||
example = "eth1";
|
||||
description = "Interface (or bridge/VLAN device) carrying this LAN.";
|
||||
};
|
||||
|
||||
ipv4 = {
|
||||
address = mkOption {
|
||||
type = types.str;
|
||||
example = "192.168.10.1";
|
||||
description = "Router address on this LAN.";
|
||||
};
|
||||
prefixLength = mkOption {
|
||||
type = types.ints.between 8 30;
|
||||
default = 24;
|
||||
};
|
||||
subnet = mkOption {
|
||||
type = types.str;
|
||||
example = "192.168.10.0/24";
|
||||
description = "Network in CIDR notation; must contain `address`.";
|
||||
};
|
||||
};
|
||||
|
||||
dhcp = {
|
||||
enable = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
};
|
||||
pool = {
|
||||
start = mkOption {
|
||||
type = types.str;
|
||||
example = "192.168.10.100";
|
||||
};
|
||||
end = mkOption {
|
||||
type = types.str;
|
||||
example = "192.168.10.199";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
allowManagement = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
description = "Whether clients on this LAN may reach SSH on the router.";
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
dns.forwarders = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
description = "Upstream resolvers. When empty, Unbound resolves recursively.";
|
||||
};
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user