From c10f016dadd1e033cac6cb567be60edc2c4ca434 Mon Sep 17 00:00:00 2001
From: Elias Coppens <elias@dgnum.eu>
Date: Sat, 1 Mar 2025 15:59:59 +0100
Subject: [PATCH] feat: add test AP

Signed-off-by: Elias Coppens <elias@dgnum.eu>
---
 hive.nix                                    |   7 +-
 machines/liminix/ap-test/_configuration.nix |  46 ++++++++
 machines/liminix/ap-test/addresses.nix      |  24 ++++
 machines/liminix/ap-test/dns.nix            |  25 ++++
 machines/liminix/ap-test/ipc.nix            |  12 ++
 machines/liminix/ap-test/lan.nix            |  20 ++++
 machines/liminix/ap-test/management.nix     |  20 ++++
 machines/liminix/ap-test/metadata.nix       |  19 ++++
 machines/liminix/ap-test/recovery.nix       |  68 +++++++++++
 machines/liminix/ap-test/system.nix         |  33 ++++++
 machines/liminix/ap-test/wlan.nix           | 120 ++++++++++++++++++++
 meta/nodes/liminix.nix                      |  16 ++-
 12 files changed, 408 insertions(+), 2 deletions(-)
 create mode 100644 machines/liminix/ap-test/_configuration.nix
 create mode 100644 machines/liminix/ap-test/addresses.nix
 create mode 100644 machines/liminix/ap-test/dns.nix
 create mode 100644 machines/liminix/ap-test/ipc.nix
 create mode 100644 machines/liminix/ap-test/lan.nix
 create mode 100644 machines/liminix/ap-test/management.nix
 create mode 100644 machines/liminix/ap-test/metadata.nix
 create mode 100644 machines/liminix/ap-test/recovery.nix
 create mode 100644 machines/liminix/ap-test/system.nix
 create mode 100644 machines/liminix/ap-test/wlan.nix

diff --git a/hive.nix b/hive.nix
index 666c79d..dd4ca6b 100644
--- a/hive.nix
+++ b/hive.nix
@@ -116,7 +116,12 @@ in
           # Import the default modules
           imports = [
             # Import the base configuration for each node
-            ./machines/liminix/ap-v01/_configuration.nix
+            (
+              if name != "ap-test" then
+                ./machines/liminix/ap-v01/_configuration.nix
+              else
+                ./machines/liminix/ap-test/_configuration.nix
+            )
             ./modules/generic
             ./modules/${category name}
           ];
