Add site gateway role (modules/router) and gw-cnx-1

Reusable cnx.router.* module for the Topton 1U boxes replacing OPNsense:
PPPoE WAN (optionally VLAN-tagged, AIS: 10, secret credentials incl.
username), VLAN-filtering bridge, nftables NAT/firewall with MSS clamp,
Kea DHCP with per-VLAN lease time, Blocky DNS, DHCPv6-PD, CrowdSec with
the ZeroTier mesh whitelisted, optional Omada controller, ZFS disk.

Fleet baseline rides along: admins sops group is now derived for every
machine in clan.nix (secrets encrypt to it from the first vars generate)
and time sync is chrony everywhere instead of systemd-timesyncd.
This commit is contained in:
Berwn
2026-07-28 17:06:07 +07:00
parent a87b579eb7
commit 158252323f
20 changed files with 857 additions and 31 deletions
+1 -3
View File
@@ -14,8 +14,6 @@ in
../../modules/docs.nix
];
clan.core.sops.defaultGroups = [ "admins" ];
# Public IPv6 (from modules/hosts.nix); SLAAC doesn't bring it up here.
cnx.staticIPv6 = {
enable = true;
@@ -23,7 +21,7 @@ in
};
time.timeZone = "Etc/GMT-3"; # UTC+3 (fixed offset, no DST)
services.timesyncd.enable = true;
services.chrony.enable = true;
# Public Hetzner Cloud firewalls, synced from this config on every deploy.
# Rules live in their own data file; see that file for the no-public-SSH note.
+41
View File
@@ -0,0 +1,41 @@
# Site gateway Chiang Mai (site 1): Topton 1U, Intel N300, 4x i226-V 2.5G.
# Port roles below use the expected igc names — verify against facter.json
# after the first install and adjust if the box enumerates differently.
{ config, lib, ... }:
{
imports = [
../../modules/router
../../modules/monitoring/exporters.nix
];
# Until the install generates facter.json (which normally provides this).
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
# ZFS (disko.nix) needs a stable machine-unique hostId; derive it from the
# hostname so every gateway gets one for free when copied for a new site.
networking.hostId = builtins.substring 0 8 (
builtins.hashString "sha256" config.networking.hostName
);
cnx.router = {
enable = true;
site = "cnx";
siteId = 1;
wan.interface = "enp1s0";
wan.vlanId = 10; # AIS delivers PPPoE tagged on VLAN 10
trunkPorts = [
"enp2s0"
"enp3s0"
"enp4s0"
];
vlans = {
mgmt.id = 10; # 10.1.10.0/24 — APs, switches, Omada, admin
lan.id = 20; # 10.1.20.0/24 — trusted clients
};
# This site runs the Omada controller for its APs/switches.
omada.enable = true;
};
time.timeZone = "Etc/GMT-7"; # UTC+7 (Thailand, fixed offset, no DST)
services.chrony.enable = true;
}
+78
View File
@@ -0,0 +1,78 @@
# Single-disk ZFS layout for the Topton's 256GB mSATA SSD: vfat ESP for boot,
# the rest a zpool (zstd compression, no atime). Single disk = no redundancy;
# ZFS buys us compression, snapshots, and checksumming. Requires
# networking.hostId (set in configuration.nix). The device is a placeholder:
# boot the installer, read the real id from `ls -l /dev/disk/by-id/`, and fill
# it in before `clan machines install`. Changing the layout later requires
# wiping and reinstalling.
{
boot.loader.grub.efiSupport = true;
boot.loader.grub.efiInstallAsRemovable = true;
boot.loader.grub.enable = true;
disko.devices = {
disk = {
main = {
name = "main-gw-cnx-1";
device = "/dev/disk/by-id/CHANGE-ME-msata-ssd";
type = "disk";
content = {
type = "gpt";
partitions = {
"boot" = {
size = "1M";
type = "EF02"; # for grub MBR
priority = 1;
};
ESP = {
type = "EF00";
size = "500M";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
mountOptions = [ "umask=0077" ];
};
};
zfs = {
size = "100%";
content = {
type = "zfs";
pool = "rpool";
};
};
};
};
};
};
zpool = {
rpool = {
type = "zpool";
options.ashift = "12";
rootFsOptions = {
compression = "zstd";
acltype = "posixacl";
xattr = "sa";
atime = "off";
mountpoint = "none";
};
datasets = {
root = {
type = "zfs_fs";
mountpoint = "/";
};
nix = {
type = "zfs_fs";
mountpoint = "/nix";
};
# Service state (Omada, Kea leases, CrowdSec db, journald) — its own
# dataset so it can be snapshotted/sent independently of the OS.
var = {
type = "zfs_fs";
mountpoint = "/var";
};
};
};
};
};
}
+1 -3
View File
@@ -10,8 +10,6 @@ in
../../modules/monitoring/exporters.nix
];
clan.core.sops.defaultGroups = [ "admins" ];
# Public IPv6 (from modules/hosts.nix); SLAAC doesn't bring it up here.
cnx.staticIPv6 = {
enable = true;
@@ -19,5 +17,5 @@ in
};
time.timeZone = "Etc/GMT-8"; # UTC+8 (Singapore, fixed offset, no DST)
services.timesyncd.enable = true;
services.chrony.enable = true;
}
+1 -3
View File
@@ -18,8 +18,6 @@ in
../../modules/monitoring/exporters.nix
];
clan.core.sops.defaultGroups = [ "admins" ];
# Knot's state dir holds the non-regenerable DNSSEC key material (KSK/ZSK
# private keys in the KASP keystore). Declaring it as clan state makes the
# borgbackup client back it up; losing it forces an emergency DS rollover at
@@ -38,7 +36,7 @@ in
};
time.timeZone = "Etc/GMT-1"; # UTC+1 (fixed offset, no DST)
services.timesyncd.enable = true;
services.chrony.enable = true;
# ACME DNS-01 (RFC 2136), general key. A dedicated TSIG key scoped by acl_acme
# (referenced by every zone below) to TXT updates at or under _acme-challenge.
+1 -3
View File
@@ -10,8 +10,6 @@ in
../../modules/monitoring/exporters.nix
];
clan.core.sops.defaultGroups = [ "admins" ];
# Public IPv6 (from modules/hosts.nix; matches the ns2 AAAA glue); SLAAC
# doesn't bring it up here.
cnx.staticIPv6 = {
@@ -20,7 +18,7 @@ in
};
time.timeZone = "Etc/GMT-3"; # UTC+3 (fixed offset, no DST)
services.timesyncd.enable = true;
services.chrony.enable = true;
# ns2 = secondary (slave): pulls every zone from ns1 and accepts its NOTIFY.
services.knot.settings.zone = map (d: {
+1 -3
View File
@@ -9,8 +9,6 @@ in
../../modules/web-proxy.nix
];
clan.core.sops.defaultGroups = [ "admins" ];
# Public IPv6 (from modules/hosts.nix); SLAAC doesn't bring it up here.
cnx.staticIPv6 = {
enable = true;
@@ -18,5 +16,5 @@ in
};
time.timeZone = "Etc/GMT-8"; # UTC+8 (Singapore, fixed offset, no DST)
services.timesyncd.enable = true;
services.chrony.enable = true;
}