From 4a2455898121f63532d1aeeeb4597db1847cc1aa Mon Sep 17 00:00:00 2001 From: m3ta-chiron Date: Wed, 12 Aug 2026 11:50:13 +0200 Subject: [PATCH] docs(thin-client): plan AD and WiFi bootstrap Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../2026-08-12-az-tc-01-ad-wifi-bootstrap.md | 634 ++++++++++++++++++ 1 file changed, 634 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-12-az-tc-01-ad-wifi-bootstrap.md diff --git a/docs/superpowers/plans/2026-08-12-az-tc-01-ad-wifi-bootstrap.md b/docs/superpowers/plans/2026-08-12-az-tc-01-ad-wifi-bootstrap.md new file mode 100644 index 0000000..1f4e4f4 --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-az-tc-01-ad-wifi-bootstrap.md @@ -0,0 +1,634 @@ +# AZ-TC-01 AD and WiFi Bootstrap Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Enable Active Directory on `AZ-TC-01` and connect the pilot to the temporary WPA-Personal network `Pluto` without requiring the deferred `Saturn` EAP-TLS certificate. + +**Architecture:** Keep the host in staging and opt into AD and WiFi only. Model WiFi authentication as an explicit `psk`/`eap-tls` mode: PSK mode decrypts a per-host password into `/run/agenix` and creates a root-only NetworkManager keyfile under `/run`, while EAP-TLS retains the existing certificate path. Generate the binary AD keytab outside Nix, encrypt it directly with agenix, and validate it before deployment. + +**Tech Stack:** NixOS modules, Nix flakes, agenix/age, NetworkManager/nmcli, systemd, SSSD, MIT Kerberos, adcli. + +--- + +## File map + +- `roles/thin-client/network/wifi.nix` — defines WiFi authentication modes and their mode-specific runtime configuration. +- `hosts/AZ-TC-01/default.nix` — opts the pilot into AD and PSK WiFi and records confirmed infrastructure values. +- `secrets.nix` — grants the host and administrators access to the new PSK secret. +- `secrets/AZ-TC-01-wifi-psk.age` — encrypted `Pluto` password; created interactively and safe to commit. +- `secrets/AZ-TC-01-krb5-keytab.age` — encrypted binary AD machine keytab; replaces the invalid current content. +- `roles/thin-client/README.md` — records the PSK bootstrap, correct realm/DCs, binary-keytab handling, and runtime checks. + +## Execution safety prerequisite + +The current working tree already contains broad, uncommitted provisioning work, including modifications to files in this plan. Do not reset, stash, or overwrite it. Before Task 1, either land that existing work in its own reviewed commits or create an exact checkpoint commit agreed with the owner. Then execute this plan on a dedicated branch/worktree. Every commit below must stage only the listed files and must be inspected with `git diff --cached` before committing. + +### Task 1: Configure the confirmed AD infrastructure on AZ-TC-01 + +**Files:** +- Modify: `hosts/AZ-TC-01/default.nix:11-24` + +- [ ] **Step 1: Verify the current host does not enable AD** + +Run: + +```bash +nix eval --json path:.#nixosConfigurations.AZ-TC-01.config.az.tc.features +``` + +Expected before implementation: JSON contains `"ad":false` and `"wifi":false`. + +- [ ] **Step 2: Add the pilot feature flags and confirmed AD values** + +Change the `az.tc` block in `hosts/AZ-TC-01/default.nix` to: + +```nix +az.tc = { + enable = true; + hardwareClass = "generic-x86_64-uefi"; + site = "staging"; + + features = { + ad = true; + wifi = false; + }; + + ad = { + domain = "az-group.local"; + realm = "AZ-GROUP.LOCAL"; + ou = "CN=Computers,DC=az-group,DC=local"; + domainControllers = [ + { + host = "azdc01.az-group.local"; + address = "192.168.152.253"; + } + { + host = "adpdc01.az-group.local"; + address = "192.168.152.254"; + } + ]; + }; +}; +``` + +Leave `networking.hostName`, `az.tc.deployment.diskDevice`, and `system.stateVersion` unchanged. + +- [ ] **Step 3: Evaluate the AD settings** + +Run: + +```bash +nix eval --raw path:.#nixosConfigurations.AZ-TC-01.config.az.tc.ad.realm +nix eval --json path:.#nixosConfigurations.AZ-TC-01.config.az.tc.ad.domainControllers +nix eval --json path:.#nixosConfigurations.AZ-TC-01.config.age.secrets +``` + +Expected: + +- First command prints `AZ-GROUP.LOCAL`. +- DC JSON contains both confirmed host/IP pairs. +- Required secrets contain `AZ-TC-01-krb5-keytab` but no WiFi certificate or PSK yet. + +- [ ] **Step 4: Commit the host AD configuration** + +```bash +git add hosts/AZ-TC-01/default.nix +git diff --cached +git commit -m "feat(thin-client): configure AZ-TC-01 Active Directory" +``` + +### Task 2: Add an explicit PSK WiFi mode + +**Files:** +- Modify: `roles/thin-client/network/wifi.nix:1-122` + +- [ ] **Step 1: Write the failing evaluation checks** + +Run these against the current module: + +```bash +nix eval --raw path:.#nixosConfigurations.AZ-TC-01.options.az.tc.wifi.mode.type.name +nix eval --raw path:.#nixosConfigurations.AZ-TC-01.config.systemd.services.wifi-psk-provision.description +``` + +Expected before implementation: both fail because `az.tc.wifi.mode` and `wifi-psk-provision` do not exist. + +- [ ] **Step 2: Add the mode option** + +In `options.az.tc.wifi`, before `ssid`, add: + +```nix +mode = mkOption { + type = types.enum ["psk" "eap-tls"]; + default = "eap-tls"; + description = "WiFi authentication mode: temporary WPA-Personal PSK or machine EAP-TLS."; +}; +``` + +Keeping `eap-tls` as the default preserves existing behavior for other hosts. + +- [ ] **Step 3: Split shared, EAP-TLS, and PSK configuration** + +Add `mkMerge` to the inherited lib functions: + +```nix +inherit (lib) mkIf mkMerge mkOption types; +``` + +Replace the current `config = mkIf ... { ... };` body with this shape, moving the existing EAP-TLS profile and certificate secret unchanged into the first mode-specific block: + +```nix +config = mkIf (cfg.enable && cfg.features.wifi) (mkMerge [ + { + networking.wireless.iwd.enable = false; + networking.networkmanager = { + enable = true; + wifi.backend = "wpa_supplicant"; + }; + + systemd.tmpfiles.rules = [ + "d /etc/wifi 0700 root root -" + "d /run/NetworkManager/system-connections 0700 root root -" + ]; + + systemd.services.NetworkManager = { + after = ["age-identity.service"]; + wants = ["age-identity.service"]; + }; + } + + (mkIf (cfg.wifi.mode == "eap-tls") { + environment.etc."NetworkManager/system-connections/${cfg.wifi.ssid}.nmconnection" = { + mode = "0600"; + source = pkgs.writeText "${cfg.wifi.ssid}.nmconnection" '' + [connection] + id=${cfg.wifi.ssid} + type=wifi + autoconnect=true + permissions= + + [wifi] + mode=infrastructure + ssid=${cfg.wifi.ssid} + + [wifi-security] + key-mgmt=wpa-eap + + [802-1x] + eap=tls + identity=${hostname}$ + ca-cert=${cfg.wifi.caCert} + client-cert=/etc/wifi/client.pem + private-key=/etc/wifi/client.pem + private-key-password= + phase2-auth= + + [ipv4] + method=auto + + [ipv6] + method=auto + ''; + }; + + age.secrets."${hostname}-wifi-client-cert" = { + file = ../../../secrets/${hostname}-wifi-client-cert.age; + path = "/etc/wifi/client.pem"; + mode = "0600"; + owner = "root"; + group = "root"; + }; + }) + + (mkIf (cfg.wifi.mode == "psk") { + age.secrets."${hostname}-wifi-psk" = { + file = ../../../secrets/${hostname}-wifi-psk.age; + mode = "0400"; + owner = "root"; + group = "root"; + }; + + systemd.services.wifi-psk-provision = { + description = "Provision the ${cfg.wifi.ssid} WPA-Personal connection"; + wantedBy = ["multi-user.target"]; + after = ["NetworkManager.service" "age-identity.service"]; + wants = ["NetworkManager.service" "age-identity.service"]; + path = [pkgs.coreutils pkgs.networkmanager]; + environment = { + WIFI_CONNECTION = "az-tc-${cfg.wifi.ssid}"; + WIFI_SSID = cfg.wifi.ssid; + WIFI_PSK_FILE = config.age.secrets."${hostname}-wifi-psk".path; + }; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + script = '' + set -eu + test -s "$WIFI_PSK_FILE" + install -d -m 0700 /run/NetworkManager/system-connections + profile="/run/NetworkManager/system-connections/$WIFI_CONNECTION.nmconnection" + umask 077 + { + printf '%s\n' \ + '[connection]' \ + "id=$WIFI_CONNECTION" \ + 'type=wifi' \ + 'autoconnect=true' \ + '' \ + '[wifi]' \ + 'mode=infrastructure' \ + "ssid=$WIFI_SSID" \ + '' \ + '[wifi-security]' \ + 'key-mgmt=wpa-psk' + printf 'psk=' + cat "$WIFI_PSK_FILE" + printf '%s\n' \ + '' \ + '[ipv4]' \ + 'method=auto' \ + '' \ + '[ipv6]' \ + 'method=auto' + } > "$profile" + chmod 0600 "$profile" + nmcli connection reload + nmcli connection up id "$WIFI_CONNECTION" + ''; + }; + }) +]); +``` + +The keyfile lives under `/run`, is root-only, and never enters the Nix store. The PSK is read from the agenix runtime file and is never passed as a command-line argument. + +- [ ] **Step 4: Format and evaluate the module** + +Run: + +```bash +nix fmt roles/thin-client/network/wifi.nix +nix eval --raw path:.#nixosConfigurations.AZ-TC-01.options.az.tc.wifi.mode.type.name +``` + +Expected: formatting succeeds and the option evaluates as an enum type. The service still does not exist because the host has not selected PSK mode. + +- [ ] **Step 5: Commit the mode implementation** + +```bash +git add roles/thin-client/network/wifi.nix +git diff --cached --check +git diff --cached +git commit -m "feat(thin-client): support WPA-PSK WiFi bootstrap" +``` + +### Task 3: Select Pluto and register its secret + +**Files:** +- Modify: `hosts/AZ-TC-01/default.nix:11-42` +- Modify: `secrets.nix:31-53` + +- [ ] **Step 1: Enable PSK WiFi for the pilot** + +In `hosts/AZ-TC-01/default.nix`, change the feature flag and add the WiFi settings inside `az.tc`: + +```nix +features = { + ad = true; + wifi = true; +}; + +wifi = { + mode = "psk"; + ssid = "Pluto"; +}; +``` + +- [ ] **Step 2: Register the encrypted PSK file** + +In `secrets.nix`, update the per-host secret documentation to include: + +```nix +# - -wifi-psk.age — temporary WPA-Personal password +``` + +Add this AZ-TC-01 recipient rule next to its other per-host secrets: + +```nix +"secrets/AZ-TC-01-wifi-psk.age".publicKeys = [AZ-TC-01] ++ users; +``` + +- [ ] **Step 3: Verify mode-specific secret selection** + +Run: + +```bash +nix eval --json path:.#nixosConfigurations.AZ-TC-01.config.age.secrets \ + | jq -r 'keys[]' \ + | sort +``` + +Expected output contains exactly these feature-specific entries: + +```text +AZ-TC-01-krb5-keytab +AZ-TC-01-wifi-psk +``` + +It must not contain `AZ-TC-01-wifi-client-cert`, NetBird, RustDesk, or monitoring secrets. + +Run: + +```bash +nix eval --raw path:.#nixosConfigurations.AZ-TC-01.config.systemd.services.wifi-psk-provision.description +``` + +Expected: `Provision the Pluto WPA-Personal connection`. + +- [ ] **Step 4: Commit host selection and recipient rule** + +```bash +git add hosts/AZ-TC-01/default.nix secrets.nix +git diff --cached --check +git diff --cached +git commit -m "feat(thin-client): bootstrap AZ-TC-01 on Pluto" +``` + +### Task 4: Create and validate the Pluto PSK secret + +**Files:** +- Create: `secrets/AZ-TC-01-wifi-psk.age` + +- [ ] **Step 1: Enter the password locally without putting it in shell history or chat** + +Run from the repository root in an interactive terminal: + +```bash +read -rsp 'Pluto WiFi password: ' WIFI_PSK +printf '\n' +printf '%s' "$WIFI_PSK" | nix develop --command agenix -e secrets/AZ-TC-01-wifi-psk.age +unset WIFI_PSK +``` + +Expected: agenix creates a non-empty encrypted file. Do not use `echo`, because it may add an unintended newline. + +- [ ] **Step 2: Verify the host identity can decrypt the secret without printing it** + +```bash +nix shell nixpkgs#age -c age --decrypt \ + -i .secrets/identities/AZ-TC-01.age \ + secrets/AZ-TC-01-wifi-psk.age \ + | test -s /dev/stdin +``` + +Expected: exit status `0` and no secret output. + +- [ ] **Step 3: Commit only the encrypted file** + +```bash +git add secrets/AZ-TC-01-wifi-psk.age +git diff --cached --stat +git commit -m "chore(secrets): add AZ-TC-01 Pluto credential" +``` + +### Task 5: Generate and replace the AD machine keytab + +**Files:** +- Modify: `secrets/AZ-TC-01-krb5-keytab.age` + +- [ ] **Step 1: Check AD discovery and clock before joining** + +On an admin workstation connected to the internal network, run: + +```bash +nix shell nixpkgs#adcli nixpkgs#krb5 nixpkgs#bind -c bash +dig +short SRV _kerberos._tcp.az-group.local @192.168.152.253 +dig +short SRV _ldap._tcp.dc._msdcs.az-group.local @192.168.152.253 +timedatectl status +``` + +Expected: both `azdc01.az-group.local` and `adpdc01.az-group.local` appear, and system time synchronization is active. + +- [ ] **Step 2: Obtain an administrator Kerberos ticket** + +```bash +kinit administrator@AZ-GROUP.LOCAL +klist +``` + +Expected: a valid TGT for `administrator@AZ-GROUP.LOCAL`. If the authorized join account has another name, substitute it consistently. + +- [ ] **Step 3: Create the default-container computer account and binary keytab** + +Because no ThinClients OU exists, omit `--domain-ou` and let AD use `CN=Computers`: + +```bash +umask 077 +adcli join \ + --domain=az-group.local \ + --domain-controller=azdc01.az-group.local \ + --host-fqdn=AZ-TC-01.az-group.local \ + --computer-name=AZ-TC-01 \ + --os-name=NixOS \ + --os-version=26.05 \ + --login-ccache="${KRB5CCNAME:-/tmp/krb5cc_$(id -u)}" \ + --host-keytab=/tmp/AZ-TC-01.keytab \ + --verbose +``` + +If the installed adcli exposes only the short option for the keytab path, replace `--host-keytab=/tmp/AZ-TC-01.keytab` with `-K /tmp/AZ-TC-01.keytab` after confirming via `adcli join --help`. + +- [ ] **Step 4: Validate the binary keytab before encryption** + +```bash +klist -k -e /tmp/AZ-TC-01.keytab +``` + +Expected: principals for the machine and FQDN in `AZ-GROUP.LOCAL`, including `AZ-TC-01$@AZ-GROUP.LOCAL` and `host/AZ-TC-01.az-group.local@AZ-GROUP.LOCAL`. + +- [ ] **Step 5: Encrypt the binary file directly and validate the round trip** + +```bash +nix develop --command agenix -e secrets/AZ-TC-01-krb5-keytab.age \ + < /tmp/AZ-TC-01.keytab + +verified_keytab="$(mktemp)" +chmod 0600 "$verified_keytab" +trap 'rm -f "$verified_keytab" /tmp/AZ-TC-01.keytab' EXIT +nix shell nixpkgs#age -c age --decrypt \ + -i .secrets/identities/AZ-TC-01.age \ + secrets/AZ-TC-01-krb5-keytab.age \ + > "$verified_keytab" +nix shell nixpkgs#krb5 -c klist -k -e "$verified_keytab" +``` + +Expected: the decrypted copy has the same valid principals. Never paste the keytab into an editor and never print its binary contents. + +- [ ] **Step 6: Remove plaintext and commit only the encrypted replacement** + +```bash +rm -f "$verified_keytab" /tmp/AZ-TC-01.keytab +trap - EXIT +git add secrets/AZ-TC-01-krb5-keytab.age +git diff --cached --stat +git commit -m "chore(secrets): provision AZ-TC-01 AD keytab" +``` + +### Task 6: Update the operator runbook + +**Files:** +- Modify: `roles/thin-client/README.md:21-25,39-180,186-194` + +- [ ] **Step 1: Correct the pilot infrastructure table and workflow** + +Document these exact facts: + +```markdown +- AD DNS domain: `az-group.local` +- Kerberos realm: `AZ-GROUP.LOCAL` +- DCs: `azdc01.az-group.local` (`192.168.152.253`) and `adpdc01.az-group.local` (`192.168.152.254`) +- Computer objects currently use the default `CN=Computers` container. +- Pilot WiFi uses WPA-Personal SSID `Pluto`; `Saturn` EAP-TLS is deferred. +``` + +Replace any instruction to paste a keytab into an editor with the binary-safe command: + +```bash +agenix -e secrets/AZ-TC-01-krb5-keytab.age < /tmp/AZ-TC-01.keytab +``` + +Add the local PSK creation and no-output decryption check from Task 4. State explicitly that the password must not be committed in plaintext or sent through chat. + +- [ ] **Step 2: Add post-boot AD checks** + +Add this operator checklist: + +```bash +nmcli --fields NAME,TYPE,DEVICE connection show --active +resolvectl query azdc01.az-group.local adpdc01.az-group.local +systemctl --no-pager --full status sssd +sssctl domain-status az-group.local +getent passwd '' +id '' +``` + +Explain that `` is replaced with a real non-administrator test account and that offline login is tested only after one successful online login. + +- [ ] **Step 3: Format/check and commit the runbook** + +```bash +git diff --check -- roles/thin-client/README.md +git add roles/thin-client/README.md +git diff --cached +git commit -m "docs(thin-client): document AD and Pluto bootstrap" +``` + +### Task 7: Run static and build verification + +**Files:** +- No new files. + +- [ ] **Step 1: Check formatting and module evaluation** + +```bash +nix fmt -- --check . +nix flake check +``` + +Expected: both commands exit `0`. + +- [ ] **Step 2: Confirm only the intended secrets are required** + +```bash +nix eval --json path:.#nixosConfigurations.AZ-TC-01.config.age.secrets \ + | jq -r 'keys[]' \ + | sort +``` + +Expected feature-specific keys: + +```text +AZ-TC-01-krb5-keytab +AZ-TC-01-wifi-psk +``` + +- [ ] **Step 3: Run local and remote deployment preflight** + +Boot the target from a supported live ISO, enable root SSH, identify its IP, and run: + +```bash +nix run .#deploy -- --check AZ-TC-01 root@ +``` + +Expected: evaluation, secret decryption, SSH, and target disk checks pass without modifying the target. + +- [ ] **Step 4: Build the host closure** + +```bash +nix build --no-link .#nixosConfigurations.AZ-TC-01.config.system.build.toplevel +``` + +Expected: exit `0`. + +- [ ] **Step 5: Review the implementation commits** + +```bash +git status --short +git log --oneline --decorate -8 +git diff origin/master...HEAD -- \ + hosts/AZ-TC-01/default.nix \ + roles/thin-client/network/wifi.nix \ + roles/thin-client/README.md \ + secrets.nix +``` + +Expected: no plaintext credential appears, no `Saturn` certificate is required, and unrelated pre-existing changes are not part of these commits. + +### Task 8: Deploy and verify the pilot + +**Files:** +- No new files. + +- [ ] **Step 1: Confirm the destructive disk target** + +On the live target: + +```bash +lsblk -o NAME,PATH,SIZE,TYPE,MODEL,SERIAL,MOUNTPOINTS +``` + +Expected: the intended disposable installation disk exactly matches `az.tc.deployment.diskDevice`. Stop if `/dev/nvme0n1` is not the intended disk. + +- [ ] **Step 2: Install through the confirmation-protected wrapper** + +```bash +nix run .#deploy -- AZ-TC-01 root@ +``` + +Expected: the wrapper displays hardware and requires typing `AZ-TC-01` before erasing the disk. + +- [ ] **Step 3: Verify WiFi, DNS, and SSSD after reboot** + +```bash +nmcli --fields NAME,TYPE,DEVICE connection show --active +resolvectl query azdc01.az-group.local adpdc01.az-group.local +sudo klist -k -e /etc/krb5.keytab +systemctl --no-pager --full status sssd wifi-psk-provision +sssctl domain-status az-group.local +``` + +Expected: `az-tc-Pluto` is active, both DC names resolve, the keytab contains `AZ-GROUP.LOCAL` principals, and both services are healthy. + +- [ ] **Step 4: Verify AD identity and login** + +```bash +getent passwd '' +id '' +``` + +Expected: both commands resolve the same real non-administrator AD account. Then log in once through SDDM while online, disconnect the network, and verify that the same account can log in from SSSD's credential cache. + +- [ ] **Step 5: Record any environment-specific failure as a follow-up issue** + +If DNS, keytab principals, GPO access, or WPA compatibility differs from the validated assumptions, capture the exact command output and open a focused `bd` issue. Do not weaken TLS, expose the PSK, or disable SSSD validation as a workaround.