Author SHA1 Message Date
kurogeek 5642a0f6fc mob next [ci-skip] [ci skip] [skip ci]
lastFile:machines/gw-cnx-1/configuration.nix
2026-08-06 11:23:46 +07:00
kurogeek df389266f6 mob next [ci-skip] [ci skip] [skip ci]
lastFile:machines/gw-cnx-1/configuration.nix
2026-08-06 11:23:37 +07:00
kurogeek 81626a75db mob next [ci-skip] [ci skip] [skip ci]
lastFile:modules/clan/gw-router/flake-module.nix
2026-08-06 11:23:14 +07:00
kurogeek 8171207e9c mob next [ci-skip] [ci skip] [skip ci]
lastFile:flake.nix
2026-08-05 11:31:09 +07:00
kurogeek 53c03af2a0 mob next [ci-skip] [ci skip] [skip ci]
lastFile:flake.nix
2026-08-05 10:57:28 +07:00
kurogeek 1a56a2d24b mob next [ci-skip] [ci skip] [skip ci]
lastFile:modules/clan/gw-router/flake-module.nix
2026-08-05 10:21:39 +07:00
kurogeek e316ec56ce flake: use flake-parts 2026-08-05 10:19:32 +07:00
kurogeek 559115e52b inventory.json: update install time of gw-cnx-1 2026-07-31 17:37:47 +07:00
kurogeek 9cfcf91b51 inventory.json: update install time of gw-cnx-1 2026-07-31 16:08:14 +07:00
kurogeek 570fe64497 inventory.json: update install time of gw-cnx-1 2026-07-31 15:09:07 +07:00
kurogeek 2ac4215237 inventory.json: update install time of gw-cnx-1 2026-07-31 13:03:43 +07:00
kurogeek d51a334ec6 inventory.json: update install time of gw-cnx-1 2026-07-31 13:00:57 +07:00
kurogeek b81c0aaa89 machines/gw-cnx-1/facter.json: update hardware configuration 2026-07-31 13:00:57 +07:00
Berwn fcd8e55024 Add untagged access ports and a staging uplink for gw-cnx-1
New cnx.router.accessPorts option pins a port untagged to one VLAN via
bridge PVID/EgressUntagged; convention is the last copper port as an
always-available mgmt recovery port. gw-cnx-1 port roles: enp1s0 WAN,
enp2s0 trunk, enp3s0 temporary DHCP uplink into the old OPNsense LAN
(back to trunk at cutover), enp4s0 untagged mgmt.
2026-07-31 12:57:34 +07:00
12 changed files with 4515 additions and 29 deletions
+90
View File
@@ -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";
+1 -1
View File
@@ -13,7 +13,7 @@ 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 |
Generated
+4
View File
@@ -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",
+33 -26
View File
@@ -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
]; ];
}; };
} };
);
}; };
} }
+3
View File
@@ -14,6 +14,9 @@
}, },
"web01": { "web01": {
"installedAt": 1781983723 "installedAt": 1781983723
},
"gw-cnx-1": {
"installedAt": 1785494267
} }
} }
} }
+15 -2
View File
@@ -28,9 +28,11 @@
wan.vlanId = null; # this ISP runs PPPoE untagged on the port 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 # Replaces the newedge.house OPNsense box; renumbered to the fleet
# convention (10.1.<vlanId>.0/24, router .1, pool .100-.199). The old # convention (10.1.<vlanId>.0/24, router .1, pool .100-.199). The old
# untagged LAN becomes tagged mgmt — infra switch ports get PVID 10. # untagged LAN becomes tagged mgmt — infra switch ports get PVID 10.
@@ -73,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) time.timeZone = "Etc/GMT-7"; # UTC+7 (Thailand, fixed offset, no DST)
services.chrony.enable = true; services.chrony.enable = true;
} }
File diff suppressed because it is too large Load Diff
+26
View File
@@ -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;
}
+291
View File
@@ -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 ];
}
+19
View File
@@ -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;
# };
# };
}
+56
View File
@@ -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;
};
};
}
+36
View File
@@ -160,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.";
@@ -172,6 +185,14 @@ 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 # Router diagnostics toolkit: packets (tcpdump), path (mtr), link
@@ -270,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}" {