Files
AZ-NIX-CLIENTS/docs/superpowers/plans/2026-08-12-az-tc-01-ad-wifi-bootstrap.md
T

19 KiB

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:

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:

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:

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

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:

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:

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:

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:

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:

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
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:

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:

#   - <host>-wifi-psk.age          — temporary WPA-Personal password

Add this AZ-TC-01 recipient rule next to its other per-host secrets:

"secrets/AZ-TC-01-wifi-psk.age".publicKeys = [AZ-TC-01] ++ users;
  • Step 3: Verify mode-specific secret selection

Run:

nix eval --json path:.#nixosConfigurations.AZ-TC-01.config.age.secrets \
  | jq -r 'keys[]' \
  | sort

Expected output contains exactly these feature-specific entries:

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:

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
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:

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
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
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:

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
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:

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
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
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
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:

- 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:

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:

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 '<known-ad-user>'
id '<known-ad-user>'

Explain that <known-ad-user> 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
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

nix fmt -- --check .
nix flake check

Expected: both commands exit 0.

  • Step 2: Confirm only the intended secrets are required
nix eval --json path:.#nixosConfigurations.AZ-TC-01.config.age.secrets \
  | jq -r 'keys[]' \
  | sort

Expected feature-specific keys:

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:

nix run .#deploy -- --check AZ-TC-01 root@<target-ip>

Expected: evaluation, secret decryption, SSH, and target disk checks pass without modifying the target.

  • Step 4: Build the host closure
nix build --no-link .#nixosConfigurations.AZ-TC-01.config.system.build.toplevel

Expected: exit 0.

  • Step 5: Review the implementation commits
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:

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
nix run .#deploy -- AZ-TC-01 root@<target-ip>

Expected: the wrapper displays hardware and requires typing AZ-TC-01 before erasing the disk.

  • Step 3: Verify WiFi, DNS, and SSSD after reboot
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
getent passwd '<known-ad-user>'
id '<known-ad-user>'

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.