Author SHA1 Message Date
kurogeek e1e18dd9f3 router/tests: cover the staging uplink as NATed fallback exit
Neither regression fixed by the previous commit was observable: the VM
test had no staging port, and pinging the ISP's PPPoE address only needs
ppp0's connected route, not the default route pppd refused to install.

Add a fifth node, `oldlan`: a networkd DHCP server on vlan 4 handing gw
its staging lease, with a second address (203.0.113.1) that gw can only
reach through that lease's default route. It has no route back to the
VLANs, so client pings only work if gw masquerades.

Proven: pppd's metric-0 default wins over the DHCP one while ppp0 is up;
lan reaches the old LAN via the staging port and iot (allowWan = false)
does not; after stopping pppd the staging route carries WAN traffic with
the same allowWan split; restarting pppd makes ppp0 preferred again.
2026-09-21 04:32:14 +00:00
kurogeek 8090ab3e6d router: let allowWan VLANs out through the staging uplink
With stagingPort set, the box itself had internet over the staging DHCP
uplink but LAN/Wi-Fi clients had none: forward and masquerade were scoped
to ppp0 only. Worse, pppd's `defaultroute` refuses to install its route
while the staging DHCP default route (metric 1024) exists ("not replacing
existing default route"), so even a live PPPoE session was never used.

- firewall: forward-allow + masquerade allowWan VLANs -> stagingPort in a
  separate `router-staging-nat` postrouting chain (networking.nat only
  takes one external interface). Same allowWan set as nixos-nat.
- pppoe: `defaultroute-metric 0`, so pppd only checks for a metric-0
  default route, installs ppp0 as the preferred exit and removes it on
  hangup, leaving the staging route as the fallback.
2026-09-21 04:32:07 +00:00
kurogeek 4146f2c878 router/dhcp: drop Restart= already set by the nixpkgs kea unit 2026-09-18 10:08:03 +00:00
kurogeek 9ea058bfb8 router/dhcp: make Kea wait for its VLAN sockets
kea-dhcp4-server orders after network-online.target, which under networkd
only waits for the WAN carrier: the vlan-* links are RequiredForOnline=no.
On stellio Kea started before networkd had addressed vlan-lan/vlan-mgmt,
failed both binds and, per its default, kept running with no socket at
all (ss -ulnp showed nothing on :67). Wireless and wired clients'
DISCOVERs reached vlan-lan and were never answered.

Set service-sockets-require-all with a bounded retry so Kea keeps trying
while networkd catches up, and Restart=on-failure on the unit for the
case it still gives up.

Verified with checks.x86_64-linux.router.
2026-09-18 10:06:01 +00:00
kurogeek f98226bc6e router/wifi: assert no supplicant/NetworkManager owns the radios
With the AP-typed match, the station-mode netdev is unmanaged until
hostapd flips it, so any wpa_supplicant/iwd/NetworkManager in the
importing config would grab the radio first and keep resetting it to
managed mode under hostapd. Fail at eval instead of at runtime.
2026-09-18 09:36:07 +00:00
kurogeek d4e98d8dd5 router/wifi: match BSS .network files on WLANInterfaceType=ap
networkd enslaves the radio to br0 once, at link init. On real hardware
(GL-MT6000, mt798x-wmac) that happens while the netdev is still in station
mode, so the kernel rejects the bridge join (IFF_DONT_BRIDGE), the link is
parked in networkd's failed state and never retried: link_reconfigure_impl()
is a no-op while the matching .network file is unchanged, and the udev
'iw set type __ap' hook meant to pre-empt this loses the race. Wireless
clients associate but their DHCP never reaches vlan-lan.

Match on WLANInterfaceType=ap as well as the name. The station-mode netdev
then matches nothing (unmanaged) and the file first matches when hostapd
has switched the radio to AP and raised carrier, so the enslave succeeds
on the first try. Drop the udev hook.

Verified with checks.x86_64-linux.router (hwsim STA lease over the bridge).
2026-09-18 09:22:57 +00:00
kurogeek 60aac6efb2 services/router: init 2026-09-17 08:57:32 +07:00
kurogeek b85d6637f1 machines/gw-cnx-1: wan.macAddress 2026-08-17 17:34:14 +07:00
kurogeek e316ec56ce flake: use flake-parts 2026-08-05 10:19:32 +07:00
kurogeek 559115e52b inventory.json: update install time of gw-cnx-1 2026-07-31 17:37:47 +07:00
kurogeek 9cfcf91b51 inventory.json: update install time of gw-cnx-1 2026-07-31 16:08:14 +07:00
kurogeek 570fe64497 inventory.json: update install time of gw-cnx-1 2026-07-31 15:09:07 +07:00
kurogeek 2ac4215237 inventory.json: update install time of gw-cnx-1 2026-07-31 13:03:43 +07:00
kurogeek d51a334ec6 inventory.json: update install time of gw-cnx-1 2026-07-31 13:00:57 +07:00
kurogeek b81c0aaa89 machines/gw-cnx-1/facter.json: update hardware configuration 2026-07-31 13:00:57 +07:00
Berwn fcd8e55024 Add untagged access ports and a staging uplink for gw-cnx-1
New cnx.router.accessPorts option pins a port untagged to one VLAN via
bridge PVID/EgressUntagged; convention is the last copper port as an
always-available mgmt recovery port. gw-cnx-1 port roles: enp1s0 WAN,
enp2s0 trunk, enp3s0 temporary DHCP uplink into the old OPNsense LAN
(back to trunk at cutover), enp4s0 untagged mgmt.
2026-07-31 12:57:34 +07:00
Berwn 19e1acda51 Renumber gw-cnx-1 to the fleet addressing scheme (OPNsense replacement)
The newedge.house site adopts the 10.1.<vlanId>.0/24 convention: mgmt 10
(the old untagged LAN), lan 20, iot 40, voip 50, dmz 60, unit1-5 110-150.
PPPoE is untagged at this site. Static leases move to a new per-VLAN
dhcp.reservations option rendered into Kea host reservations.
2026-07-31 11:39:54 +07:00
Berwn f5b6b4b55e Serve newedge.house from the fleet nameservers 2026-07-31 11:39:54 +07:00
Berwn 3646d43ac5 Add CNX WAN Speed dashboard for the gateway speed tests
Per-gateway download/upload/ping from the hourly librespeed runs, with
each link's 7-day median overlaid so the WANSpeedDegraded alert
condition is visible at a glance. Label-driven — new sites appear
automatically once scraped.
2026-07-31 11:03:31 +07:00
Berwn d8d4a686eb Add gateway diagnostics: iperf3, CLI toolkit, periodic WAN speed test
iperf3 serves throughput tests from every VLAN and the mesh; an hourly
librespeed run feeds speedtest_* metrics through node_exporter's textfile
collector, and vmalert flags download rates below half the link's own
7-day median so ISP degradation surfaces without per-site thresholds.
2026-07-31 10:44:52 +07:00
Berwn b11ff75ca6 Add internal reverse proxy for gateways (Caddy, wildcard via DNS-01)
cnx.router.proxy serves <service>.<site><n>.cnx.network with a real Let's
Encrypt wildcard obtained via a gateway-scoped TSIG key against ns1; Blocky
resolves the names to the router's LAN address, so they exist only
internally. First user: Omada UI on gw-cnx-1 (omada.cnx1.cnx.network).
2026-07-31 10:09:08 +07:00
Berwn 4735968433 vars: update via generator dns-acme-gw-cnx-1-knot (machine: ns1) 2026-07-31 10:09:08 +07:00
Berwn 02efeb454b vars: update via generator dns-acme-gw-cnx-1-rfc2136 (machine: gw-cnx-1) 2026-07-31 10:09:08 +07:00
Berwn 868cd9eebd vars: update via generator dns-acme-gw-cnx-1-secret (shared) 2026-07-31 10:09:08 +07:00
Berwn 99fdde666c secrets: add group admins to secret gw-cnx-1-age.key 2026-07-31 10:09:08 +07:00
Berwn 7b706eb3e7 secrets: add group admins to secret ns2-age.key 2026-07-31 10:09:08 +07:00
Berwn 6c89a7031a secrets: add group admins to secret ns1-age.key 2026-07-31 10:09:08 +07:00
Berwn 4ca5fd3afd secrets: add group admins to secret control-age.key 2026-07-31 10:09:08 +07:00
kurogeek 7958ddd771 .gitignore 2026-07-31 09:38:57 +07:00
kurogeek 0c11aca91f machines/gw-cnx-1: disko update 2026-07-31 09:38:07 +07:00
kurogeek 292a699a2b clan.core.sops.defaultGroups to all machines 2026-07-31 09:18:18 +07:00
Berwn 7d549a9c17 Migrate deprecated admin service to sshd + users
sshd carries the admin SSH keys and now also CA-signed host certificates
(TOFU-less verification via the shared openssh-ca); the users service
generates per-machine root passwords (share = false, no prompts).
2026-07-28 17:17:56 +07:00
Berwn ac60d527ae vars: update via generator openssh-cert (machine: web01) 2026-07-28 17:15:55 +07:00
Berwn 3471faa466 vars: update via generator openssh-cert (machine: ns2) 2026-07-28 17:15:54 +07:00
Berwn db22afc6e3 vars: update via generator openssh-cert (machine: ns1) 2026-07-28 17:15:54 +07:00
Berwn 04d3518c03 vars: update via generator openssh-cert (machine: mx1) 2026-07-28 17:15:53 +07:00
Berwn 94daf06814 vars: update via generator openssh-cert (machine: gw-cnx-1) 2026-07-28 17:15:53 +07:00
Berwn 3093ed7044 vars: update via generator openssh-cert (machine: control) 2026-07-28 17:15:52 +07:00
Berwn c1fad33ad7 vars: update via generator user-password-root (machine: web01) 2026-07-28 17:15:47 +07:00
Berwn 60f6884f35 vars: update via generator user-password-root (machine: ns2) 2026-07-28 17:15:47 +07:00
Berwn d2b352543b vars: update via generator user-password-root (machine: ns1) 2026-07-28 17:15:46 +07:00
Berwn 7639d477c8 vars: update via generator user-password-root (machine: mx1) 2026-07-28 17:15:45 +07:00
Berwn 8327e4997a vars: update via generator user-password-root (machine: gw-cnx-1) 2026-07-28 17:15:44 +07:00
Berwn 060091247a vars: update via generator user-password-root (machine: control) 2026-07-28 17:15:43 +07:00
Berwn 9ccfbc3959 vars: update via generator openssh-ca (shared) 2026-07-28 17:15:43 +07:00
Berwn 4585fe7b7f Update flake inputs (clan-core 2026-07-08, nixos-mailserver, treefmt-nix) 2026-07-28 17:11:00 +07:00
Berwn 89eb50fcd7 vars: re-encrypt gw-cnx-1 emergency-access and openssh for admins group 2026-07-28 17:06:11 +07:00
Berwn 430c9996ae devshell: run multi-machine clan updates sequentially
Parallel updates interleave output and bury the YubiKey PIN prompts; a
clan wrapper in the dev shell loops one machine at a time instead.
2026-07-28 17:06:11 +07:00
Berwn 158252323f 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.
2026-07-28 17:06:07 +07:00
Berwn a87b579eb7 docs: add mail runbook page (mailbox and domain add procedures) 2026-07-28 17:06:07 +07:00
Berwn 7cd44d957c vars: update via generator pppoe-credentials (machine: gw-cnx-1) 2026-07-28 16:34:28 +07:00
Berwn dc2183a83a vars: update via generator pppoe-credentials (machine: gw-cnx-1) 2026-07-28 16:31:09 +07:00
Berwn b0aa12ac07 secrets: fix vars for gw-cnx-1 2026-07-28 16:30:29 +07:00
Berwn 745b82a4ee secrets: fix vars for gw-cnx-1 2026-07-28 16:30:16 +07:00
Berwn 88bbba0eae secrets: fix vars for gw-cnx-1 2026-07-28 16:30:04 +07:00
Berwn 3f9559766f vars: update via generator zerotier-ip-gw-cnx-1-zerotier (shared) 2026-07-28 16:03:23 +07:00
Berwn d53a1e6524 vars: update via generator tor_tor (machine: gw-cnx-1) 2026-07-28 16:03:18 +07:00
Berwn b7317ea4ff vars: update via generator state-version (machine: gw-cnx-1) 2026-07-28 16:03:15 +07:00
Berwn 832dcb1af9 vars: update via generator root-password (machine: gw-cnx-1) 2026-07-28 16:03:15 +07:00
Berwn 41a2d22be0 vars: update via generator openssh (machine: gw-cnx-1) 2026-07-28 16:03:14 +07:00
Berwn 2fbdd8e2c0 vars: update via generator emergency-access (machine: gw-cnx-1) 2026-07-28 16:03:14 +07:00
Berwn 62b0b81d19 vars: update via generator zerotier-identity-gw-cnx-1 (shared) 2026-07-28 16:03:13 +07:00
Berwn 4fa491fd63 secrets: add machine gw-cnx-1 2026-07-28 16:03:13 +07:00
Berwn b8622b3e93 secrets: update gw-cnx-1-age.key 2026-07-28 16:03:13 +07:00
166 changed files with 7441 additions and 76 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
result
result*
.direnv/
+47 -11
View File
@@ -1,30 +1,61 @@
{ ... }:
let
hosts = import ./modules/hosts.nix;
in
{
# Ensure this is unique among all clans you want to use.
meta.name = "cnx-network-clan";
meta.domain = "cnx-network.internal";
inventory.machines = {
# Single source of the machine list: inventory AND the per-machine baseline
# below are both derived from it, so no machine can be added without the
# baseline (e.g. admins group encryption for all its generated secrets).
fleet = {
control = { };
ns1 = { };
ns2 = { };
mx1 = { };
web01 = { };
# Site gateways (Topton 1U routers): dynamic PPPoE WAN, so they are NOT in
# modules/hosts.nix / the `internet` instance — clan reaches them over the
# zerotier mesh (or Tor) instead.
gw-cnx-1 = { };
};
in
{
# Site gateways: the router service instance lives in inventory.nix.
imports = [ ./inventory.nix ];
# Ensure this is unique among all clans you want to use.
meta.name = "cnx-network-clan";
meta.domain = "cnx-network.internal";
inventory.machines = fleet;
# Services defined in this repo (module.input = "self"). See modules/clan/.
modules.router = ./modules/clan/router;
inventory.instances = {
admin = {
roles.default.tags.all = { };
roles.default.settings.allowedKeys = {
# Admin SSH keys + root password, split per the clan-core migration off
# the deprecated `admin` service (sshd handles keys, users the password).
sshd = {
roles.server.tags.all = { };
roles.server.settings.authorizedKeys = {
"berwn" = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIENAjhGQGraQoAjJzsomKP8GAmQPeGL1rNRNHgRcLqtT";
"kurogeek" =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEcZ/p1Ofa9liwIzPWzNtONhJ7+FUWd2lCz33r81t8+w kurogeek@kurogeek";
};
};
root-user = {
module = {
name = "users";
input = "clan-core";
};
roles.default.tags.all = { };
roles.default.settings = {
user = "root";
prompt = false; # auto-generate, like the old admin service
share = false; # per-machine password, not fleet-wide
};
};
zerotier = {
roles.controller.machines."control" = { };
roles.peer.tags.all = { };
@@ -65,6 +96,11 @@ in
};
};
machines = {
};
# Fleet-wide baseline applied to every machine. Secrets minted by
# `clan vars generate` are encrypted for the admins group from the very
# first run — generating before this took effect is what forced the
# re-encryption dance (`clan vars fix`) on gw-cnx-1.
machines = builtins.mapAttrs (_: _: {
clan.core.sops.defaultGroups = [ "admins" ];
}) fleet;
}
+2
View File
@@ -3,5 +3,7 @@
- [Overview](./overview.md)
- [ZeroTier mesh](./mesh.md)
- [DNS](./dns.md)
- [Mail](./mail.md)
- [Site gateways](./gateways.md)
- [Monitoring](./monitoring.md)
- [Backups](./backups.md)
+168
View File
@@ -0,0 +1,168 @@
# Site gateways
NixOS routers (OPNsense replacements) on Topton 1U fanless boxes — Intel N300,
4x i226-V 2.5G ports (some units add 2x 10G SFP+), 16GB DDR5, 256GB mSATA.
Four sites are planned; the first is `gw-cnx-1` (Chiang Mai). Everything lives
in the `router` clan service (`modules/clan/router/`, registered as
`modules.router` in `clan.nix`); a site joins by adding its machine to the
`router` inventory instance in `inventory.nix` with settings for port names,
VLANs, and feature flags (`roles.default.machines.<gw>.settings`).
Naming: `gw-<city>-<n>`, e.g. `gw-cnx-1`.
The service has a NixOS VM test (`modules/clan/router/tests/vm/`): an ISP
node runs a PPPoE access concentrator, a `client` sits on the tagged lan/iot
VLANs behind the trunk, an `admin` on the untagged mgmt access port, and a
simulated radio (`mac80211_hwsim`) carries two SSIDs with a WPA3 station. It
checks the PPPoE dial-in, leases/reservations, Blocky, NAT, `allowWan`, the
mgmt-only trust model, SSH exposure and the Wi-Fi bridge ports. Run it with
`nix build .#checks.x86_64-linux.router` (also part of `nix flake check`).
## What each gateway runs
| Function | Implementation |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| WAN | PPPoE (`pppd`), per-site ISP credentials via clan vars prompts; `wan.vlanId` when the ISP tags the session (AIS: 10); `wan.macAddress` to clone the old router's MAC if the ISP has it pinned |
| LAN | VLAN-filtering bridge `br0` over the trunk ports (networkd); `accessPorts` pin a port untagged to one VLAN — convention: the last copper port is an untagged `mgmt` recovery port; `stagingPort` turns a spare port into a DHCP uplink into the old LAN while the box runs alongside the router it replaces (drop at cutover) |
| Firewall/NAT | nftables: default-deny WAN, no inter-VLAN, MSS clamp, v4 NAT |
| DHCP | Kea, one subnet per VLAN |
| DNS | Blocky (blocklist resolver), metrics on :4000 scraped by control |
| IPv6 | DHCPv6-PD on ppp0, /64 per VLAN via SLAAC |
| Bans | CrowdSec + nftables bouncer (sshd log parsing) |
| Omada | Optional per site: TP-Link Omada controller as a podman container |
| Wi-Fi | Optional: hostapd on the router's radios; each SSID (`wifi.networks`) is an untagged access port of its VLAN, passphrases via vars prompts — see `modules/clan/router/README.md` |
| Proxy | Optional: Caddy reverse proxy for internal services under `*.<site><n>.cnx.network` with a real Let's Encrypt wildcard (DNS-01 against ns1) |
| Diagnostics | iperf3 server on 5201, reachable from every VLAN and the mesh (`iperf3 -c <router-vlan-addr>`); CLI toolkit: tcpdump, mtr, ethtool, conntrack, kdig, iftop, librespeed-cli |
| Speed test | Hourly librespeed run (`speedtest.timer`) → `speedtest_*` metrics via node_exporter; vmalert flags download < 50% of the link's own 7-day median (`WANSpeedDegraded`) |
| Management | ZeroTier mesh: SSH, node_exporter, journald upload — like the fleet |
| Storage | Single-disk ZFS (zstd, `/var` its own dataset for snapshots) |
## Addressing plan
Each site owns `10.<siteId>.0.0/16`. Defaults per VLAN: subnet
`10.<siteId>.<vlanId>.0/24`, router at `.1`, DHCP pool `.100.199`
(`.2.99` static/infra, `.200.254` reserved).
VLAN id convention (fleet-wide): **10 = mgmt**, **20 = lan** (both mandatory at
every site), 30 = guest, 40 = iot (reserved). Sites add their own beyond that.
Larger subnets (e.g. public-WiFi guest networks that outgrow a /24) are carved
from the **upper half** `10.<siteId>.128.0/17` and set explicitly on the VLAN.
The lower half stays reserved for /24s indexed by VLAN id. High-churn VLANs
should also shorten `dhcp.leaseTime` (default 86400 s) so the pool recycles.
First user: `gw-cnx-2` (site 2) runs the public WiFi — guest VLAN 30 at
`10.2.128.0/22`, pool `10.2.128.100 10.2.131.250`, `dhcp.leaseTime = 3600`.
| Site | siteId | mgmt | lan | site-specific VLANs |
| ---- | ------ | -------------- | -------------- | --------------------------------------------------------------- |
| cnx | 1 | `10.1.10.0/24` | `10.1.20.0/24` | iot 40, voip 50, dmz 60, unit15 110/120/130/140/150 (all /24s) |
Static DHCP leases are declared per VLAN via `dhcp.reservations` (attribute
name = hostname, plus `hwAddress`/`ipAddress`); park them outside the pool,
in the `.2.99` infra range or `.200.254`.
Trust model: mgmt → everything; other VLANs → router DNS/DHCP + internet only
(no inter-VLAN); WAN → nothing inbound; mesh → SSH, metrics, Omada UI.
## Adding a site
1. Copy `machines/gw-cnx-1/` to `machines/gw-<city>-<n>/` (disko, facter,
hostId, timezone). Fill the real disk id in `disko.nix` (from the
installer: `ls -l /dev/disk/by-id/`).
2. Add the machine to `inventory.machines` in `clan.nix`, to the `router`
instance in `inventory.nix` (`roles.default.machines.gw-<city>-<n>.settings`: `site`,
`siteId` (next free number), port names, VLANs, `omada.enable`; keep the
`mgmt`/`lan` VLANs), and to the machine list in `modules/mesh-hosts.nix`.
Do **not** add it to `modules/hosts.nix` (dynamic PPPoE IP; clan connects
over the mesh).
3. Add its node_exporter and Blocky scrape targets in
`modules/monitoring/server.nix`.
4. If the site uses the internal proxy (`proxy.enable` in its settings), wire
ns1: import `(import ../../modules/clan/router/acme-secret.nix
"gw-<city>-<n>")` in ns1's configuration (the gateway declares the same
shared generator itself), and on ns1 add the `dns-acme-gw-<city>-<n>-knot`
generator, its `keyFiles` entry, and an `acl_acme_gw_<city>_<n>` scoped to
`_acme-challenge.<city><n>` on the `cnx.network` zone (copy the `gw-cnx-1`
blocks). Then `clan vars generate ns1` and redeploy ns1.
5. `git add` the new machine directory — flake evaluation only sees
git-tracked files, so an untracked `machines/gw-…/` is silently ignored.
6. `clan vars generate gw-<city>-<n>` — prompts for the site's PPPoE
credentials, mints the ZeroTier identity etc. (`nix flake check` fails
until this has run, because mesh-hosts reads the ZeroTier IP var.)
7. Boot the box from a NixOS installer USB on the local network, then:
`clan machines install gw-<city>-<n> --target-host root@<lan-ip>`
8. Check `facter.json` for the real NIC names, fix `wan.interface` /
`trunkPorts` if the enumeration differs, and
`clan machines update gw-<city>-<n>` (rides the mesh from then on).
9. Add a row to the site table above and to the machines table in
[Overview](./overview.md).
## Omada controller
Sites with TP-Link Omada APs/switches set `omada.enable = true` in their
router settings. The controller runs as a podman container
(`mbentley/omada-controller` — there is no nixpkgs package) with host
networking on the mgmt VLAN, where adoption
broadcasts (UDP 29810) live. UI: `https://<mgmt-addr>:8043` from mgmt, or the
gateway's mesh address from an admin machine. Controller state is under
`/var/lib/omada`, declared as clan state (`clan.core.state.omada`); wiring it
into the borgbackup instance is still a follow-up.
## Internal reverse proxy
`proxy.enable` in a site's router settings puts Caddy on the gateway,
terminating TLS for `<service>.<site><n>.cnx.network` (e.g.
`https://omada.cnx1.cnx.network`) and forwarding to internal backends:
```nix
proxy = {
enable = true;
services.omada = {
backend = "https://127.0.0.1:8043";
insecureSkipVerify = true; # Omada's cert is self-signed
};
};
```
- **Cert**: one real Let's Encrypt wildcard `*.<site><n>.cnx.network` per
gateway, issued via ACME DNS-01 (lego/RFC2136) against ns1 — works behind
PPPoE with zero inbound reachability, and browsers trust it without a CA
install. Each gateway has its own TSIG key (`acme_gw_<city>_<n>`) that ns1
scopes to `_acme-challenge.<site><n>` TXT records only. The zone, ns1's
address and the ACME contact are fleet-wide role settings in `inventory.nix`
(`proxy.domain`, `proxy.acme.nameserver`, `proxy.acme.email`).
- **Resolution**: the names exist only internally — Blocky answers
`*.<site><n>.cnx.network` with the router's `lan` address; the public
`cnx.network` zone never carries them.
- **Access**: `proxy.allowVlans` (default `mgmt` + `lan`) get 443 (and 80 for
the HTTP→HTTPS redirect). Not exposed to WAN, guest VLANs, or the mesh.
## Using the service from another clan
The service is exported as `clan.modules.router` of this flake; add this
repo as a flake input and reference it with `module.input = "<input name>"`.
`modules/clan/router/README.md` is the consumer-facing description (it is
also the module's manifest readme, shown by `clan modules`).
## Runbook
- **PPPoE down**: `systemctl status pppd-wan`, `journalctl -u pppd-wan` on the
gateway (over the mesh). ISP credentials live in the `pppoe-credentials`
vars generator; re-enter with `clan vars generate gw-<city>-<n> --regenerate`.
- **A VLAN gets no leases**: `systemctl status kea-dhcp4-server`; check the
port's `bridgeVLANs` tagging and that the switch trunk carries the VLAN.
- **DNS/blocklist issues**: Blocky metrics are in VictoriaMetrics (job
`blocky`); `journalctl -u blocky` on the gateway.
- **Slow internet reported / `WANSpeedDegraded` firing**: check the
`speedtest_download_mbps` / `speedtest_ping_ms` trend in VictoriaMetrics,
then `librespeed-cli` and `mtr <isp-gw>` on the gateway to separate ISP
degradation from local saturation (`iftop` on ppp0 shows who's eating it).
- **Banned yourself**: `cscli decisions list` / `cscli decisions delete --ip <ip>`
on the gateway. The ZeroTier mesh `/88` is whitelisted at the parser stage
(`cnx/mesh-whitelist`), so admin access over the mesh can never be banned.
## Follow-ups
- Borgbackup client for Omada + Kea lease state.
- Suricata (IDS-only) if CPU headroom allows — deliberately skipped for now.
- Remaining three sites.
+90
View File
@@ -0,0 +1,90 @@
# Mail
A single mail server, `mx1`, runs the [Simple NixOS Mailserver](https://nixos-mailserver.readthedocs.io/)
stack (Postfix + Dovecot + Rspamd + OpenDKIM) for the `cnx.email` domain. All of
it is declared in `modules/mail.nix`, imported by `machines/mx1/configuration.nix`.
Mailboxes are **virtual** (not system users): each address is a login account
whose password is a four-word passphrase auto-generated by a clan vars generator
(e.g. `otter-lantern-cobalt-driftwood-42`). The generator stores both the
passphrase (handed to the user) and its sha-512 hash (consumed by SNM); plaintext
never lands in the repo.
The mail cert is obtained by `mx1` itself via ACME DNS-01 against `ns1` (key
`acme_mx1`), and carries `mx1.cnx.email` plus the `mta-sts.cnx.email` and
`mail.cnx.email` SANs. See [DNS → ACME DNS-01](./dns.md#acme-dns-01).
## Add a mailbox
1. Append the address to `accounts` in `modules/mail.nix`:
```nix
accounts = [
"postmaster@cnx.email"
"alice@cnx.email" # new
];
```
2. Mint its generated password (user-run, YubiKey touch):
```
clan vars generate mx1
```
3. Redeploy: `clan machines update mx1`.
4. Hand the passphrase to the user. The generator name replaces `@`→`-at-` and
`.`→`-`:
```
clan vars get mx1 mail-passwd-alice-at-cnx-email/passphrase
```
The DMARC report inbox (`dmarc@cnx.email`) is special: its password comes from
the **shared** `mail-dmarc-cred` generator (not the per-machine set) so parsedmarc
on `control` can read the same passphrase over the mesh.
## Add a domain
Adding a second mail domain is more than a config line — it needs its own DNS
records and DKIM key. Steps:
1. **Serve the zone** (if not already): add it in `modules/dns/domains.nix` and
drop a `<domain>.zone` file — see [DNS](./dns.md).
2. **Declare the domain** in `modules/mail.nix`:
```nix
mailserver.domains = [ "cnx.email" "newdomain.tld" ];
```
Add at least a `postmaster@newdomain.tld` account (RFC 5321 requires it) the
same way as above.
3. **Publish the mail DNS records** in the new zone, mirroring `cnx.email`'s
`; ---- Mail ----` block:
- `MX 10 mx1.cnx.email.` — point at the existing MX; do **not** stand up a new
hostname unless you really want a separate server.
- SPF: `@ TXT "v=spf1 mx -all"`.
- DKIM: SNM generates a key per domain on first start under
`mailserver.dkimKeyDirectory` (selector `mail`). After deploy, read the
public key off `mx1` and paste it as `mail._domainkey TXT`. The private keys
are declared as clan state and backed up.
- DMARC: `_dmarc TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@cnx.email; ..."`
— reports can keep flowing to the existing `dmarc@cnx.email` inbox.
4. **DANE / MTA-STS are tied to `mx1.cnx.email`**, the MX hostname — they do **not**
need duplicating per sending-domain. A new domain that uses `mx1.cnx.email` as
its MX inherits the existing TLSA and MTA-STS policy. Only add new TLSA/MTA-STS
records if you introduce a new MX hostname.
5. `clan vars generate mx1` (if you added accounts), then
`clan machines update mx1`.
## DKIM, DANE, MTA-STS reminders
These three are easy to get subtly wrong; the live records for `cnx.email` and
the exact commands to regenerate them are in `modules/dns/zones/cnx.email.zone`:
- **DKIM** key rotation = regenerate under `dkimKeyDirectory`, then republish the
`mail._domainkey TXT`.
- **DANE TLSA** (`_25._tcp.mx1 TLSA 3 1 1 …`) is the SHA-256 of `mx1`'s cert
**public key**. It stays valid across renewals because lego runs with
`--reuse-key`; only recompute it if the key changes. The `openssl` one-liner is
in the zone file.
- **MTA-STS**: bump the `_mta-sts TXT` id **every time** the policy in
`modules/mail.nix` changes, or senders keep the cached policy.
## DMARC reporting
`mx1` delivers aggregate/forensic DMARC reports to `dmarc@cnx.email`; parsedmarc
on `control` polls that mailbox over the mesh and feeds the results into Grafana.
See [Monitoring](./monitoring.md).
+4
View File
@@ -52,6 +52,10 @@ there is picked up):
- **CNX Uptime** (`uptime.json`) — per-host up/down status, current uptime,
availability over the selected window, and up/down history. Label-driven, so
every scraped host appears automatically.
- **CNX WAN Speed** (`wan.json`) — per-gateway WAN speed from the hourly
librespeed runs: latest download/upload/ping, throughput history with each
link's 7-day median as reference, latency/jitter, and test status. See
[Site gateways](./gateways.md).
- **parsedmarc** — DMARC aggregate/forensic report viewer. Auto-provisioned by
the `parsedmarc` module (not from `dashboards/`); reads its own Elasticsearch
datasource, not VictoriaMetrics. See [DMARC reports](#dmarc-reports) below.
+10 -1
View File
@@ -7,12 +7,13 @@ this book is built from `docs/` and served on `control` over the ZeroTier mesh.
## Machines
| Machine | Role | Public IPv4 | Public IPv6 |
| --------- | -------------------------------------- | ---------------- | ----------------------- |
| ---------- | -------------------------------------- | ---------------- | ----------------------- |
| `control` | ZeroTier controller, monitoring, docs | `77.42.68.181` | `2a01:4f9:c013:e6d0::1` |
| `ns1` | Knot DNS **primary** (master) | `46.224.170.206` | `2a01:4f8:c014:b5c5::1` |
| `ns2` | Knot DNS **secondary** (slave) | `157.180.70.82` | `2a01:4f9:c014:6d87::1` |
| `mx1` | Mail server (**MX** for cnx.email) | `5.223.65.38` | `2a01:4ff:2f0:1963::1` |
| `web01` | Public reverse proxy (TLS termination) | `5.223.55.246` | `2a01:4ff:2f0:2d8f::1` |
| `gw-cnx-1` | Site gateway Chiang Mai (router) | dynamic (PPPoE) | — |
## Access
@@ -21,6 +22,14 @@ this book is built from `docs/` and served on `control` over the ZeroTier mesh.
- clan reaches machines by their public IPs first (the `internet` instance), with
the mesh and Tor as automatic fallbacks.
## Host baseline
Every machine sets a fixed-offset `time.timeZone` and runs **chrony** for time
sync (`services.chrony.enable`). chrony is the single NTP implementation across
the fleet — do **not** use `systemd-timesyncd`; the two are mutually exclusive
and we standardise on chrony everywhere. New hosts must set both the timezone and
chrony as part of their baseline config.
## Editing these docs
Commit-to-edit: change the markdown under `docs/src/`, commit, and redeploy
Generated
+14 -10
View File
@@ -29,11 +29,11 @@
"treefmt-nix": "treefmt-nix"
},
"locked": {
"lastModified": 1782204050,
"narHash": "sha256-o8PXnm00Ecim/7WIQWxnFciWxvxgbll9fxH5MJ3a1Kg=",
"rev": "246e2bcba4dc9c009a96d017c030c7a53c83955e",
"lastModified": 1783497933,
"narHash": "sha256-TxmwEews6URFPqOWEHNychtXbFDgLZjbOfEXtvtOm6U=",
"rev": "3dc0221ca09033599fe98055e9bbc81bdf32732a",
"type": "tarball",
"url": "https://git.clan.lol/api/v1/repos/clan/clan-core/archive/246e2bcba4dc9c009a96d017c030c7a53c83955e.tar.gz"
"url": "https://git.clan.lol/api/v1/repos/clan/clan-core/archive/3dc0221ca09033599fe98055e9bbc81bdf32732a.tar.gz"
},
"original": {
"type": "tarball",
@@ -217,11 +217,11 @@
]
},
"locked": {
"lastModified": 1781301671,
"narHash": "sha256-rq6WOopxq3U2AGEWO80o9LIJDYcYIdgw6jyl+y+19w8=",
"lastModified": 1785198005,
"narHash": "sha256-falBPi+PJtMx0vwII8L24wjDGXNZMwRiVXsw8WTXLEo=",
"owner": "simple-nixos-mailserver",
"repo": "nixos-mailserver",
"rev": "661ec59a97ccee13a63f79280b282eb6f7d3f817",
"rev": "d357b9f048c5532ec81b0e0034c0b8463d5ddd46",
"type": "gitlab"
},
"original": {
@@ -247,6 +247,10 @@
"root": {
"inputs": {
"clan-core": "clan-core",
"flake-parts": [
"clan-core",
"flake-parts"
],
"nixos-mailserver": "nixos-mailserver",
"nixpkgs": [
"clan-core",
@@ -320,11 +324,11 @@
]
},
"locked": {
"lastModified": 1780220602,
"narHash": "sha256-eynAfOmbmxJnkp7YewvCEbShNnnYJ9gLLqkzsYtBPeM=",
"lastModified": 1784369104,
"narHash": "sha256-47cxbcZODibHv3rELFQ9vZly0vUNkND/atn/U7HLeb0=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "db947814a175b7ca6ded66e21383d938df01c227",
"rev": "df3c0640565d04a0261253cdd89fce78ec50168a",
"type": "github"
},
"original": {
+53 -17
View File
@@ -7,13 +7,16 @@
inputs.nixos-mailserver.url = "gitlab:simple-nixos-mailserver/nixos-mailserver/nixos-26.05";
inputs.nixos-mailserver.inputs.nixpkgs.follows = "nixpkgs";
inputs.flake-parts.follows = "clan-core/flake-parts";
outputs =
{
inputs@{
self,
clan-core,
nixpkgs,
flake-parts,
...
}@inputs:
}:
let
# Usage see: https://docs.clan.lol
clan = clan-core.lib.clan {
@@ -36,36 +39,69 @@
"age-plugin-fido2-hmac"
];
};
in
flake-parts.lib.mkFlake { inherit inputs; } {
systems = [
"x86_64-linux"
"aarch64-linux"
"aarch64-darwin"
"x86_64-darwin"
];
forAllSystems = nixpkgs.lib.genAttrs systems;
pkgsFor = system: clan-core.inputs.nixpkgs.legacyPackages.${system};
treefmtFor = system: inputs.treefmt-nix.lib.evalModule (pkgsFor system) ./fmt.nix;
in
{
# perSystem.clan.nixosTests: NixOS VM tests of our own clan services.
imports = [ clan-core.flakeModules.testModule ];
flake = {
inherit (clan.config) nixosConfigurations nixosModules clanInternals;
clan = clan.config;
};
perSystem =
{ system, ... }:
let
pkgs = clan-core.inputs.nixpkgs.legacyPackages.${system};
treefmtEval = inputs.treefmt-nix.lib.evalModule pkgs ./fmt.nix;
clanCli = clan-core.packages.${system}.clan-cli;
# `clan machines update a b c` normally runs machines in parallel,
# which interleaves their output and buries the YubiKey PIN prompts.
# This wrapper (first in PATH) runs them one at a time instead; any
# flags fall through to the real CLI untouched.
clanSequential = pkgs.writeShellScriptBin "clan" ''
if [ "$#" -gt 3 ] && [ "$1" = machines ] && [ "$2" = update ]; then
shift 2
for arg in "$@"; do
case "$arg" in
-*) exec ${clanCli}/bin/clan machines update "$@" ;;
esac
done
for machine in "$@"; do
${clanCli}/bin/clan machines update "$machine" || exit
done
exit 0
fi
exec ${clanCli}/bin/clan "$@"
'';
in
{
# `nix fmt` and the `nix flake check` formatting gate.
formatter = forAllSystems (system: (treefmtFor system).config.build.wrapper);
checks = forAllSystems (system: {
formatting = (treefmtFor system).config.build.check self;
});
formatter = treefmtEval.config.build.wrapper;
checks.formatting = treefmtEval.config.build.check self;
# `nix build .#checks.x86_64-linux.router` (VM test, Linux only).
clan.nixosTests.router = {
imports = [ ./modules/clan/router/tests/vm ];
clan.modules.router = ./modules/clan/router;
};
# Add the Clan cli tool to the dev shell.
# Use "nix develop" to enter the dev shell.
devShells = forAllSystems (system: {
default = (pkgsFor system).mkShell {
devShells.default = pkgs.mkShell {
packages = [
clan-core.packages.${system}.clan-cli
(treefmtFor system).config.build.wrapper
clanSequential
clanCli
treefmtEval.config.build.wrapper
];
};
});
};
};
}
+3
View File
@@ -14,6 +14,9 @@
},
"web01": {
"installedAt": 1781983723
},
"gw-cnx-1": {
"installedAt": 1785494267
}
}
}
+93
View File
@@ -0,0 +1,93 @@
# Site gateways: the `router` service instance (modules/clan/router). One
# instance for the fleet; each site adds its machine with the site's port /
# VLAN layout under roles.default.machines.<gw>.settings. Everything else
# (PPPoE, Kea/Blocky, nftables, IPv6-PD, CrowdSec, iperf3, speedtest) follows
# from the service. Imported by clan.nix.
{ config, lib, ... }:
let
hosts = import ./modules/hosts.nix;
mesh = import ./modules/mesh-hosts.nix {
dir = config.directory;
inherit lib;
};
in
{
inventory.instances.router = {
module = {
name = "router";
input = "self";
};
# Fleet-wide settings: admin paths (SSH, scrapes, iperf3, Omada UI) are
# opened to the mesh; internal proxy names live under
# <site><n>.cnx.network with wildcard certs issued via DNS-01 against
# ns1 (which must declare the gateway's acme-secret.nix generator).
roles.default.settings = {
mesh.subnet = mesh.subnet;
proxy = {
domain = "cnx.network";
acme.nameserver = hosts.ns1.ipv4;
acme.email = "postmaster@cnx.email";
};
};
# Chiang Mai (site 1): Topton 1U, Intel N300, 4x i226-V 2.5G. Port roles
# use the expected igc names — verify against facter.json after the
# first install and adjust if the box enumerates differently.
roles.default.machines.gw-cnx-1.settings = {
site = "cnx";
siteId = 1;
wan.interface = "enp1s0";
wan.vlanId = null; # this ISP runs PPPoE untagged on the port
wan.macAddress = "a8:b8:e0:01:06:87";
trunkPorts = [ "enp2s0" ];
# STAGING (remove at cutover, move enp3s0 back into trunkPorts):
# DHCP-client uplink into the existing OPNsense LAN so the box has
# internet + mesh while it runs alongside the old router.
stagingPort = "enp3s0";
# Dedicated on-site recovery port: untagged mgmt, always available even
# if the switch config is broken.
accessPorts.enp4s0 = "mgmt";
# Replaces the newedge.house OPNsense box; renumbered to the fleet
# convention (10.1.<vlanId>.0/24, router .1, pool .100-.199). The old
# untagged LAN becomes tagged mgmt — infra switch ports get PVID 10.
vlans = {
mgmt = {
id = 10; # 10.1.10.0/24 — servers, APs, switches, Omada, admin
dhcp.reservations.storinator01 = {
hwAddress = "7c:c2:55:e0:d6:40";
ipAddress = "10.1.10.53";
};
};
lan.id = 20; # 10.1.20.0/24 — trusted clients
iot.id = 40; # 10.1.40.0/24
voip.id = 50; # 10.1.50.0/24
dmz.id = 60; # 10.1.60.0/24
unit1.id = 110; # 10.1.110.0/24
unit2.id = 120; # 10.1.120.0/24
unit3.id = 130; # 10.1.130.0/24
unit4.id = 140; # 10.1.140.0/24
unit5 = {
id = 150; # 10.1.150.0/24
dhcp.reservations.newt = {
hwAddress = "7c:d3:0a:21:58:0b";
ipAddress = "10.1.150.22";
};
};
};
# This site runs the Omada controller for its APs/switches.
omada.enable = true;
# Internal reverse proxy: real wildcard cert *.cnx1.cnx.network; Blocky
# resolves the names to the router's LAN address for mgmt+lan clients.
proxy = {
enable = true;
services.omada = {
# Omada's UI is HTTPS with a self-signed cert on the host network.
backend = "https://127.0.0.1:8043";
insecureSkipVerify = true;
};
};
};
};
}
+1 -1
View File
@@ -23,7 +23,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.
+21
View File
@@ -0,0 +1,21 @@
# Site gateway Chiang Mai (site 1): Topton 1U, Intel N300, 4x i226-V 2.5G.
# The router itself (ports, VLANs, features) is the `router` service instance
# in clan.nix; only machine-local bits live here.
{ config, lib, ... }:
{
imports = [ ../../modules/monitoring/exporters.nix ];
clan.core.sops.defaultGroups = [ "admins" ];
# 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
);
time.timeZone = "Etc/GMT-7"; # UTC+7 (Thailand, fixed offset, no DST)
services.chrony.enable = true;
}
+97
View File
@@ -0,0 +1,97 @@
{ ... }:
let
hashDisk = disk: "os-${builtins.substring 0 5 (builtins.hashString "sha256" disk)}";
os = "/dev/disk/by-id/ata-TS256GMSA230S_J927900030";
in
{
boot.loader = {
systemd-boot = {
enable = true;
};
efi = {
canTouchEfiVariables = true;
};
};
boot.zfs.forceImportRoot = true;
disko.devices = {
disk = {
"os-${hashDisk os}" = {
type = "disk";
device = os;
content = {
type = "gpt";
partitions = {
ESP = {
size = "1G";
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
mountOptions = [ "nofail" ];
};
};
system = {
size = "100%";
content = {
type = "zfs";
pool = "zroot";
};
};
swap = {
size = "16G";
content = {
type = "swap";
};
};
};
};
};
};
zpool = {
zroot = {
type = "zpool";
rootFsOptions = {
mountpoint = "none";
compression = "lz4";
acltype = "posixacl";
xattr = "sa";
"com.sun:auto-snapshot" = "true";
};
options.ashift = "12";
datasets = {
"root" = {
type = "zfs_fs";
options.mountpoint = "none";
};
"root/nixos" = {
type = "zfs_fs";
options.mountpoint = "/";
mountpoint = "/";
};
"root/home" = {
type = "zfs_fs";
options.mountpoint = "/home";
mountpoint = "/home";
};
"root/tmp" = {
type = "zfs_fs";
mountpoint = "/tmp";
options = {
mountpoint = "/tmp";
sync = "disabled";
};
};
"root/var" = {
type = "zfs_fs";
options.mountpoint = "/var";
mountpoint = "/var";
};
};
};
};
};
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -19,5 +19,5 @@ in
};
time.timeZone = "Etc/GMT-8"; # UTC+8 (Singapore, fixed offset, no DST)
services.timesyncd.enable = true;
services.chrony.enable = true;
}
+41 -3
View File
@@ -6,7 +6,10 @@
}:
let
domains = import ../../modules/dns/domains.nix;
mesh = import ../../modules/mesh-hosts.nix { inherit config lib; };
mesh = import ../../modules/mesh-hosts.nix {
dir = config.clan.core.settings.directory;
inherit lib;
};
hosts = import ../../modules/hosts.nix;
in
{
@@ -14,6 +17,8 @@ in
../../modules/dns/authoritative.nix
../../modules/dns/acme-mx1-secret.nix
../../modules/dns/acme-web01-secret.nix
# Same shared TSIG secret the gateway's router service declares (proxy).
(import ../../modules/clan/router/acme-secret.nix "gw-cnx-1")
../../modules/static-ipv6.nix
../../modules/monitoring/exporters.nix
];
@@ -38,7 +43,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.
@@ -93,10 +98,29 @@ in
'';
};
# ACME DNS-01, dedicated gateway keys. Same pattern as web01: each gateway
# holds its own TSIG key (acme_gw_<site>_<n>), rendered from the shared
# secret generator imported above. acl_acme_gw_<site>_<n> scopes it to TXT
# updates at _acme-challenge.<site><n> on cnx.network — the owner its internal
# wildcard *.<site><n>.cnx.network challenge uses — and nothing else.
clan.core.vars.generators.dns-acme-gw-cnx-1-knot = {
files."acme.conf" = {
secret = true;
owner = "knot";
group = "knot";
};
dependencies = [ "dns-acme-gw-cnx-1-secret" ];
script = ''
printf 'key:\n - id: acme_gw_cnx_1\n algorithm: hmac-sha256\n secret: %s\n' \
"$(cat "$in"/dns-acme-gw-cnx-1-secret/secret)" > "$out"/acme.conf
'';
};
services.knot.keyFiles = [
config.clan.core.vars.generators.dns-acme-tsig.files."acme.conf".path
config.clan.core.vars.generators.dns-acme-mx1-knot.files."acme.conf".path
config.clan.core.vars.generators.dns-acme-web01-knot.files."acme.conf".path
config.clan.core.vars.generators.dns-acme-gw-cnx-1-knot.files."acme.conf".path
];
services.knot.settings.acl = [
@@ -133,6 +157,17 @@ in
# i.e. _acme-challenge at the cnx.network apex (where this acl is attached).
"update-owner-name" = [ "_acme-challenge" ];
}
{
id = "acl_acme_gw_cnx_1";
key = "acme_gw_cnx_1";
action = [ "update" ];
"update-type" = [ "TXT" ];
"update-owner" = "name";
"update-owner-match" = "sub-or-equal";
# The internal wildcard *.cnx1.cnx.network places its challenge at
# _acme-challenge.cnx1.cnx.network.
"update-owner-name" = [ "_acme-challenge.cnx1" ];
}
];
# Automatic DNSSEC signing policy (primary only). ECDSA P-256/SHA-256 with
@@ -168,6 +203,9 @@ in
"acl_acme"
]
++ lib.optionals (d == "cnx.email") [ "acl_acme_mx1" ]
++ lib.optionals (d == "cnx.network") [ "acl_acme_web01" ];
++ lib.optionals (d == "cnx.network") [
"acl_acme_web01"
"acl_acme_gw_cnx_1"
];
}) domains;
}
+1 -1
View File
@@ -20,7 +20,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 -1
View File
@@ -18,5 +18,5 @@ in
};
time.timeZone = "Etc/GMT-8"; # UTC+8 (Singapore, fixed offset, no DST)
services.timesyncd.enable = true;
services.chrony.enable = true;
}
+91
View File
@@ -0,0 +1,91 @@
# router
Turns a machine with several NICs into a site gateway: PPPoE WAN (ISP
credentials via vars prompts), a VLAN-filtering bridge over the LAN ports with
one L3 interface per VLAN, Kea DHCP and Blocky DNS per VLAN, nftables
firewall/NAT, DHCPv6-PD, CrowdSec, an iperf3 server and a WAN speed-test
timer. Optional: a Wi-Fi access point on the router's own radios (hostapd),
the TP-Link Omada controller (podman) and an internal Caddy reverse proxy
with a real wildcard certificate (ACME DNS-01).
Addressing convention: a site owns `10.<siteId>.0.0/16`; VLAN `<id>` defaults
to `10.<siteId>.<id>.0/24`, router at `.1`, DHCP pool `.100-.199`. The `mgmt`
and `lan` VLANs are mandatory. Trust model: mgmt reaches everything; other
VLANs get router DNS/DHCP and (with `allowWan`) the internet, no inter-VLAN;
WAN nothing inbound; the admin mesh (`mesh.subnet`) gets SSH, metrics, iperf3
and the Omada UI.
## Usage from another clan
```nix
# flake.nix
inputs.cnx-network.url = "git+https://<host>/B4L/cnx-network-clan";
# clan.nix
inventory.instances.router = {
module = { name = "router"; input = "cnx-network"; };
roles.default.settings.mesh.subnet = "fd..::/88"; # your admin overlay
roles.default.machines.gw-1.settings = {
site = "ams";
siteId = 1;
wan.interface = "enp1s0";
wan.vlanId = 10; # or null for untagged PPPoE
trunkPorts = [ "enp2s0" ];
accessPorts.enp4s0 = "mgmt"; # untagged on-site recovery port
# stagingPort = "enp3s0"; # DHCP uplink into the old LAN until cutover
vlans = {
mgmt.id = 10;
lan.id = 20;
iot = { id = 40; allowWan = false; };
};
};
};
```
Then `clan vars generate gw-1` prompts for the PPPoE username/password.
### Wi-Fi access point
If the box has wireless cards, the router can be the site's AP. An SSID is
defined once and behaves like an untagged access port of its VLAN; radios
choose what to broadcast, so a dual-band card serves the same SSID twice:
```nix
wifi = {
enable = true;
countryCode = "TH";
networks = {
home.vlan = "lan"; # WPA3 with WPA2 fallback
things = { vlan = "iot"; security = "wpa2"; }; # legacy IoT
guest = { vlan = "guest"; isolateClients = true; };
};
radios = {
wlp5s0 = { band = "2g"; channel = 6; macAddress = "…"; networks = [ "home" "things" ]; };
wlp6s0 = { band = "5g"; channel = 36; networks = [ "home" ]; };
};
};
```
Passphrases are vars prompts (`wifi-<name>-passphrase`), asked once at `clan
vars generate`. A radio broadcasting more than one SSID needs its hardware
`macAddress`: hostapd wants a fixed BSSID per extra SSID, derived from it.
`security = "wpa3-transition"` (the default) offers SAE and WPA2-PSK-SHA256;
devices that only speak classic WPA2-PSK need `security = "wpa2"`.
### Internal proxy
`proxy.enable` serves `<name>.<site><siteId>.<proxy.domain>` under a wildcard
certificate obtained via RFC 2136 DNS-01 against `proxy.acme.nameserver`. The
gateway signs updates with TSIG key `acme_<hostname with _>`, whose secret is
the shared `dns-acme-<hostname>-secret` generator declared by this service.
The nameserver machine must declare the same generator so both sides hold one
secret — import `acme-secret.nix` from this directory with the gateway's name:
```nix
imports = [ (import "${inputs.cnx-network}/modules/clan/router/acme-secret.nix" "gw-1") ];
```
and load the key with an acl scoped to `_acme-challenge.<site><siteId>`.
The service does not open the WAN to anything; reach gateways over your mesh.
One instance per machine.
+22
View File
@@ -0,0 +1,22 @@
# Shared TSIG secret for a gateway's dedicated ACME key (function: machine
# name -> NixOS module). The acme_<machine> key lets that gateway — and only
# it — write _acme-challenge.<site><siteId> TXT records on the authoritative
# nameserver to obtain its internal wildcard cert via DNS-01 (proxy.nix).
#
# The router service declares it on the gateway automatically when
# proxy.enable is set. The nameserver machine must declare the very same
# generator so both sides share one secret:
# imports = [ (import <router-service>/acme-secret.nix "gw-cnx-1") ];
# and then load it into its DNS server as key acme_gw_cnx_1 (hmac-sha256)
# with an acl scoped to that gateway's _acme-challenge label.
machine:
{ pkgs, ... }:
{
clan.core.vars.generators."dns-acme-${machine}-secret" = {
share = true;
files."secret".secret = true;
runtimeInputs = [ pkgs.openssl ];
# 32 random bytes, base64 — a valid hmac-sha256 TSIG secret.
script = ''openssl rand -base64 32 | tr -d '\n' > "$out"/secret'';
};
}
+46
View File
@@ -0,0 +1,46 @@
# CrowdSec security engine + nftables bouncer: parses sshd auth attempts from
# the journal and bans offending source IPs at the firewall. Log-based (no
# inline DPI) so it costs the N300 next to nothing.
{ settings }:
{ ... }:
let
cfg = settings;
in
{
services.crowdsec = {
enable = true;
autoUpdateService = true;
hub.collections = [
"crowdsecurity/linux"
"crowdsecurity/sshd"
];
localConfig = {
acquisitions = [
{
source = "journalctl";
journalctl_filter = [ "_SYSTEMD_UNIT=sshd.service" ];
labels.type = "syslog";
}
];
# Never ban the ZeroTier mesh — it is the only admin path to these
# boxes (no public SSH), so a false positive would lock us out.
# Parser-stage whitelist: mesh events are dropped before any scenario.
parsers.s02Enrich = [
{
name = "cnx/mesh-whitelist";
description = "Whitelist the ZeroTier management mesh";
whitelist = {
reason = "ZeroTier mesh is the admin path";
cidr = [ cfg.mesh.subnet ];
};
}
];
};
};
services.crowdsec-firewall-bouncer = {
enable = true;
registerBouncer.enable = true;
settings.mode = "nftables";
};
}
+67
View File
@@ -0,0 +1,67 @@
# Site gateway (OPNsense replacement) for the Topton 1U boxes, as a clan
# service: PPPoE WAN, VLAN-filtering bridge over the LAN ports, per-VLAN
# DHCP/DNS, firewall/NAT, and the optional Omada controller / internal proxy.
#
# Exported as `clan.modules.router` of this flake; used here with
# `module.input = "self"` and from other clans with `module.input =
# "<this flake's input name>"`. README.md has the consumer view. A site joins
# through the inventory:
#
# inventory.instances.router = {
# module = { name = "router"; input = "self"; };
# roles.default.settings.mesh.subnet = ...; # fleet-wide
# roles.default.machines.gw-<city>-<n>.settings = { site = ...; ... };
# };
#
# The settings schema lives in interface.nix. Implementation files that need
# the settings are functions `{ settings }: <NixOS module>`; the evaluated
# settings are handed in with importApply so nothing goes through
# machine-level options. The rest are plain NixOS modules.
{ lib, ... }:
{
_class = "clan.service";
manifest.name = "router";
manifest.description = "Site gateway: PPPoE WAN, VLAN bridge, DHCP/DNS, firewall/NAT";
manifest.categories = [ "Network" ];
manifest.readme = builtins.readFile ./README.md;
roles.default = {
description = "Turns the machine into the site's router (one instance per machine).";
interface = ./interface.nix;
perInstance =
{ settings, machine, ... }:
{
nixosModule.imports = [
./ipv6.nix
]
# The proxy's TSIG secret is shared with the nameserver (acme-secret.nix).
++ lib.optional settings.proxy.enable (import ./acme-secret.nix machine.name)
++ map (file: lib.modules.importApply file { inherit settings; }) [
./network.nix
./pppoe.nix
./firewall.nix
./dns-dhcp.nix
./crowdsec.nix
./omada.nix
./proxy.nix
./iperf.nix
./speedtest.nix
./wifi.nix
];
};
};
# A machine has exactly one WAN port and one VLAN layout; two instances would
# both claim br0/ppp0 and fight over Kea/Blocky/nftables.
perMachine =
{ instances, machine, ... }:
{
nixosModule.assertions = [
{
assertion = lib.length (lib.attrNames instances) == 1;
message = "router: ${machine.name} is a gateway in several instances (${lib.concatStringsSep ", " (lib.attrNames instances)}); a machine can only be one router.";
}
];
};
}
+98
View File
@@ -0,0 +1,98 @@
# LAN DHCP (Kea) and DNS (Blocky). Fully declarative: one Kea subnet per VLAN
# with dhcp.enable, Blocky as the blocklist resolver every DHCP lease points
# at. Blocky's HTTP listener (:4000) serves Prometheus metrics, scraped by
# control over the mesh (firewall.nix scopes it to the mesh subnet).
{ settings }:
{ lib, ... }:
let
cfg = settings;
dhcpVlans = lib.filterAttrs (_: vlan: vlan.dhcp.enable) cfg.vlans;
in
{
services.kea.dhcp4 = {
enable = true;
settings = {
interfaces-config = {
interfaces = lib.mapAttrsToList (name: _: "vlan-${name}") dhcpVlans;
# The unit orders after network-online.target, which under networkd
# only waits for the WAN carrier (the vlan-* links are
# RequiredForOnline=no), so Kea can start before vlan-* have their
# addresses. By default it then logs the failed bind and runs with no
# socket at all: clients' DISCOVERs reach vlan-lan and nobody answers.
# Insist on every socket and keep retrying while networkd catches up;
# if it still cannot bind, exit and let systemd restart the unit.
service-sockets-require-all = true;
service-sockets-max-retries = 60;
service-sockets-retry-wait-time = 1000;
};
lease-database = {
type = "memfile";
persist = true;
name = "/var/lib/kea/dhcp4.leases";
};
valid-lifetime = 86400;
subnet4 = lib.mapAttrsToList (name: vlan: {
id = vlan.id;
subnet = vlan.subnet;
interface = "vlan-${name}";
valid-lifetime = vlan.dhcp.leaseTime;
pools = [ { pool = "${vlan.dhcp.pool.from} - ${vlan.dhcp.pool.to}"; } ];
reservations = lib.mapAttrsToList (host: res: {
hostname = host;
hw-address = res.hwAddress;
ip-address = res.ipAddress;
}) vlan.dhcp.reservations;
option-data = [
{
name = "routers";
data = vlan.address;
}
{
name = "domain-name-servers";
data = vlan.address;
}
];
}) dhcpVlans;
};
};
# The nixpkgs unit already has Restart=on-failure; space the restarts out so
# a persistent bind failure does not trip the start-rate limit.
systemd.services.kea-dhcp4-server.serviceConfig.RestartSec = 5;
services.blocky = {
enable = true;
settings = {
ports = {
dns = 53;
http = 4000;
};
upstreams.groups.default = [
"9.9.9.9"
"149.112.112.112"
"2620:fe::fe"
];
blocking = {
denylists.ads = [
"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts"
];
clientGroupsBlock.default = [ "ads" ];
};
caching = {
minTime = "5m";
prefetching = true;
};
prometheus.enable = true;
};
};
# The router itself resolves via public resolvers, not via Blocky, so DNS
# for deploys/updates survives a broken local resolver. networkd would
# enable systemd-resolved by default, whose stub listener on 127.0.0.53:53
# makes Blocky's wildcard :53 bind fail — plain resolv.conf instead.
services.resolved.enable = false;
networking.nameservers = [
"9.9.9.9"
"1.1.1.1"
];
}
+91
View File
@@ -0,0 +1,91 @@
# Router firewall/NAT policy (nftables). Trust model:
# mgmt VLAN -> trusted: router services, all VLANs, WAN
# other VLANs -> DNS/DHCP on the router + WAN (if allowWan); no inter-VLAN
# WAN (ppp0) -> nothing inbound beyond established/related
# mesh -> admin SSH + metrics scrapes (same trust boundary as the fleet)
# staging -> admin SSH only inbound (pre-cutover uplink into the old LAN);
# allowWan VLANs are NATed out through it while ppp0 is down
{ settings }:
{ lib, ... }:
let
cfg = settings;
vlanIfs = lib.mapAttrsToList (name: _: "vlan-${name}") cfg.vlans;
wanVlanIfs = lib.mapAttrsToList (name: _: "vlan-${name}") (
lib.filterAttrs (_: vlan: vlan.allowWan) cfg.vlans
);
nonMgmtIfs = lib.filter (i: i != "vlan-mgmt") vlanIfs;
# allowWan VLANs may also leave through the staging uplink. Same set as
# networking.nat.internalInterfaces below, so `allowWan` holds on both
# exits. The kernel picks the exit: ppp0 (metric 0, see pppoe.nix) while
# the session is up, the staging DHCP route (metric 1024) otherwise.
stagingExit = cfg.stagingPort != null && wanVlanIfs != [ ];
wanVlanSet = "{ ${lib.concatMapStringsSep ", " (i: ''"${i}"'') wanVlanIfs} }";
in
{
networking.nftables.enable = true;
# SSH reachable only from the mgmt VLAN (trusted) and the mesh — never
# from the WAN or the other VLANs.
services.openssh.openFirewall = false;
networking.firewall = {
enable = true;
filterForward = true;
trustedInterfaces = [ "vlan-mgmt" ];
# Non-mgmt VLANs may only talk to the router's DNS and DHCP; the staging
# uplink (old LAN, pre-cutover) gets admin SSH so the box can be reached
# before the mgmt VLAN or the mesh are up.
interfaces =
lib.genAttrs nonMgmtIfs (_: {
allowedTCPPorts = [ 53 ];
allowedUDPPorts = [
53
67
];
})
// lib.optionalAttrs (cfg.stagingPort != null) {
${cfg.stagingPort}.allowedTCPPorts = [ 22 ];
};
extraInputRules = ''
ip6 saddr ${cfg.mesh.subnet} tcp dport 22 accept comment "admin ssh over the mesh"
ip6 saddr ${cfg.mesh.subnet} tcp dport 4000 accept comment "blocky metrics scrape from control"
'';
extraForwardRules = ''
tcp flags syn tcp option maxseg size set rt mtu comment "MSS clamp for PPPoE mtu 1492"
iifname "vlan-mgmt" accept comment "mgmt reaches all VLANs and the WAN"
''
+ lib.optionalString stagingExit ''
iifname ${wanVlanSet} oifname "${cfg.stagingPort}" accept comment "allowWan VLANs out via the staging uplink"
'';
};
# networking.nat both masquerades and opens forward-to-WAN for exactly its
# internalInterfaces — so this list, not a rule of our own, is where
# `allowWan` is enforced. Listing every VLAN here would silently let
# allowWan = false VLANs out.
networking.nat = {
enable = true;
externalInterface = "ppp0";
internalInterfaces = wanVlanIfs;
};
# networking.nat only masquerades on its single externalInterface; the
# staging uplink needs its own postrouting chain (nixos-nat's is
# oifname-scoped to ppp0, so the two never both apply).
networking.nftables.tables = lib.optionalAttrs stagingExit {
router-staging-nat = {
family = "ip";
content = ''
chain post {
type nat hook postrouting priority srcnat;
iifname ${wanVlanSet} oifname "${cfg.stagingPort}" masquerade comment "allowWan VLANs out via the staging uplink"
}
'';
};
};
}
+380
View File
@@ -0,0 +1,380 @@
# Settings of the `router` service (inventory `roles.default.settings`).
# Pure schema: no machine config is reachable here; the implementation files
# get the evaluated result as `settings`.
#
# Fleet addressing convention: each site owns 10.<siteId>.0.0/16. A VLAN's
# subnet defaults to 10.<siteId>.<vlanId>.0/24 with the router at .1 and the
# DHCP pool at .100-.199. VLANs that need more space (e.g. public-wifi guest)
# override `subnet`/`address`/`dhcp.pool` and take a wider block from the
# upper half (10.<siteId>.128.0/17), e.g. guest -> 10.<siteId>.128.0/22.
# VLAN ids: 10 = mgmt, 20 = lan (mandatory); 30 = guest, 40 = iot (reserved).
{ config, lib, ... }:
let
site = toString config.siteId;
vlanModule =
{ config, ... }:
let
octet = toString config.id;
in
{
options = {
id = lib.mkOption {
type = lib.types.ints.between 1 4094;
description = "802.1Q VLAN id (fleet convention: 10 mgmt, 20 lan, 30 guest, 40 iot).";
};
address = lib.mkOption {
type = lib.types.str;
default = "10.${site}.${octet}.1";
defaultText = lib.literalExpression ''"10.<siteId>.<id>.1"'';
description = "Router address on this VLAN.";
};
prefixLength = lib.mkOption {
type = lib.types.ints.between 8 30;
default = 24;
};
subnet = lib.mkOption {
type = lib.types.str;
default = "10.${site}.${octet}.0/24";
defaultText = lib.literalExpression ''"10.<siteId>.<id>.0/24"'';
description = "The VLAN's network in CIDR form (must contain `address`).";
};
dhcp = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
};
pool = {
from = lib.mkOption {
type = lib.types.str;
default = "10.${site}.${octet}.100";
defaultText = lib.literalExpression ''"10.<siteId>.<id>.100"'';
};
to = lib.mkOption {
type = lib.types.str;
default = "10.${site}.${octet}.199";
defaultText = lib.literalExpression ''"10.<siteId>.<id>.199"'';
};
};
leaseTime = lib.mkOption {
type = lib.types.ints.positive;
default = 86400;
description = ''
Lease validity in seconds. Lower it for high-churn networks,
e.g. public-WiFi guest VLANs (3600-7200), so the pool recycles.
'';
};
reservations = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule {
options = {
hwAddress = lib.mkOption {
type = lib.types.str;
example = "aa:bb:cc:dd:ee:ff";
description = "Client MAC address.";
};
ipAddress = lib.mkOption {
type = lib.types.str;
description = "Fixed address handed to this client (inside the VLAN's subnet, outside the pool).";
};
};
}
);
default = { };
description = "Static DHCP leases; the attribute name becomes the client's hostname.";
};
};
allowWan = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether clients on this VLAN may reach the internet.";
};
};
};
proxyServiceModule = {
options = {
backend = lib.mkOption {
type = lib.types.str;
example = "https://127.0.0.1:8043";
description = "URL Caddy forwards to (internal/mesh address).";
};
insecureSkipVerify = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Skip TLS verification towards the backend (self-signed upstreams like Omada).";
};
};
};
wifiNetworkModule =
{ name, ... }:
{
options = {
ssid = lib.mkOption {
type = lib.types.str;
default = name;
defaultText = lib.literalExpression "<attribute name>";
description = "SSID as advertised; defaults to the attribute name.";
};
vlan = lib.mkOption {
type = lib.types.str;
example = "lan";
description = "VLAN (by name) the clients of this SSID land in, like an untagged access port.";
};
security = lib.mkOption {
type = lib.types.enum [
"wpa3"
"wpa3-transition"
"wpa2"
"open"
];
default = "wpa3-transition";
description = ''
- `wpa3`: WPA3-Personal (SAE) only.
- `wpa3-transition`: WPA3 with WPA2 fallback for older clients.
- `wpa2`: WPA2-PSK only, for legacy IoT devices.
- `open`: no encryption (captive/guest use; pair with an isolated VLAN).
Except for `open`, the passphrase is a vars prompt
(`wifi-<name>-passphrase`, entered at `clan vars generate`).
'';
};
hidden = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Do not advertise the SSID in beacons (clients must know it).";
};
isolateClients = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Keep wireless clients of this SSID from talking to each other (guest networks).";
};
};
};
wifiRadioModule = {
options = {
band = lib.mkOption {
type = lib.types.enum [
"2g"
"5g"
"6g"
];
default = "2g";
description = "Frequency band of this radio; a dual-band card exposes one radio interface per band.";
};
channel = lib.mkOption {
type = lib.types.ints.unsigned;
default = 0;
description = "Channel; 0 lets hostapd pick one (ACS) not every driver supports that.";
};
wifi6 = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enable 802.11ax (WiFi 6) on this radio; WiFi 4/5 are always on.";
};
macAddress = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "aa:bb:cc:dd:ee:ff";
description = ''
Hardware address of the radio (facter.json / `ip link`). Needed when
the radio serves more than one network: hostapd wants a fixed BSSID
per extra network, derived from this address (locally administered
variants of its first octet).
'';
};
networks = lib.mkOption {
type = lib.types.listOf lib.types.str;
example = [
"home"
"iot"
];
description = "Networks (from `wifi.networks`) this radio broadcasts; at most four per radio.";
};
};
};
in
{
options = {
site = lib.mkOption {
type = lib.types.str;
description = "City code of the site, e.g. \"cnx\".";
};
siteId = lib.mkOption {
type = lib.types.ints.between 1 254;
description = "Site number; drives the 10.<siteId>.<vlan>.0/24 addressing.";
};
wan.interface = lib.mkOption {
type = lib.types.str;
description = "Physical WAN port the PPPoE session runs on.";
};
wan.vlanId = lib.mkOption {
type = lib.types.nullOr (lib.types.ints.between 1 4094);
default = null;
description = ''
802.1Q tag the ISP requires for the PPPoE session (AIS Thailand: 10);
null for untagged PPPoE directly on the port. Unrelated to the LAN
VLANs this tag exists only on the WAN port.
'';
};
wan.macAddress = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "aa:bb:cc:dd:ee:ff";
description = ''
Spoofed MAC for the WAN port, e.g. to keep the MAC the ISP has
pinned (cloned from the old router). null keeps the hardware MAC.
'';
};
trunkPorts = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "LAN ports carrying all VLANs tagged (incl. any 10G SFP+ ports).";
};
accessPorts = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = { };
example = {
enp4s0 = "mgmt";
};
description = ''
Ports acting as untagged access ports on a single VLAN (port name ->
VLAN name). Frames are untagged on the wire; the bridge tags them with
the VLAN's PVID. Use for an always-available on-site mgmt port.
'';
};
stagingPort = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "enp3s0";
description = ''
Temporary DHCPv4-client uplink into the existing LAN while the box
runs alongside the router it replaces: gives it (and, NATed, the
allowWan VLANs) internet + mesh before the WAN port is cabled; once
the PPPoE session is up its default route wins, and the staging
route only carries traffic again if the session drops (PPPoE simply
retries until then). The port is in no VLAN zone; inbound, the
firewall admits only SSH on it. Do NOT connect the trunk ports to
the production switch while staging Kea on the mgmt tag would
fight the old router's DHCP in one broadcast domain. Set to null at
cutover (and usually hand the port back to `trunkPorts`).
'';
};
vlans = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule vlanModule);
description = "VLANs served at this site; `mgmt` and `lan` are mandatory.";
};
mesh.subnet = lib.mkOption {
type = lib.types.str;
example = "fd12:3456:789a:bcde:f000::/88";
description = ''
IPv6 prefix of the admin mesh (the ZeroTier overlay; see
modules/mesh-hosts.nix). Admin SSH, metrics scrapes, iperf3 and the
Omada UI accept connections from it, and CrowdSec never bans it.
'';
};
omada.enable = lib.mkEnableOption "TP-Link Omada SDN controller (podman container)";
proxy = {
enable = lib.mkEnableOption "internal reverse proxy (Caddy, wildcard cert via DNS-01)";
domain = lib.mkOption {
type = lib.types.str;
example = "example.net";
description = ''
Parent zone of the proxy names: services are served as
<name>.<site><siteId>.<domain> under a wildcard certificate.
'';
};
acme = {
nameserver = lib.mkOption {
type = lib.types.str;
example = "203.0.113.53";
description = ''
Authoritative nameserver of `domain` that accepts RFC 2136
updates for _acme-challenge.<site><siteId> with this gateway's
TSIG key (acme_<hostname with _>, secret from the shared
dns-acme-<hostname>-secret generator, see acme-secret.nix).
'';
};
email = lib.mkOption {
type = lib.types.str;
example = "postmaster@example.net";
description = "ACME account contact.";
};
};
services = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule proxyServiceModule);
default = { };
description = "Proxied services; attr name becomes <name>.<site><siteId>.<domain>.";
};
allowVlans = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [
"mgmt"
"lan"
];
description = "VLANs whose clients may reach the proxy (443, plus 80 for the redirect).";
};
};
wifi = {
enable = lib.mkEnableOption "a Wi-Fi access point on the router's own radios (hostapd)";
countryCode = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "TH";
description = "ISO 3166-1 country code for the regulatory domain; required when enabled.";
};
networks = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule wifiNetworkModule);
default = { };
example = {
home.vlan = "lan";
things = {
vlan = "iot";
security = "wpa2";
};
};
description = ''
Wireless networks (SSIDs). Each one behaves like an untagged access
port on its VLAN; the radios below choose which to broadcast.
'';
};
radios = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule wifiRadioModule);
default = { };
example = {
wlp5s0 = {
band = "5g";
channel = 36;
networks = [ "home" ];
};
};
description = "Wireless radios of the router (interface name -> config); at least one when enabled.";
};
};
speedtest.interval = lib.mkOption {
type = lib.types.str;
default = "hourly";
description = "systemd OnCalendar spec for the WAN speed test.";
};
};
}
+22
View File
@@ -0,0 +1,22 @@
# iperf3 server on every gateway, for throughput testing from any LAN segment
# (e.g. validating AP/switch links: `iperf3 -c 10.<siteId>.<vlan>.1`) and from
# admin machines over the mesh. Never reachable from the WAN (default-deny).
{ settings }:
{ lib, ... }:
let
cfg = settings;
vlanIfs = lib.mapAttrsToList (name: _: "vlan-${name}") cfg.vlans;
in
{
services.iperf3.enable = true;
networking.firewall.interfaces = lib.genAttrs vlanIfs (_: {
allowedTCPPorts = [ 5201 ];
allowedUDPPorts = [ 5201 ];
});
networking.firewall.extraInputRules = ''
ip6 saddr ${cfg.mesh.subnet} tcp dport 5201 accept comment "iperf3 over the mesh"
ip6 saddr ${cfg.mesh.subnet} udp dport 5201 accept comment "iperf3 over the mesh"
'';
}
+22
View File
@@ -0,0 +1,22 @@
# IPv6 on the PPPoE uplink: run networkd's DHCPv6 client on ppp0 to obtain a
# delegated prefix; each vlan-* interface (network.nix) carves a /64 out of it
# via DHCPPrefixDelegation and announces it to clients with SLAAC.
{ lib, ... }:
{
systemd.network.networks."45-ppp0" = {
matchConfig.Name = "ppp0";
networkConfig = {
DHCP = "ipv6";
# pppd owns the v4 address/route on this link; don't let networkd
# tear them down.
KeepConfiguration = "static";
# Default v6 route comes from the ISP's RA when they send one.
IPv6AcceptRA = true;
};
# Many PPPoE ISPs never send an RA with the M flag; solicit regardless.
dhcpV6Config.WithoutRA = "solicit";
linkConfig.RequiredForOnline = "no";
};
boot.kernel.sysctl."net.ipv6.conf.all.forwarding" = lib.mkDefault 1;
}
+170
View File
@@ -0,0 +1,170 @@
# L2/L3 of the gateway: PPPoE WAN port (optionally on an ISP VLAN), a
# VLAN-filtering bridge over the LAN ports, and one L3 interface per VLAN.
{ settings }:
{ lib, pkgs, ... }:
let
cfg = settings;
vlanIf = name: "vlan-${name}";
in
{
assertions = [
{
assertion = cfg.vlans ? mgmt && cfg.vlans ? lan;
message = "router: every site must define the `mgmt` and `lan` VLANs.";
}
{
assertion = lib.all (v: cfg.vlans ? ${v}) (lib.attrValues cfg.accessPorts);
message = "router: every accessPorts value must name a defined VLAN.";
}
{
assertion = lib.all (p: !(cfg.accessPorts ? ${p})) cfg.trunkPorts;
message = "router: a port cannot be both a trunk and an access port.";
}
{
assertion =
cfg.stagingPort == null
|| !(
cfg.stagingPort == cfg.wan.interface
|| lib.elem cfg.stagingPort cfg.trunkPorts
|| cfg.accessPorts ? ${cfg.stagingPort}
);
message = "router: stagingPort ${toString cfg.stagingPort} is also the WAN, a trunk or an access port.";
}
];
# Router diagnostics toolkit: packets (tcpdump), path (mtr), link
# negotiation (ethtool), NAT state (conntrack), DNS (kdig), per-flow
# bandwidth (iftop), WAN throughput (librespeed-cli; iperf3 covers LAN).
environment.systemPackages = with pkgs; [
tcpdump
mtr
ethtool
conntrack-tools
knot-dns
iftop
librespeed-cli
];
networking.useNetworkd = true;
networking.useDHCP = false;
systemd.network.enable = true;
systemd.network.netdevs = {
"20-br0" = {
netdevConfig = {
Name = "br0";
Kind = "bridge";
};
bridgeConfig.VLANFiltering = true;
};
}
// lib.optionalAttrs (cfg.wan.vlanId != null) {
"15-wan-vlan" = {
netdevConfig = {
Name = "wan-vlan";
Kind = "vlan";
};
vlanConfig.Id = cfg.wan.vlanId;
};
}
// lib.mapAttrs' (
name: vlan:
lib.nameValuePair "30-${vlanIf name}" {
netdevConfig = {
Name = vlanIf name;
Kind = "vlan";
};
vlanConfig.Id = vlan.id;
}
) cfg.vlans;
systemd.network.networks =
let
taggedAll = lib.mapAttrsToList (_: vlan: { VLAN = vlan.id; }) cfg.vlans;
in
lib.optionalAttrs (cfg.stagingPort != null) {
# Staging uplink (see interface.nix): plain DHCPv4 client on a spare
# port, no bridge/VLAN membership, so the firewall treats it as untrusted.
"05-staging" = {
matchConfig.Name = cfg.stagingPort;
networkConfig.DHCP = "ipv4";
};
}
// {
# WAN port carries only the PPPoE session; no IP config of its own.
"10-wan" = {
matchConfig.Name = cfg.wan.interface;
networkConfig.LinkLocalAddressing = "no";
vlan = lib.optional (cfg.wan.vlanId != null) "wan-vlan";
linkConfig = {
RequiredForOnline = "carrier";
}
# The wan-vlan subinterface (and thus the PPPoE session) inherits
# the parent port's MAC, so spoofing here covers both cases.
// lib.optionalAttrs (cfg.wan.macAddress != null) {
MACAddress = cfg.wan.macAddress;
};
};
}
// lib.optionalAttrs (cfg.wan.vlanId != null) {
# The ISP-side VLAN subinterface pppd dials on (e.g. AIS tags PPPoE).
"15-wan-vlan" = {
matchConfig.Name = "wan-vlan";
networkConfig.LinkLocalAddressing = "no";
linkConfig.RequiredForOnline = "no";
};
}
// {
# The bridge itself is L2-only; L3 lives on the vlan-* interfaces,
# which hang off the bridge (tagged on the bridge "self" port).
"20-br0" = {
matchConfig.Name = "br0";
networkConfig.LinkLocalAddressing = "no";
vlan = lib.mapAttrsToList (name: _: vlanIf name) cfg.vlans;
bridgeVLANs = taggedAll;
linkConfig.RequiredForOnline = "no";
};
}
// lib.listToAttrs (
map (port: {
name = "25-trunk-${port}";
value = {
matchConfig.Name = port;
networkConfig.Bridge = "br0";
bridgeVLANs = taggedAll;
linkConfig.RequiredForOnline = "no";
};
}) cfg.trunkPorts
)
// lib.mapAttrs' (
port: vlanName:
lib.nameValuePair "25-access-${port}" {
matchConfig.Name = port;
networkConfig.Bridge = "br0";
bridgeVLANs = [
{
VLAN = cfg.vlans.${vlanName}.id;
PVID = cfg.vlans.${vlanName}.id;
EgressUntagged = cfg.vlans.${vlanName}.id;
}
];
linkConfig.RequiredForOnline = "no";
}
) cfg.accessPorts
// lib.mapAttrs' (
name: vlan:
lib.nameValuePair "40-${vlanIf name}" {
matchConfig.Name = vlanIf name;
address = [ "${vlan.address}/${toString vlan.prefixLength}" ];
networkConfig = {
IPv6AcceptRA = false;
# Announce a /64 carved from the DHCPv6-PD prefix on ppp0 (SLAAC).
IPv6SendRA = true;
DHCPPrefixDelegation = true;
};
dhcpPrefixDelegationConfig.SubnetId = "auto";
linkConfig.RequiredForOnline = "no";
}
) cfg.vlans;
}
+36
View File
@@ -0,0 +1,36 @@
# TP-Link Omada SDN controller for sites with Omada APs/switches. There is no
# nixpkgs package, so it runs as a podman container (mbentley/omada-controller,
# the de-facto standard image). Host networking because device adoption relies
# on L2 broadcast discovery (UDP 29810) on the mgmt VLAN; the default-deny
# input firewall keeps its ports unreachable from WAN and non-mgmt VLANs.
{ settings }:
{ config, lib, ... }:
let
cfg = settings;
in
{
config = lib.mkIf cfg.omada.enable {
virtualisation.podman.enable = true;
virtualisation.oci-containers = {
backend = "podman";
containers.omada = {
image = "docker.io/mbentley/omada-controller:5.15";
extraOptions = [ "--network=host" ];
environment.TZ = config.time.timeZone;
volumes = [
"/var/lib/omada/data:/opt/tplink/EAPController/data"
"/var/lib/omada/logs:/opt/tplink/EAPController/logs"
];
};
};
# Admin UI (8043) also reachable over the mesh, like Grafana on control.
networking.firewall.extraInputRules = ''
ip6 saddr ${cfg.mesh.subnet} tcp dport 8043 accept comment "omada ui over the mesh"
'';
# Controller state (adopted devices, site config, cert) — declared as clan
# state so a borgbackup client can pick it up; backup wiring is a later step.
clan.core.state.omada.folders = [ "/var/lib/omada" ];
};
}
+67
View File
@@ -0,0 +1,67 @@
# PPPoE WAN session. ISP credentials are entered once at `clan vars generate`
# (prompts). Both are secret — AIS often uses the same string for username and
# password — so neither may land in the Nix store: pppd reads the username from
# an included secret options file and the password from chap/pap-secrets.
{ settings }:
{ config, ... }:
let
cfg = settings;
creds = config.clan.core.vars.generators.pppoe-credentials;
# Interface pppd dials on: the WAN port itself, or its ISP VLAN (network.nix).
pppInterface = if cfg.wan.vlanId == null then cfg.wan.interface else "wan-vlan";
in
{
clan.core.vars.generators.pppoe-credentials = {
prompts.username = {
description = "PPPoE username (from the ISP)";
type = "hidden";
};
prompts.password = {
description = "PPPoE password (from the ISP)";
type = "hidden";
};
files."user-opts".secret = true;
files."chap-secrets".secret = true;
script = ''
user="$(cat "$prompts"/username)"
pass="$(cat "$prompts"/password)"
printf 'user "%s"\n' "$user" > "$out"/user-opts
printf '"%s" * "%s"\n' "$user" "$pass" > "$out"/chap-secrets
'';
};
# defaultroute-metric 0: pppd refuses `defaultroute` while any other
# default route exists (e.g. the staging uplink's DHCP route, metric 1024,
# network.nix) unless given a metric; with 0 it only checks for a metric-0
# route, installs its own as the preferred exit, and removes it again on
# hangup so the staging route takes over.
services.pppd = {
enable = true;
peers.wan = {
autostart = true;
config = ''
plugin pppoe.so ${pppInterface}
ifname ppp0
file ${creds.files."user-opts".path}
noipdefault
defaultroute
defaultroute-metric 0
noauth
hide-password
persist
maxfail 0
holdoff 5
lcp-echo-interval 15
lcp-echo-failure 3
+ipv6
mtu 1492
mru 1492
'';
};
};
# pppd looks up the password for `user` in these files at dial time; both
# point at the same generated `"<user>" * "<pass>"` line (PAP and CHAP).
environment.etc."ppp/chap-secrets".source = creds.files."chap-secrets".path;
environment.etc."ppp/pap-secrets".source = creds.files."chap-secrets".path;
}
+104
View File
@@ -0,0 +1,104 @@
# Internal reverse proxy for the gateway: Caddy terminates TLS for
# <service>.<site><siteId>.<proxy.domain> (e.g. omada.cnx1.cnx.network) and
# forwards to backends by their internal address. The cert is a real Let's
# Encrypt wildcard (*.<site><siteId>.<domain>) obtained via ACME DNS-01
# against proxy.acme.nameserver with a gateway-scoped TSIG key, so browsers
# trust it without any CA install; the names only *resolve* internally —
# Blocky answers them with the router's LAN address, the public zone never
# carries them.
#
# The TSIG secret is the shared dns-acme-<hostname>-secret generator
# (acme-secret.nix, declared here via default.nix); the nameserver machine
# must declare the same generator and load the key (this fleet: ns1).
{ settings }:
{ config, lib, ... }:
let
cfg = settings;
hostname = config.networking.hostName;
tsigKey = "acme_${lib.replaceStrings [ "-" ] [ "_" ] hostname}";
certName = "${cfg.site}${toString cfg.siteId}.${cfg.proxy.domain}";
nameserver = "${cfg.proxy.acme.nameserver}:53";
in
{
config = lib.mkIf cfg.proxy.enable {
assertions = [
{
assertion = lib.all (v: cfg.vlans ? ${v}) cfg.proxy.allowVlans;
message = "router: proxy.allowVlans must name VLANs defined in vlans.";
}
];
# Render the shared per-gateway TSIG secret into a lego rfc2136 env file,
# scoped on the nameserver to _acme-challenge.<site><siteId> TXT records.
clan.core.vars.generators."dns-acme-${hostname}-rfc2136" = {
files."rfc2136.env".secret = true; # root-owned; systemd reads it as root
dependencies = [ "dns-acme-${hostname}-secret" ];
script = ''
printf 'RFC2136_NAMESERVER=${nameserver}\nRFC2136_TSIG_ALGORITHM=hmac-sha256.\nRFC2136_TSIG_KEY=${tsigKey}\nRFC2136_TSIG_SECRET=%s\n' \
"$(cat "$in"/dns-acme-${hostname}-secret/secret)" > "$out"/rfc2136.env
'';
};
security.acme = {
acceptTerms = true;
defaults.email = cfg.proxy.acme.email;
# One wildcard for every proxied service; DNS-01, so issuance works
# behind PPPoE with no inbound reachability at all.
certs.${certName} = {
domain = "*.${certName}";
dnsProvider = "rfc2136";
environmentFile =
config.clan.core.vars.generators."dns-acme-${hostname}-rfc2136".files."rfc2136.env".path;
# Only that nameserver accepts this key's UPDATE; check propagation
# against it directly rather than a public resolver.
dnsResolver = nameserver;
# Caddy reads the cert from explicit file paths (tls directive below),
# so it won't notice a renewal on its own.
reloadServices = [ "caddy.service" ];
};
};
# The lego-issued cert is owned group=acme; Caddy needs to read the key.
users.users.caddy.extraGroups = [ "acme" ];
# The explicit `tls cert key` points Caddy at the wildcard cert and disables
# its automatic ACME, so no extra issuance happens.
services.caddy = {
enable = true;
virtualHosts = lib.mapAttrs' (
name: svc:
lib.nameValuePair "${name}.${certName}" {
extraConfig = ''
tls /var/lib/acme/${certName}/cert.pem /var/lib/acme/${certName}/key.pem
${
if svc.insecureSkipVerify then
''
reverse_proxy ${svc.backend} {
transport http {
tls_insecure_skip_verify
}
}''
else
"reverse_proxy ${svc.backend}"
}
'';
}
) cfg.proxy.services;
};
# Blocky answers <anything>.<site><siteId>.cnx.network (customDNS covers
# subdomains) with the router's LAN address — clients on any allowed VLAN
# reach that address through the router's input path.
services.blocky.settings.customDNS.mapping.${certName} = cfg.vlans.lan.address;
# 443 serves the proxy; 80 only carries Caddy's automatic HTTP->HTTPS
# redirect. mgmt is already a trusted interface; listed anyway so shrinking
# trustedInterfaces later doesn't silently break the proxy.
networking.firewall.interfaces = lib.genAttrs (map (v: "vlan-${v}") cfg.proxy.allowVlans) (_: {
allowedTCPPorts = [
80
443
];
});
};
}
+55
View File
@@ -0,0 +1,55 @@
# Periodic WAN speed test so ISP degradation shows up as a trend instead of a
# complaint. A timer runs librespeed-cli and writes the results as Prometheus
# metrics into node_exporter's textfile collector — they ride the existing
# 9100 scrape to VictoriaMetrics, where alerts.nix compares each run against
# the link's own 7-day median (no per-site threshold to maintain).
{ settings }:
{ pkgs, ... }:
let
cfg = settings;
textfileDir = "/var/lib/speedtest";
in
{
services.prometheus.exporters.node.extraFlags = [
"--collector.textfile.directory=${textfileDir}"
];
systemd.services.speedtest = {
description = "WAN speed test to Prometheus textfile metrics";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
path = [
pkgs.librespeed-cli
pkgs.jq
];
serviceConfig = {
Type = "oneshot";
StateDirectory = "speedtest";
# One test at boot would race PPPoE and log a spurious failure.
ExecCondition = "${pkgs.iproute2}/bin/ip link show ppp0";
};
script = ''
tmp="${textfileDir}/.speedtest.prom.tmp"
if result=$(librespeed-cli --json); then
jq -r '.[0]
| "speedtest_download_mbps \(.download)",
"speedtest_upload_mbps \(.upload)",
"speedtest_ping_ms \(.ping)",
"speedtest_jitter_ms \(.jitter)",
"speedtest_success 1"' <<<"$result" > "$tmp"
else
echo "speedtest_success 0" > "$tmp"
fi
mv "$tmp" "${textfileDir}/speedtest.prom"
'';
};
systemd.timers.speedtest = {
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = cfg.speedtest.interval;
RandomizedDelaySec = "10m";
Persistent = true;
};
};
}
+361
View File
@@ -0,0 +1,361 @@
# End-to-end VM test of the router service: a PPPoE access concentrator plays
# the ISP on the WAN port, a trunk carries tagged lan/iot VLANs to `client`,
# an untagged access port carries mgmt to `admin`, and `oldlan` is the DHCP
# network the box is staged in before cutover.
#
# isp ---(vlan 1: PPPoE)--- wan [gw] trunk ---(vlan 2: tagged 20/40)--- client
# access --(vlan 3: untagged mgmt)--- admin
# staging -(vlan 4: DHCP client)--- oldlan
#
# What is proven: PPPoE dial-in with the vars-provided credentials, bridge
# VLAN tagging/untagging, Kea leases and reservations per VLAN, Blocky
# answering on the VLAN with the blocklist active, NAT to the WAN, the
# firewall trust model (allowWan, mgmt-only SSH, no inter-VLAN forwarding),
# and the staging uplink as NATed fallback exit behind ppp0.
{ pkgs, lib, ... }:
let
# The vars mock answers every prompt with "mock-prompt-value-<name>"; the
# ISP side must accept exactly those.
chapSecrets = ''"mock-prompt-value-username" * "mock-prompt-value-password" *'';
ispAddress = "192.0.2.1";
clientMac = "02:00:00:00:00:20";
clientAddress = "10.9.20.50";
adminMac = "02:00:00:00:00:10";
adminAddress = "10.9.10.50";
oldlanAddress = "192.168.88.1";
# Only reachable through oldlan's router role, i.e. via gw's staging
# default route (metric 1024); ppp0's metric-0 default must win while up.
beyondStaging = "203.0.113.1";
in
{
name = "router";
clan = {
directory = ./.;
# Bridges, VLAN netdevs, PPPoE and nftables need a real kernel.
test.useContainers = false;
inventory = {
# Every node is a clan machine (the test framework's defaults require
# it); only gw gets the router role.
machines = {
gw = { };
isp = { };
client = { };
admin = { };
oldlan = { };
};
instances.router = {
module.name = "router";
module.input = "self";
roles.default.machines.gw.settings = {
site = "tst";
siteId = 9;
mesh.subnet = "fd00:7e57:c1a1:c0de::/64";
wan.interface = "wan";
trunkPorts = [ "trunk" ];
accessPorts.access = "mgmt";
stagingPort = "staging";
vlans = {
mgmt = {
id = 10;
dhcp.reservations.admin = {
hwAddress = adminMac;
ipAddress = adminAddress;
};
};
lan = {
id = 20;
dhcp.reservations.client = {
hwAddress = clientMac;
ipAddress = clientAddress;
};
};
iot = {
id = 40;
allowWan = false;
};
};
# Access point on the simulated radio wlan0 (mac80211_hwsim, address
# 02:00:00:00:00:00): two SSIDs, one per VLAN.
wifi = {
enable = true;
countryCode = "US";
networks = {
home.vlan = "lan";
things = {
vlan = "iot";
security = "wpa2";
hidden = true;
isolateClients = true;
};
};
radios.wlan0 = {
channel = 6;
macAddress = "02:00:00:00:00:00";
networks = [
"home"
"things"
];
};
};
};
};
};
};
nodes = {
gw = {
virtualisation.interfaces = {
wan = {
vlan = 1;
assignIP = false;
};
trunk = {
vlan = 2;
assignIP = false;
};
access = {
vlan = 3;
assignIP = false;
};
staging = {
vlan = 4;
assignIP = false;
};
};
# Something must listen on 22 for the mgmt-only SSH rule to be observable
# (a refused and a dropped connection look the same to the client).
services.openssh.enable = true;
# The sandbox has no internet: serve the blocklist from a local file
# instead of GitHub, and skip CrowdSec, whose hub sync needs the network
# (it is not what this test exercises).
services.blocky.settings.blocking.denylists.ads = lib.mkForce [
(toString (pkgs.writeText "ads.hosts" "0.0.0.0 ads.example.com\n"))
];
services.crowdsec.enable = lib.mkForce false;
services.crowdsec-firewall-bouncer.enable = lib.mkForce false;
# Two simulated radios: wlan0 is the AP (settings above), wlan1 plays a
# wireless client. It lives in its own network namespace, like the
# separate host it stands in for — otherwise its lease would add a
# second 10.9.20.0/24 route to the router's own table. Its DHCP lease
# must come from Kea on the SSID's VLAN: wlan1 -> air -> wlan0 -> br0 ->
# vlan-lan. The mock passphrase is what the vars mock fed into the
# wifi-home-passphrase generator.
boot.kernelModules = [ "mac80211_hwsim" ];
systemd.services.wifi-station = {
wantedBy = [ "multi-user.target" ];
# No BindsTo: the device unit vanishes once wlan1 moves into the netns.
after = [ "sys-subsystem-net-devices-wlan1.device" ];
path = [
pkgs.iproute2
pkgs.iw
];
preStart = ''
ip netns add sta
iw phy phy1 set netns name sta
ip netns exec sta ip link set lo up
mkdir -p /run/wpa_supplicant/client # nixpkgs' wpa_cli keeps its sockets here
'';
serviceConfig.ExecStart =
"${pkgs.iproute2}/bin/ip netns exec sta ${pkgs.wpa_supplicant}/bin/wpa_supplicant -i wlan1 -c "
+ pkgs.writeText "sta.conf" ''
ctrl_interface=/run/wpa_supplicant/control
network={
ssid="home"
key_mgmt=SAE
sae_password="mock-prompt-value-passphrase"
ieee80211w=2
scan_freq=2437
}
'';
};
environment.systemPackages = [
pkgs.wpa_supplicant
(pkgs.writeShellScriptBin "sta-dhcp" ''
# One DHCP round on the station, applying the offered address.
exec ${pkgs.iproute2}/bin/ip netns exec sta ${pkgs.busybox}/bin/udhcpc -i wlan1 -n -q -f \
-s ${pkgs.writeShellScript "udhcpc-apply" ''
case "$1" in
bound|renew) ${pkgs.iproute2}/bin/ip addr replace "$ip/$mask" dev "$interface" ;;
esac
''}
'')
];
};
isp = {
virtualisation.interfaces.wan = {
vlan = 1;
assignIP = false;
};
# PPPoE access concentrator: one session, peer gets 192.0.2.10.
systemd.services.pppoe-server = {
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
serviceConfig.ExecStart =
"${pkgs.rp-pppoe}/sbin/pppoe-server -F -O /etc/ppp/pppoe-server-options"
+ " -q ${pkgs.ppp}/sbin/pppd -I wan -L ${ispAddress} -R 192.0.2.10";
};
environment.etc = {
"ppp/pppoe-server-options".text = ''
plugin pppoe.so
require-chap
lcp-echo-interval 10
lcp-echo-failure 2
nobsdcomp
noccp
novj
'';
"ppp/chap-secrets" = {
text = chapSecrets;
mode = "0640";
};
};
};
client = {
virtualisation.interfaces.trunk = {
vlan = 2;
assignIP = false;
};
networking.useDHCP = false;
networking.vlans = {
lan0 = {
id = 20;
interface = "trunk";
};
iot0 = {
id = 40;
interface = "trunk";
};
};
networking.interfaces.lan0 = {
useDHCP = true;
macAddress = clientMac;
};
networking.interfaces.iot0.useDHCP = true;
# Dual-homed on purpose (lan + iot). Both leases bring a default route:
# prefer lan0 so replies to other VLANs and the WAN leave where the
# router expects them (its rp-filter would drop them on vlan-iot), and
# loosen this client's own rp-filter so a WAN reply on iot0 would be
# seen — the negative allowWan check must fail at the router, not here.
systemd.network.networks."40-iot0".dhcpV4Config.RouteMetric = 2048;
networking.firewall.checkReversePath = "loose";
environment.systemPackages = [
pkgs.dnsutils
pkgs.netcat
];
};
admin = {
virtualisation.interfaces.access = {
vlan = 3;
assignIP = false;
};
networking.useDHCP = false;
networking.interfaces.access = {
useDHCP = true;
macAddress = adminMac;
};
environment.systemPackages = [ pkgs.netcat ];
};
# The LAN the box is staged in: a DHCP server handing gw its uplink
# lease, plus an address that is only reachable via that uplink's
# default route. No route back to 10.9.0.0/16: replies only reach the
# clients if gw masquerades them.
oldlan = {
virtualisation.interfaces.staging = {
vlan = 4;
assignIP = false;
};
networking.useDHCP = false;
networking.useNetworkd = true;
systemd.network.networks."10-staging" = {
matchConfig.Name = "staging";
address = [
"${oldlanAddress}/24"
"${beyondStaging}/32"
];
networkConfig.DHCPServer = true;
dhcpServerConfig = {
PoolOffset = 100;
PoolSize = 50;
};
};
networking.firewall.allowedUDPPorts = [ 67 ];
};
};
testScript = ''
start_all()
with subtest("PPPoE session comes up with the vars credentials"):
gw.wait_for_unit("pppd-wan.service")
gw.wait_until_succeeds("ping -c1 -W1 ${ispAddress}")
with subtest("DHCP hands out reserved leases per VLAN"):
gw.wait_for_unit("kea-dhcp4-server.service")
client.wait_until_succeeds("ip -4 addr show lan0 | grep -q 'inet ${clientAddress}/24'")
client.wait_until_succeeds("ip -4 addr show iot0 | grep -q 'inet 10.9.40.1[0-9][0-9]/24'")
admin.wait_until_succeeds("ip -4 addr show access | grep -q 'inet ${adminAddress}/24'")
with subtest("Blocky serves the VLAN and blocks the denylist"):
gw.wait_for_unit("blocky.service")
answer = client.wait_until_succeeds("dig +short +time=2 @10.9.20.1 ads.example.com")
assert answer.strip() == "0.0.0.0", f"expected blocked answer, got: {answer!r}"
with subtest("NAT to the WAN only for VLANs with allowWan"):
client.wait_until_succeeds("ping -c1 -W1 -I lan0 ${ispAddress}")
# iot has a route to the WAN; the router is what refuses to forward.
client.succeed("ip route show dev iot0 | grep -q ^default")
client.fail("ping -c1 -W2 -I iot0 ${ispAddress}")
with subtest("mgmt reaches other VLANs, other VLANs do not"):
admin.succeed("ping -c1 -W2 ${clientAddress}")
client.fail("ping -c1 -W2 -I lan0 ${adminAddress}")
with subtest("SSH on the router only from mgmt"):
gw.wait_for_open_port(22)
admin.succeed("nc -z -w2 10.9.10.1 22")
client.fail("nc -z -w2 10.9.20.1 22")
with subtest("Wireless SSIDs are bridge access ports of their VLAN"):
gw.wait_for_unit("hostapd.service")
# Second SSID: derived BSSID, hidden, bridged into the iot VLAN.
gw.wait_until_succeeds("ip link show wlan0-1 | grep -q '06:00:00:00:00:00'")
gw.wait_until_succeeds("bridge vlan show dev wlan0-1 | grep -q '40 PVID Egress Untagged'")
gw.succeed("hostapd_cli -i wlan0-1 get_config | grep -q '^ssid=things'")
# The second BSS section of the rendered config carries hidden + isolation.
things = gw.succeed("sed -n '/^bss=wlan0-1/,$p' /run/hostapd/wlan0.hostapd.conf")
assert "ignore_broadcast_ssid=1" in things and "ap_isolate=1" in things, things
assert "sae_password=mock-prompt-value-passphrase" not in things, "wpa2 SSID must not carry SAE entries"
# A WPA3 (SAE) station on the first SSID authenticates with the vars
# passphrase and gets its lease from Kea on the lan VLAN, through the bridge.
gw.wait_for_unit("wifi-station.service")
gw.wait_until_succeeds("ip netns exec sta wpa_cli -i wlan1 status | grep -q wpa_state=COMPLETED")
gw.succeed("timeout 60 sta-dhcp")
gw.succeed("ip netns exec sta ip -4 addr show wlan1 | grep -q 'inet 10.9.20.1[0-9][0-9]/24'")
with subtest("Staging uplink: NATed exit for allowWan VLANs, behind ppp0 while it is up"):
gw.wait_until_succeeds("ip -4 route show default dev staging | grep -q 'via ${oldlanAddress}'")
# pppd installs its default route despite the DHCP one (defaultroute-metric 0).
gw.succeed("ip route get ${beyondStaging} | grep -q 'dev ppp0'")
# On-link old-LAN hosts are reached through the staging port regardless.
client.succeed("ping -c1 -W2 -I lan0 ${oldlanAddress}")
client.fail("ping -c1 -W2 -I iot0 ${oldlanAddress}")
# ppp0 down: the staging route carries the WAN traffic, allowWan still holds.
gw.systemctl("stop pppd-wan.service")
gw.wait_until_succeeds("ip route get ${beyondStaging} | grep -q 'dev staging'")
client.succeed("ping -c1 -W2 -I lan0 ${beyondStaging}")
client.fail("ping -c1 -W2 -I iot0 ${beyondStaging}")
# ppp0 back: preferred again.
gw.systemctl("start pppd-wan.service")
gw.wait_until_succeeds("ip route get ${beyondStaging} | grep -q 'dev ppp0'")
'';
}
+207
View File
@@ -0,0 +1,207 @@
# Wireless access point on the router itself (hostapd). Every SSID is a BSS
# interface that joins the VLAN bridge as an untagged access port of its VLAN,
# so wireless clients get exactly the same DHCP/DNS/firewall treatment as a
# wired port in that VLAN. Passphrases are vars prompts, never in the store.
#
# hostapd names BSS interfaces <radio>, <radio>-1, <radio>-2 ... and wants a
# fixed BSSID for every extra one; they are derived from the radio's hardware
# address by setting the locally-administered bit and flipping bits 2-3 of
# the first octet per index (02 -> 06, 0a, 0e), which never collides with the
# radio's own address.
{ settings }:
{
config,
lib,
...
}:
let
cfg = settings;
wifi = cfg.wifi;
bssIf = radio: i: if i == 0 then radio else "${radio}-${toString i}";
hexDigit =
c:
{
"0" = 0;
"1" = 1;
"2" = 2;
"3" = 3;
"4" = 4;
"5" = 5;
"6" = 6;
"7" = 7;
"8" = 8;
"9" = 9;
a = 10;
b = 11;
c = 12;
d = 13;
e = 14;
f = 15;
}
.${lib.toLower c};
octetToInt = s: 16 * hexDigit (builtins.substring 0 1 s) + hexDigit (builtins.substring 1 1 s);
intToOctet = n: lib.toLower (lib.fixedWidthString 2 "0" (lib.toHexString n));
deriveBssid =
mac: i:
let
octets = lib.splitString ":" mac;
first = builtins.bitXor (builtins.bitOr (octetToInt (builtins.head octets)) 2) (i * 4);
in
lib.concatStringsSep ":" ([ (intToOctet first) ] ++ builtins.tail octets);
# Every BSS of every radio, flattened.
bsses = lib.concatLists (
lib.mapAttrsToList (
radio: r:
lib.imap0 (i: name: {
inherit radio name;
index = i;
iface = bssIf radio i;
net = wifi.networks.${name} or null;
}) r.networks
) wifi.radios
);
referenced = lib.unique (map (b: b.name) bsses);
secured = lib.filter (name: wifi.networks.${name}.security != "open") (
lib.filter (name: wifi.networks ? ${name}) referenced
);
passphraseFile =
name: config.clan.core.vars.generators."wifi-${name}-passphrase".files.passphrase.path;
authentication =
name: net:
{
wpa3 = {
mode = "wpa3-sae";
saePasswordsFile = passphraseFile name;
};
wpa3-transition = {
mode = "wpa3-sae-transition";
saePasswordsFile = passphraseFile name;
wpaPasswordFile = passphraseFile name;
};
wpa2 = {
mode = "wpa2-sha1";
wpaPasswordFile = passphraseFile name;
};
open.mode = "none";
}
.${net.security};
in
{
config = lib.mkIf wifi.enable {
assertions = [
{
assertion = wifi.radios != { };
message = "router: wifi.enable needs at least one radio in wifi.radios.";
}
{
assertion = wifi.countryCode != null;
message = "router: wifi.countryCode is required when wifi.enable is set (regulatory domain).";
}
{
assertion = lib.all (b: b.net != null) bsses;
message = "router: every wifi.radios.<radio>.networks entry must name a network in wifi.networks.";
}
{
assertion = lib.all (b: b.net == null || cfg.vlans ? ${b.net.vlan}) bsses;
message = "router: every wifi.networks.<name>.vlan must name a VLAN in vlans.";
}
{
assertion = lib.all (r: lib.length r.networks <= 4) (lib.attrValues wifi.radios);
message = "router: a radio can broadcast at most four networks.";
}
{
assertion = lib.all (r: lib.length r.networks <= 1 || r.macAddress != null) (
lib.attrValues wifi.radios
);
message = "router: wifi.radios.<radio>.macAddress is required for radios broadcasting more than one network.";
}
{
assertion =
!config.networking.wireless.enable
&& !config.networking.wireless.iwd.enable
&& !config.networking.networkmanager.enable;
message = "router: wifi.enable needs the radios for hostapd; disable networking.wireless (wpa_supplicant), iwd and NetworkManager.";
}
];
# Regulatory database for the kernel, so countryCode actually applies.
hardware.wirelessRegulatoryDatabase = true;
clan.core.vars.generators = lib.genAttrs (map (name: "wifi-${name}-passphrase") secured) (
gen:
let
name = lib.removeSuffix "-passphrase" (lib.removePrefix "wifi-" gen);
in
{
prompts.passphrase = {
description = "Wi-Fi passphrase for SSID \"${wifi.networks.${name}.ssid}\" (8-63 ASCII characters)";
type = "hidden";
};
files.passphrase.secret = true;
# No trailing newline: hostapd turns every line of the file into a
# sae_password entry, and an empty one wipes the list.
script = ''printf '%s' "$(cat "$prompts"/passphrase)" > "$out"/passphrase'';
}
);
services.hostapd = {
enable = true;
radios = lib.mapAttrs (radio: r: {
inherit (r) band channel;
inherit (wifi) countryCode;
wifi6.enable = r.wifi6;
networks = lib.listToAttrs (
map (
b:
lib.nameValuePair b.iface (
{
inherit (b.net) ssid;
ignoreBroadcastSsid = if b.net.hidden then "empty" else "disabled";
apIsolate = b.net.isolateClients;
authentication = authentication b.name b.net;
}
// lib.optionalAttrs (lib.length r.networks > 1) {
bssid = if b.index == 0 then r.macAddress else deriveBssid r.macAddress b.index;
}
)
) (lib.filter (b: b.radio == radio) bsses)
);
}) wifi.radios;
};
# Each BSS is an untagged access port of its VLAN on br0 (cf. accessPorts
# in network.nix). The kernel refuses to bridge a wireless interface in
# station mode (IFF_DONT_BRIDGE), and a failed enslave parks the link in
# networkd's `failed` state for good: networkd only re-evaluates a link
# when its matching .network file changes. So match on the AP interface
# type as well as the name: the radio's initial station-mode netdev
# matches nothing (unmanaged), and once hostapd switches it to AP and
# brings the carrier up, networkd matches this file for the first time
# and enslaves it. Extra BSSes are created by hostapd in AP mode already.
systemd.network.networks = lib.listToAttrs (
map (
b:
lib.nameValuePair "27-wifi-${b.iface}" {
matchConfig = {
Name = b.iface;
WLANInterfaceType = "ap";
};
networkConfig.Bridge = "br0";
bridgeVLANs = [
{
VLAN = cfg.vlans.${b.net.vlan}.id;
PVID = cfg.vlans.${b.net.vlan}.id;
EgressUntagged = cfg.vlans.${b.net.vlan}.id;
}
];
linkConfig.RequiredForOnline = "no";
}
) bsses
);
};
}
+4 -1
View File
@@ -6,7 +6,10 @@
}:
let
# ZeroTier addresses — zone transfers run over the mesh, not the public net.
mesh = import ../mesh-hosts.nix { inherit config lib; };
mesh = import ../mesh-hosts.nix {
dir = config.clan.core.settings.directory;
inherit lib;
};
ns1zt = mesh.hosts.ns1;
ns2zt = mesh.hosts.ns2;
in
+1
View File
@@ -4,4 +4,5 @@
"cnx.network"
"buildfor.life"
"cnx.email"
"newedge.house"
]
+13
View File
@@ -0,0 +1,13 @@
$ORIGIN newedge.house.
$TTL 3600
@ IN SOA ns1.cnx.network. hostmaster.cnx.network. (
2026061401 ; serial (ignored: Knot auto-assigns a dateserial on signing)
3600 ; refresh
900 ; retry
604800 ; expire
300 ) ; negative-cache TTL
; Served by the same nameservers (out-of-bailiwick, no glue needed here).
@ IN NS ns1.cnx.network.
@ IN NS ns2.cnx.network.
+4 -1
View File
@@ -8,7 +8,10 @@
...
}:
let
mesh = import ./mesh-hosts.nix { inherit config lib; };
mesh = import ./mesh-hosts.nix {
dir = config.clan.core.settings.directory;
inherit lib;
};
port = 8080;
site = pkgs.stdenvNoCC.mkDerivation {
+4 -3
View File
@@ -8,10 +8,10 @@
# vars/shared/zerotier-network-<instance>/network-id (instance = "zerotier",
# the inventory.instances.zerotier name in clan.nix). This keeps the mesh map in
# lockstep with the actual identities: regenerate or re-key a node and its
# address here follows automatically. Call as: import ../mesh-hosts.nix { inherit config lib; }.
{ config, lib }:
# address here follows automatically. `dir` is the clan directory: from NixOS
# `config.clan.core.settings.directory`, from clan.nix `config.directory`.
{ dir, lib }:
let
dir = config.clan.core.settings.directory;
instance = "zerotier";
readIp =
@@ -23,6 +23,7 @@ let
"ns2"
"mx1"
"web01"
"gw-cnx-1"
] readIp;
# RFC 4193 prefix of this ZeroTier network: fd + the 8-byte network id + the
+25
View File
@@ -59,6 +59,31 @@ in
}
];
}
{
# WAN quality on the site gateways, fed by the hourly speedtest timer
# (modules/clan/router/speedtest.nix) via the node_exporter textfile
# collector. Degradation is judged against the link's own 7-day median,
# so no per-site/per-ISP-plan threshold needs maintaining.
name = "wan";
rules = [
{
alert = "WANSpeedDegraded";
expr = "speedtest_download_mbps < 0.5 * quantile_over_time(0.5, speedtest_download_mbps[7d])";
for = "3h";
labels.severity = "warning";
annotations.summary = "WAN download on {{ $labels.instance }} is below half its 7-day median";
annotations.description = "Sustained for 3h (several test runs) likely ISP degradation, line fault, or saturation. Compare speedtest_upload_mbps/speedtest_ping_ms and run `librespeed-cli` manually on the gateway.";
}
{
alert = "WANSpeedTestFailing";
expr = "speedtest_success == 0";
for = "3h";
labels.severity = "warning";
annotations.summary = "Speed tests on {{ $labels.instance }} have been failing for 3h";
annotations.description = "librespeed-cli cannot complete a test while other traffic may still flow; check `journalctl -u speedtest` on the gateway.";
}
];
}
{
name = "backup";
rules = [
+267
View File
@@ -0,0 +1,267 @@
{
"uid": "cnx-wan",
"title": "CNX WAN Speed",
"tags": ["wan", "speedtest", "gateway", "cnx"],
"timezone": "browser",
"schemaVersion": 39,
"version": 1,
"refresh": "5m",
"time": { "from": "now-7d", "to": "now" },
"templating": { "list": [] },
"annotations": { "list": [] },
"panels": [
{
"type": "row",
"title": "WAN speed (site gateways)",
"id": 1,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }
},
{
"type": "stat",
"title": "Download (latest)",
"description": "Most recent hourly librespeed result per gateway.",
"id": 2,
"datasource": { "type": "prometheus", "uid": "victoriametrics" },
"gridPos": { "h": 5, "w": 6, "x": 0, "y": 1 },
"fieldConfig": {
"defaults": {
"unit": "bps",
"color": { "mode": "fixed", "fixedColor": "green" },
"noValue": "no data"
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"colorMode": "value",
"graphMode": "area",
"textMode": "value_and_name",
"orientation": "auto"
},
"targets": [
{
"refId": "A",
"datasource": { "type": "prometheus", "uid": "victoriametrics" },
"expr": "speedtest_download_mbps * 1e6",
"legendFormat": "{{instance}}",
"instant": true
}
]
},
{
"type": "stat",
"title": "Upload (latest)",
"description": "Most recent hourly librespeed result per gateway.",
"id": 3,
"datasource": { "type": "prometheus", "uid": "victoriametrics" },
"gridPos": { "h": 5, "w": 6, "x": 6, "y": 1 },
"fieldConfig": {
"defaults": {
"unit": "bps",
"color": { "mode": "fixed", "fixedColor": "blue" },
"noValue": "no data"
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"colorMode": "value",
"graphMode": "area",
"textMode": "value_and_name",
"orientation": "auto"
},
"targets": [
{
"refId": "A",
"datasource": { "type": "prometheus", "uid": "victoriametrics" },
"expr": "speedtest_upload_mbps * 1e6",
"legendFormat": "{{instance}}",
"instant": true
}
]
},
{
"type": "stat",
"title": "Ping (latest)",
"description": "Latency to the librespeed test server. Yellow over 50 ms, red over 100 ms.",
"id": 4,
"datasource": { "type": "prometheus", "uid": "victoriametrics" },
"gridPos": { "h": 5, "w": 6, "x": 12, "y": 1 },
"fieldConfig": {
"defaults": {
"unit": "ms",
"color": { "mode": "thresholds" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 50 },
{ "color": "red", "value": 100 }
]
},
"noValue": "no data"
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"colorMode": "value",
"graphMode": "none",
"textMode": "value_and_name",
"orientation": "auto"
},
"targets": [
{
"refId": "A",
"datasource": { "type": "prometheus", "uid": "victoriametrics" },
"expr": "speedtest_ping_ms",
"legendFormat": "{{instance}}",
"instant": true
}
]
},
{
"type": "stat",
"title": "Test status",
"description": "Whether the last speed test on each gateway completed. FAILING also fires the WANSpeedTestFailing alert after 3h.",
"id": 5,
"datasource": { "type": "prometheus", "uid": "victoriametrics" },
"gridPos": { "h": 5, "w": 6, "x": 18, "y": 1 },
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": {
"mode": "absolute",
"steps": [{ "color": "green", "value": null }]
},
"noValue": "no data",
"mappings": [
{
"type": "value",
"options": {
"0": { "text": "FAILING", "color": "red", "index": 0 },
"1": { "text": "OK", "color": "green", "index": 1 }
}
}
]
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"colorMode": "background",
"graphMode": "none",
"textMode": "value_and_name",
"orientation": "auto"
},
"targets": [
{
"refId": "A",
"datasource": { "type": "prometheus", "uid": "victoriametrics" },
"expr": "speedtest_success",
"legendFormat": "{{instance}}",
"instant": true
}
]
},
{
"type": "timeseries",
"title": "Download",
"description": "Hourly download rate per gateway, with each link's own 7-day median as reference — the WANSpeedDegraded alert fires when a link stays below half its median for 3h.",
"id": 6,
"datasource": { "type": "prometheus", "uid": "victoriametrics" },
"gridPos": { "h": 9, "w": 12, "x": 0, "y": 6 },
"fieldConfig": {
"defaults": { "unit": "bps", "min": 0 },
"overrides": [
{
"matcher": { "id": "byRegexp", "options": ".*7d median.*" },
"properties": [
{
"id": "custom.lineStyle",
"value": { "fill": "dash", "dash": [10, 10] }
},
{ "id": "custom.fillOpacity", "value": 0 }
]
}
]
},
"targets": [
{
"refId": "A",
"datasource": { "type": "prometheus", "uid": "victoriametrics" },
"expr": "speedtest_download_mbps * 1e6",
"legendFormat": "{{instance}}"
},
{
"refId": "B",
"datasource": { "type": "prometheus", "uid": "victoriametrics" },
"expr": "quantile_over_time(0.5, speedtest_download_mbps[7d]) * 1e6",
"legendFormat": "{{instance}} 7d median"
}
]
},
{
"type": "timeseries",
"title": "Upload",
"description": "Hourly upload rate per gateway.",
"id": 7,
"datasource": { "type": "prometheus", "uid": "victoriametrics" },
"gridPos": { "h": 9, "w": 12, "x": 12, "y": 6 },
"fieldConfig": {
"defaults": { "unit": "bps", "min": 0 },
"overrides": []
},
"targets": [
{
"refId": "A",
"datasource": { "type": "prometheus", "uid": "victoriametrics" },
"expr": "speedtest_upload_mbps * 1e6",
"legendFormat": "{{instance}}"
}
]
},
{
"type": "timeseries",
"title": "Latency & jitter",
"description": "Ping and jitter towards the librespeed test server. A latency climb with stable throughput usually means bufferbloat or an ISP routing change.",
"id": 8,
"datasource": { "type": "prometheus", "uid": "victoriametrics" },
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 15 },
"fieldConfig": {
"defaults": { "unit": "ms", "min": 0 },
"overrides": []
},
"targets": [
{
"refId": "A",
"datasource": { "type": "prometheus", "uid": "victoriametrics" },
"expr": "speedtest_ping_ms",
"legendFormat": "{{instance}} ping"
},
{
"refId": "B",
"datasource": { "type": "prometheus", "uid": "victoriametrics" },
"expr": "speedtest_jitter_ms",
"legendFormat": "{{instance}} jitter"
}
]
}
]
}
+4 -1
View File
@@ -10,7 +10,10 @@
...
}:
let
mesh = import ../mesh-hosts.nix { inherit config lib; };
mesh = import ../mesh-hosts.nix {
dir = config.clan.core.settings.directory;
inherit lib;
};
knotEnabled = config.services.knot.enable;
# node_exporter on every host; knot-exporter only where Knot runs.
ports = [ 9100 ] ++ lib.optional knotEnabled 9433;
+4 -1
View File
@@ -16,7 +16,10 @@
...
}:
let
mesh = import ../mesh-hosts.nix { inherit config lib; };
mesh = import ../mesh-hosts.nix {
dir = config.clan.core.settings.directory;
inherit lib;
};
# Grafana 13 (since the externalisation of core data sources) no longer bundles
# the Elasticsearch data source, and nixpkgs has no grafanaPlugins entry for it,
+11 -1
View File
@@ -9,7 +9,10 @@
...
}:
let
mesh = import ../mesh-hosts.nix { inherit config lib; };
mesh = import ../mesh-hosts.nix {
dir = config.clan.core.settings.directory;
inherit lib;
};
probes = import ./blackbox-probes.nix { inherit lib; };
vmPort = 8428;
logsPort = 9428;
@@ -48,6 +51,13 @@ in
(target "ns2" (v6 mesh.hosts.ns2) 9100)
(target "mx1" (v6 mesh.hosts.mx1) 9100)
(target "web01" (v6 mesh.hosts.web01) 9100)
(target "gw-cnx-1" (v6 mesh.hosts.gw-cnx-1) 9100)
];
}
{
job_name = "blocky";
static_configs = [
(target "gw-cnx-1" (v6 mesh.hosts.gw-cnx-1) 4000)
];
}
{
+4 -1
View File
@@ -10,7 +10,10 @@
...
}:
let
mesh = import ./mesh-hosts.nix { inherit config lib; };
mesh = import ./mesh-hosts.nix {
dir = config.clan.core.settings.directory;
inherit lib;
};
hosts = import ./hosts.nix;
certName = "cnx.network";
in
+6
View File
@@ -0,0 +1,6 @@
[
{
"publickey": "age1g8a6fqhqg8q8qu4jyuhxc7hf00t5qvra55chyfm0qk45wlmvc9qs2cft8t",
"type": "age"
}
]
+1
View File
@@ -0,0 +1 @@
../../../groups/admins
+6 -2
View File
@@ -3,8 +3,12 @@
"sops": {
"age": [
{
"recipient": "age1yubikey1qd859y9ehz2ya8j2cftwrtmdeqhuk7r7yc52zp64wpff6068gwrac3q6nsa",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHBpdi1wMjU2IHFWcTVydyBBcmJ4cXNt\nL3k4UmtqQUJZOGRoZFhab0pRSTdBUm9ZZkRxdHNvaCtRc05JVgphbHhHQThiRDNF\neW8xVGhXQ1pvSkRDR2hod2Zpa3pwaWZDVUpGZlpPWkNZCi0tLSB2T3JCcElnbVU2\nekJ5ZTdyQzZwbmR4aHBJUTc3M3loUm0wbHpHTGM0ZW1RCs0kHes9rkjd3uwHzxhi\n8x8AcQcQTR+CGsV5XPzGVMKKUZpwXWeNnvcHfVwYcqOnXucbUdCxLc6d57c9GQpC\nd3Y=\n-----END AGE ENCRYPTED FILE-----\n"
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBNRWlKNFVSOThSWlI3LzAz\ndnQxbnNGSlhoVmZrQmpIVEJieStlUzlseDJJCmdGbldCSU1oTGszZmtzSWhJaWta\nVDhoQU8vVXB0bG1VUkxoWDlyR0I1a0EKLS0tIDFzdklCTTBjeXBoT3FqVW1Pbkxl\nYVZ5Vm55OFp4YWwzS3dVNXRoZmpBT1EK0pNbk20gwPe5Q1bZR3XRHMWz+x+8FzRa\niq5tLcGkgfMO6WTzj8vtB7g0Q3sFgmPXTqb8yMzqAkZd4z9FKJGt6A==\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1hlzrpqqgndcthq5m5yj9egfgyet2fzrxwa6ynjzwx2r22uy6m3hqr3rd06"
},
{
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHBpdi1wMjU2IHFWcTVydyBBcFBHaFpS\nbjJiL0ZnOXlzMVVNdjhvUGxmaEJhTklSR2ZaTnhPWXFCaC8vbgpLdE04aW16dEls\neGw3VllEYVAzbGJ5TVZoOXhkR3dCUkREdUkxMU13clVzCi0tLSA3eW93QnFsNW1r\nK0ZiWEtrWFVqeTJyTk1maGVTMzdBTkJUdk5WN2N3WlprCp5oak+Bh/g4+ikE1LR5\n7kYrWQlzjC0Z+F5GegEtRjGCPnolWllnPUH4dGGjfuyAxy9qfi1J6Bi6m8uzo5rY\n+zs=\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1yubikey1qd859y9ehz2ya8j2cftwrtmdeqhuk7r7yc52zp64wpff6068gwrac3q6nsa"
}
],
"lastmodified": "2026-06-14T05:20:21Z",
+1
View File
@@ -0,0 +1 @@
../../../groups/admins
+19
View File
@@ -0,0 +1,19 @@
{
"data": "ENC[AES256_GCM,data:bsb98446kjvnDAVWNIqvH64uD6XyfEpohPFZs38jDaxxTp1wqJmFdeDW+p3SGld8Q+4h6rq7rFtTUJnQyslvgsPfYN5tBhJuTbI=,iv:pPUf3RgHqS8LlRIBdZ7jmzxp5VmlP68nrX8TCVP8aGU=,tag:SseZa3Sf5LuMXWN8CNSbxw==,type:str]",
"sops": {
"age": [
{
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBLRkppdUoza2l0SnFpME5n\nM0xBRVk4c2lhaHMvazFNaGNTdkJDcnJEQkVrCjhFUlZMUitnZ20zQlFEcm5WQmVG\nWmJVeUh6S09MS0xpQUR4aDF1V1dCZFkKLS0tICtOMEJ6QmRRR3BkaU5sZHdzOElO\nSnV4K0dHMHhDOURPTEpucXFuNEUwaU0Kx4xAaKTrw1bKJPQVCHcByTcapFhslwrV\nqssEBnYWXqFgI3vh3CatBXuZY1Lm3eTKhDDQ2ZyPKgc/l0gXyFwH2g==\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1hlzrpqqgndcthq5m5yj9egfgyet2fzrxwa6ynjzwx2r22uy6m3hqr3rd06"
},
{
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHBpdi1wMjU2IHFWcTVydyBBMHFKQTNG\nazdEcThReFZGVVBtVFBDY1luQk1UTkl0a0p1bzQ0cUxBZWVyYwo4aU5qaEs0czJB\nUzZFcG9uTUZ1OUFScW9ZeHljbDBEbjkzZ08vS2F1Qjd3Ci0tLSBHTXhVWjNsWHhK\nbWtrMWw3Nm8wSU9MNXZwcWxVZXRJS1dTK0hycnFoVlRJChemDXVb7LgM7OeUUeqa\nTl0IuDHmIIx/lUYnJWGZmWQHz2znk679j36VQQNWcK6eZlKUIqYmBj3fMu+OYir2\nMLI=\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1yubikey1qd859y9ehz2ya8j2cftwrtmdeqhuk7r7yc52zp64wpff6068gwrac3q6nsa"
}
],
"lastmodified": "2026-07-28T09:03:13Z",
"mac": "ENC[AES256_GCM,data:MKIyykYwtQqpUnXqZQKzKwoRhSon2PH0wYiTDTvogaVREHfpEPs/mdOjQBUHtbr1QGqWNh4AxdBTKfpjXTlncHAEtcIdVH3qPccVxsXqfqRmZPDFzcHZAIw+VSvDsKwD3anixiXDeHmeLawwbyb1rnS0P8KquXbmjvirwGUIQLU=,iv:+hdLXMJMCby/6Q6JGxp4p95GgPKbdA/OTAZloyylr08=,tag:ZZ4dKjhjpE2xqcoNf4qeoA==,type:str]",
"unencrypted_suffix": "_unencrypted",
"version": "3.13.1"
}
}
+1
View File
@@ -0,0 +1 @@
../../../users/berwn
+1
View File
@@ -0,0 +1 @@
../../../groups/admins
+6 -2
View File
@@ -3,8 +3,12 @@
"sops": {
"age": [
{
"recipient": "age1yubikey1qd859y9ehz2ya8j2cftwrtmdeqhuk7r7yc52zp64wpff6068gwrac3q6nsa",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHBpdi1wMjU2IHFWcTVydyBBemRVWUs1\naXFpVm80UmloUUpnR1BVaGsxampHV1JFNWFnSUhiY0orOTU0cgppVEJ5QVcvbGpC\nZlErd01XUzh0MnZOekkvTkc5Z3owS3pBbkNCUDJ6SlJRCi0tLSBRVVgrd3hIVEla\nMEhoN3FDSG1HdGk5YU8ydzlVUGd1RW5zM1lka3VuSHFNClrBItoFo7uqpS5fuvaG\nOvh6mTylk4bHgcSRGKTk383srgsvPdZaioJfUs7fbVmLD3+bhfM/Wgv7tI6kO1dj\nmDg=\n-----END AGE ENCRYPTED FILE-----\n"
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHBpdi1wMjU2IHFWcTVydyBBcU01TXl0\nRmU5ZlZOK3NZZE8vNktOWXBkRnZtZHlUbnBHYlVQbDdzZlRDbApFeFI0OE1KdndQ\nZWczZGRYZXBONTc2aG4vZTJEZ1hCdGljNFJBcmF0UGpRCi0tLSBGMGJVWUtJMWFP\nQUZwWVZTWUFKZnVJUTJSMHlRMGRNcExPSEpXTFpLSkVNCj1WEuRyATcxp0GYeoqC\nACpQpcqgIBVD1dYB0pPo+I6yDXvx+JbwVdgGl+2Cxj40HQOfyvJecoOO3efOZrB3\nUZU=\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1yubikey1qd859y9ehz2ya8j2cftwrtmdeqhuk7r7yc52zp64wpff6068gwrac3q6nsa"
},
{
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBRb0ZvUmN2RWhtQkpXMXJr\nY1o2MXZsQ0dXNFYxa2RLUGlJWnN3Z25zbnp3CmtLbkYvdHR2MUZIY2U1MzhyU1VD\ndm5MTmMveEJLOHZWWnVWdVpDcTFsTmMKLS0tIFEwYzZYeU5uc29BTEVxN2YvN0Zw\nd0VSeTNhQk1pNVdaZVdTVk1oNjFwQWcKwEbHyT354Cbam9wuiIhapre+nbqtMr9h\nbOjF53LR9RZ9/mbpTNDLOkq2QW2aM4tKkRfdUgP1XgboilzcTjba8A==\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1hlzrpqqgndcthq5m5yj9egfgyet2fzrxwa6ynjzwx2r22uy6m3hqr3rd06"
}
],
"lastmodified": "2026-06-14T05:20:24Z",
+1
View File
@@ -0,0 +1 @@
../../../groups/admins
+6 -2
View File
@@ -3,8 +3,12 @@
"sops": {
"age": [
{
"recipient": "age1yubikey1qd859y9ehz2ya8j2cftwrtmdeqhuk7r7yc52zp64wpff6068gwrac3q6nsa",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHBpdi1wMjU2IHFWcTVydyBBL2doZFpO\na2Y2QzZSckJaZmZsTlFtQU1mckxJWTBiUXA2SUljVWZDemFJYgpwcFl6RHh2ZXdn\nNTBsaEVpWm5oYWtteU5WZmxWK1RRK2NoYW0yWlpCd3dBCi0tLSBsZVNpczR4dUdv\nSmtqQzMvc1ZYY0xUY0JMQmt2YzB5YmZOMEtXcW9vWUcwCnCdgcgiwF2rkWsV0IdO\n/6cs35FBypWpfflGwOwP0GnTrNizc9HphcMcwAi5NMQVi6X90Xgm5aFdpAsSWepX\n/SI=\n-----END AGE ENCRYPTED FILE-----\n"
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHBpdi1wMjU2IHFWcTVydyBBaTVGQWRC\nZUJocGx5Zjd0c1JpUEdHbGczNUh1RjRqSERRNHNSbnRndWFUMAoxbFBEU0Ezajhr\nSUlIRStWUUdVTDJMQWxmSkw0c0FNYnFxNEJIdnExTmx3Ci0tLSBMRGRPLzlXTEN3\ncHZCMGF3WDZTcFhTd2srL3Vpc2dRdExrVzFJcWhjQVJrCrA1y2SapkZoppI3z4LD\nH71D/WxIcTHp3fIzICNvgxWHn3+vkDDZcpcgH+gRiPCDqrgDp0NZcZiDlz6zyHGr\nmaM=\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1yubikey1qd859y9ehz2ya8j2cftwrtmdeqhuk7r7yc52zp64wpff6068gwrac3q6nsa"
},
{
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB0Rmp1S29hVE5LNkZpLzRV\nMGZJL242YmdHNkFXR2d0TmlVWXViTlJFNGpnClBSdEhaRlNWbVNrb05tTHh0Q3Rk\naVFNdUd1clNVckREajVoRmZCenFEaVkKLS0tIExOaHd1WWxZRFM1UFk4K0E0cWpE\nK2duUE1KeEtmeVorRnc3MFdDZXFGZjAKKw4ykWu+HzBN3WAj/gx0mMYk0RiyRR50\nYv7hg1B/+x4lDCTjAJIWrrBEjLJBZTZRnECmTMTlJ39uOhxeUOizCA==\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1hlzrpqqgndcthq5m5yj9egfgyet2fzrxwa6ynjzwx2r22uy6m3hqr3rd06"
}
],
"lastmodified": "2026-06-14T05:20:27Z",
@@ -0,0 +1 @@
9a9e2f23526941b204dacfe8fc77dad5f4b1c7aff945cdbf301ec61129959228
@@ -0,0 +1 @@
ssh-ed25519-cert-v01@openssh.com AAAAIHNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAAIHiQTWR/pRz9vlrtQSe+u2g8XPDZvFpRBCpF7bQ6mBPaAAAAIH+Ix/XbsbqHU87Kg7Y9frnhYgfUEMNKOmQ3DSlb9LCmAAAAAAAAAAAAAAACAAAAB2NvbnRyb2wAAAAgAAAAHGNvbnRyb2wuY254LW5ldHdvcmsuaW50ZXJuYWwAAAAAAAAAAP//////////AAAAAAAAAAAAAAAAAAAAMwAAAAtzc2gtZWQyNTUxOQAAACCboWwGkseX7mqk2GVtM2o1WPRzh8U3X6A3woanYG9xoQAAAFMAAAALc3NoLWVkMjU1MTkAAABAbWgsXjGd/QGdbYHNfHs4LiMgF1weHHirWk4ZLMuTLRDXObwdJ4tbc9IGDfNBZnAfOSxBBy+U/sjGbKQtRWt4Aw== /tmp/vars-irrhty3h/in/openssh/ssh.id_ed25519.pub
@@ -0,0 +1 @@
../../../../../../sops/groups/admins
@@ -0,0 +1 @@
../../../../../../sops/machines/control
@@ -0,0 +1,22 @@
{
"data": "ENC[AES256_GCM,data:r0i0hSQpZQfTP/2VhM7DkFAits86qDRlowbnwZqs4JrtvNjmReJe/mkF8xPT4eKusIzB/THAY4udgltrrOP0iFRMBPQqeFsd0w==,iv:+oYEln5OVeaFujy22IGKxKEgsExw0pSK3+bYQ82riPI=,tag:/FP5zwVfUszr3oAv6nwyVA==,type:str]",
"sops": {
"age": [
{
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBWSkthdHhQMHJ5THMxNUdS\nTGRZUVFDMEd6OVBPWFN3bXVzcFNnT0lrR1hrCkZ2bVRvVnJlM3BLc0ZqNUUwQ1VD\nMlJ1bmpkdzVYYURPOUorNkZKWDk5NGMKLS0tIC80Yy81K2owMDh6SFVMSmNOeERq\nVEJWNFRLV1gxOUx5WWJwWE5uNDR1eTgKdEcfmvFy5E81t2SWWEdPCbQy9nxGsMCT\nsZZxIbGwN0W3pf+QoPtIHl2tAixKy+gHPp8UNTGToDuK6ILSf8OoSQ==\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1env5y2eey0p3dggs8tydwmtyhtwqylpg7spuququ4vvax6arzp2qjnm4tx"
},
{
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBjTU8vOVRNbjBNOWpBWWpJ\nTUtIY1g4R1VVb2krNWxDOFpobkhuSmVkTFV3ClA0azFHVzkrY0xJdERZV0tLZkhN\nbldVYjJObjBIZm1RelB5VzBiU3dqK2sKLS0tIEdxRFRJMTI3RzI3ampRYVM2YWJI\nSU1pTWtRWVU2RDRHcmZtaGxhWElwd2sKK1zZtqqkdRMdITSKbJ/JilpAAflkgG5G\nnaeHwBIw75pZa6lQDM2aRWN+yFUMQtbJ3i7Zw/Du+emikyN1+hz5cg==\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1hlzrpqqgndcthq5m5yj9egfgyet2fzrxwa6ynjzwx2r22uy6m3hqr3rd06"
},
{
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHBpdi1wMjU2IHFWcTVydyBBdzdqU0M0\nOGNqMHJLZ0V4RHhnclE2SUY0aVFVMjB1azVlUlE5VXBTbGRsTgpFWjNPaDErU0cy\nNFFGR0VIODRTamc0bWxCTFN4MU9CdllaWjhvOUJaeWU4Ci0tLSBlK09xeGpRenk0\nZjlLTkhtR0NZZ1Jna2xHbFpZeUl3aEZ4cndhcHFOQkk0CuX8TF7mW7GowFZ2X75v\ng8zrxp3xtMzPp/C+dbuu02Lfe9f/1jaBOTtJjR6BnV0ucfYjUhtw7OQ3qCUV7a+Z\nG0c=\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1yubikey1qd859y9ehz2ya8j2cftwrtmdeqhuk7r7yc52zp64wpff6068gwrac3q6nsa"
}
],
"lastmodified": "2026-07-28T10:15:43Z",
"mac": "ENC[AES256_GCM,data:uq8xDJBop1yPYMb6gNNWhKxJAiBdMjuYHhA3fvpCauDAakKgEGcSmc7/cnSAtds4NbsPkxi6tn32aJvF8QsKlSIycnqDpxGqpGVgYjvt/lasM50Ukwk6W+YGwTx8bnCmFazimJuaNg92G0VZAGI0uybITR3EEHws/9na69470jc=,iv:hJcVMW1Gaf9Rle5K3LJV/P9Eo7AVBss40rZ5csQml1o=,tag:5ql+BvXOYlgOgbNDl5o/vw==,type:str]",
"version": "3.13.1"
}
}
@@ -0,0 +1 @@
../../../../../../sops/users/berwn
@@ -0,0 +1 @@
../../../../../../sops/groups/admins
@@ -0,0 +1,18 @@
{
"data": "ENC[AES256_GCM,data:TyTbzOZ9AOAaP5e96sleUEuuSq/FPjbfHZ6aeR5iOwvDtoc8ZhLYXEO+AF1p+DKS5hDA,iv:9VgMtKiHt8oYnNY8E9+bodU5ei3riw+Gx2KiOQBhoyk=,tag:zJXsCVITlqqrw72QqE/coQ==,type:str]",
"sops": {
"age": [
{
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBDdWJBOEkwM01BZDlMVS9W\nekFmT0JuMnZHNnBBOVhYK05Ra3VzMW9kK0ZrCkkzRk1JZHdzVlhBeEtTamNHb21x\nU1RkaDZYVEVFV1pyL0lSb1JXeWN6ZDgKLS0tIEVqWDZwZ1RBSS8rVVJLM1pSVkRY\nNEVub1diUytCUHZ1c3NPaWlCUHFYc2MK2lqzBTaw9qIFB6cucfxbMQGhLfSyHYhi\nRJf+pwI0BehAteT87IAbA63Sdx82QNb21mU1AYHPxLdDdrrHWnFxJA==\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1hlzrpqqgndcthq5m5yj9egfgyet2fzrxwa6ynjzwx2r22uy6m3hqr3rd06"
},
{
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHBpdi1wMjU2IHFWcTVydyBBOUhYNXIz\nMU9VU3ZMN0NVT1JSZ2N0Q1hxS2dnZm5iY1Z2WUtsaWlXVks0MApxbEtmWWVmQlZ5\nNzZrLy85M1ZiSlc1Y01OM1VEYy9rZ2FhdU5uNTFPa3U0Ci0tLSBkM0ppSUJtMkVP\nWC9jN0NOam9iSy9xakNhOUN3NjBtVllvUU13Tlo4RUdNCtY/2d3ANDMh2Sx1aFlm\n/b3GKbXeeBeeHuFBAsQmuZXqb5EHGJsB9JpjsR0TJJVflCgYYSjn0L1xg9DG/ANa\nn1k=\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1yubikey1qd859y9ehz2ya8j2cftwrtmdeqhuk7r7yc52zp64wpff6068gwrac3q6nsa"
}
],
"lastmodified": "2026-07-28T10:15:43Z",
"mac": "ENC[AES256_GCM,data:hVEI3iNwXa0O9ZkZWzb0OP8tnsTXWB0znLPg6FIx7Z7D9yPSwCpVu4qdtLwFnB6SEk1tnFykQofCKJ+UQtf6AuziGymwONUQWwUW7pF3zoKtxJ4bJQEKAVChgJqKx7GTp5HXxFkbrp03USJqaHqE9M0leRWIamdBeyXI9rdX9OE=,iv:dn4BqTra2ljHmXfoEoVFwRXXtwiLOs3j5fRNbvndIgA=,tag:zOaRMxl23pJRIvJo3ettEg==,type:str]",
"version": "3.13.1"
}
}
@@ -0,0 +1 @@
../../../../../../sops/users/berwn
@@ -0,0 +1 @@
../../../../../../sops/groups/admins
@@ -0,0 +1 @@
../../../../../../sops/machines/gw-cnx-1
@@ -0,0 +1,22 @@
{
"data": "ENC[AES256_GCM,data:4OPDbpmYb7nhHbUvu8dYmF7B/LipnKNE5g8vE33LMzUVg5S+EAyRu2u9dbeUDayqq+ms8WT/oqN/Q0hjsE67+3PCEYBbeAgb5tibsn3tmh1Erp0QUlA4V/erhDwvH+Y8GA3UEJITQ1TtPhCSCKnQbGm85+Tir0nsocjQi3g9Q67TRqUAF7US8H+MVM+YEnIE7HcUH/LKc9z3kg43AaMa2Ey83PjmkMmRdQ==,iv:3VdMulw2DUIReb2werCE4+VLMlxn4FeVUwl8R9Bn3Vw=,tag:TZJZcRiBSCjeTzd10a3rHA==,type:str]",
"sops": {
"age": [
{
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBBTXlXNzBJRisyeVM3YmRO\nK3IyTTFOcW5VVCtJcnVySFU2RE9PdUdpeWpJCnpscGhsakZkL1hDenhXUW1yY3JH\naHFzUS8velQxV3lYc2hPL0FWcWRpYUEKLS0tIE9BckgrUVRKM1hOWGg0N0V2Z2ds\nUzlhZ0luWitPS3U0UHpWQzZwdG1temMKf5SKmUh3LYD50tObR5tfEvNRoVegg/Tu\n4jXlMKNVg7yOgXET3CNS/Sm4ork4Wz4bSA6gYhLD5JIW356d5WBO4w==\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1g8a6fqhqg8q8qu4jyuhxc7hf00t5qvra55chyfm0qk45wlmvc9qs2cft8t"
},
{
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAybzRIVjhDUHZBVHFSTDF1\nZUNScTRoZzFzdkRISFMzZ0E3M0ZWeFF3bncwCi9rbzg2bUV0OVhBNlROQlhSQnpF\nSm9mVkF2MzhycnNqRUwvTFV2bW5QRWsKLS0tIHJUc2NyOVprMzBjOTgyM1IvZDU2\na0RSenNHYXNqSzR1K0JRVVBpMlpZN0kKk63q9i5zCP3lAZ+JiknOgFcp/quFGYdp\nzoAmkEeGLK8nR5lOVdUutHktG9Rneci+7Q49bk2S4gqeLDt2AnOCsw==\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1hlzrpqqgndcthq5m5yj9egfgyet2fzrxwa6ynjzwx2r22uy6m3hqr3rd06"
},
{
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHBpdi1wMjU2IHFWcTVydyBBcXRoTXMy\nOG01R2hEY3R2NTd1bzdPTVUvOWorN1NOYlVLYWk3VThtUEpoUgpzOHNYN1N0bzlv\naWhDVCs0b1ozQ0FBREpiVXpiTkxOeEl5b3YzdkR5YXowCi0tLSBBbTZUZDVpN0I0\ndWRPUXdVaGx5Mk56UzFUbzBvT0xBa0h3dFdmajNXak9RCmDpIFatc4U+k3NDDw7m\nn/D+azJwB6KS2s9YeUy85RoClEqiNSGC9t7qqffVFdiNS8Zww6MTzY++lgj1bYlK\nLMc=\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1yubikey1qd859y9ehz2ya8j2cftwrtmdeqhuk7r7yc52zp64wpff6068gwrac3q6nsa"
}
],
"lastmodified": "2026-07-31T03:07:42Z",
"mac": "ENC[AES256_GCM,data:aXgLJn6HmSS01GjwYi/lpDil2/FmQitKZOZHUEr1k1r31gTb87RYVJd11MXuGiCCtc5ASKsbZIxoRuPf9DLiiJfuCaVY6Sv4fH/JXkSaLh9YoRCPeX7lrcyYiqficAhmC6+0D3Y0e9tpLSuEdaNB9wHwvslEZ4LSkVscTPSM8VQ=,iv:eY9+6mPJaVDNkZH4LvPBzggEHtyj4H/3QPfN0cjJdx4=,tag:iZyexO/rlt/8XOJ9yR4zgA==,type:str]",
"version": "3.13.1"
}
}
@@ -0,0 +1 @@
../../../../../../sops/users/berwn
@@ -0,0 +1 @@
$y$j9T$tSEgEho.KxGPAaQTjU03W1$M6aXoOlLF6B/QaH5XZl/kAGxuC5VClCUpYlr9gOCJt/
@@ -0,0 +1 @@
../../../../../../sops/groups/admins
@@ -0,0 +1,19 @@
{
"data": "ENC[AES256_GCM,data:WtA3B4lfAERXHgc6hyjaBdFxNMlW26VeAYVb6qosXuFGxwqzbf7XXrEWJ0CSFhb5CSJO0SFJ,iv:ATeO9O1uZ3M40Y8q6Udi5qhmGsjSR7GxIRCpGnls8bg=,tag:NKiLATUjXWVJ2SXNI984Ag==,type:str]",
"sops": {
"age": [
{
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHBpdi1wMjU2IHFWcTVydyBBOWVrRjh2\nZjgxSHdYeUFkRFVLRjc0WVZPNjFoU0wyMnB3cDU3ODJLeU4rQwpxdXRLOWtuMEhx\nR0NROE5xeHdBL1NhQ3NrSlQ5eUE0OGcrcGt6NGdRdTRvCi0tLSA2aDdydHB2TWpR\nQ09kakg3V09iNHpvdVYxck9DeVgwMFhMZ2g5cUxyWXo0CleMojWF4rB5GlAAM1D1\nKEES65mAa/3GPM0vHhd/YBwwyzyv4HeZHSVao5O10FzMWc226Rc029nhNwcTAlA7\nphU=\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1yubikey1qd859y9ehz2ya8j2cftwrtmdeqhuk7r7yc52zp64wpff6068gwrac3q6nsa"
},
{
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB5aFlkMktXRlIxZE8xTXNL\nRzFzWisrQkxEYXJ4STU5VkxZN2d0QjN2V0hrCnZxalRXZno3bk9TUG9oWFhnVkZM\nT3VLK1lLMWNtaHZ3Qk5odm9rRXFRTVEKLS0tIGd2K3ZNZUxoQXROVng0b1c0UU1r\nUWJxWnpCeWxEK3A2SnNBWjAxczZkeWcKOl+DtJQkkKaS1YYaDzqmasnxiY8iEuAd\n57f0zP+peA9Q4U3TZg3UGG94V9kuweNMTWPfj1GZvwBvZP9sZYuFYw==\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1hlzrpqqgndcthq5m5yj9egfgyet2fzrxwa6ynjzwx2r22uy6m3hqr3rd06"
}
],
"lastmodified": "2026-07-28T09:03:14Z",
"mac": "ENC[AES256_GCM,data:8lemWJvcdPy0sg0K+Zn9A7xWd5X4q3XGTb+R5DiJMSo8BWxpP+QGNqxEuni8sNxP2d0bkrsEI3vG89lx37x3iot667Gds0qAaHq7h8l+3iwAvGMXAEzwIms9GLdIcJiUWYlFiAcicfUINWtPQ+adxACFzTOBIBEGqD6iQ5B+KsM=,iv:OuBeq83CopTNeJJM/UHUrI7RKIGVJ3tM+DcckuFxLBc=,tag:JAXpfqwSDRr4e58wiJeMNQ==,type:str]",
"unencrypted_suffix": "_unencrypted",
"version": "3.13.1"
}
}
@@ -0,0 +1 @@
../../../../../../sops/users/berwn
@@ -0,0 +1 @@
210641414b2391ee4659814fb67a1869d8f1920716b53f7b5226be36887ec7ce
@@ -0,0 +1 @@
ssh-ed25519-cert-v01@openssh.com AAAAIHNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAAICg/CnZm/jIFijKdkXTrJS2Fman7vhPtUcpTFVhLAoyLAAAAIP+kt1X3TTJhWQYakYQzrr2lI8tanZ74uEahnD4meuCaAAAAAAAAAAAAAAACAAAACGd3LWNueC0xAAAAIQAAAB1ndy1jbngtMS5jbngtbmV0d29yay5pbnRlcm5hbAAAAAAAAAAA//////////8AAAAAAAAAAAAAAAAAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIJuhbAaSx5fuaqTYZW0zajVY9HOHxTdfoDfChqdgb3GhAAAAUwAAAAtzc2gtZWQyNTUxOQAAAEAhM9fTE9iCnV1wGKunJcRqOmNWbu0iZjPqXNcPp8rpwFJm/8CW0qC/TV/H5im7sX68K+ZOOaOTuOqu+D6U+lIA /tmp/vars-gc02c4xe/in/openssh/ssh.id_ed25519.pub
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIP+kt1X3TTJhWQYakYQzrr2lI8tanZ74uEahnD4meuCa
@@ -0,0 +1 @@
../../../../../../sops/groups/admins
@@ -0,0 +1 @@
../../../../../../sops/machines/gw-cnx-1
@@ -0,0 +1,23 @@
{
"data": "ENC[AES256_GCM,data:C+f7BWR2JNfMIqrfCrfkBC6fouAMLFXAEvoltExJU9FkiE8JN+AxtMVEH38PTui9NzlPnmLFc95MGHstj8Kb3rViwuoY2lCHCRM3uHPTau0Cxp8Ekq5ILMebjKLVeeyisQXKelMXg0zYBX+HBUHYJi+SYqQM7Pw3iI1WRStW43PGPjcSExAhgQts8V5xaTjseCf7EqgtjfOmya/uQXe6asL+GVjH8icpc0A9+AyrZci4Vr3lsJ1qgy0ej4a17glfuXnjaXjnlw4vDxDAz0c7xjx88xRxagqtN19Yi0jLRIuLU7Brka9wDMbu6yj6HzaYRaha+4HLDTbg+0Gd+iyH6z0tiXAj7p35VtBRefL/0CW/kIzwyl7GWAZpaX9xPUzZre20TYnPpxToP6n/6SJNccJhNd5GiGYtVzHXj9ni1kulX5N7A9QEikZGyujxPi+LFQVIKnDL1cxS8EeDqiRuAEi5LJa7xYKBXBIFdFO/9DMDesMQcvPP3HjpdFtaaybvpzai,iv:0dUn8InsV5N893LRfJNmYieSFSzPv1ceqXZzwkAmoIw=,tag:0eVSEfOHzD6AcuCDLY+ggw==,type:str]",
"sops": {
"age": [
{
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBld0NYcG9weENnN1JadG5G\nSFVPQzE1TG1POFc0RFZnSk5PbWZJNWpFY2tjCnBxdWM1R0kvOXUzUCtqUjMxcjMr\nQ2Rsd2d1QWd2bzZxaUN4VTAwdHZPSHcKLS0tIFhWNmZYSzJhR255NFRFa1VBcWtK\nVXF1YTVRVVVMLzVMQ0l2RnJLVDVWVU0KMGbb1Nnw5eEDFpGGi0Y3Dr15J6lyLeKY\n4hQJN4gEYcBokEbAV3wTYbkDQqDuQ1dKfS+zgybA1pomKcubkDCWlQ==\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1g8a6fqhqg8q8qu4jyuhxc7hf00t5qvra55chyfm0qk45wlmvc9qs2cft8t"
},
{
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHBpdi1wMjU2IHFWcTVydyBBK2E4TWdr\nTCttMjFmN1NMRWQ4YmpUZGNWa3NWTkJWUnRJSEthcnJncEhSRgpFTGx5L1VDSVhY\nWE1Udmt5NUkyOHdTd0JqUmlxdGt6UFEyK3RHakNHVklBCi0tLSBVbVFQSWMxdGlj\nTmdSN3Q3NVZaOVRWQk03K1o5WmphMGFFNHUwOGdCcUV3CmqsQ6E3U20tvO0ciXqb\nUIdmcfuy7u4SGQY4qskEefYQrn1TaD/zVM9yL+Uw6GvxxSnKeytgkwxnCTTjh10C\nVB0=\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1yubikey1qd859y9ehz2ya8j2cftwrtmdeqhuk7r7yc52zp64wpff6068gwrac3q6nsa"
},
{
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBSOWVZT0hRQWJiSG9OcFg3\ndm9rN0ZyZm45MGE1bDJ0N3dFWi9BV0R2WGhNClY1MmZsSWxESFljM3FQTFlkSlha\nenF2QkRmU3VSTWNqOG5qeEU4TXA5U3MKLS0tIENlNXpvRHJIQk83Q3ZIV24rdGU0\nWFB5NllWTU1ldUtNWmNGN21XMXBYZGsKhlAkMshZJjY48PBgAd8W7JAISnC/BGjL\n4sZez/+Qnz06y/ZyvatV3yIDmUTvp2jSqFzvwvDBeHuuAowpa6Yg4w==\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1hlzrpqqgndcthq5m5yj9egfgyet2fzrxwa6ynjzwx2r22uy6m3hqr3rd06"
}
],
"lastmodified": "2026-07-28T09:03:14Z",
"mac": "ENC[AES256_GCM,data:07tbbE0nqs0P0svDnzg5f6bPklOjTVFLGIsDc+ozllTU3VLYWHwvvkdgoJbPDaFH5mXykK6JyMZiI5jorx8iG/0+3s1bVMR0lCom7cXOqzai/h5meFZ8ymkON0cVXqTfwy7QTAlxg7ysjAqHLvJlHorTNFVUjxZelfDapa4g/ew=,iv:0SdRCRUcD+QxSZdOrzJ6Ri3eK1rvysFWDOrxSNc/5B8=,tag:c6R5gzhvlaBOl+J8zNjiOw==,type:str]",
"unencrypted_suffix": "_unencrypted",
"version": "3.13.1"
}
}
@@ -0,0 +1 @@
../../../../../../sops/users/berwn
@@ -0,0 +1 @@
../../../../../../sops/groups/admins
@@ -0,0 +1 @@
../../../../../../sops/machines/gw-cnx-1
@@ -0,0 +1,22 @@
{
"data": "ENC[AES256_GCM,data:KEymUXwLPa5F2Y2W3LHGKnzGwmekRR/Wjs0aIg==,iv:g8LUbeDkjavPuo9zeEgJaWt9WOMTFXjAoZH6freFzHA=,tag:JiJRR5lOMTiBNxnnByv1dw==,type:str]",
"sops": {
"age": [
{
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB5RHZqdkUvK0MyWkg3L2RK\nbjE2ZGdsSGRrYTRtRW1uQlRsVExCS2FkdEd3CkpkTGl6TTZndGRPRDh0VmRuTzk3\nZmdWUEJqSTdIZUEwQzJqM3QwOW1VS0EKLS0tIFU0QWx0d3h5c2F1U21qUzRMMExS\nbTFTc2lEWDVQSHZFYURxaEcyQTloQTAK6yIaBPEVqj3nlvsIl3srGWm9o6a9aE91\nZelv3QXjKUE1yQlrStZPKFNo4cZN/bt/81IEZIKcASE/b2DOJWW+ag==\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1g8a6fqhqg8q8qu4jyuhxc7hf00t5qvra55chyfm0qk45wlmvc9qs2cft8t"
},
{
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBIOTFQTWxlWkFXTXUyczIw\nV1U0RXBya3FiUXdrQzRLcWNCVG1lM1hobjFvCmZLcS9VTmJORDN2U0JjUFJnTGNx\nWW5BQzNRUlU2UTlTNVFsaGFwWldBYkEKLS0tIDhkTEk4dlllYms4WVM5dmhlcTZW\neE5tUzl1RVRjOTV0c0tRSmYvWlVyYlkKRCoMiXvMWSeZpgbI8xZ3FoioZjzCPHhW\nGQhcl3oTicZfyhSsDn51Y4GGHQJxCoRJFVs1jw2/R/V+q3/rUEwjXw==\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1hlzrpqqgndcthq5m5yj9egfgyet2fzrxwa6ynjzwx2r22uy6m3hqr3rd06"
},
{
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHBpdi1wMjU2IHFWcTVydyBBdjRpYm9L\nd25aTjJKdkJxbnErVU5Wb2dBbjdkaTZ0YmcyOUR1OEhIR1REYQo1WENYcjQrSXJJ\nM0c2Zldpd1ByYUxmNE9vOFRNclZZQk1uT0Y1SWlyQTB3Ci0tLSBSOXd4MDRYNHlI\nZUdGajVmTjU1aXJLNXpLS0docS9GZUZlSng0aVpaeW1rCkLpdGashC/Dmvjp2nOb\n3oH8GROhquOXPBJemxH4Xr3sZE4NzO5UdAf5ErBMLgmLCKEOM3A+DXOffGyHVPBf\ndZY=\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1yubikey1qd859y9ehz2ya8j2cftwrtmdeqhuk7r7yc52zp64wpff6068gwrac3q6nsa"
}
],
"lastmodified": "2026-07-28T09:34:28Z",
"mac": "ENC[AES256_GCM,data:/cKylOQQRWKIoKwOlL/SBe/cPWyIFixUDfeh/KOIHOSdJFa6dcV78IHZgiy+aNF4ogjX+y5cy1TXsUFiagFiBRpsO9IOLj6NhSWp8ox2q0vwj0Vjk5RF+SmASLSREuGe7MvsNwwesEkbvOpRE2PAJer518EGk9EvFwjUWE5Y7z8=,iv:GuHbdaf9hq0XBhniVsYZcfo9WstOQJM1UGbd9Gcsmgk=,tag:bZQiL2NJA6R254KAcajfgQ==,type:str]",
"version": "3.13.1"
}
}
@@ -0,0 +1 @@
../../../../../../sops/users/berwn
@@ -0,0 +1 @@
../../../../../../sops/groups/admins
@@ -0,0 +1 @@
../../../../../../sops/machines/gw-cnx-1
@@ -0,0 +1,22 @@
{
"data": "ENC[AES256_GCM,data:mmdooyUC9Kh7y64dTTBn1wtT,iv:syGCZO3EzGXKiBe/gudF93dc6oJz0wWxqpbd4UX2hcU=,tag:L1EzmPnx5B/vGscoKYYjeQ==,type:str]",
"sops": {
"age": [
{
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBaaXV3amcxV3M2dEFOLzhO\nV2prS0ZsbC9OYmg5TU5CbThDd3p5TGNGL0JVCnNMMkI5NU02a0RNZDdqcVUwRXVQ\nQURQOVB2MTg5QzJjeVpoZmJnUk93WncKLS0tIGJHOHdqVmRBQTJDMjdCWFFOeVkw\nekdEb2x5a0NseityMFVWeWFLckZsK2sKstOfMyYSgw2+DELgzHZl6GY5S0Ph45Jv\nTLQlyRPUd1IqvLVtmQOieUB+9e5k9TUTfLdvxUxIqSNKxcxVnJ7nsw==\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1g8a6fqhqg8q8qu4jyuhxc7hf00t5qvra55chyfm0qk45wlmvc9qs2cft8t"
},
{
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSByL0x4bHJNNVZPTEttK3A0\nNEc4Q2RQU09nWkFVQmxCL0NPem04ODNOY2l3ClJ0UTE4VEd0ZWNWSjdrbVhqcEF6\nbUYzQUFIeFJvT2QxMU8xNDlROE5WQ1EKLS0tIFlJSUR4NTBUSUFseUVNUk8wTkVi\nS29YWVJTM1o4VG01Y3pYSFBUb3dkQk0KM1VGs6ZIMO7/uj8aoLScsRevwhDBgWiW\nCLfEllrjicWomfjHBUBFeRKqKhvI9eWtBi64gNL+d+DUZ4NMx3fDyg==\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1hlzrpqqgndcthq5m5yj9egfgyet2fzrxwa6ynjzwx2r22uy6m3hqr3rd06"
},
{
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHBpdi1wMjU2IHFWcTVydyBBeFM0UDJU\nTytwOE82QUV5RjNaYkVaWDJhTDQxTWRoUzJ6VjN5R0tWa1phdgpUVCtsWW1paE4v\nMmdSbTZvbURaZUcwb3U3OXRMdXZLRHFXS1NPZmlZY3pJCi0tLSBYMysydUFORmJi\nbHVrU1BtaFBRNCtLYjRRVndjZGR4R1hYdHZicnNDQlg0CkTyVvREnp/73rMF7XOq\nsvP1jr09gIy5o3URUcv+IanFLkiBCZciyMoTnOJKJOwWlf+1SXBAKO86hUrgOvW2\nYOM=\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1yubikey1qd859y9ehz2ya8j2cftwrtmdeqhuk7r7yc52zp64wpff6068gwrac3q6nsa"
}
],
"lastmodified": "2026-07-28T09:34:28Z",
"mac": "ENC[AES256_GCM,data:EIGsqX6qnOocm0pxEZAsZf6tprPst88csaAIX0PnbgJSkO7DlYT6dC9tXeTtGSujkolAhLG9fB5FGfT1hCFMzHzgZCUbPaT9tei8X74XwC7nABqc5mSjQDS4vUGpoToH33yu3UStFQGVstOEG+FSjQpiY2fFKVJ+8u+Y2EcghDA=,iv:E56Tu6Fihtaj+NlZw8EQ3sam7SxRjPPZ/KgFjzBu4DE=,tag:hR5o2f/Jrigtp+Zjm695KA==,type:str]",
"version": "3.13.1"
}
}
@@ -0,0 +1 @@
../../../../../../sops/users/berwn
@@ -0,0 +1 @@
8804124153
@@ -0,0 +1 @@
../../../../../../sops/groups/admins
@@ -0,0 +1 @@
../../../../../../sops/machines/gw-cnx-1
@@ -0,0 +1,23 @@
{
"data": "ENC[AES256_GCM,data:HxPAe9vXu18iI/mvwCGgQFgcZjOdzUwNlwel/89e4eFD3RUQX3RaD+Vb3HylYQ/Y/gahdWM9NYBoOf/fOMTzXFN+D/VkQHrKXw==,iv:u6NuNha/Z60x5ig11O/S1mAO+P74u7vFvjhxR17KRE4=,tag:4ae0Si08BiHFMDNKRMLRTg==,type:str]",
"sops": {
"age": [
{
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHBpdi1wMjU2IHFWcTVydyBBL0QzY2lq\nU3NqRGY0dVpRZWtxRWhmNmZvd0FaSElBekl6UmdXVkkyeXp6RwpSamdORnluMlhK\nOEJ2OGNCZUh6Z1VjTHJoMytoOC9vcGFBMmtIVHlPRSt3Ci0tLSBxR1BCNy95NlZy\nek55bSs5OXRrSkdCWmpqVzFWMmtVTVd2a1NqNWh4MlhRCktog39bvtPgDWcSNLGr\nL36h37Q0h4/dYO1enkre0v7UvgVDjC0FmP5jQEUl1YEtCIvOSsSTPHYvzuQ5JUJB\nd5I=\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1yubikey1qd859y9ehz2ya8j2cftwrtmdeqhuk7r7yc52zp64wpff6068gwrac3q6nsa"
},
{
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB3L1RPcHlZbXQxMEh3WEFP\nRGhEYWhwRVhQTk5XM1N5NXZTMjh6cTErYWo4CkliZWcvZzVXNEhlaTgvTFpVc1M3\nNGdETDEyS1JlZnZ5eStQOHdhYTh0aE0KLS0tIG9mQ2hQaWR6dTUyRmNob0p3bE9R\nYmZwZGZ6TDZGdEtsTUVUMHdQOVMxbE0Ko48BMimAX8Zu5o0lUeJcpm/1G0Ed/P07\nOVVc5hHIQJCmgUOVvYQvA1uHdzfFOl4jChjwk7bejDXGoOrNwht57Q==\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1g8a6fqhqg8q8qu4jyuhxc7hf00t5qvra55chyfm0qk45wlmvc9qs2cft8t"
},
{
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB4WEFHNmg1SmhqOVkzbkpl\nUnpZZkNHMTcrMklzSGo3RkhzSG9sK3Njb25VCjgvb2NzeDJNKytXQ2RGRHRsRmM4\nZUw5RlRsblRaZ3Y5d052QnlGT3cxbzQKLS0tIFlQZ1FHSlUwM1FpOVNxK3dIK0h0\nVTFTQzhCRHcyc0NzNTFlbGF6TzN1MVUKTCUyyPE0YxTYEwORmK6c/wWuCvia3oJL\nWOTR149gsMpPfgaUqU5ncPyqgkR6fEF1T+iqaVdwOXuBPZJ8qlYCtQ==\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1hlzrpqqgndcthq5m5yj9egfgyet2fzrxwa6ynjzwx2r22uy6m3hqr3rd06"
}
],
"lastmodified": "2026-07-28T09:03:15Z",
"mac": "ENC[AES256_GCM,data:qPfMhfrfjpvZGqyrhDuudG6+GmGWThZD3sTAal/9E0x98FPg3GrJbD1UCmr2nWXltkrPDpBRZPfRjUzSKUEtH7Sy2mZ8m4mZgQTeS1wLUe6/oaid/zz9vRNbNXZS+UregW+6Xd1qWwfuZ/rcciNyR/ijRMj4Mm434ZvsuMirzgw=,iv:wT2TJ/ZwgvpmjHEkDOj5hrBO30IgbPcQ/yle06V4/8E=,tag:BkH1Blt9Mg73PjK3zpchRQ==,type:str]",
"unencrypted_suffix": "_unencrypted",
"version": "3.13.1"
}
}
@@ -0,0 +1 @@
../../../../../../sops/users/berwn
@@ -0,0 +1 @@
../../../../../../sops/groups/admins
@@ -0,0 +1,19 @@
{
"data": "ENC[AES256_GCM,data:Yr5kdmNaa42lDVwyTx/C7fKgluBKMBijLTO20y7mH8G+xsn1eQd+/fNl+FJks3QYiiE=,iv:FyZ7lLikZwxBo6V7gbGg0HX6/ljtowNX8M8H7K1/9nI=,tag:6N61vAWpayMPDojAfZvmOg==,type:str]",
"sops": {
"age": [
{
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHBpdi1wMjU2IHFWcTVydyBBbm1RcUI3\nWmRFSEdwSmRCamNKM2JNczN2Um9mUFhxMEk1dW95anpTZXNQNApETGtVUmFWTGZs\nNHpMTU5XUXhUN2lSQ0dIYXpMemFmVFhTLytyL1R2UWM0Ci0tLSBqWlkzMVJ2ODZp\nRTZBbjFxWUhRQkdNNktrRVpER0NEN3ZIY1RBVVllckl3CrQGPl5f4kv5Mt9omQ3A\niu9izVDlqt6H+knUpPBjEvCz3lJ6wE43V1513X7pabYese0WsBQY/PK8qBJpug2M\nro0=\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1yubikey1qd859y9ehz2ya8j2cftwrtmdeqhuk7r7yc52zp64wpff6068gwrac3q6nsa"
},
{
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBoWkxHaXFkOTBTVG9GTDYy\nV3NXNGttN2ovazMvUzNsU2hiaEcvUFNQZG1ZCmFiZVV5M09LNkc4QW5JOGQyQ3Fj\nejdNTjVrU2tZb2FpaEFJYklzZXhEWDAKLS0tIHZITnBuU093Wks1YTBUNnZkR1NF\ncFJWSzFqQW1CVWJCb3o5ZFZRR3RLZjgKsxstaYtfL4WA/WlkOmSq/5YT8Zu83iMG\nTQCNHCvHvwprtmRfeUe0x1wUXqynVPRRcxbQCi5KiHh5zZR2d73Dsg==\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1hlzrpqqgndcthq5m5yj9egfgyet2fzrxwa6ynjzwx2r22uy6m3hqr3rd06"
}
],
"lastmodified": "2026-07-28T09:03:15Z",
"mac": "ENC[AES256_GCM,data:A9lKOiRMDamC3QXSpRhS1x+xefOkk7ONCGG338dQ4J6tFRBKS8sJvMA/DlHxucCAKaWA6y8fcEf6k1Q0z+469OolrIAphG8TKYEKvOd7X5isvz/u6zXpwafV19mrHDcPB2WFGHM1g0A7wfSVzHnvwJ2vlcSfh/PYLiir1MbD+G4=,iv:C84tv9oUZ5uxg1ivfkCXgCamtl16UhPEVJ3OFIrAZQ0=,tag:cRT/RGqpvyzt3GHUYM9kuQ==,type:str]",
"unencrypted_suffix": "_unencrypted",
"version": "3.13.1"
}
}
@@ -0,0 +1 @@
../../../../../../sops/users/berwn
@@ -0,0 +1 @@
26.05
@@ -0,0 +1 @@
../../../../../../sops/groups/admins
@@ -0,0 +1 @@
../../../../../../sops/machines/gw-cnx-1
@@ -0,0 +1,23 @@
{
"data": "ENC[AES256_GCM,data:mXJtT9SvhkiJq5R1kONHBGCuAXaJb8fDrkDZxNw0uu7DFaYAzuhTd6OvjHj9dljgnZDQjAVBPDj3ykZNVvAv,iv:bGGww3Dx94EeTFRVXkT8mK/a+Ai961aM0Po0QxEUgr8=,tag:0loC/c+GoT6my1ESs+3Bnw==,type:str]",
"sops": {
"age": [
{
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHBpdi1wMjU2IHFWcTVydyBBdEtaRHh6\nN0RaNVpudDBYYk9iRG9QNGt0NHNuNlowNk5LbHZLSm9FOXByMgpiMHlRdGRnTjI1\nbUt4YWxDdGtjUnd5L2R2dnlCN3o2bUtzOTc2WWh1WVlRCi0tLSBWMjIyS3VhMkRm\nT0lkdldwVC93QzdMc0taZWRqZmJIaFFFSkx1Nnh0K0U0CidB2Un7vuPdC4+sX6q6\n+rCmDpFbPqWy+AENgrOdXlRnAcXH7dBWmgLGt85l6XtxAjIUAS9euWlLRkO0OpUY\nc7c=\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1yubikey1qd859y9ehz2ya8j2cftwrtmdeqhuk7r7yc52zp64wpff6068gwrac3q6nsa"
},
{
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBjalVSdlRvRFkvZkcwekR4\nOXF6MkczUWNSSTVFcFB6OWVBQ3MzemNqaFcwCmVKRUdxbldUY0JRQW0yR1MxeEJs\nejVRVmVyYUN4QUlJWjc4ZDRrTmZBK1kKLS0tIHU5RldrckFGbzNOWGZRaFB4cURs\nQUtuRC83MERUeTB6RFRRQVE0SnNCRUUKMh4JmS5Tv3Yluw2HtFSdnjp0rJHb+Pm9\njgWcRxX1a7/qxGaYDEKFJ30RW5O2xebiyE3f77YZUmfmfw0/qRUvmA==\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1hlzrpqqgndcthq5m5yj9egfgyet2fzrxwa6ynjzwx2r22uy6m3hqr3rd06"
},
{
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1YkllWEhjSStBSnU5eGcz\nQU9XRWVQOTg1Q29hK0pGd3RwNUhkUXFDd0RJCnE2TkduNkIwaDdYdjQwVVRpQ3lz\nWDVMaW9JZ085YTFtQjNhN3REK0t2MFEKLS0tIEVZYU5iWk9IV0JYYnhPK2UrcVps\nTk9qR0pqYmtXSnZmVDMvR1lVQXc2YWsKUBXa610I4RKHAUXrr3GOyHlnzOtxPxdW\n4+UgXK5ByrwqV5Ul+JKCmU2WpfcRYJlGVGGNcA1r37Df9QFRezgw0g==\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1g8a6fqhqg8q8qu4jyuhxc7hf00t5qvra55chyfm0qk45wlmvc9qs2cft8t"
}
],
"lastmodified": "2026-07-28T09:03:17Z",
"mac": "ENC[AES256_GCM,data:dEk6VblChVD9XtqnMWOCuzXtpF1kMJ4KNO8E/xxo6SxvIdR0FwjaPrL3B5mRKJRLX9IdehqfeDVfZykCS4oSfog6AGKCl+FtDu7lXZwJfV3JpM59VuaI48H0u1lMupw0jR6nkd6wAvwqXAGM/KKMD9cKs83WbSbp8wpow/Ejk38=,iv:QqTPg0n53MeMF5oI39jf4KKg5AkZuPeEqNrFpWhXdkI=,tag:hjX+wFgSRAbSWvMnxLXrGQ==,type:str]",
"unencrypted_suffix": "_unencrypted",
"version": "3.13.1"
}
}

Some files were not shown because too many files have changed in this diff Show More