diff --git a/machines/liminix/ap-test/_configuration.nix b/machines/liminix/ap-test/_configuration.nix
new file mode 100644
index 0000000..6a420c5
--- /dev/null
+++ b/machines/liminix/ap-test/_configuration.nix
@@ -0,0 +1,46 @@
+# SPDX-FileCopyrightText: 2024 Ryan Lahfa <ryan.lahfa@dgnum.eu>
+#
+# SPDX-License-Identifier: EUPL-1.2
+
+{
+  modulesPath,
+  sourcePkgs,
+  name,
+  ...
+}:
+{
+  imports = [
+    "${modulesPath}/wlan.nix"
+    "${modulesPath}/network"
+    "${modulesPath}/hostapd"
+    "${modulesPath}/ssh"
+    "${modulesPath}/ntp"
+    "${modulesPath}/vlan"
+    "${modulesPath}/bridge"
+    "${modulesPath}/jitter-rng"
+    "${modulesPath}/pki"
+    "${modulesPath}/ubus"
+    "${modulesPath}/openwrt-prometheus-exporter"
+    # System-level configuration
+    ./system.nix
+    # Configures our own WLAN.
+    ./wlan.nix
+    # Configures our LAN interfaces, e.g. bridge + VLANs.
+    ./lan.nix
+    # Configures our IPv4/IPv6 addresses, e.g. DHCPv4 on VLAN 0, SLAAC on VLAN 3001.
+    ./addresses.nix
+    # Configures a basic local DNS.
+    ./dns.nix
+    # Configures our management layer, e.g. SSH server + DGNum FAI keys.
+    ./management.nix
+    # Configures our recovery system, e.g. a levitation script.
+    ./recovery.nix
+    # Metadata on the system for field recovery.
+    ./metadata.nix
+    # TODO: god that's so a fucking hack.
+    (import "${modulesPath}/../devices/zyxel-nwa50ax").module
+  ];
+
+  hostname = name;
+  nixpkgs.source = sourcePkgs.path;
+}
diff --git a/machines/liminix/ap-test/addresses.nix b/machines/liminix/ap-test/addresses.nix
new file mode 100644
index 0000000..fb27045
--- /dev/null
+++ b/machines/liminix/ap-test/addresses.nix
@@ -0,0 +1,24 @@
+# SPDX-FileCopyrightText: 2024 Ryan Lahfa <ryan.lahfa@dgnum.eu>
+#
+# SPDX-License-Identifier: EUPL-1.2
+
+{ config, ... }:
+let
+  svc = config.system.service;
+  # FIXME switch to ipv6 tu be able to scale
+  adminIp = "10.0.253.253";
+in
+{
+  services.admin-ip = svc.network.address.build {
+    interface = config.services.admin-vlan;
+    address = adminIp;
+    prefixLength = 24;
+    family = "inet";
+  };
+
+  services.admin-defaultroute4 = svc.network.route.build {
+    via = "10.0.253.1";
+    target = "default";
+    dependencies = [ config.services.admin-ip ];
+  };
+}
diff --git a/machines/liminix/ap-test/dns.nix b/machines/liminix/ap-test/dns.nix
new file mode 100644
index 0000000..b371292
--- /dev/null
+++ b/machines/liminix/ap-test/dns.nix
@@ -0,0 +1,25 @@
+# SPDX-FileCopyrightText: 2024 Ryan Lahfa <ryan.lahfa@dgnum.eu>
+#
+# SPDX-License-Identifier: EUPL-1.2
+
+{ pkgs, lib, ... }:
+let
+  inherit (pkgs.pseudofile) dir symlink;
+  # TODO: imho, DNS should be static and provided by the router?
+  dns = [
+    "8.8.8.8"
+    "8.8.4.4"
+    "1.0.0.1"
+  ];
+  resolvconf = pkgs.writeText "resolv.conf" (
+    lib.concatMapStringsSep "\n" (dns: ''echo "nameserver ${dns}" >> resolv.conf'') dns
+  );
+in
+{
+  # TODO: support dynamic reconfiguration once we are in the target VLAN?
+  filesystem = dir {
+    etc = dir {
+      "resolv.conf" = symlink "${resolvconf}";
+    };
+  };
+}
diff --git a/machines/liminix/ap-test/ipc.nix b/machines/liminix/ap-test/ipc.nix
new file mode 100644
index 0000000..43c918c
--- /dev/null
+++ b/machines/liminix/ap-test/ipc.nix
@@ -0,0 +1,12 @@
+# SPDX-FileCopyrightText: 2024 Ryan Lahfa <ryan.lahfa@dgnum.eu>
+#
+# SPDX-License-Identifier: EUPL-1.2
+
+{ config, ... }:
+let
+  svc = config.system.service;
+in
+{
+  # ubus socket for various needs.
+  services.ubus = svc.ubus.build { };
+}
diff --git a/machines/liminix/ap-test/lan.nix b/machines/liminix/ap-test/lan.nix
new file mode 100644
index 0000000..1d73ece
--- /dev/null
+++ b/machines/liminix/ap-test/lan.nix
@@ -0,0 +1,20 @@
+# SPDX-FileCopyrightText: 2024 Ryan Lahfa <ryan.lahfa@dgnum.eu>
+#
+# SPDX-License-Identifier: EUPL-1.2
+
+{ config, ... }:
+let
+  svc = config.system.service;
+in
+{
+  # Our bridging is a bit complicated, therefore, we need iproute2.
+  programs.iproute2.enable = true;
+
+  services = {
+    admin-vlan = svc.vlan.build {
+      ifname = "admin";
+      primary = config.hardware.networkInterfaces.lan;
+      vid = "3001";
+    };
+  };
+}
diff --git a/machines/liminix/ap-test/management.nix b/machines/liminix/ap-test/management.nix
new file mode 100644
index 0000000..0fe6d87
--- /dev/null
+++ b/machines/liminix/ap-test/management.nix
@@ -0,0 +1,20 @@
+# SPDX-FileCopyrightText: 2024 Ryan Lahfa <ryan.lahfa@dgnum.eu>
+#
+# SPDX-License-Identifier: EUPL-1.2
+
+{ config, ... }:
+let
+  svc = config.system.service;
+in
+{
+  # SSH keys are handled by the access control module.
+  dgn-access-control.enable = true;
+  users.root = {
+    # TODO: Change this well-known password
+    passwd = "$6$Z2MiaMXkpUJRPl2/$fxVE3iD/n208CISM2F6OnWj0Qq0QG2tTQqLCjU80PFJJGIwNLLyOp6SeYH3dH20OvJX1loZRETrThZfIPw.rb/";
+  };
+  services.sshd = svc.ssh.build { allowRoot = true; };
+  services.openwrt-prometheus-exporter = svc.openwrt-prometheus-exporter.build {
+    httpPorts = [ 9100 ];
+  };
+}
diff --git a/machines/liminix/ap-test/metadata.nix b/machines/liminix/ap-test/metadata.nix
new file mode 100644
index 0000000..80dce50
--- /dev/null
+++ b/machines/liminix/ap-test/metadata.nix
@@ -0,0 +1,19 @@
+# SPDX-FileCopyrightText: 2024 Ryan Lahfa <ryan.lahfa@dgnum.eu>
+#
+# SPDX-License-Identifier: EUPL-1.2
+
+{ pkgs, ... }:
+let
+  inherit (pkgs.pseudofile) dir;
+in
+{
+  filesystem = dir {
+    etc = dir {
+      "nixpkgs.version" = {
+        type = "f";
+        file = "${pkgs.lib.version}";
+        mode = "0444";
+      };
+    };
+  };
+}
diff --git a/machines/liminix/ap-test/recovery.nix b/machines/liminix/ap-test/recovery.nix
new file mode 100644
index 0000000..35edaa7
--- /dev/null
+++ b/machines/liminix/ap-test/recovery.nix
@@ -0,0 +1,68 @@
+# SPDX-FileCopyrightText: 2024 Ryan Lahfa <ryan.lahfa@dgnum.eu>
+#
+# SPDX-License-Identifier: EUPL-1.2
+
+{
+  config,
+  pkgs,
+  modulesPath,
+  ...
+}:
+let
+  svc = config.system.service;
+  parentConfig = config;
+in
+{
+  defaultProfile.packages = [
+    # Levitate enable us to mass-reinstall the system on the fly.
+    # TODO: Test levitation
+    (pkgs.levitate.override {
+      config = {
+        imports = [
+          "${modulesPath}/network"
+          "${modulesPath}/ssh"
+          "${modulesPath}/hardware.nix"
+          "${modulesPath}/kernel"
+          "${modulesPath}/outputs/tftpboot.nix"
+          "${modulesPath}/outputs.nix"
+          # FIXME: DHCP has a hidden deps on this, shoud be done in a more intelligent way upstream
+          "${modulesPath}/iproute2.nix"
+          (
+            { config, ... }:
+            {
+              # FIXME: DHCP has a hidden deps on this, shoud be done in a more intelligent way upstream
+              programs.iproute2.enable = true;
+              services = {
+                # In this situation, we fallback to the appro VLAN but keep admin vlan.
+                # Simplest DHCPv4 we can find.
+                dhcpv4 = svc.network.dhcp.client.build {
+                  interface = parentConfig.hardware.networkInterfaces.lan;
+                };
+                inherit (parentConfig.services)
+                  sshd
+                  admin-vlan
+                  admin-ip
+                  admin-defaultroute4
+                  ;
+                defaultroute4 = svc.network.route.build {
+                  via = "$(output ${config.services.dhcpv4} router)";
+                  target = "default";
+                  dependencies = [ config.services.dhcpv4 ];
+                };
+              };
+            }
+          )
+        ];
+        hostname = "${parentConfig.hostname}-live";
+        nixpkgs.buildPlatform = builtins.currentSystem;
+
+        defaultProfile.packages = with pkgs; [
+          mtdutils
+          zyxel-bootconfig
+        ];
+        # Only keep root, which should inherit from DGN access control's root permissions.
+        users.root = config.users.root;
+      };
+    })
+  ];
+}
diff --git a/machines/liminix/ap-test/system.nix b/machines/liminix/ap-test/system.nix
new file mode 100644
index 0000000..0d258b8
--- /dev/null
+++ b/machines/liminix/ap-test/system.nix
@@ -0,0 +1,33 @@
+# SPDX-FileCopyrightText: 2024 Ryan Lahfa <ryan.lahfa@dgnum.eu>
+#
+# SPDX-License-Identifier: EUPL-1.2
+
+{ pkgs, config, ... }:
+let
+  svc = config.system.service;
+in
+{
+  # Get moar random please
+  services = {
+    jitter = svc.jitter-rng.build { };
+    packet_forwarding = svc.network.forward.build { };
+    ntp = config.system.service.ntp.build {
+      pools = {
+        "pool.ntp.org" = [ "iburst" ];
+      };
+
+      dependencies = [ config.services.jitter ];
+    };
+  };
+
+  boot.tftp = {
+    serverip = "192.0.2.10";
+    ipaddr = "192.0.2.12";
+  };
+
+  defaultProfile.packages = with pkgs; [
+    zyxel-bootconfig
+    min-collect-garbage
+    hostapd-radius
+  ];
+}
diff --git a/machines/liminix/ap-test/wlan.nix b/machines/liminix/ap-test/wlan.nix
new file mode 100644
index 0000000..cfb562f
--- /dev/null
+++ b/machines/liminix/ap-test/wlan.nix
@@ -0,0 +1,120 @@
+# SPDX-FileCopyrightText: 2024 Ryan Lahfa <ryan.lahfa@dgnum.eu>
+#
+# SPDX-License-Identifier: EUPL-1.2
+
+{
+  config,
+  pkgs,
+  lib,
+  ...
+}:
+let
+  svc = config.system.service;
+
+  mac-1 = "02:5B:6A:FF:FF:FE";
+  mac-2 = "02:5B:6A:FF:FF:FF";
+
+  channel-1 = 1;
+  channel-2 = 36;
+
+  secrets-1 = {
+    ssid = "DGNum";
+  };
+  secrets-2 = {
+    ssid = "DGNum 5G";
+  };
+  baseParams = {
+    country_code = "FR";
+    hw_mode = "g";
+    channel = channel-1;
+    wmm_enabled = 1;
+    ieee80211n = 1;
+    ht_capab = "[LDPC][GF][HT40-][HT40+][SHORT-GI-40][MAX-AMSDU-7935][TX-STBC]";
+    auth_algs = 1;
+    wpa = 2;
+    wpa_pairwise = "TKIP CCMP";
+    rsn_pairwise = "CCMP";
+  };
+
+  radiusKeyMgmt = {
+    wpa_key_mgmt = "WPA-EAP";
+  };
+
+  modernParams = {
+    hw_mode = "a";
+    he_su_beamformer = 1;
+    he_su_beamformee = 1;
+    he_mu_beamformer = 1;
+    preamble = 1;
+    # Allow radar detection.
+    ieee80211d = 1;
+    ieee80211h = 1;
+    ieee80211ac = 1;
+    ieee80211ax = 1;
+    vht_capab = "[MAX-MPDU-7991][SU-BEAMFORMEE][SU-BEAMFORMER][RXLDPC][SHORT-GI-80][MAX-A-MPDU-LEN-EXP3][RX-ANTENNA-PATTERN][TX-ANTENNA-PATTERN][TX-STBC-2BY1][RX-STBC-1][MU-BEAMFORMER]";
+    vht_oper_chwidth = 1;
+    he_oper_chwidth = 1;
+    channel = channel-2; # TODO understand interferences
+    vht_oper_centr_freq_seg0_idx = channel-2 + 6;
+    he_oper_centr_freq_seg0_idx = channel-2 + 6;
+    require_vht = 1;
+  };
+
+  clientRadius = {
+    ieee8021x = 1;
+    eapol_version = 2;
+    use_pae_group_addr = 1;
+    dynamic_vlan = 3;
+    vlan_tagged_interface = "lan";
+  };
+
+  externalRadius = {
+    # TODO: when we have proper IPAM, set the right value here.
+    own_ip_addr = "127.0.0.1";
+    nas_identifier = "ap01.dgnum.eu";
+
+    # No DNS here, hostapd do not support this mode.
+    auth_server_addr = "129.199.195.129";
+    auth_server_port = 1812;
+    auth_server_shared_secret =
+      let
+        secret = builtins.getEnv "RADIUS_SECRET";
+      in
+      if secret == "" then
+        lib.warn "Using a dummy RADIUS secret. Please do not use in production" "DUMMYSECRET"
+      else
+        secret;
+  };
+
+  mkWifiSta =
+    params: interface: secrets:
+    svc.hostapd.build {
+      inherit interface;
+      package = pkgs.hostapd-radius;
+      params = params // secrets;
+      dependencies = [ config.services.jitter ];
+    };
+in
+{
+  hardware.wlanMacAddresses = {
+    wlan0 = mac-1;
+    wlan1 = mac-2;
+  };
+  services = {
+    # wlan0 is the 2.4GHz interface.
+    hostap-1 = mkWifiSta (
+      baseParams // clientRadius // externalRadius // radiusKeyMgmt
+    ) config.hardware.networkInterfaces.wlan0 secrets-1;
+    hostap-1-ready = svc.hostapd-ready.build {
+      interface = config.hardware.networkInterfaces.wlan0;
+    };
+    # wlan1 is the 5GHz interface, e.g. AX capable.
+    hostap-2 = mkWifiSta (
+      baseParams // clientRadius // externalRadius // radiusKeyMgmt // modernParams
+    ) config.hardware.networkInterfaces.wlan1 secrets-2;
+    # Oneshot that waits until the hostapd has set the interface in operational state.
+    hostap-2-ready = svc.hostapd-ready.build {
+      interface = config.hardware.networkInterfaces.wlan1;
+    };
+  };
+}
diff --git a/meta/nodes/liminix.nix b/meta/nodes/liminix.nix
index c421af3..e771936 100644
--- a/meta/nodes/liminix.nix
+++ b/meta/nodes/liminix.nix
@@ -128,5 +128,19 @@ let
 
   mkRange = { from, to }: builtins.genList (x: x + from) (to - from);
 in
-{ }
+{
+  ap-test = {
+    site = "unknown";
+    adminGroups = [ "fai" ];
+
+    hashedPassword = "$y$j9T$DMOQEWOYFHjNS0myrXp4x/$MG33VSdXGvib.99eN.AbvyVdNNJw4ERjAwK4.ULJe/A";
+
+    stateVersion = null;
+
+    nixpkgs = {
+      system = "zyxel-nwa50ax";
+      version = "24.05";
+    };
+  };
+}
 // builtins.foldl' (nodes: building: nodes // mkAPs-building building) { } (builtins.attrValues APs)