Files
nixos-router/modules/router/networking.nix
T
nilsandClaude Sonnet 5.5 586274af4e Add the appliance web UI look, interface names, MCP and REST API, local zone
Web UI: dark/light themes (appliance layout, sidebar, switch checkboxes),
theme preference on the Advanced page, Changes page with the pending diff.

Interface names: router.interfaces.<name>.mac, lans.<l>.deviceName and
pppoe.vlanName; a rename is staged for the next reboot by router-apply.

Remote access: an MCP server (POST /mcp) and an OpenAPI REST API
(/api/v1, /api/openapi.json) with the same tokens, scopes, limits and audit
log; tools to read status, settings, leases, logs (get_logs), and to edit
and check the saved settings (never applied). Tokens on the API access page.

Firewall: a reserved `local` zone (the router itself) as the destination of
rules, so the web UI or SSH can be allowed from the WAN.

PLAN.md lists what is still left.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-11-01 16:56:59 +01:00

231 lines
8.1 KiB
Nix

{ config, lib, ... }:
let
cfg = config.router;
v6 = cfg.wan.ipv6.enable;
v6ppp = v6 && cfg.wan.ipv6.transport == "ppp";
# IPv6 on the physical link (not inside the PPP session).
v6native = v6 && !v6ppp;
# The device carrying DHCPv6 / RAs.
v6dev = if v6ppp then cfg.wan.ipv4.interface else cfg.wan.interface;
pppoe = cfg.wan.ipv4.type == "pppoe";
vlan = cfg.wan.ipv4.pppoe.vlan;
vlanDevice =
if cfg.wan.ipv4.pppoe.vlanName != null then
cfg.wan.ipv4.pppoe.vlanName
else
"${cfg.wan.interface}.${toString vlan}";
wanVlanOn = parent: lib.optional (pppoe && vlan != null && parent == cfg.wan.interface) vlanDevice;
lans = lib.attrValues cfg.lans;
vlanLans = lib.filter (l: l.vlan != null) lans;
# VLAN devices hang off their parent; the parent's own unit must list them.
vlansOn =
parent: map (l: l.device) (lib.filter (l: l.interface == parent) vlanLans) ++ wanVlanOn parent;
# The WAN link's own unit is only needed when no LAN shares the link.
sharedLink = lib.elem cfg.wan.interface (map (l: l.interface) lans);
untaggedParents = map (l: l.interface) (lib.filter (l: l.vlan == null) lans);
# Parents that carry only tagged LANs still need a unit to be brought up.
trunkParents = lib.filter (p: !(lib.elem p untaggedParents)) (
lib.unique (map (l: l.interface) vlanLans)
);
dhcp = if pppoe then (if v6native then "ipv6" else "no") else (if v6 then "yes" else "ipv4");
in
{
config = lib.mkIf cfg.enable {
networking.useNetworkd = true;
networking.useDHCP = false;
# Wait for the LAN devices (VLANs included: Kea only binds interfaces that
# exist when it starts) but not for the uplink: an ISP outage or a pulled
# cable must not hold up boot or a deployment.
systemd.network.wait-online.extraArgs = map (l: "--interface=${l.device}") (
lib.attrValues cfg.lans
);
# Rename network cards by MAC address. Only at boot: the interface that is
# up cannot be renamed, and router-apply stages such a change for the
# next boot instead of activating it (see the names file below).
systemd.network.links = lib.mapAttrs' (
name: i:
lib.nameValuePair "05-${name}" {
matchConfig = {
PermanentMACAddress = i.mac;
Type = "ether";
};
linkConfig.Name = name;
}
) cfg.interfaces;
# The names this system gives to devices; router-apply compares the file
# between the running and the new system and stages a change of a name for
# the next boot (a device that is up cannot be renamed).
environment.etc."router/interface-names".text =
lib.concatMapStrings (l: l + "\n") (
lib.sort lib.lessThan (
lib.mapAttrsToList (name: i: "nic ${i.mac} ${name}") cfg.interfaces
++ map (l: "vlan ${l.interface} ${toString l.vlan} ${l.device}") vlanLans
++ lib.optional (pppoe && vlan != null) "vlan ${cfg.wan.interface} ${toString vlan} ${vlanDevice}"
)
);
boot.kernel.sysctl = {
"net.ipv4.conf.all.forwarding" = 1;
"net.ipv6.conf.all.forwarding" = 1;
};
systemd.network.netdevs =
lib.optionalAttrs (pppoe && vlan != null) {
"05-wan-pppoe" = {
netdevConfig = {
Kind = "vlan";
Name = vlanDevice;
};
vlanConfig.Id = vlan;
};
}
// lib.mapAttrs' (
name: lan:
lib.nameValuePair "06-lan-${name}" {
netdevConfig = {
Kind = "vlan";
Name = lan.device;
};
vlanConfig.Id = lan.vlan;
}
) (lib.filterAttrs (_: l: l.vlan != null) cfg.lans);
systemd.network.networks =
lib.optionalAttrs (!sharedLink) {
"10-wan" = {
matchConfig.Name = cfg.wan.interface;
networkConfig = {
DHCP = dhcp;
}
// lib.optionalAttrs v6native {
IPv6AcceptRA = true;
KeepConfiguration = "yes";
}
// lib.optionalAttrs (pppoe && !v6native) {
# IPv6 (if any) lives inside the PPP session, never on the link.
LinkLocalAddressing = "no";
IPv6AcceptRA = false;
}
// lib.optionalAttrs (pppoe && vlan != null) {
VLAN = [ vlanDevice ];
};
}
// lib.optionalAttrs (!pppoe) {
# The router runs its own resolver; never adopt the ISP's DNS for the host.
dhcpV4Config.UseDNS = false;
}
// lib.optionalAttrs v6native {
# The ISP sends RAs (default route) without DHCPv6 flags, so solicit anyway.
dhcpV6Config = {
WithoutRA = "solicit";
UseDelegatedPrefix = true;
UseDNS = false;
};
};
}
// lib.optionalAttrs v6ppp {
# IPv6 inside the PPP session: pppd (IPv4, IPv6CP link-local) creates the
# device; networkd takes RAs and DHCPv6 (prefix delegation) on it.
"12-wan-ppp" = {
matchConfig.Name = cfg.wan.ipv4.interface;
networkConfig = {
DHCP = "ipv6";
IPv6AcceptRA = true;
KeepConfiguration = "yes";
};
dhcpV6Config = {
WithoutRA = "solicit";
UseDelegatedPrefix = true;
UseDNS = false;
};
};
}
// lib.optionalAttrs (pppoe && vlan != null) {
# pppd configures the session; networkd only has to bring the device up.
"11-wan-pppoe" = {
matchConfig.Name = vlanDevice;
networkConfig = {
LinkLocalAddressing = "no";
IPv6AcceptRA = false;
};
};
}
// lib.mapAttrs' (
name: lan:
lib.nameValuePair "20-lan-${name}" (
{
matchConfig.Name = lan.device;
address = [
"${lan.ipv4.address}/${toString lan.ipv4.prefixLength}"
]
++ lib.optional (lan.ipv6.address != null) lan.ipv6.address;
networkConfig = {
ConfigureWithoutCarrier = true;
# Never accept RAs from the inside.
IPv6AcceptRA = false;
}
// lib.optionalAttrs (lan.vlan == null && vlansOn lan.interface != [ ]) {
VLAN = vlansOn lan.interface;
}
// lib.optionalAttrs lan.ipv6.pd {
DHCPPrefixDelegation = true;
}
// lib.optionalAttrs (lan.ipv6.pd || lan.ipv6.address != null) {
IPv6SendRA = true;
};
}
// lib.optionalAttrs (lan.ipv6.address != null) {
# With prefix delegation networkd announces only the delegated
# prefix, so announce the ULA explicitly (SLAAC, on-link).
ipv6Prefixes = [
{
Prefix = (import ./lib.nix { inherit lib; }).ipv6.prefix64 lan.ipv6.address;
OnLink = true;
AddressAutoconfiguration = true;
}
];
}
// lib.optionalAttrs lan.ipv6.pd {
dhcpPrefixDelegationConfig = {
UplinkInterface = v6dev;
SubnetId = 0;
Announce = true;
Assign = true;
};
}
// lib.optionalAttrs (lan.ipv6.pd || lan.ipv6.address != null) {
# RDNSS points hosts at the router's resolver on its stable ULA; the
# delegated global address is unknown at evaluation time, so without
# a static `ipv6.address` there is nothing to announce.
ipv6SendRAConfig =
if lan.ipv6.address != null then
{
EmitDNS = true;
DNS = [ (builtins.head (lib.splitString "/" lan.ipv6.address)) ];
}
else
{ EmitDNS = false; };
}
)
) cfg.lans
// lib.listToAttrs (
map (
parent:
lib.nameValuePair "19-trunk-${parent}" {
matchConfig.Name = parent;
networkConfig = {
LinkLocalAddressing = "no";
IPv6AcceptRA = false;
ConfigureWithoutCarrier = true;
VLAN = vlansOn parent;
};
}
) trunkParents
);
};
}