# xlab-gateway - Lab Gateway / Router
# Current services: Kea DHCP4/DDNS, radvd, WireGuard, NAT, policy routing
{ config, pkgs, lib, ... }:
{
imports = [
./hardware-configuration.nix
./networking.nix
./dhcp.nix
];
age.identityPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
networking.hostName = "xlab-gateway";
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
# This router has no interactive virtual console. On this hardware,
# systemd-vconsole-setup can block indefinitely writing its UTF-8 escape to
# /dev/tty0, which also stalls switch-to-configuration. Disable the real
# setup (including in initrd), but retain no-op units so a live switch never
# hits a remove-then-reload ordering failure.
console.enable = false;
systemd.services.systemd-vconsole-setup = {
description = "No-op virtual console setup on headless xlab gateway";
serviceConfig = {
Type = "oneshot";
ExecStart = "${pkgs.coreutils}/bin/true";
RemainAfterExit = true;
};
};
systemd.services.reload-systemd-vconsole-setup = {
description = "No-op virtual console reload on headless xlab gateway";
wantedBy = [ "multi-user.target" ];
reloadIfChanged = true;
serviceConfig = {
Type = "oneshot";
ExecStart = "${pkgs.coreutils}/bin/true";
ExecReload = "${pkgs.coreutils}/bin/true";
RemainAfterExit = true;
};
};
boot = {
loader = {
systemd-boot.enable = true;
efi.canTouchEfiVariables = true;
};
kernel.sysctl = {
"net.ipv4.ip_forward" = 1;
"net.ipv6.conf.all.forwarding" = 1;
# BBR + fq: better throughput for xlab-ORIGINATED TCP over the high-RTT
# abroad WG paths (forwarded transit keeps the endpoints' own CC).
"net.core.default_qdisc" = "fq";
"net.ipv4.tcp_congestion_control" = "bbr";
# Bigger socket buffers / autotune ceiling for high-BDP abroad links.
"net.core.rmem_max" = 67108864;
"net.core.wmem_max" = 67108864;
"net.core.rmem_default" = 1048576;
"net.core.wmem_default" = 1048576;
"net.ipv4.tcp_rmem" = "4096 1048576 67108864";
"net.ipv4.tcp_wmem" = "4096 1048576 67108864";
"net.core.netdev_max_backlog" = 16384;
};
# tcp_bbr is a module on this kernel (reno cubic only built-in) — load it
# so tcp_congestion_control = bbr above takes effect.
kernelModules = [ "tcp_bbr" "wireguard" ];
};
# WireGuard-crypto + routing appliance: keep cores pinned so WG throughput
# doesn't sag while frequencies ramp on a burst, and to cut forwarding jitter.
powerManagement.cpuFreqGovernor = "performance";
# Gateway doesn't need to block boot waiting for all interfaces
systemd.network.wait-online.enable = false;
users.users.ldx = {
extraGroups = [ "networkmanager" ];
hashedPassword = "$y$j9T$R0XBoDSGk700h7UdglfaJ1$mjRwpJir/Tno1.fCbjet0cp/JPb1DW.JILvmE5.NJuD";
};
users.users."ye-lw21" = {
extraGroups = [ "networkmanager" ];
hashedPassword = "$y$j9T$jiLKGLB/gJKEYYn2zaoUw/$9mfwEUo5z2sH9OXwioLnbAVpCMOg2lUpA3ph9Vqx228";
};
environment.systemPackages = with pkgs; [
wireguard-tools
iperf3
ethtool
tcpdump
nftables
iproute2
glances
smartmontools
];
services.smartd = {
enable = true;
autodetect = true;
};
# CAPWAP over the 1420-MTU wg-to-wgnet tunnel: the lab Cisco APs (e.g. 3802
# b08b.cfa8.4fa8) send ~1469B DON'T-FRAGMENT DTLS packets to the C9800 WLC at
# 10.0.10.10 during join, and ignore both PMTUD and DHCP interface-mtu, so the
# CAPWAP/DTLS handshake blackholes on the tunnel and the AP never joins. Clear
# the DF bit on UDP->WLC at ingress so those packets fragment through the tunnel
# (the WLC reassembles). Confirmed: AP joins with this in place. See memory
# cisco-wlc-discovery.
systemd.services.capwap-df-clear = {
description = "Clear DF on lab AP CAPWAP->WLC (10.0.10.10) so DTLS fits the 1420 wg tunnel";
after = [ "network.target" "systemd-networkd.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = pkgs.writeShellScript "capwap-df-clear-up" ''
set -eu
for _ in $(${pkgs.coreutils}/bin/seq 1 30); do
${pkgs.iproute2}/bin/ip link show bond.lan254 >/dev/null 2>&1 && break
${pkgs.coreutils}/bin/sleep 1
done
${pkgs.iproute2}/bin/ip link show bond.lan254 >/dev/null
# clsact is shared infrastructure. Never replace or remove it, and own
# only the two CAPWAP priorities below.
if ! ${pkgs.iproute2}/bin/tc qdisc show dev bond.lan254 | ${pkgs.gnugrep}/bin/grep -q '^qdisc clsact '; then
${pkgs.iproute2}/bin/tc qdisc add dev bond.lan254 clsact
fi
for port in 5246 5247; do
# `replace` without an explicit flower handle can still return
# EEXIST. Delete only our owned preference, then recreate it.
${pkgs.iproute2}/bin/tc filter del dev bond.lan254 ingress \
protocol ip pref "$port" 2>/dev/null || true
${pkgs.iproute2}/bin/tc filter add dev bond.lan254 ingress \
protocol ip pref "$port" flower \
ip_proto udp dst_ip 10.0.10.10 dst_port "$port" \
action pedit ex munge ip df set 0 pipe action csum ip
done
'';
ExecStop = pkgs.writeShellScript "capwap-df-clear-down" ''
for port in 5246 5247; do
${pkgs.iproute2}/bin/tc filter del dev bond.lan254 ingress \
protocol ip pref "$port" 2>/dev/null || true
done
'';
};
};
system.stateVersion = "25.11";
}