From 1a56a2d24b039424be90b0c5a28d0571623aec40 Mon Sep 17 00:00:00 2001 From: kurogeek Date: Wed, 5 Aug 2026 10:21:39 +0700 Subject: [PATCH] mob next [ci-skip] [ci skip] [skip ci] lastFile:modules/clan/gw-router/flake-module.nix --- clan.nix | 90 ++++++++ modules/clan/flake-module.nix | 26 +++ modules/clan/gw-router/default.nix | 291 ++++++++++++++++++++++++ modules/clan/gw-router/flake-module.nix | 19 ++ modules/clan/gw-router/pppoe.nix | 56 +++++ 5 files changed, 482 insertions(+) create mode 100644 modules/clan/flake-module.nix create mode 100644 modules/clan/gw-router/default.nix create mode 100644 modules/clan/gw-router/flake-module.nix create mode 100644 modules/clan/gw-router/pppoe.nix diff --git a/clan.nix b/clan.nix index 47badb2..89983fa 100644 --- a/clan.nix +++ b/clan.nix @@ -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 = { module = { name = "users"; diff --git a/modules/clan/flake-module.nix b/modules/clan/flake-module.nix new file mode 100644 index 0000000..1cf471b --- /dev/null +++ b/modules/clan/flake-module.nix @@ -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; +} diff --git a/modules/clan/gw-router/default.nix b/modules/clan/gw-router/default.nix new file mode 100644 index 0000000..5231b6c --- /dev/null +++ b/modules/clan/gw-router/default.nix @@ -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 ]; +} diff --git a/modules/clan/gw-router/flake-module.nix b/modules/clan/gw-router/flake-module.nix new file mode 100644 index 0000000..1f95cdc --- /dev/null +++ b/modules/clan/gw-router/flake-module.nix @@ -0,0 +1,19 @@ +{ self, inputs, ... }: +let + module = ./default.nix; +in +{ + 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; + # }; + # }; +} diff --git a/modules/clan/gw-router/pppoe.nix b/modules/clan/gw-router/pppoe.nix new file mode 100644 index 0000000..2abd082 --- /dev/null +++ b/modules/clan/gw-router/pppoe.nix @@ -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; + }; + }; +}