Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e316ec56ce | ||
|
|
559115e52b | ||
|
|
9cfcf91b51 | ||
|
|
570fe64497 | ||
|
|
2ac4215237 | ||
|
|
d51a334ec6 | ||
|
|
b81c0aaa89 | ||
|
|
fcd8e55024 | ||
|
|
19e1acda51 | ||
|
|
f5b6b4b55e |
@@ -13,7 +13,7 @@ Naming: `gw-<city>-<n>`, e.g. `gw-cnx-1`.
|
||||
| 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) |
|
||||
| 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 |
|
||||
| DHCP | Kea, one subnet per VLAN |
|
||||
| DNS | Blocky (blocklist resolver), metrics on :4000 scraped by control |
|
||||
@@ -42,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
|
||||
`10.2.128.0/22`, pool `10.2.128.100 – 10.2.131.250`, `dhcp.leaseTime = 3600`.
|
||||
|
||||
| Site | siteId | mgmt | lan |
|
||||
| ---- | ------ | -------------- | -------------- |
|
||||
| cnx | 1 | `10.1.10.0/24` | `10.1.20.0/24` |
|
||||
| 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, 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
|
||||
(no inter-VLAN); WAN → nothing inbound; mesh → SSH, metrics, Omada UI.
|
||||
|
||||
Generated
+4
@@ -247,6 +247,10 @@
|
||||
"root": {
|
||||
"inputs": {
|
||||
"clan-core": "clan-core",
|
||||
"flake-parts": [
|
||||
"clan-core",
|
||||
"flake-parts"
|
||||
],
|
||||
"nixos-mailserver": "nixos-mailserver",
|
||||
"nixpkgs": [
|
||||
"clan-core",
|
||||
|
||||
@@ -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,38 +39,31 @@
|
||||
"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
|
||||
{
|
||||
|
||||
flake = {
|
||||
inherit (clan.config) nixosConfigurations nixosModules clanInternals;
|
||||
clan = clan.config;
|
||||
};
|
||||
|
||||
# `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;
|
||||
});
|
||||
|
||||
# Add the Clan cli tool to the dev shell.
|
||||
# Use "nix develop" to enter the dev shell.
|
||||
devShells = forAllSystems (
|
||||
system:
|
||||
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 = (pkgsFor system).writeShellScriptBin "clan" ''
|
||||
clanSequential = pkgs.writeShellScriptBin "clan" ''
|
||||
if [ "$#" -gt 3 ] && [ "$1" = machines ] && [ "$2" = update ]; then
|
||||
shift 2
|
||||
for arg in "$@"; do
|
||||
@@ -84,14 +80,19 @@
|
||||
'';
|
||||
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 = [
|
||||
clanSequential
|
||||
clanCli
|
||||
(treefmtFor system).config.build.wrapper
|
||||
treefmtEval.config.build.wrapper
|
||||
];
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
},
|
||||
"web01": {
|
||||
"installedAt": 1781983723
|
||||
},
|
||||
"gw-cnx-1": {
|
||||
"installedAt": 1785494267
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,15 +25,40 @@
|
||||
site = "cnx";
|
||||
siteId = 1;
|
||||
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 = [
|
||||
"enp2s0"
|
||||
"enp3s0"
|
||||
"enp4s0"
|
||||
# "enp3s0" # STAGING: serves as the uplink below until cutover
|
||||
];
|
||||
# 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 — 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
|
||||
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;
|
||||
@@ -50,6 +75,17 @@
|
||||
};
|
||||
};
|
||||
|
||||
# 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)
|
||||
services.chrony.enable = true;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,4 +4,5 @@
|
||||
"cnx.network"
|
||||
"buildfor.life"
|
||||
"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.
|
||||
@@ -68,6 +68,25 @@ let
|
||||
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;
|
||||
@@ -141,6 +160,19 @@ in
|
||||
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 {
|
||||
type = lib.types.attrsOf (lib.types.submodule vlanModule);
|
||||
description = "VLANs served at this site; `mgmt` and `lan` are mandatory.";
|
||||
@@ -153,6 +185,14 @@ in
|
||||
assertion = cfg.vlans ? mgmt && cfg.vlans ? lan;
|
||||
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
|
||||
@@ -251,6 +291,21 @@ in
|
||||
};
|
||||
}) 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}" {
|
||||
|
||||
@@ -29,6 +29,11 @@ in
|
||||
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";
|
||||
|
||||
Reference in New Issue
Block a user