Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5642a0f6fc | ||
|
|
df389266f6 | ||
|
|
81626a75db | ||
|
|
8171207e9c | ||
|
|
53c03af2a0 | ||
|
|
1a56a2d24b | ||
|
|
e316ec56ce | ||
|
|
559115e52b | ||
|
|
9cfcf91b51 | ||
|
|
570fe64497 | ||
|
|
2ac4215237 | ||
|
|
d51a334ec6 | ||
|
|
b81c0aaa89 | ||
|
|
fcd8e55024 | ||
|
|
19e1acda51 | ||
|
|
f5b6b4b55e | ||
|
|
3646d43ac5 | ||
|
|
d8d4a686eb | ||
|
|
b11ff75ca6 | ||
|
|
4735968433 | ||
|
|
02efeb454b | ||
|
|
868cd9eebd | ||
|
|
99fdde666c | ||
|
|
7b706eb3e7 | ||
|
|
6c89a7031a | ||
|
|
4ca5fd3afd |
@@ -36,6 +36,96 @@ in
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
gw-router = {
|
||||||
|
module = {
|
||||||
|
name = "gw-router";
|
||||||
|
input = "self";
|
||||||
|
};
|
||||||
|
roles.default.machines."gw-cnx-1" = {
|
||||||
|
settings = {
|
||||||
|
wan = {
|
||||||
|
interface = "enp1s0";
|
||||||
|
vlanId = null;
|
||||||
|
pppInterface = "enp1s0";
|
||||||
|
};
|
||||||
|
vlans = {
|
||||||
|
mgmt = {
|
||||||
|
id = 10;
|
||||||
|
address = "10.1.10.1";
|
||||||
|
prefixLength = 24;
|
||||||
|
subnet = "10.1.10.0/24";
|
||||||
|
dhcp.fixIPs.storinator01 = {
|
||||||
|
hwAddress = "7c:c2:55:e0:d6:40";
|
||||||
|
ipAddress = "10.1.10.53";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
lan = {
|
||||||
|
id = 20;
|
||||||
|
address = "10.1.20.1";
|
||||||
|
prefixLength = 24;
|
||||||
|
subnet = "10.1.20.0/24";
|
||||||
|
};
|
||||||
|
iot = {
|
||||||
|
id = 40;
|
||||||
|
address = "10.1.40.1";
|
||||||
|
prefixLength = 24;
|
||||||
|
subnet = "10.1.40.0/24";
|
||||||
|
};
|
||||||
|
voip = {
|
||||||
|
id = 50;
|
||||||
|
address = "10.1.50.1";
|
||||||
|
prefixLength = 24;
|
||||||
|
subnet = "10.1.50.0/24";
|
||||||
|
};
|
||||||
|
dmz = {
|
||||||
|
id = 60;
|
||||||
|
address = "10.1.60.1";
|
||||||
|
prefixLength = 24;
|
||||||
|
subnet = "10.1.60.0/24";
|
||||||
|
};
|
||||||
|
unit1 = {
|
||||||
|
id = 110;
|
||||||
|
address = "10.1.110.1";
|
||||||
|
prefixLength = 24;
|
||||||
|
subnet = "10.1.110.0/24";
|
||||||
|
};
|
||||||
|
unit2 = {
|
||||||
|
id = 120;
|
||||||
|
address = "10.1.120.1";
|
||||||
|
prefixLength = 24;
|
||||||
|
subnet = "10.1.120.0/24";
|
||||||
|
};
|
||||||
|
unit3 = {
|
||||||
|
id = 130;
|
||||||
|
address = "10.1.130.1";
|
||||||
|
prefixLength = 24;
|
||||||
|
subnet = "10.1.130.0/24";
|
||||||
|
};
|
||||||
|
unit4 = {
|
||||||
|
id = 140;
|
||||||
|
address = "10.1.140.1";
|
||||||
|
prefixLength = 24;
|
||||||
|
subnet = "10.1.140.0/24";
|
||||||
|
};
|
||||||
|
unit5 = {
|
||||||
|
id = 150;
|
||||||
|
address = "10.1.150.1";
|
||||||
|
prefixLength = 24;
|
||||||
|
subnet = "10.1.150.0/24";
|
||||||
|
dhcp.fixIPs.newt = {
|
||||||
|
hwAddress = "7c:d3:0a:21:58:0b";
|
||||||
|
ipAddress = "10.1.150.22";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
trunkPorts = [ "enp2s0" ];
|
||||||
|
accessPorts = {
|
||||||
|
enp4s0.vlanId = 10;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
root-user = {
|
root-user = {
|
||||||
module = {
|
module = {
|
||||||
name = "users";
|
name = "users";
|
||||||
|
|||||||
+54
-9
@@ -13,13 +13,16 @@ Naming: `gw-<city>-<n>`, e.g. `gw-cnx-1`.
|
|||||||
| Function | Implementation |
|
| 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 |
|
| 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) |
|
| 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 |
|
||||||
| Firewall/NAT | nftables: default-deny WAN, no inter-VLAN, MSS clamp, v4 NAT |
|
| Firewall/NAT | nftables: default-deny WAN, no inter-VLAN, MSS clamp, v4 NAT |
|
||||||
| DHCP | Kea, one subnet per VLAN |
|
| DHCP | Kea, one subnet per VLAN |
|
||||||
| DNS | Blocky (blocklist resolver), metrics on :4000 scraped by control |
|
| DNS | Blocky (blocklist resolver), metrics on :4000 scraped by control |
|
||||||
| IPv6 | DHCPv6-PD on ppp0, /64 per VLAN via SLAAC |
|
| IPv6 | DHCPv6-PD on ppp0, /64 per VLAN via SLAAC |
|
||||||
| Bans | CrowdSec + nftables bouncer (sshd log parsing) |
|
| Bans | CrowdSec + nftables bouncer (sshd log parsing) |
|
||||||
| Omada | Optional per site: TP-Link Omada controller as a podman container |
|
| Omada | Optional per site: TP-Link Omada controller as a podman container |
|
||||||
|
| 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 |
|
| Management | ZeroTier mesh: SSH, node_exporter, journald upload — like the fleet |
|
||||||
| Storage | Single-disk ZFS (zstd, `/var` its own dataset for snapshots) |
|
| Storage | Single-disk ZFS (zstd, `/var` its own dataset for snapshots) |
|
||||||
|
|
||||||
@@ -39,9 +42,13 @@ 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
|
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`.
|
`10.2.128.0/22`, pool `10.2.128.100 – 10.2.131.250`, `dhcp.leaseTime = 3600`.
|
||||||
|
|
||||||
| Site | siteId | mgmt | lan |
|
| Site | siteId | mgmt | lan | site-specific VLANs |
|
||||||
| ---- | ------ | -------------- | -------------- |
|
| ---- | ------ | -------------- | -------------- | --------------------------------------------------------------- |
|
||||||
| cnx | 1 | `10.1.10.0/24` | `10.1.20.0/24` |
|
| cnx | 1 | `10.1.10.0/24` | `10.1.20.0/24` | iot 40, voip 50, dmz 60, unit1–5 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
|
Trust model: mgmt → everything; other VLANs → router DNS/DHCP + internet only
|
||||||
(no inter-VLAN); WAN → nothing inbound; mesh → SSH, metrics, Omada UI.
|
(no inter-VLAN); WAN → nothing inbound; mesh → SSH, metrics, Omada UI.
|
||||||
@@ -57,17 +64,24 @@ Trust model: mgmt → everything; other VLANs → router DNS/DHCP + internet onl
|
|||||||
(dynamic PPPoE IP; clan connects over the mesh).
|
(dynamic PPPoE IP; clan connects over the mesh).
|
||||||
3. Add its node_exporter and Blocky scrape targets in
|
3. Add its node_exporter and Blocky scrape targets in
|
||||||
`modules/monitoring/server.nix`.
|
`modules/monitoring/server.nix`.
|
||||||
4. `git add` the new machine directory — flake evaluation only sees
|
4. If the site uses the internal proxy (`cnx.router.proxy.enable`), wire ns1:
|
||||||
|
import `(import ../../modules/dns/acme-gw-secret.nix "gw-<city>-<n>")` in
|
||||||
|
**both** the gateway's and ns1's configuration, 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.
|
git-tracked files, so an untracked `machines/gw-…/` is silently ignored.
|
||||||
5. `clan vars generate gw-<city>-<n>` — prompts for the site's PPPoE
|
6. `clan vars generate gw-<city>-<n>` — prompts for the site's PPPoE
|
||||||
credentials, mints the ZeroTier identity etc. (`nix flake check` fails
|
credentials, mints the ZeroTier identity etc. (`nix flake check` fails
|
||||||
until this has run, because mesh-hosts reads the ZeroTier IP var.)
|
until this has run, because mesh-hosts reads the ZeroTier IP var.)
|
||||||
6. Boot the box from a NixOS installer USB on the local network, then:
|
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>`
|
`clan machines install gw-<city>-<n> --target-host root@<lan-ip>`
|
||||||
7. Check `facter.json` for the real NIC names, fix `wan.interface` /
|
8. Check `facter.json` for the real NIC names, fix `wan.interface` /
|
||||||
`trunkPorts` if the enumeration differs, and
|
`trunkPorts` if the enumeration differs, and
|
||||||
`clan machines update gw-<city>-<n>` (rides the mesh from then on).
|
`clan machines update gw-<city>-<n>` (rides the mesh from then on).
|
||||||
8. Add a row to the site table above and to the machines table in
|
9. Add a row to the site table above and to the machines table in
|
||||||
[Overview](./overview.md).
|
[Overview](./overview.md).
|
||||||
|
|
||||||
## Omada controller
|
## Omada controller
|
||||||
@@ -80,6 +94,33 @@ 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
|
`/var/lib/omada`, declared as clan state (`clan.core.state.omada`); wiring it
|
||||||
into the borgbackup instance is still a follow-up.
|
into the borgbackup instance is still a follow-up.
|
||||||
|
|
||||||
|
## Internal reverse proxy
|
||||||
|
|
||||||
|
`cnx.router.proxy.enable` 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
|
||||||
|
cnx.router.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.
|
||||||
|
- **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.
|
||||||
|
|
||||||
## Runbook
|
## Runbook
|
||||||
|
|
||||||
- **PPPoE down**: `systemctl status pppd-wan`, `journalctl -u pppd-wan` on the
|
- **PPPoE down**: `systemctl status pppd-wan`, `journalctl -u pppd-wan` on the
|
||||||
@@ -89,6 +130,10 @@ into the borgbackup instance is still a follow-up.
|
|||||||
port's `bridgeVLANs` tagging and that the switch trunk carries the VLAN.
|
port's `bridgeVLANs` tagging and that the switch trunk carries the VLAN.
|
||||||
- **DNS/blocklist issues**: Blocky metrics are in VictoriaMetrics (job
|
- **DNS/blocklist issues**: Blocky metrics are in VictoriaMetrics (job
|
||||||
`blocky`); `journalctl -u blocky` on the gateway.
|
`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>`
|
- **Banned yourself**: `cscli decisions list` / `cscli decisions delete --ip <ip>`
|
||||||
on the gateway. The ZeroTier mesh `/88` is whitelisted at the parser stage
|
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.
|
(`cnx/mesh-whitelist`), so admin access over the mesh can never be banned.
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ there is picked up):
|
|||||||
- **CNX Uptime** (`uptime.json`) — per-host up/down status, current uptime,
|
- **CNX Uptime** (`uptime.json`) — per-host up/down status, current uptime,
|
||||||
availability over the selected window, and up/down history. Label-driven, so
|
availability over the selected window, and up/down history. Label-driven, so
|
||||||
every scraped host appears automatically.
|
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
|
- **parsedmarc** — DMARC aggregate/forensic report viewer. Auto-provisioned by
|
||||||
the `parsedmarc` module (not from `dashboards/`); reads its own Elasticsearch
|
the `parsedmarc` module (not from `dashboards/`); reads its own Elasticsearch
|
||||||
datasource, not VictoriaMetrics. See [DMARC reports](#dmarc-reports) below.
|
datasource, not VictoriaMetrics. See [DMARC reports](#dmarc-reports) below.
|
||||||
|
|||||||
Generated
+4
@@ -247,6 +247,10 @@
|
|||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"clan-core": "clan-core",
|
"clan-core": "clan-core",
|
||||||
|
"flake-parts": [
|
||||||
|
"clan-core",
|
||||||
|
"flake-parts"
|
||||||
|
],
|
||||||
"nixos-mailserver": "nixos-mailserver",
|
"nixos-mailserver": "nixos-mailserver",
|
||||||
"nixpkgs": [
|
"nixpkgs": [
|
||||||
"clan-core",
|
"clan-core",
|
||||||
|
|||||||
@@ -7,19 +7,24 @@
|
|||||||
inputs.nixos-mailserver.url = "gitlab:simple-nixos-mailserver/nixos-mailserver/nixos-26.05";
|
inputs.nixos-mailserver.url = "gitlab:simple-nixos-mailserver/nixos-mailserver/nixos-26.05";
|
||||||
inputs.nixos-mailserver.inputs.nixpkgs.follows = "nixpkgs";
|
inputs.nixos-mailserver.inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
|
||||||
|
inputs.flake-parts.follows = "clan-core/flake-parts";
|
||||||
|
|
||||||
outputs =
|
outputs =
|
||||||
{
|
inputs@{
|
||||||
self,
|
self,
|
||||||
clan-core,
|
clan-core,
|
||||||
nixpkgs,
|
nixpkgs,
|
||||||
|
flake-parts,
|
||||||
...
|
...
|
||||||
}@inputs:
|
}:
|
||||||
let
|
let
|
||||||
# Usage see: https://docs.clan.lol
|
# Usage see: https://docs.clan.lol
|
||||||
clan = clan-core.lib.clan {
|
clan = clan-core.lib.clan {
|
||||||
inherit self;
|
inherit self;
|
||||||
imports = [ ./clan.nix ];
|
imports = [
|
||||||
specialArgs = { inherit inputs; };
|
./clan.nix
|
||||||
|
];
|
||||||
|
specialArgs = { inherit inputs self; };
|
||||||
|
|
||||||
# Customize nixpkgs
|
# Customize nixpkgs
|
||||||
# pkgsForSystem =
|
# pkgsForSystem =
|
||||||
@@ -36,38 +41,35 @@
|
|||||||
"age-plugin-fido2-hmac"
|
"age-plugin-fido2-hmac"
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
in
|
||||||
|
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||||
systems = [
|
systems = [
|
||||||
"x86_64-linux"
|
"x86_64-linux"
|
||||||
"aarch64-linux"
|
"aarch64-linux"
|
||||||
"aarch64-darwin"
|
"aarch64-darwin"
|
||||||
"x86_64-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
|
|
||||||
{
|
|
||||||
inherit (clan.config) nixosConfigurations nixosModules clanInternals;
|
|
||||||
clan = clan.config;
|
|
||||||
|
|
||||||
# `nix fmt` and the `nix flake check` formatting gate.
|
imports = [
|
||||||
formatter = forAllSystems (system: (treefmtFor system).config.build.wrapper);
|
./modules/clan/flake-module.nix
|
||||||
checks = forAllSystems (system: {
|
];
|
||||||
formatting = (treefmtFor system).config.build.check self;
|
|
||||||
});
|
|
||||||
|
|
||||||
# Add the Clan cli tool to the dev shell.
|
# flake = {
|
||||||
# Use "nix develop" to enter the dev shell.
|
# inherit (clan.config) nixosConfigurations nixosModules clanInternals;
|
||||||
devShells = forAllSystems (
|
# clan = clan.config;
|
||||||
system:
|
# };
|
||||||
|
|
||||||
|
perSystem =
|
||||||
|
{ system, ... }:
|
||||||
let
|
let
|
||||||
|
pkgs = clan-core.inputs.nixpkgs.legacyPackages.${system};
|
||||||
|
treefmtEval = inputs.treefmt-nix.lib.evalModule pkgs ./fmt.nix;
|
||||||
clanCli = clan-core.packages.${system}.clan-cli;
|
clanCli = clan-core.packages.${system}.clan-cli;
|
||||||
# `clan machines update a b c` normally runs machines in parallel,
|
# `clan machines update a b c` normally runs machines in parallel,
|
||||||
# which interleaves their output and buries the YubiKey PIN prompts.
|
# which interleaves their output and buries the YubiKey PIN prompts.
|
||||||
# This wrapper (first in PATH) runs them one at a time instead; any
|
# This wrapper (first in PATH) runs them one at a time instead; any
|
||||||
# flags fall through to the real CLI untouched.
|
# flags fall through to the real CLI untouched.
|
||||||
clanSequential = (pkgsFor system).writeShellScriptBin "clan" ''
|
clanSequential = pkgs.writeShellScriptBin "clan" ''
|
||||||
if [ "$#" -gt 3 ] && [ "$1" = machines ] && [ "$2" = update ]; then
|
if [ "$#" -gt 3 ] && [ "$1" = machines ] && [ "$2" = update ]; then
|
||||||
shift 2
|
shift 2
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
@@ -84,14 +86,19 @@
|
|||||||
'';
|
'';
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
default = (pkgsFor system).mkShell {
|
# `nix fmt` and the `nix flake check` formatting gate.
|
||||||
|
formatter = treefmtEval.config.build.wrapper;
|
||||||
|
checks.formatting = treefmtEval.config.build.check self;
|
||||||
|
|
||||||
|
# Add the Clan cli tool to the dev shell.
|
||||||
|
# Use "nix develop" to enter the dev shell.
|
||||||
|
devShells.default = pkgs.mkShell {
|
||||||
packages = [
|
packages = [
|
||||||
clanSequential
|
clanSequential
|
||||||
clanCli
|
clanCli
|
||||||
(treefmtFor system).config.build.wrapper
|
treefmtEval.config.build.wrapper
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
},
|
},
|
||||||
"web01": {
|
"web01": {
|
||||||
"installedAt": 1781983723
|
"installedAt": 1781983723
|
||||||
|
},
|
||||||
|
"gw-cnx-1": {
|
||||||
|
"installedAt": 1785494267
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
imports = [
|
imports = [
|
||||||
../../modules/router
|
../../modules/router
|
||||||
../../modules/monitoring/exporters.nix
|
../../modules/monitoring/exporters.nix
|
||||||
|
(import ../../modules/dns/acme-gw-secret.nix "gw-cnx-1")
|
||||||
];
|
];
|
||||||
|
|
||||||
clan.core.sops.defaultGroups = [ "admins" ];
|
clan.core.sops.defaultGroups = [ "admins" ];
|
||||||
@@ -24,18 +25,65 @@
|
|||||||
site = "cnx";
|
site = "cnx";
|
||||||
siteId = 1;
|
siteId = 1;
|
||||||
wan.interface = "enp1s0";
|
wan.interface = "enp1s0";
|
||||||
wan.vlanId = 10; # AIS delivers PPPoE tagged on VLAN 10
|
wan.vlanId = null; # this ISP runs PPPoE untagged on the port
|
||||||
trunkPorts = [
|
trunkPorts = [
|
||||||
"enp2s0"
|
"enp2s0"
|
||||||
"enp3s0"
|
# "enp3s0" # STAGING: serves as the uplink below until cutover
|
||||||
"enp4s0"
|
|
||||||
];
|
];
|
||||||
|
# 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 = {
|
vlans = {
|
||||||
mgmt.id = 10; # 10.1.10.0/24 — APs, switches, Omada, admin
|
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
|
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.
|
# This site runs the Omada controller for its APs/switches.
|
||||||
omada.enable = true;
|
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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# STAGING (remove at cutover, and restore enp3s0 to trunkPorts): DHCP-client
|
||||||
|
# uplink into the existing OPNsense LAN so the box has internet + mesh while
|
||||||
|
# it runs alongside the old router. Default-deny firewall on this interface
|
||||||
|
# (it's in no VLAN zone); PPPoE simply retries until the WAN port is cabled.
|
||||||
|
# Do NOT connect the trunk ports to the production switch while staging —
|
||||||
|
# Kea on tag 10 would fight the OPNsense LAN DHCP in one broadcast domain.
|
||||||
|
systemd.network.networks."05-staging" = {
|
||||||
|
matchConfig.Name = "enp3s0";
|
||||||
|
networkConfig.DHCP = "ipv4";
|
||||||
};
|
};
|
||||||
|
|
||||||
time.timeZone = "Etc/GMT-7"; # UTC+7 (Thailand, fixed offset, no DST)
|
time.timeZone = "Etc/GMT-7"; # UTC+7 (Thailand, fixed offset, no DST)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ in
|
|||||||
../../modules/dns/authoritative.nix
|
../../modules/dns/authoritative.nix
|
||||||
../../modules/dns/acme-mx1-secret.nix
|
../../modules/dns/acme-mx1-secret.nix
|
||||||
../../modules/dns/acme-web01-secret.nix
|
../../modules/dns/acme-web01-secret.nix
|
||||||
|
(import ../../modules/dns/acme-gw-secret.nix "gw-cnx-1")
|
||||||
../../modules/static-ipv6.nix
|
../../modules/static-ipv6.nix
|
||||||
../../modules/monitoring/exporters.nix
|
../../modules/monitoring/exporters.nix
|
||||||
];
|
];
|
||||||
@@ -93,10 +94,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 = [
|
services.knot.keyFiles = [
|
||||||
config.clan.core.vars.generators.dns-acme-tsig.files."acme.conf".path
|
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-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-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 = [
|
services.knot.settings.acl = [
|
||||||
@@ -133,6 +153,17 @@ in
|
|||||||
# i.e. _acme-challenge at the cnx.network apex (where this acl is attached).
|
# i.e. _acme-challenge at the cnx.network apex (where this acl is attached).
|
||||||
"update-owner-name" = [ "_acme-challenge" ];
|
"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
|
# Automatic DNSSEC signing policy (primary only). ECDSA P-256/SHA-256 with
|
||||||
@@ -168,6 +199,9 @@ in
|
|||||||
"acl_acme"
|
"acl_acme"
|
||||||
]
|
]
|
||||||
++ lib.optionals (d == "cnx.email") [ "acl_acme_mx1" ]
|
++ 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;
|
}) domains;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
inputs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
{
|
||||||
|
imports =
|
||||||
|
let
|
||||||
|
# Get all subdirectories in the current directory
|
||||||
|
dirContents = builtins.readDir ./.;
|
||||||
|
|
||||||
|
# Filter to include only directories that have a flake-module.nix file
|
||||||
|
# and exclude special directories like 'result'
|
||||||
|
validModuleDirs = builtins.filter (
|
||||||
|
name:
|
||||||
|
name != "result"
|
||||||
|
&& dirContents.${name} == "directory"
|
||||||
|
&& builtins.pathExists (./. + "/${name}/flake-module.nix")
|
||||||
|
) (builtins.attrNames dirContents);
|
||||||
|
|
||||||
|
# Create import paths for each valid directory
|
||||||
|
imports = (map (name: ./. + "/${name}/flake-module.nix") validModuleDirs) ++ [
|
||||||
|
inputs.clan-core.flakeModules.testModule
|
||||||
|
];
|
||||||
|
in
|
||||||
|
imports;
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
{ ... }:
|
||||||
|
{
|
||||||
|
_class = "clan.service";
|
||||||
|
manifest.name = "gw-router";
|
||||||
|
manifest.description = "A gateway router service to configure most of a router features";
|
||||||
|
manifest.readme = "A gateway router service to configure most of a router features";
|
||||||
|
manifest.categories = [ "System" ];
|
||||||
|
|
||||||
|
roles.default = {
|
||||||
|
description = "Site gateway router role";
|
||||||
|
|
||||||
|
interface =
|
||||||
|
{ lib, config, ... }:
|
||||||
|
let
|
||||||
|
vlanModule =
|
||||||
|
{ ... }:
|
||||||
|
{
|
||||||
|
options = {
|
||||||
|
id = lib.mkOption {
|
||||||
|
type = lib.types.ints.between 1 4094;
|
||||||
|
description = "802.1Q VLAN id.";
|
||||||
|
};
|
||||||
|
address = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
example = "10.0.10.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;
|
||||||
|
example = "10.0.10.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;
|
||||||
|
example = "10.0.10.100";
|
||||||
|
};
|
||||||
|
to = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
example = "10.0.10.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.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
fixedIPs = 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.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options = {
|
||||||
|
wan = {
|
||||||
|
interface = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
description = "Physical WAN port the PPPoE session runs on.";
|
||||||
|
};
|
||||||
|
|
||||||
|
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.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
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.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
pppInterface = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
internal = true;
|
||||||
|
readOnly = true;
|
||||||
|
default = if config.wan.vlanId == null then config.wan.interface else "wan-vlan";
|
||||||
|
description = "Interface pppd dials on (the WAN port or its ISP VLAN).";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
vlans = lib.mkOption {
|
||||||
|
type = lib.types.attrsOf (lib.types.submodule vlanModule);
|
||||||
|
description = "VLANs setup for this router";
|
||||||
|
};
|
||||||
|
|
||||||
|
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.submodule {
|
||||||
|
options = {
|
||||||
|
vlanId = lib.mkOption {
|
||||||
|
type = lib.types.int;
|
||||||
|
description = "Untagged traffic in from the device gets tagged VLANs inside the bridge, and VLANs traffic going back out to the device gets untagged, so the device itself never has to know VLANs exist.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
description = ''
|
||||||
|
Ports acting as untagged access ports on a single VLAN (port name ->
|
||||||
|
VLAN id). Frames are untagged on the wire; the bridge tags them with
|
||||||
|
the VLAN's PVID. Use for an always-available on-site mgmt port.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
perInstance =
|
||||||
|
{ settings, ... }:
|
||||||
|
{
|
||||||
|
nixosModule =
|
||||||
|
{
|
||||||
|
pkgs,
|
||||||
|
lib,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
vlanIf = name: "vlan=${name}";
|
||||||
|
in
|
||||||
|
{
|
||||||
|
|
||||||
|
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 (settings.wan.vlanId != null) {
|
||||||
|
"15-wan-lan" = {
|
||||||
|
netdevConfig = {
|
||||||
|
Name = "wan-vlan";
|
||||||
|
Kind = "vlan";
|
||||||
|
};
|
||||||
|
vlanConfig.Id = settings.wan.vlanId;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// lib.mapAttrs' (
|
||||||
|
name: vlan:
|
||||||
|
lib.nameValuePair "30-${vlanIf name}" {
|
||||||
|
netdevConfig = {
|
||||||
|
Name = vlanIf name;
|
||||||
|
Kind = "vlan";
|
||||||
|
};
|
||||||
|
vlanConfig.Id = vlan.id;
|
||||||
|
}
|
||||||
|
) settings.vlans;
|
||||||
|
|
||||||
|
systemd.network.networks =
|
||||||
|
let
|
||||||
|
allVLANs = lib.mapAttrsToList (_: vlan: { VLAN = vlan.id; }) settings.vlans;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
"10-wan" = {
|
||||||
|
matchConfig.Name = settings.wan.interface;
|
||||||
|
networkConfig.LinkLocalAddressing = "no";
|
||||||
|
vlan = lib.optional (settings.wan.vlanId != null) "wan-wlan";
|
||||||
|
linkConfig = {
|
||||||
|
RequiredForOnline = "carrier";
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs (settings.wan.macAddress != null) {
|
||||||
|
MACAddress = settings.wan.macAddress;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs (settings.wan.vlanId != null) {
|
||||||
|
"15-wan-lan" = {
|
||||||
|
matchConfig.Name = "wan-vlan";
|
||||||
|
networkConfig.LinkLocalAddressing = "no";
|
||||||
|
linkConfig.RequiredForOnline = "no";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// {
|
||||||
|
"20-br0" = {
|
||||||
|
matchConfig.Name = "br0";
|
||||||
|
networkConfig.LinkLocalAddressing = "no";
|
||||||
|
vlan = lib.mapAttrsToList (name: _: vlanIf name) settings.vlans;
|
||||||
|
bridgeVLANs = allVLANs;
|
||||||
|
linkConfig.RequiredForOnline = "no";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// lib.listToAttrs (
|
||||||
|
map (port: {
|
||||||
|
name = "25-trunk-${port}";
|
||||||
|
value = {
|
||||||
|
matchConfig.Name = port;
|
||||||
|
networkConfig.Bridge = "br0";
|
||||||
|
bridgeVLANs = allVLANs;
|
||||||
|
linkConfig.RequiredForOnline = "no";
|
||||||
|
};
|
||||||
|
}) settings.trunkPorts
|
||||||
|
)
|
||||||
|
// lib.mapAttrs' (
|
||||||
|
iface: port:
|
||||||
|
lib.nameValuePair "20-access-${iface}" {
|
||||||
|
matchConfig.Name = port;
|
||||||
|
networkConfig.Bridge = "br0";
|
||||||
|
bridgeVLANs = [
|
||||||
|
{
|
||||||
|
VLAN = port.vlanId;
|
||||||
|
PVID = port.vlanId;
|
||||||
|
EgressUntagged = port.vlanId;
|
||||||
|
}
|
||||||
|
];
|
||||||
|
linkConfig.RequiredForOnline = "no";
|
||||||
|
}
|
||||||
|
) settings.accessPorts
|
||||||
|
// lib.mapAttrs' (
|
||||||
|
name: vlan:
|
||||||
|
lib.nameValuePair "40-${vlanIf name}" {
|
||||||
|
matchConfig.Name = vlanIf name;
|
||||||
|
address = [ "${vlan.address}/${toString vlan.prefixLength}" ];
|
||||||
|
networkConfig = {
|
||||||
|
IPv6AcceptRA = false;
|
||||||
|
IPv6SendRA = true;
|
||||||
|
DHCPPrefixDelegation = true;
|
||||||
|
};
|
||||||
|
dhcpPrefixDelegationConfig.SubnetId = "auto";
|
||||||
|
linkConfig.RequiredForOnline = "no";
|
||||||
|
}
|
||||||
|
) settings.vlans;
|
||||||
|
|
||||||
|
environment.systemPackages = with pkgs; [
|
||||||
|
tcpdump
|
||||||
|
mtr
|
||||||
|
ethtool
|
||||||
|
conntrack-tools
|
||||||
|
knot-dns
|
||||||
|
iftop
|
||||||
|
librespeed-cli
|
||||||
|
];
|
||||||
|
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
imports = [ ./pppoe.nix ];
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{ self, inputs, ... }:
|
||||||
|
let
|
||||||
|
module = ./default.nix;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
flake.clan.modules = {
|
||||||
|
gw-router = module;
|
||||||
|
};
|
||||||
|
# perSystem =
|
||||||
|
# { ... }:
|
||||||
|
# {
|
||||||
|
# clan.nixosTests.service-headplane = {
|
||||||
|
# imports = [ ./tests/vm/default.nix ];
|
||||||
|
# _module.args = { inherit self inputs; };
|
||||||
|
#
|
||||||
|
# clan.modules."@clan/headplane" = module;
|
||||||
|
# };
|
||||||
|
# };
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
roles.default.perInstance = { settings, ... }: {
|
||||||
|
nixosModule =
|
||||||
|
{ config, ... }:
|
||||||
|
let
|
||||||
|
creds = config.clan.core.vars.generators.gw-router;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
clan.core.vars.generators.gw-router = {
|
||||||
|
prompts.pppoe-username = {
|
||||||
|
description = "PPPoE username (from the ISP)";
|
||||||
|
type = "hidden";
|
||||||
|
};
|
||||||
|
prompts.pppoe-password = {
|
||||||
|
description = "PPPoE password (from the ISP)";
|
||||||
|
type = "hidden";
|
||||||
|
};
|
||||||
|
files."pppoe-username".secret = true;
|
||||||
|
files."pppoe-password".secret = true;
|
||||||
|
script = ''
|
||||||
|
user="$(cat "$prompts"/pppoe-username)"
|
||||||
|
pass="$(cat "$prompts"/pppoe-password)"
|
||||||
|
printf 'user "%s"\n' "$user" > "$out"/pppoe-username
|
||||||
|
printf '"%s" * "%s"\n' "$user" "$pass" > "$out"/pppoe-password
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
services.pppd = {
|
||||||
|
enable = true;
|
||||||
|
peers.wan = {
|
||||||
|
autostart = true;
|
||||||
|
config = ''
|
||||||
|
plugin pppoe.so ${settings.wan.pppInterface}
|
||||||
|
ifname ppp0
|
||||||
|
file ${creds.files."pppoe-username".path}
|
||||||
|
noipdefault
|
||||||
|
defaultroute
|
||||||
|
noauth
|
||||||
|
hide-password
|
||||||
|
persist
|
||||||
|
maxfail 0
|
||||||
|
holdoff 5
|
||||||
|
lcp-echo-interval 15
|
||||||
|
lcp-echo-failure 3
|
||||||
|
+ipv6
|
||||||
|
mtu 1492
|
||||||
|
mru 1492
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
environment.etc."ppp/chap-secrets".source = creds.files."pppoe-password".path;
|
||||||
|
environment.etc."ppp/pap-secrets".source = creds.files."pppoe-password".path;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Shared TSIG secret for a gateway's dedicated ACME key (function: machine
|
||||||
|
# name -> module). The acme_gw_<x> key lets that gateway — and only it — write
|
||||||
|
# _acme-challenge.<label> TXT records on ns1 to obtain its internal wildcard
|
||||||
|
# cert via DNS-01. ns1 scopes it with a matching acl on the cnx.network zone.
|
||||||
|
# Import on BOTH ns1 and the gateway machine, applied with the machine name:
|
||||||
|
# (import ../../modules/dns/acme-gw-secret.nix "gw-cnx-1")
|
||||||
|
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'';
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,4 +4,5 @@
|
|||||||
"cnx.network"
|
"cnx.network"
|
||||||
"buildfor.life"
|
"buildfor.life"
|
||||||
"cnx.email"
|
"cnx.email"
|
||||||
|
"newedge.house"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -59,6 +59,31 @@ in
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
# WAN quality on the site gateways, fed by the hourly speedtest timer
|
||||||
|
# (modules/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";
|
name = "backup";
|
||||||
rules = [
|
rules = [
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
{
|
{
|
||||||
config,
|
config,
|
||||||
lib,
|
lib,
|
||||||
|
pkgs,
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
@@ -67,6 +68,25 @@ let
|
|||||||
e.g. public-WiFi guest VLANs (3600-7200), so the pool recycles.
|
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 {
|
allowWan = lib.mkOption {
|
||||||
type = lib.types.bool;
|
type = lib.types.bool;
|
||||||
@@ -84,6 +104,9 @@ in
|
|||||||
./dns-dhcp.nix
|
./dns-dhcp.nix
|
||||||
./crowdsec.nix
|
./crowdsec.nix
|
||||||
./omada.nix
|
./omada.nix
|
||||||
|
./proxy.nix
|
||||||
|
./iperf.nix
|
||||||
|
./speedtest.nix
|
||||||
];
|
];
|
||||||
|
|
||||||
options.cnx.router = {
|
options.cnx.router = {
|
||||||
@@ -137,6 +160,19 @@ in
|
|||||||
description = "LAN ports carrying all VLANs tagged (incl. any 10G SFP+ ports).";
|
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.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
vlans = lib.mkOption {
|
vlans = lib.mkOption {
|
||||||
type = lib.types.attrsOf (lib.types.submodule vlanModule);
|
type = lib.types.attrsOf (lib.types.submodule vlanModule);
|
||||||
description = "VLANs served at this site; `mgmt` and `lan` are mandatory.";
|
description = "VLANs served at this site; `mgmt` and `lan` are mandatory.";
|
||||||
@@ -149,6 +185,27 @@ in
|
|||||||
assertion = cfg.vlans ? mgmt && cfg.vlans ? lan;
|
assertion = cfg.vlans ? mgmt && cfg.vlans ? lan;
|
||||||
message = "cnx.router: every site must define the `mgmt` and `lan` VLANs.";
|
message = "cnx.router: every site must define the `mgmt` and `lan` VLANs.";
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
assertion = lib.all (v: cfg.vlans ? ${v}) (lib.attrValues cfg.accessPorts);
|
||||||
|
message = "cnx.router: every accessPorts value must name a defined VLAN.";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
assertion = lib.all (p: !(cfg.accessPorts ? ${p})) cfg.trunkPorts;
|
||||||
|
message = "cnx.router: a port cannot be both a trunk and 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.useNetworkd = true;
|
||||||
@@ -234,6 +291,21 @@ in
|
|||||||
};
|
};
|
||||||
}) cfg.trunkPorts
|
}) 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' (
|
// lib.mapAttrs' (
|
||||||
name: vlan:
|
name: vlan:
|
||||||
lib.nameValuePair "40-${vlanIf name}" {
|
lib.nameValuePair "40-${vlanIf name}" {
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ in
|
|||||||
interface = "vlan-${name}";
|
interface = "vlan-${name}";
|
||||||
valid-lifetime = vlan.dhcp.leaseTime;
|
valid-lifetime = vlan.dhcp.leaseTime;
|
||||||
pools = [ { pool = "${vlan.dhcp.pool.from} - ${vlan.dhcp.pool.to}"; } ];
|
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 = [
|
option-data = [
|
||||||
{
|
{
|
||||||
name = "routers";
|
name = "routers";
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# 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).
|
||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
cfg = config.cnx.router;
|
||||||
|
mesh = import ../mesh-hosts.nix { inherit config lib; };
|
||||||
|
vlanIfs = lib.mapAttrsToList (name: _: "vlan-${name}") cfg.vlans;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
services.iperf3.enable = true;
|
||||||
|
|
||||||
|
networking.firewall.interfaces = lib.genAttrs vlanIfs (_: {
|
||||||
|
allowedTCPPorts = [ 5201 ];
|
||||||
|
allowedUDPPorts = [ 5201 ];
|
||||||
|
});
|
||||||
|
|
||||||
|
networking.firewall.extraInputRules = ''
|
||||||
|
ip6 saddr ${mesh.subnet} tcp dport 5201 accept comment "iperf3 over the mesh"
|
||||||
|
ip6 saddr ${mesh.subnet} udp dport 5201 accept comment "iperf3 over the mesh"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# Internal reverse proxy for the gateway: Caddy terminates TLS for
|
||||||
|
# <service>.<site><siteId>.cnx.network (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>.cnx.network) obtained via ACME DNS-01
|
||||||
|
# against ns1 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.
|
||||||
|
#
|
||||||
|
# Requires the machine to also import the shared secret generator:
|
||||||
|
# (import ../../modules/dns/acme-gw-secret.nix "<hostname>")
|
||||||
|
# and ns1 to hold the matching key + acl (see machines/ns1/configuration.nix).
|
||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
cfg = config.cnx.router;
|
||||||
|
hosts = import ../hosts.nix;
|
||||||
|
hostname = config.networking.hostName;
|
||||||
|
tsigKey = "acme_${lib.replaceStrings [ "-" ] [ "_" ] hostname}";
|
||||||
|
certName = "${cfg.site}${toString cfg.siteId}.cnx.network";
|
||||||
|
|
||||||
|
serviceModule = {
|
||||||
|
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).";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.cnx.router.proxy = {
|
||||||
|
enable = lib.mkEnableOption "internal reverse proxy (Caddy, wildcard cert via DNS-01)";
|
||||||
|
|
||||||
|
services = lib.mkOption {
|
||||||
|
type = lib.types.attrsOf (lib.types.submodule serviceModule);
|
||||||
|
default = { };
|
||||||
|
description = "Proxied services; attr name becomes <name>.${certName}.";
|
||||||
|
};
|
||||||
|
|
||||||
|
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).";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf (cfg.enable && cfg.proxy.enable) {
|
||||||
|
assertions = [
|
||||||
|
{
|
||||||
|
assertion = lib.all (v: cfg.vlans ? ${v}) cfg.proxy.allowVlans;
|
||||||
|
message = "cnx.router.proxy.allowVlans must name VLANs defined in cnx.router.vlans.";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
# Render the shared per-gateway TSIG secret into a lego rfc2136 env file;
|
||||||
|
# same pattern as web01 (modules/web-proxy.nix), scoped on ns1 to
|
||||||
|
# _acme-challenge.<site><siteId> TXT records only.
|
||||||
|
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=${hosts.ns1.ipv4}:53\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 = "postmaster@cnx.email";
|
||||||
|
# One wildcard for every proxied service; DNS-01 against ns1, 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;
|
||||||
|
# ns1 is the only nameserver that accepts this key's UPDATE; check
|
||||||
|
# propagation against it directly rather than a public resolver.
|
||||||
|
dnsResolver = "${hosts.ns1.ipv4}:53";
|
||||||
|
# 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
|
||||||
|
];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# 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).
|
||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
cfg = config.cnx.router;
|
||||||
|
textfileDir = "/var/lib/speedtest";
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.cnx.router.speedtest.interval = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "hourly";
|
||||||
|
description = "systemd OnCalendar spec for the WAN speed test.";
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../groups/admins
|
||||||
@@ -3,8 +3,12 @@
|
|||||||
"sops": {
|
"sops": {
|
||||||
"age": [
|
"age": [
|
||||||
{
|
{
|
||||||
"recipient": "age1yubikey1qd859y9ehz2ya8j2cftwrtmdeqhuk7r7yc52zp64wpff6068gwrac3q6nsa",
|
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBNRWlKNFVSOThSWlI3LzAz\ndnQxbnNGSlhoVmZrQmpIVEJieStlUzlseDJJCmdGbldCSU1oTGszZmtzSWhJaWta\nVDhoQU8vVXB0bG1VUkxoWDlyR0I1a0EKLS0tIDFzdklCTTBjeXBoT3FqVW1Pbkxl\nYVZ5Vm55OFp4YWwzS3dVNXRoZmpBT1EK0pNbk20gwPe5Q1bZR3XRHMWz+x+8FzRa\niq5tLcGkgfMO6WTzj8vtB7g0Q3sFgmPXTqb8yMzqAkZd4z9FKJGt6A==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHBpdi1wMjU2IHFWcTVydyBBcmJ4cXNt\nL3k4UmtqQUJZOGRoZFhab0pRSTdBUm9ZZkRxdHNvaCtRc05JVgphbHhHQThiRDNF\neW8xVGhXQ1pvSkRDR2hod2Zpa3pwaWZDVUpGZlpPWkNZCi0tLSB2T3JCcElnbVU2\nekJ5ZTdyQzZwbmR4aHBJUTc3M3loUm0wbHpHTGM0ZW1RCs0kHes9rkjd3uwHzxhi\n8x8AcQcQTR+CGsV5XPzGVMKKUZpwXWeNnvcHfVwYcqOnXucbUdCxLc6d57c9GQpC\nd3Y=\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",
|
"lastmodified": "2026-06-14T05:20:21Z",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../groups/admins
|
||||||
@@ -3,12 +3,17 @@
|
|||||||
"sops": {
|
"sops": {
|
||||||
"age": [
|
"age": [
|
||||||
{
|
{
|
||||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHBpdi1wMjU2IHFWcTVydyBBcC92dDND\nUFFiMGYzSE5zWFdoRXlSV1lQdHc4TDBab21wSWpxMGxsSDVweQplTW5lcUY3dXdR\nMUJPamMzREZlN0N6WTVUbE9kaXlZMVVDUFBRVnU5TkJjCi0tLSBzZW5SYXMrRTlT\nYmJLSDRlWTg5bFdNRmw1WkhpQVQ5Zi9GeG91SUtxM3RVClP9r+sAX8BVngpVGPJh\n/tYHqmSl67sFL5K6TJog1QQ1Gj2GiiTXE/kLTN+vsPbQaepqXamCKNrMXZWYOA2t\ntWw=\n-----END AGE ENCRYPTED FILE-----\n",
|
"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"
|
"recipient": "age1yubikey1qd859y9ehz2ya8j2cftwrtmdeqhuk7r7yc52zp64wpff6068gwrac3q6nsa"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"lastmodified": "2026-07-28T09:03:13Z",
|
"lastmodified": "2026-07-28T09:03:13Z",
|
||||||
"mac": "ENC[AES256_GCM,data:MKIyykYwtQqpUnXqZQKzKwoRhSon2PH0wYiTDTvogaVREHfpEPs/mdOjQBUHtbr1QGqWNh4AxdBTKfpjXTlncHAEtcIdVH3qPccVxsXqfqRmZPDFzcHZAIw+VSvDsKwD3anixiXDeHmeLawwbyb1rnS0P8KquXbmjvirwGUIQLU=,iv:+hdLXMJMCby/6Q6JGxp4p95GgPKbdA/OTAZloyylr08=,tag:ZZ4dKjhjpE2xqcoNf4qeoA==,type:str]",
|
"mac": "ENC[AES256_GCM,data:MKIyykYwtQqpUnXqZQKzKwoRhSon2PH0wYiTDTvogaVREHfpEPs/mdOjQBUHtbr1QGqWNh4AxdBTKfpjXTlncHAEtcIdVH3qPccVxsXqfqRmZPDFzcHZAIw+VSvDsKwD3anixiXDeHmeLawwbyb1rnS0P8KquXbmjvirwGUIQLU=,iv:+hdLXMJMCby/6Q6JGxp4p95GgPKbdA/OTAZloyylr08=,tag:ZZ4dKjhjpE2xqcoNf4qeoA==,type:str]",
|
||||||
|
"unencrypted_suffix": "_unencrypted",
|
||||||
"version": "3.13.1"
|
"version": "3.13.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
../../../groups/admins
|
||||||
@@ -3,8 +3,12 @@
|
|||||||
"sops": {
|
"sops": {
|
||||||
"age": [
|
"age": [
|
||||||
{
|
{
|
||||||
"recipient": "age1yubikey1qd859y9ehz2ya8j2cftwrtmdeqhuk7r7yc52zp64wpff6068gwrac3q6nsa",
|
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHBpdi1wMjU2IHFWcTVydyBBcU01TXl0\nRmU5ZlZOK3NZZE8vNktOWXBkRnZtZHlUbnBHYlVQbDdzZlRDbApFeFI0OE1KdndQ\nZWczZGRYZXBONTc2aG4vZTJEZ1hCdGljNFJBcmF0UGpRCi0tLSBGMGJVWUtJMWFP\nQUZwWVZTWUFKZnVJUTJSMHlRMGRNcExPSEpXTFpLSkVNCj1WEuRyATcxp0GYeoqC\nACpQpcqgIBVD1dYB0pPo+I6yDXvx+JbwVdgGl+2Cxj40HQOfyvJecoOO3efOZrB3\nUZU=\n-----END AGE ENCRYPTED FILE-----\n",
|
||||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHBpdi1wMjU2IHFWcTVydyBBemRVWUs1\naXFpVm80UmloUUpnR1BVaGsxampHV1JFNWFnSUhiY0orOTU0cgppVEJ5QVcvbGpC\nZlErd01XUzh0MnZOekkvTkc5Z3owS3pBbkNCUDJ6SlJRCi0tLSBRVVgrd3hIVEla\nMEhoN3FDSG1HdGk5YU8ydzlVUGd1RW5zM1lka3VuSHFNClrBItoFo7uqpS5fuvaG\nOvh6mTylk4bHgcSRGKTk383srgsvPdZaioJfUs7fbVmLD3+bhfM/Wgv7tI6kO1dj\nmDg=\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",
|
"lastmodified": "2026-06-14T05:20:24Z",
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
../../../groups/admins
|
||||||
@@ -3,8 +3,12 @@
|
|||||||
"sops": {
|
"sops": {
|
||||||
"age": [
|
"age": [
|
||||||
{
|
{
|
||||||
"recipient": "age1yubikey1qd859y9ehz2ya8j2cftwrtmdeqhuk7r7yc52zp64wpff6068gwrac3q6nsa",
|
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHBpdi1wMjU2IHFWcTVydyBBaTVGQWRC\nZUJocGx5Zjd0c1JpUEdHbGczNUh1RjRqSERRNHNSbnRndWFUMAoxbFBEU0Ezajhr\nSUlIRStWUUdVTDJMQWxmSkw0c0FNYnFxNEJIdnExTmx3Ci0tLSBMRGRPLzlXTEN3\ncHZCMGF3WDZTcFhTd2srL3Vpc2dRdExrVzFJcWhjQVJrCrA1y2SapkZoppI3z4LD\nH71D/WxIcTHp3fIzICNvgxWHn3+vkDDZcpcgH+gRiPCDqrgDp0NZcZiDlz6zyHGr\nmaM=\n-----END AGE ENCRYPTED FILE-----\n",
|
||||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHBpdi1wMjU2IHFWcTVydyBBL2doZFpO\na2Y2QzZSckJaZmZsTlFtQU1mckxJWTBiUXA2SUljVWZDemFJYgpwcFl6RHh2ZXdn\nNTBsaEVpWm5oYWtteU5WZmxWK1RRK2NoYW0yWlpCd3dBCi0tLSBsZVNpczR4dUdv\nSmtqQzMvc1ZYY0xUY0JMQmt2YzB5YmZOMEtXcW9vWUcwCnCdgcgiwF2rkWsV0IdO\n/6cs35FBypWpfflGwOwP0GnTrNizc9HphcMcwAi5NMQVi6X90Xgm5aFdpAsSWepX\n/SI=\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",
|
"lastmodified": "2026-06-14T05:20:27Z",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../sops/groups/admins
|
||||||
+1
@@ -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 @@
|
|||||||
|
../../../../../../sops/groups/admins
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../sops/machines/ns1
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"data": "ENC[AES256_GCM,data:Y0fYYbD4wqi/WNt+dzVFkJ6ZH4PLC34T3dbmPw0bVFCNCbNF/twHwALVihVc18Oc2PbLpudY5DKr3s0zwfoG7YzPn0Hlc6+KsotoG+8R71WRo3D9yZbGz2ajdDCuglfLbhFmuDAlQjAHFWVUV5HH,iv:CbNFBo0As6cu/1n/BuwqqMn8wbx76i1HZ1Y3g6DiBcA=,tag:0VlCjAK6ZwP0eDxRcqowvg==,type:str]",
|
||||||
|
"sops": {
|
||||||
|
"age": [
|
||||||
|
{
|
||||||
|
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB5NndmSXF2MWc2MEtZZ09u\nR0xQWUlGWWo5VFRMTFgwOCtFMVdJbXB0MlcwCktRNG9tQVJJOUlPbXlBbTFyb1NE\nSmZ2OVpXNUxUdjZhYTl1aG1DV3ZkNzAKLS0tIEtiOGVaMTZrbHBRZGVBRlQ2VE1G\ncWhkVzZ4Yk1GY3ZXNnJzcFk3NUs0N2sK44Dw4YMuUSiu+XVR/By1Tdm+ikGZr8sW\nsOr8BVOUH14yF3L5pGd3DoJIYPKqcaUiDpg6qSsqYXD8UYaB8c6d4Q==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||||
|
"recipient": "age1fanu282vm7njjweqhrpcfcwpttuhce8js4tsyfry98l0neaqpewqs5s7nt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBrSkppQU1sbm9WaDIrUGEx\nRjJWakpReXFCVTBaR2RqN3pNVDV6c1hySVVrCjdtRlFTMHNEc3J1NjMwczlvVWdx\nSVBjVXhGQVFFbXdEbVVFWHFHbEppQ3MKLS0tIG1DdzFqYURxNFBWQjF6NXl6YVU3\nZ2tYWjN4MVNMVGNjSmVJYWs2SnVjQVkK5kS5kpZO22EAAdokjM44g7hZy+bdOKrc\nrC6DjBwpI+BtJjXdO8nltBIXKhOBz+8sHI0XPuQP58UjKRD3YtaaJA==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||||
|
"recipient": "age1hlzrpqqgndcthq5m5yj9egfgyet2fzrxwa6ynjzwx2r22uy6m3hqr3rd06"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHBpdi1wMjU2IHFWcTVydyBBMnFiMFhr\nci9GdmZvRnhaVXNtR1NDcDZrcXhKd0V1Nm4wQnpUeWNiY253WApnUlJVNG4wZStX\nRkZIOEVBVTlNcS8zUWVqM0xldGRXVjNTWlZqWE5lTXpNCi0tLSBxSkVLY0k4dkpH\nbjRJOWhWekhhbXV4bldUWVE2UXRRbXlpeUJtUWVJcU1nCmxN/fEWLPJuzt6bYdNA\n+FUSn7Q3WLLgIGmCMxy+xxNBh3Sbu9ZRd9and2tNwcwBcCiywsrESQc5SD0RFqNU\ncqQ=\n-----END AGE ENCRYPTED FILE-----\n",
|
||||||
|
"recipient": "age1yubikey1qd859y9ehz2ya8j2cftwrtmdeqhuk7r7yc52zp64wpff6068gwrac3q6nsa"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lastmodified": "2026-07-31T03:07:42Z",
|
||||||
|
"mac": "ENC[AES256_GCM,data:0Ugi6vc7hWdXxCbVuRoq0yuAl15N0Udndf3QISlteWdiV9F3Cg99S/NpCa9PPk7EKOGJ9+Ow0mJAZVJqz9l5Zuvq4wpClU9hSe7VMuUO2sF8EambdEQtrHccV560DecFZn/lgYhnJIhJOUAKuqR7PoyGN5A+64vSc18KgNU8Euc=,iv:cnhktstJBZIAt1RU84fX8YV4IPrYPUQAjNGlXweNJFo=,tag:D2Qqyu+utno09ADAPXUkVA==,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 @@
|
|||||||
|
../../../../../sops/machines/ns1
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"data": "ENC[AES256_GCM,data:juFDgvxu7l6xf8wXVvlMz6m8bOaBAG3JTok/5YNwEIs/XxmL+GqkI0WuYr0=,iv:/Ga2ENxUUMK1U/WoYU8McatIPT9lRyFYfSZiTZTMIFs=,tag:Rxxkwu73i45HlCLBgSqi+g==,type:str]",
|
||||||
|
"sops": {
|
||||||
|
"age": [
|
||||||
|
{
|
||||||
|
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBmMXpyMko0d3NMeFZMc1kx\nWEtyZjVvWkpwK2tCNy9WQmNwRFpsb3hXUENzCjF1ZXhQRTVxSm40aStZLy9oWGxn\nMU5BSDFsd0ZNVTROVGs4SkcweFgvTDgKLS0tIHhEcEdScFluT1pGTTBXVW9jTzV5\nalhPT0FyRXNveHBwUTArMjhrNDJMR3cKiBBQheppYxyJ4uwS8MIWSuo7EGm3GYcc\nE4vdoIEp2NkDlPEWNudMKlV0fMoOdAt1psPFXUy37XEWLsOgJTIMEg==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||||
|
"recipient": "age1fanu282vm7njjweqhrpcfcwpttuhce8js4tsyfry98l0neaqpewqs5s7nt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBMWlNVS2VDR2pRUWdlUlFG\nUjJPNzl6YUpSRnc3bHRveU9Xd1kyTTU5N0I4CmZ1bDRzU2o3YVNFRnE0MGhFMVYx\ndXcrZGc4TEFJQjNwZEVDaW5hTVZTblEKLS0tIDlRbHkrdEFJM2NsckJ1d2VSb1Bh\na3Y4VXE0UjRVUU41ODBzeDc3T3AvcjQKBS9bhuSlag2xuUyhnPniY1EFIXzsSNDE\nONpxAmsuGm/aQ6u2+W+46Vm0Ow3Oc+cIkB35XeB0D9wXQjbJt8r0Rw==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||||
|
"recipient": "age1g8a6fqhqg8q8qu4jyuhxc7hf00t5qvra55chyfm0qk45wlmvc9qs2cft8t"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqcUlLTU5HOVFxTEwrSFQ5\nTkkzd0JxN0V5RkM5UXZ6OENnYU02T2Jtd0dzCjBmOEZ1SFBmdGZERVE2bThMREs4\nbmVWemlqRkFQVzBqTk9hZTd5QkhWSWMKLS0tIFcvdGVpQXBxSjZCbk52WVlhVnpD\nUjhxRWdZbk5tSTUxeUt4cHhQVlVFbHMKg0VVYZwf9ykVaZ17pqrha3HG7ZRjcRdk\nsTTuTZ9c+5IhBThrv24+8vkidQhJKSJY+aLiYL9BraKS86lvZSPzpg==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||||
|
"recipient": "age1hlzrpqqgndcthq5m5yj9egfgyet2fzrxwa6ynjzwx2r22uy6m3hqr3rd06"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHBpdi1wMjU2IHFWcTVydyBBN2RKU0FW\nakJJcXB6dHllbytBSldiV1NvZFFneEVtdmw5L2xQc1kvWnJwRQoyNGkyUUtUZ29K\nS2hudkhPTDVtMVg1ZTFpakxZb3BkM0JQYXBSS21iNEtNCi0tLSBVWWpGRUFpOEEy\nNU1SUmVOQnozcjdwcDJkQnY3bldFeHptTUJ2bjJPamlFCre4Bzl0E8yj1Ld79Snk\nTRE/1l2kep1/9ufwq59R1eyE7nTCmnuaMUj1nB2na64S1orjmVa69buBEQORaoNp\n8yg=\n-----END AGE ENCRYPTED FILE-----\n",
|
||||||
|
"recipient": "age1yubikey1qd859y9ehz2ya8j2cftwrtmdeqhuk7r7yc52zp64wpff6068gwrac3q6nsa"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lastmodified": "2026-07-31T03:07:39Z",
|
||||||
|
"mac": "ENC[AES256_GCM,data:MVoUIB0J8GvTEgEVdFD3CWmqaJkuvhx21ApnfMJgTABwKN/q+luPhn2i/2LI9S1NQx26k6fbiYtq5DypuOBfxW+wmeDwOMFVmA5tQLCuvzn6HR1hC3EMGAI5AmXulAdOWpP86R8tfVjrPNyb94iq6haNkFJ+N8Vacq5Iq8FyKyk=,iv:9vTa0xZD5wscJ614ELcTzu7SpMu7+BjRxx3soBwlMwA=,tag:/MPGGTQN2DLBubm93FY/LA==,type:str]",
|
||||||
|
"version": "3.13.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../../../sops/users/berwn
|
||||||
Reference in New Issue
Block a user