From 98dc2917e818bfe5001533e1cd37ec3d3fdc5381 Mon Sep 17 00:00:00 2001 From: m3ta-chiron Date: Wed, 29 Jul 2026 08:34:01 +0200 Subject: [PATCH] feat: +thin-client role + AZ-TC-01..03 pilot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auf Basis des Grilling-Sessions mit 20 design decisions umgesetztes v1-Skeleton der Thin-Client-Rolle plus 3 Pilot-Hosts. Architektur: - roles/thin-client/ als geschlossene Rolle (default.nix compose + 7 Subdirectories: hardware, session, identity, network, peripherals, apps, monitoring, deployment) - hosts/AZ-TC-NN/default.nix als ~20-Zeilen-Wrapper pro Fleet-Host - flake.nix instanziiert AZ-TC-01..03 via Fleet-Helper - secrets.nix mit per-host agenix-Secret-Stubs Submodule: - hardware: dell-optiplex-micro + generic-x86_64-uefi Fallback - identity: AD (sssd/krb5/keytab via agenix), lokale Notfalluser (sascha.koenig + jannik.mueller ohne m3ta-home), sudo-Policy - session: KDE Plasma 6 + Wayland + SDDM, Branding (Wallpaper + Footer), PipeWire Audio - network: NetworkManager + wpa_supplicant + 802.1X EAP-TLS, NetBird + SSH via NetBird, systemd-resolved (Corp + NetBird DNS), hardened firewall, OpenSSH - peripherals: CUPS mit Pull-Print-Queue, pam_mount für DFS-Shares - apps: Chromium (ManagedBookmarks, Bitwarden force-install, no local passwords), Office-Web .desktop-Shortcuts, Remmina (mehrere TS, Kerberos SSO), RustDesk Client + Daemon, OBS Studio, Autostart - monitoring: node_exporter → Pushgateway, Alloy (stub für Loki), Snipe-IT Asset-Checkin (stub) - deployment: Disko BTRFS-Layout, auto-upgrade daily + reboot window, snapper snapshots Build-Validierung: 'nix flake check' bestanden für AZ-TC-01/02/03. Siehe roles/thin-client/README.md für den Provisionierungs-Workflow und die Liste der noch auszufüllenden Platzhalter (TODO-Kommentare in den jeweiligen Modulen). --- flake.nix | 32 +++ hosts/AZ-TC-01/default.nix | 23 ++ hosts/AZ-TC-02/default.nix | 15 ++ hosts/AZ-TC-03/default.nix | 15 ++ roles/thin-client/README.md | 214 ++++++++++++++++++ roles/thin-client/apps/autostart.nix | 42 ++++ roles/thin-client/apps/chromium.nix | 119 ++++++++++ roles/thin-client/apps/default.nix | 14 ++ roles/thin-client/apps/obs.nix | 85 +++++++ roles/thin-client/apps/office-web.nix | 45 ++++ roles/thin-client/apps/remmina.nix | 96 ++++++++ roles/thin-client/apps/rustdesk.nix | 82 +++++++ roles/thin-client/default.nix | 114 ++++++++++ roles/thin-client/deployment/auto-upgrade.nix | 33 +++ roles/thin-client/deployment/default.nix | 12 + roles/thin-client/deployment/disko.nix | 94 ++++++++ roles/thin-client/deployment/snapper.nix | 40 ++++ roles/thin-client/hardware/default.nix | 29 +++ .../hardware/dell-optiplex-micro.nix | 51 +++++ .../hardware/generic-x86_64-uefi.nix | 26 +++ roles/thin-client/identity/ad.nix | 167 ++++++++++++++ roles/thin-client/identity/default.nix | 16 ++ roles/thin-client/identity/local-users.nix | 41 ++++ roles/thin-client/identity/sudo.nix | 46 ++++ roles/thin-client/monitoring/default.nix | 113 +++++++++ .../network/assets/corp-wifi-ca.pem | 11 + roles/thin-client/network/default.nix | 14 ++ roles/thin-client/network/dns.nix | 24 ++ roles/thin-client/network/firewall.nix | 23 ++ roles/thin-client/network/netbird.nix | 70 ++++++ roles/thin-client/network/ssh.nix | 28 +++ roles/thin-client/network/wifi.nix | 112 +++++++++ roles/thin-client/peripherals/default.nix | 7 + roles/thin-client/peripherals/printing.nix | 54 +++++ roles/thin-client/peripherals/smb-mounts.nix | 114 ++++++++++ roles/thin-client/session/assets/logo.svg | 6 + .../thin-client/session/assets/wallpaper.svg | 12 + roles/thin-client/session/audio.nix | 25 ++ roles/thin-client/session/branding.nix | 85 +++++++ roles/thin-client/session/default.nix | 11 + roles/thin-client/session/desktop.nix | 80 +++++++ secrets.nix | 42 +++- 42 files changed, 2281 insertions(+), 1 deletion(-) create mode 100644 hosts/AZ-TC-01/default.nix create mode 100644 hosts/AZ-TC-02/default.nix create mode 100644 hosts/AZ-TC-03/default.nix create mode 100644 roles/thin-client/README.md create mode 100644 roles/thin-client/apps/autostart.nix create mode 100644 roles/thin-client/apps/chromium.nix create mode 100644 roles/thin-client/apps/default.nix create mode 100644 roles/thin-client/apps/obs.nix create mode 100644 roles/thin-client/apps/office-web.nix create mode 100644 roles/thin-client/apps/remmina.nix create mode 100644 roles/thin-client/apps/rustdesk.nix create mode 100644 roles/thin-client/default.nix create mode 100644 roles/thin-client/deployment/auto-upgrade.nix create mode 100644 roles/thin-client/deployment/default.nix create mode 100644 roles/thin-client/deployment/disko.nix create mode 100644 roles/thin-client/deployment/snapper.nix create mode 100644 roles/thin-client/hardware/default.nix create mode 100644 roles/thin-client/hardware/dell-optiplex-micro.nix create mode 100644 roles/thin-client/hardware/generic-x86_64-uefi.nix create mode 100644 roles/thin-client/identity/ad.nix create mode 100644 roles/thin-client/identity/default.nix create mode 100644 roles/thin-client/identity/local-users.nix create mode 100644 roles/thin-client/identity/sudo.nix create mode 100644 roles/thin-client/monitoring/default.nix create mode 100644 roles/thin-client/network/assets/corp-wifi-ca.pem create mode 100644 roles/thin-client/network/default.nix create mode 100644 roles/thin-client/network/dns.nix create mode 100644 roles/thin-client/network/firewall.nix create mode 100644 roles/thin-client/network/netbird.nix create mode 100644 roles/thin-client/network/ssh.nix create mode 100644 roles/thin-client/network/wifi.nix create mode 100644 roles/thin-client/peripherals/default.nix create mode 100644 roles/thin-client/peripherals/printing.nix create mode 100644 roles/thin-client/peripherals/smb-mounts.nix create mode 100644 roles/thin-client/session/assets/logo.svg create mode 100644 roles/thin-client/session/assets/wallpaper.svg create mode 100644 roles/thin-client/session/audio.nix create mode 100644 roles/thin-client/session/branding.nix create mode 100644 roles/thin-client/session/default.nix create mode 100644 roles/thin-client/session/desktop.nix diff --git a/flake.nix b/flake.nix index b8f35b5..6cda22c 100644 --- a/flake.nix +++ b/flake.nix @@ -54,6 +54,7 @@ outputs = { self, agenix, + disko, nixpkgs, m3ta-nixpkgs, ... @@ -107,6 +108,37 @@ m3ta-nixpkgs.nixosModules.default ]; }; + + # ── Thin Client fleet (AZ-TC-NN) ────────────────────────────── + # Each host is a minimal wrapper around the thin-client role. + # Add new hosts by: + # 1. Create hosts/AZ-TC-NN/default.nix (copy from AZ-TC-01). + # 2. Add an entry here. + # 3. Run `agenix -e secrets/AZ-TC-NN-*.age` to provision secrets. + AZ-TC-01 = inputs.nixpkgs.lib.nixosSystem { + specialArgs = {inherit inputs outputs; system = "x86_64-linux";}; + modules = [ + disko.nixosModules.disko + agenix.nixosModules.default + ./hosts/AZ-TC-01 + ]; + }; + AZ-TC-02 = inputs.nixpkgs.lib.nixosSystem { + specialArgs = {inherit inputs outputs; system = "x86_64-linux";}; + modules = [ + disko.nixosModules.disko + agenix.nixosModules.default + ./hosts/AZ-TC-02 + ]; + }; + AZ-TC-03 = inputs.nixpkgs.lib.nixosSystem { + specialArgs = {inherit inputs outputs; system = "x86_64-linux";}; + modules = [ + disko.nixosModules.disko + agenix.nixosModules.default + ./hosts/AZ-TC-03 + ]; + }; }; }; } diff --git a/hosts/AZ-TC-01/default.nix b/hosts/AZ-TC-01/default.nix new file mode 100644 index 0000000..c606beb --- /dev/null +++ b/hosts/AZ-TC-01/default.nix @@ -0,0 +1,23 @@ +# hosts/AZ-TC-01/default.nix +# +# Fleet Host AZ-TC-01 — pilot #1 of the AZ-NIX-CLIENTS Thin Client fleet. +# Imports the thin-client role, declares hardware class, and that's it. +# Everything else is composed by roles/thin-client/default.nix. +{...}: { + imports = [ + ../../roles/thin-client + ]; + + az.tc = { + enable = true; + hardwareClass = "dell-optiplex-micro"; + }; + + networking.hostName = "AZ-TC-01"; + + # Disk device override for this specific box (Dell OptiPlex 3040 Micro + # with a 256GB SATA SSD). + az.tc.deployment.diskDevice = "/dev/sda"; + + system.stateVersion = "25.05"; +} diff --git a/hosts/AZ-TC-02/default.nix b/hosts/AZ-TC-02/default.nix new file mode 100644 index 0000000..353ec9f --- /dev/null +++ b/hosts/AZ-TC-02/default.nix @@ -0,0 +1,15 @@ +# hosts/AZ-TC-02/default.nix +# Fleet Host AZ-TC-02 — pilot #2. +{...}: { + imports = [../../roles/thin-client]; + + az.tc = { + enable = true; + hardwareClass = "dell-optiplex-micro"; + }; + + networking.hostName = "AZ-TC-02"; + az.tc.deployment.diskDevice = "/dev/sda"; + + system.stateVersion = "25.05"; +} diff --git a/hosts/AZ-TC-03/default.nix b/hosts/AZ-TC-03/default.nix new file mode 100644 index 0000000..ca5947a --- /dev/null +++ b/hosts/AZ-TC-03/default.nix @@ -0,0 +1,15 @@ +# hosts/AZ-TC-03/default.nix +# Fleet Host AZ-TC-03 — pilot #3. +{...}: { + imports = [../../roles/thin-client]; + + az.tc = { + enable = true; + hardwareClass = "dell-optiplex-micro"; + }; + + networking.hostName = "AZ-TC-03"; + az.tc.deployment.diskDevice = "/dev/sda"; + + system.stateVersion = "25.05"; +} diff --git a/roles/thin-client/README.md b/roles/thin-client/README.md new file mode 100644 index 0000000..31feb23 --- /dev/null +++ b/roles/thin-client/README.md @@ -0,0 +1,214 @@ +# roles/thin-client — AZ-NIX-CLIENTS Thin Client Fleet + +NixOS role for the `AZ-TC-NN` fleet. Replaces a Windows 10 workstation +with a locked-down NixOS + KDE Plasma client that authenticates against +Active Directory and provides pre-configured RDP, browser, and Office-Web +access. + +This document describes the provisioning workflow for a new Fleet Host. + +## Decision summary + +All 20 design decisions from the grilling session are captured in the +top-level `default.nix` comments and the individual submodule headers. +Quick reference: + +| Area | Decision | +| --- | --- | +| Hardware | Dell OptiPlex Micro (3020/3040/5040/7040) + generic-x86_64-uefi fallback | +| Deployment | Stock Linux ISO + `nixos-anywhere` | +| Disk | BTRFS (`@root`, `@home`, `@nix`, `@persist`, `@snapshots`), zstd | +| AD | Realm `AZ-GROUP` / DNS `az-group.local`, pre-created computer accounts + keytab via agenix | +| Shell | bash for domain users | +| Session | KDE Plasma 6, Wayland, SDDM, no autologin | +| Sudo | `%domain admins` with password; local `sascha.koenig` + `jannik.mueller` as break-glass | +| WiFi | 802.1X EAP-TLS per-machine, NetworkManager + wpa_supplicant (not iwd) | +| SMB | pam_mount, Kerberos, DFS namespace, lax offline | +| RDP | System-wide Remmina, Kerberos SSO, fullscreen multi-monitor, audio local only | +| Office | 5 .desktop shortcuts via `chromium --app=URL`; Outlook + Teams autostart | +| Chromium | ManagedBookmarks, sync disabled, uBlock Origin + Bitwarden, no local passwords | +| RustDesk | Client + daemon, self-hosted server, pre-shared key, Wayland portal pre-authorized | +| OBS | Pre-configured "AZ-Default" profile, output to `~/Videos/OBS/` | +| NetBird | Per-host setup key, corp DNS + NetBird DNS parallel, accept-routes | +| Printers | Single Pull-Print queue, direct IPPS, Avahi off | +| Branding | Light: corporate wallpaper + SDDM logo + property footer, no banner | +| Updates | `system.autoUpgrade` daily at 03:00, reboot window 03:00-05:00 | +| Monitoring | node_exporter → Pushgateway, promtail → Loki, Snipe-IT asset check-in | +| Rollout | Pilot: 3 hosts, 2 weeks; then 5 → 10 → rest | + +## Provisioning workflow for a new Fleet Host + +Assuming you've copied `hosts/AZ-TC-01/default.nix` to +`hosts/AZ-TC-NN/default.nix`, updated `networking.hostName`, added the +flake entry, and committed. + +### Step 1 — Pre-create the AD computer account + keytab + +Run from a host that can reach the AD DC (e.g. `AZ-LT-NIX`): + +```bash +# Pre-create the computer object in the ThinClients OU +adcli precreate --computer-name=AZ-TC-01\$ \ + --domain=az-group.local \ + --host-fqdn=AZ-TC-01.az-group.local \ + --login-type=computer \ + --os-name="NixOS" --os-version="25.11" \ + --domain-ou="OU=ThinClients,DC=az-group,DC=local" \ + administrator@AZ-GROUP + +# Join offline and produce a keytab (no real join happens) +adcli join --computer-name=AZ-TC-01\$ \ + --domain=az-group.local \ + --host-fqdn=AZ-TC-01.az-group.local \ + --login-type=computer \ + --user=administrator --verbose \ + -K /tmp/AZ-TC-01.keytab + +# Provision the agenix secret +agenix -e secrets/AZ-TC-01-krb5-keytab.age +# In the editor: paste /tmp/AZ-TC-01.keytab contents, save, exit. +rm /tmp/AZ-TC-01.keytab +``` + +### Step 2 — Provision NetBird setup key + +In the NetBird UI (`https://netbird.az-group.local`): + +1. Go to **Setup Keys** → **Generate new key**. +2. Name: `AZ-TC-01`, Type: **one-time** (or reusable for fleet), Expires: 90d. +3. Copy the key value. +4. `agenix -e secrets/AZ-TC-01-netbird-setupkey.age` → paste the key. + +### Step 3 — Provision WiFi client certificate + +In AD CS (`https://certsrv.az-group.local/certsrv`): + +1. **Request a Certificate** → **Advanced certificate request**. +2. Template: **Workstation Authentication** (or your corp 802.1X template). +3. Subject: `CN=AZ-TC-01$`. +4. Export the cert + private key as PKCS#12 (`.pfx`), with exportable key. +5. Convert to combined PEM (cert + key in one file): + + ```bash + openssl pkcs12 -in AZ-TC-01.pfx -out AZ-TC-01.pem -nodes + ``` + +6. `agenix -e secrets/AZ-TC-01-wifi-client-cert.age` → paste the PEM. + +### Step 4 — Provision RustDesk credentials + +```bash +# Generate a strong permanent password per host +PW=$(openssl rand -base64 24) +echo -n "$PW" > /tmp/AZ-TC-01-rustdesk-pw.txt +agenix -e secrets/AZ-TC-01-rustdesk-password.age +# Paste /tmp/AZ-TC-01-rustdesk-pw.txt contents. +rm /tmp/AZ-TC-01-rustdesk-pw.txt +``` + +The shared `rustdesk-psk.age` is one-time setup, reused for all hosts. + +### Step 5 — Provision the host + +Boot the OptiPlex from a stock Linux ISO (any modern NixOS installer ISO +or Ubuntu live ISO works). From the admin workstation: + +```bash +# Bootstrap nixos-anywhere into the booted ISO (via SSH or physical console) +# Then run the install: +nixos-anywhere \ + --flake .#AZ-TC-01 \ + --disko-config ./roles/thin-client/deployment/disko.nix \ + root@ +``` + +The host will: + +1. Partition the disk via disko (BTRFS layout). +2. Install NixOS from the flake. +3. Reboot into the new system. +4. On first boot: agenix decrypts secrets, sssd starts with keytab, + NetBird enrolls via setup key, WiFi connects via EAP-TLS. + +### Step 6 — Capture the host's SSH host key + +After first boot: + +```bash +ssh-keyscan -t ed25519 AZ-TC-01.netbird | awk '{print $2 " " $3}' +``` + +Update `secrets.nix` with the real `AZ-TC-01` SSH key value (replacing +the `PLACEHOLDER` line). Rebuild and redeploy. + +## Option reference + +All tunable parameters live under `az.tc.*`: + +| Option | Default | Description | +| --- | --- | --- | +| `az.tc.enable` | `false` | Activate the thin-client role | +| `az.tc.hardwareClass` | `generic-x86_64-uefi` | One of `dell-optiplex-micro` or `generic-x86_64-uefi` | +| `az.tc.site` | `default` | Site identifier (informational) | +| `az.tc.ad.ou` | `OU=ThinClients,DC=az-group,DC=local` | AD Organizational Unit for computer objects | +| `az.tc.wifi.ssid` | `AZ-CORP` | Corp WiFi SSID | +| `az.tc.wifi.caCert` | `./assets/corp-wifi-ca.pem` | Corp CA cert (NOT secret) | +| `az.tc.netbird.managementUrl` | `https://netbird.az-group.local:443` | NetBird server URL | +| `az.tc.smb.dfsNamespace` | `\\\\az-group.local\\dfs` | DFS namespace root | +| `az.tc.smb.userShare` | `users/%u` | Per-user share path | +| `az.tc.smb.commonShares` | `[public software]` | Common share names | +| `az.tc.smb.mountRoot` | `/mnt/az-dfs` | Local mount root | +| `az.tc.printing.pullPrintEndpoint` | `ipps://pull-print.az-group.local:443/ipp/print` | Pull-Print IPPS URI | +| `az.tc.printing.queueName` | `Pull-Print` | Local CUPS queue name | +| `az.tc.rdp.servers` | `[{name="TS Berlin"; fqdn="ts-berlin.az-group.local";}]` | TS endpoints | +| `az.tc.chromium.homepage` | `https://www.office.com` | Browser homepage | +| `az.tc.chromium.bookmarks` | `[Outlook, Teams, SharePoint, Office]` | Managed bookmarks | +| `az.tc.chromium.bitwardenServerUrl` | `https://vault.bitwarden.com` | Bitwarden URL | +| `az.tc.rustdesk.serverHost` | `rustdesk.az-group.local` | RustDesk server host | +| `az.tc.branding.hotline` | `+49 30 1234567` | IT hotline for footer | +| `az.tc.branding.wallpaper` | `./assets/wallpaper.svg` | Wallpaper path | +| `az.tc.branding.logo` | `./assets/logo.svg` | Logo path | +| `az.tc.branding.company` | `AzIntec GmbH` | Company name | +| `az.tc.monitoring.prometheusPushGateway` | `pushgateway.az-group.local:9091` | Pushgateway URL | +| `az.tc.monitoring.lokiUrl` | `http://loki.az-group.local:3100` | Loki URL | +| `az.tc.monitoring.assetTool` | `snipe-it` | Asset tool integration | +| `az.tc.deployment.diskDevice` | `/dev/sda` | Disko target disk | +| `az.tc.deployment.swapSizeGB` | `4` | Swap size in GB | + +## Open items before pilot deploy + +These placeholders need real values before pilot deploy. They're flagged +with `# TODO:` in the respective modules. + +- [ ] Real corp WiFi SSID +- [ ] Real CA root cert (replace `corp-wifi-ca.pem`) +- [ ] Real DFS namespace path +- [ ] Real common share names +- [ ] Real Terminal Server endpoints +- [ ] Real Chromium bookmarks (Intranet, Helpdesk, ERP, HR) +- [ ] Real Bitwarden server URL (if self-hosted) +- [ ] Real RustDesk server hostname +- [ ] Real Pull-Print IPPS endpoint +- [ ] Real NetBird management URL +- [ ] Real Prometheus Pushgateway URL +- [ ] Real Loki URL +- [ ] Real IT-Hotline phone number +- [ ] Real corporate wallpaper asset (replace placeholder SVG) +- [ ] Real corporate logo asset (replace placeholder SVG) +- [ ] Real AD DCs (`az-dc01.az-group.local`, `az-dc02.az-group.local` — currently guessed) +- [ ] Real AD Computer OU DN + +## Build validation + +To validate the config without deploying: + +```bash +# Eval-check all hosts +nix flake check + +# Build a pilot host closure +nix build .#nixosConfigurations.AZ-TC-01.config.system.build.toplevel + +# Try a VM boot +nix run .#nixosConfigurations.AZ-TC-01.config.system.build.vm +``` diff --git a/roles/thin-client/apps/autostart.nix b/roles/thin-client/apps/autostart.nix new file mode 100644 index 0000000..8ed4402 --- /dev/null +++ b/roles/thin-client/apps/autostart.nix @@ -0,0 +1,42 @@ +# roles/thin-client/apps/autostart.nix +# +# KDE autostart for Outlook + Teams at login. +# Q11 decision: "Outlook + Teams beim Login". +# +# Implemented as system-wide .desktop files in +# /etc/xdg/autostart/ — KDE's Plasma session picks them up automatically +# for every user. +{config, lib, pkgs, ...}: let + inherit (lib) mkIf; + cfg = config.az.tc; + + mkAutostart = name: url: let + filename = builtins.replaceStrings [" "] ["-"] (lib.toLower name); + in + pkgs.writeTextFile { + name = "${filename}-autostart.desktop"; + destination = "/etc/xdg/autostart/${filename}-autostart.desktop"; + text = '' + [Desktop Entry] + Type=Application + Name=${name} (Auto-Start) + Exec=${pkgs.chromium}/bin/chromium --app=${url} --no-default-browser-check --no-first-run + Icon=web-browser + Terminal=false + X-KDE-autostart-after=panel + X-KDE-autostart-phase=2 + X-GNOME-Autostart-enabled=true + ''; + }; +in { + config = mkIf cfg.enable { + environment.etc = { + "xdg/autostart/outlook-autostart.desktop".source = + (mkAutostart "Outlook" "https://outlook.office.com") + + "/etc/xdg/autostart/outlook-autostart.desktop"; + "xdg/autostart/teams-autostart.desktop".source = + (mkAutostart "Teams" "https://teams.microsoft.com") + + "/etc/xdg/autostart/teams-autostart.desktop"; + }; + }; +} diff --git a/roles/thin-client/apps/chromium.nix b/roles/thin-client/apps/chromium.nix new file mode 100644 index 0000000..f44a863 --- /dev/null +++ b/roles/thin-client/apps/chromium.nix @@ -0,0 +1,119 @@ +# roles/thin-client/apps/chromium.nix +# +# Chromium enterprise policy. +# Q12 decisions: ManagedBookmarks, SyncDisabled, uBlock Origin + Allowlist, +# PasswordManagerEnabled=false, Bitwarden Extension force-install. +# +# Policy file path on NixOS: /etc/chromium/policies/managed/*.json +{config, lib, pkgs, ...}: let + inherit (lib) mkIf mkOption types; + cfg = config.az.tc; +in { + options.az.tc.chromium = { + homepage = mkOption { + type = types.str; + default = "https://www.office.com"; # TODO: real homepage + description = "Chromium startup URL."; + }; + + bookmarks = mkOption { + type = types.listOf (types.submodule { + options = { + name = mkOption {type = types.str;}; + url = mkOption {type = types.str;}; + }; + }); + default = [ + {name = "Outlook Web"; url = "https://outlook.office.com";} + {name = "Teams Web"; url = "https://teams.microsoft.com";} + {name = "SharePoint"; url = "https://azgroup.sharepoint.com";} + {name = "Office Home"; url = "https://www.office.com";} + # TODO: Intranet, Helpdesk, ERP, HR — fill real URLs + ]; + description = "Managed bookmarks (cannot be deleted by user)."; + }; + + bitwardenServerUrl = mkOption { + type = types.str; + default = "https://vault.bitwarden.com"; # cloud default; override for self-hosted + description = '' + Bitwarden server URL (cloud or self-hosted). Pushed via Chromium + extension settings. + ''; + }; + }; + + config = mkIf cfg.enable { + programs.chromium = { + enable = true; + extraOpts = { + # Bookmarks + ManagedBookmarks = [ + {cn = "AZ Corporate Bookmarks"; children = config.az.tc.chromium.bookmarks;} + ]; + + # Homepage + HomepageLocation = config.az.tc.chromium.homepage; + RestoreOnStartup = 4; # always restore homepage + StartupUrls = [config.az.tc.chromium.homepage]; + + # Sync disabled (Q12) + SyncDisabled = true; + BrowserSignin = 0; # no Google account sign-in + RestrictSigninToPattern = ".*@az-group\\.local"; + + # Password manager disabled (Q12) + PasswordManagerEnabled = false; + + # Autofill + AutofillAddressEnabled = true; + AutofillCreditCardEnabled = false; + + # Background mode (Chromium keeps running in background) + BackgroundModeEnabled = false; + + # Extensions: force-install uBlock Origin + Bitwarden + ExtensionInstallForcelist = [ + # uBlock Origin + "cjpalhdlnbpafiamejdnhcphjbkeiagm" + # Bitwarden Password Manager + "nngceckbapebfimnlniiiahkandclblb" + ]; + ExtensionInstallAllowlist = [ + "cjpalhdlnbpafiamejdnhcphjbkeiagm" # uBlock Origin + "nngceckbapebfimnlniiiahkandclblb" # Bitwarden + ]; + ExtensionInstallBlocklist = ["*"]; # block everything not in allowlist + + # Bitwarden configuration via extension policy + "3rdparty" = { + "extensions" = { + "nngceckbapebfimnlniiiahkandclblb" = { + environment = { + base = config.az.tc.chromium.bitwardenServerUrl; + }; + }; + }; + }; + + # Disable telemetry + UrlKeyedAnonymizedDataCollectionEnabled = false; + SafeBrowsingEnabled = true; + + # Default browser check + DefaultBrowserSettingEnabled = true; + + # PDF / plugins / popups + AlwaysOpenPdfExternally = false; + BlockThirdPartyCookies = true; + + # Pinch-to-zoom, etc. + TouchEnabled = false; + }; + }; + + environment.systemPackages = with pkgs; [ + chromium + ]; + }; +} diff --git a/roles/thin-client/apps/default.nix b/roles/thin-client/apps/default.nix new file mode 100644 index 0000000..d3f0ad6 --- /dev/null +++ b/roles/thin-client/apps/default.nix @@ -0,0 +1,14 @@ +# roles/thin-client/apps/default.nix +# +# All user-facing applications: Chromium, Office-Web shortcuts, Remmina +# (RDP), RustDesk, OBS, and login-autostart for Outlook + Teams. +{config, lib, ...}: { + imports = [ + ./chromium.nix + ./office-web.nix + ./remmina.nix + ./rustdesk.nix + ./obs.nix + ./autostart.nix + ]; +} diff --git a/roles/thin-client/apps/obs.nix b/roles/thin-client/apps/obs.nix new file mode 100644 index 0000000..e7f16ba --- /dev/null +++ b/roles/thin-client/apps/obs.nix @@ -0,0 +1,85 @@ +# roles/thin-client/apps/obs.nix +# +# OBS Studio for Schulungs-Recording. Pre-configured profile with +# PipeWire screen capture + Mic + System-Audio, Output to ~/Videos/OBS/. +# Q14 decisions: Use Case Schulungs-Recording, Pre-configuriertes Profil, +# Output local, no streaming. +{config, lib, pkgs, ...}: let + inherit (lib) mkIf; + cfg = config.az.tc; +in { + config = mkIf cfg.enable { + environment = { + systemPackages = with pkgs; [ + obs-studio + obs-studio-plugins.obs-pipewire-audio-capture + obs-studio-plugins.wlrobs + ]; + + etc = { + # System-wide OBS profile "AZ-Default". + # Installed to /etc/obs/az-default/ and copied to user profile on + # first run. + "obs/az-default/basic.ini".text = '' + [General] + Name=AZ-Default + + [Output] + Mode=Simple + FilenameFormatting=%CCYY-%MM-%DD %hh-%mm-%ss + DelayEnable=false + DelaySec=20 + DelayPreserve=false + ReplayWhileRecording=false + ReplayBufferEnable=false + + [SimpleOutput] + FilePath=/home/%/Videos/OBS/ + RecFormat=mp4 + VEncoder=x264 + VBitrate=6000 + AEncoder=aac + ABitrate=192 + + [Audio] + SampleRate=48000 + ChannelSetup=stereo + + [Video] + Base=1920x1080 + Output=1920x1080 + FPSType=0 + FPSCommon=30 + + [AdvOut] + ApplyServiceSettings=true + RescaleRes=1920x1080 + ''; + + # Pre-configured scene collection with PipeWire screen capture. + "obs/az-default/scenes.json".text = '' + { + "sources": [ + { + "name": "Bildschirm (PipeWire)", + "id": "pipewire-screen-capture-source", + "type": "input" + }, + { + "name": "Mikrofon", + "id": "pulse_input_capture", + "type": "input" + }, + { + "name": "System-Audio", + "id": "pulse_output_capture", + "type": "input" + } + ], + "scene": "AZ-Default" + } + ''; + }; + }; + }; +} diff --git a/roles/thin-client/apps/office-web.nix b/roles/thin-client/apps/office-web.nix new file mode 100644 index 0000000..6aaaca6 --- /dev/null +++ b/roles/thin-client/apps/office-web.nix @@ -0,0 +1,45 @@ +# roles/thin-client/apps/office-web.nix +# +# Office 365 (cloud) web apps as Chromium app-mode .desktop files. +# Q11 decisions: Word, Excel, PowerPoint, Outlook, Teams via +# `chromium --app=`, system-wide, Outlook+Teams autostart at login. +# +# The .desktop files are installed system-wide via +# environment.systemPackages, so every user sees them in the KDE menu +# under "Office". +{config, lib, pkgs, ...}: let + inherit (lib) mkIf; + cfg = config.az.tc; + + # Helper: builds a Chromium app-mode .desktop file. + mkOfficeWebDesktop = name: url: comment: + pkgs.writeTextFile { + name = builtins.replaceStrings [" "] ["-"] (lib.toLower name) + ".desktop"; + destination = "/share/applications/${builtins.replaceStrings [" "] ["-"] (lib.toLower name)}.desktop"; + text = '' + [Desktop Entry] + Version=1.0 + Type=Application + Name=${name} + GenericName=${name} (Web) + Comment=${comment} + Exec=${pkgs.chromium}/bin/chromium --app=${url} --no-default-browser-check --no-first-run + Icon=web-browser + Terminal=false + Categories=Office;Network;WebApps; + Keywords=office;${lib.toLower name};web; + StartupNotify=true + StartupWMClass=${url} + ''; + }; +in { + config = mkIf cfg.enable { + environment.systemPackages = [ + (mkOfficeWebDesktop "Word" "https://www.office.com/launch/word" "Microsoft Word Online") + (mkOfficeWebDesktop "Excel" "https://www.office.com/launch/excel" "Microsoft Excel Online") + (mkOfficeWebDesktop "PowerPoint" "https://www.office.com/launch/powerpoint" "Microsoft PowerPoint Online") + (mkOfficeWebDesktop "Outlook" "https://outlook.office.com" "Microsoft Outlook Web") + (mkOfficeWebDesktop "Teams" "https://teams.microsoft.com" "Microsoft Teams Web") + ]; + }; +} diff --git a/roles/thin-client/apps/remmina.nix b/roles/thin-client/apps/remmina.nix new file mode 100644 index 0000000..d012b53 --- /dev/null +++ b/roles/thin-client/apps/remmina.nix @@ -0,0 +1,96 @@ +# roles/thin-client/apps/remmina.nix +# +# Remmina RDP client with system-wide connection profiles. +# Q10 decisions: System-wide /etc/remmina/, Kerberos SSO, several TS, +# Fullscreen+Multi-Monitor, only Audio local redirection. +# +# Note on "Several TS": we ship a small placeholder list with one example. +# Add real TS endpoints in az.tc.rdp.servers below. +{config, lib, pkgs, ...}: let + inherit (lib) mkIf mkOption types; + cfg = config.az.tc; + + # Helper: builds a .remmina profile for one TS + mkRemminaProfile = server: let + name = server.name; + fqdn = server.fqdn; + in + pkgs.writeTextFile { + name = "${lib.toLower name}.remmina"; + destination = "/etc/remmina/${lib.toLower name}.remmina"; + text = '' + [remmina] + name=${name} + protocol=RDP + server=${fqdn} + username= + domain=AZ-GROUP + security= + colordepth=32 + resolution=multimonitor + viewmode=2 + fullscreen=1 + multimon=1 + audio-mode=1 + audiocapture=0 + clipboard=both + quality=2 + kerberos=1 + enable-authecol=kerberos + disableclipboard=0 + redirectsound=1 + redirectprinter=0 + redirectdrive=0 + redirectusb=0 + keyboard-grab=1 + showcursor=1 + ''; + }; +in { + options.az.tc.rdp = { + servers = mkOption { + type = types.listOf (types.submodule { + options = { + name = mkOption { + type = types.str; + description = "Display name (e.g. 'TS Berlin', 'TS ERP')."; + }; + fqdn = mkOption { + type = types.str; + description = "FQDN of the TS endpoint (e.g. 'ts-berlin.az-group.local')."; + }; + }; + }); + default = [ + # TODO: fill real TS endpoints + { + name = "TS Berlin"; + fqdn = "ts-berlin.az-group.local"; + } + ]; + description = '' + List of Terminal Server endpoints. One Remmina profile per entry + is generated system-wide. + ''; + }; + }; + + config = mkIf cfg.enable { + environment.systemPackages = with pkgs; [ + remmina + freerdp3 + ]; + + # System-wide Remmina profiles + environment.etc = builtins.listToAttrs (builtins.map (server: { + name = "remmina/${lib.toLower server.name}.remmina"; + value.source = (mkRemminaProfile server) + "/etc/remmina/${lib.toLower server.name}.remmina"; + }) + config.az.tc.rdp.servers); + + # Allow Domain Users to read the system-wide profiles + systemd.tmpfiles.rules = [ + "d /etc/remmina 0755 root root -" + ]; + }; +} diff --git a/roles/thin-client/apps/rustdesk.nix b/roles/thin-client/apps/rustdesk.nix new file mode 100644 index 0000000..377599c --- /dev/null +++ b/roles/thin-client/apps/rustdesk.nix @@ -0,0 +1,82 @@ +# roles/thin-client/apps/rustdesk.nix +# +# RustDesk client (Hilfe annehmen) + daemon (unattended support). +# Q13 decisions: Beides (Client+Daemon), Self-hosted Server, Pre-Shared Key +# via agenix, Wayland portal pre-authorized, Permanent password via agenix. +{config, lib, pkgs, ...}: let + inherit (lib) mkIf mkOption types; + cfg = config.az.tc; + hostname = config.networking.hostName; +in { + options.az.tc.rustdesk = { + serverHost = mkOption { + type = types.str; + default = "rustdesk.az-group.local"; # TODO: real host + description = "Self-hosted RustDesk hbbs/hbbr endpoint (via NetBird)."; + }; + + serverPort = mkOption { + type = types.int; + default = 21116; + }; + }; + + config = mkIf cfg.enable { + environment = { + systemPackages = with pkgs; [rustdesk]; + + etc = { + # /etc/rustdesk/RustDesk.toml — daemon config. Pre-shared key and + # server endpoints go here. Permanent password is in a separate + # agenix secret and written to a path RustDesk reads. + "rustdesk/RustDesk.toml".text = '' + [options] + custom-rendezvous-server = ${config.az.tc.rustdesk.serverHost} + relay-server = ${config.az.tc.rustdesk.serverHost} + api-server = https://${config.az.tc.rustdesk.serverHost} + key = file:/run/agenix/rustdesk-psk + verification-method = fixed-password + permanent-password-file = /run/agenix/${hostname}-rustdesk-password + enable-wayland-screen-share = true + allow-auto-disconnect = false + ''; + + # Wayland portal pre-authorization for RustDesk — KDE-specific + # xdg portal config so the "screen capture" prompt is pre-approved. + "xdg-desktop-portal/kde-screen-share.conf".text = '' + [allowed] + rustdesk=true + ''; + }; + }; + + # Systemd service: RustDesk daemon (runs as root for unattended). + systemd.services.rustdesk = { + description = "RustDesk unattended support daemon"; + wantedBy = ["multi-user.target"]; + after = ["network-online.target" "age-identity.service"]; + wants = ["network-online.target" "age-identity.service"]; + serviceConfig = { + Type = "simple"; + ExecStart = "${pkgs.rustdesk}/bin/rustdesk --service"; + Restart = "always"; + RestartSec = "5s"; + User = "root"; + }; + }; + + # Shared pre-shared key (one for all thin clients) + age.secrets."rustdesk-psk" = { + file = ../../secrets/rustdesk-psk.age; + mode = "0400"; + owner = "root"; + }; + + # Per-host permanent password + age.secrets."${hostname}-rustdesk-password" = { + file = ../../secrets/${hostname}-rustdesk-password.age; + mode = "0400"; + owner = "root"; + }; + }; +} diff --git a/roles/thin-client/default.nix b/roles/thin-client/default.nix new file mode 100644 index 0000000..89ffc47 --- /dev/null +++ b/roles/thin-client/default.nix @@ -0,0 +1,114 @@ +# roles/thin-client/default.nix +# +# Top-level NixOS role module for the AZ-NIX-CLIENTS Thin Client fleet +# (AZ-TC-01..NN). Replaces a Windows 10 workstation with a locked-down +# NixOS + KDE Plasma client that authenticates against Active Directory +# and provides pre-configured RDP, browser, and Office-Web access. +# +# A Fleet Host (hosts/AZ-TC-NN/default.nix) only needs to set: +# az.tc.enable = true; +# az.tc.hardwareClass = "dell-optiplex-micro"; +# networking.hostName = "AZ-TC-01"; +# Everything else is composed by this role. +# +# See roles/thin-client/README.md for the provisioning workflow. +{ + config, + lib, + pkgs, + ... +}: let + cfg = config.az.tc; +in { + imports = [ + ./hardware + ./session + ./identity + ./network + ./peripherals + ./apps + ./monitoring + ./deployment + ]; + + options.az.tc = { + enable = lib.mkEnableOption "the AzIntec Thin Client role (KDE Plasma + AD + RDP fleet host)"; + + hardwareClass = lib.mkOption { + type = lib.types.enum ["dell-optiplex-micro" "generic-x86_64-uefi"]; + default = "generic-x86_64-uefi"; + description = '' + Hardware class of the Thin Client. Selects the appropriate + kernel modules, firmware, and video driver under + `roles/thin-client/hardware/`. + ''; + }; + + site = lib.mkOption { + type = lib.types.str; + default = "default"; + description = '' + Site identifier (e.g. "BER", "MUC"). Currently informational; + reserved for per-site printer maps or branding variations. + ''; + }; + }; + + config = lib.mkIf cfg.enable { + # ── Fleet-wide base configuration ──────────────────────────────── + # Everything that every Thin Client needs regardless of which + # submodule pulls it in. Submodules opt-in via mkIf themselves. + + # We do NOT import hosts/common here — that pulls nushell as default + # shell and m3ta-home profile system, neither of which belongs on a + # Thin Client. The minimum system config is set explicitly below. + + nixpkgs.hostPlatform = "x86_64-linux"; + nixpkgs.config.allowUnfree = true; + + nix = { + settings = { + experimental-features = "nix-command flakes"; + trusted-users = ["root"]; + }; + gc = { + automatic = true; + dates = "weekly"; + options = "--delete-older-than 14d"; + }; + optimise.automatic = true; + }; + + # Thin Clients are managed centrally; users do not run nix commands. + users.defaultUserShell = pkgs.bash; + + # Console / i18n / time — matches AZ-LT-NIX conventions. + i18n.defaultLocale = "de_DE.UTF-8"; + time.timeZone = "Europe/Berlin"; + console.useXkbConfig = true; + + # Persistence-relevant state lives on the BTRFS root; nothing + # tmpfs-related here. /home persists per-user (Q6: "alles persistieren"). + + # Polkit for udisks/colord/etc. — needed so non-root users can mount + # USB sticks, change brightness, etc. + security.polkit.enable = true; + + # XDG portal wiring for Wayland screen-capture (RustDesk + OBS). + environment.pathsToLink = [ + "/share/xdg-desktop-portal" + "/share/applications" + ]; + + # SSH is enabled by the network module (via NetBird only). + # Firewall is enabled by the network module. + + # ── assertions / warnings ──────────────────────────────────────── + assertions = [ + { + assertion = cfg.enable -> (config.networking.hostName or "") != ""; + message = "az.tc.enable requires networking.hostName to be set (e.g. 'AZ-TC-01')."; + } + ]; + }; +} diff --git a/roles/thin-client/deployment/auto-upgrade.nix b/roles/thin-client/deployment/auto-upgrade.nix new file mode 100644 index 0000000..c9cf54b --- /dev/null +++ b/roles/thin-client/deployment/auto-upgrade.nix @@ -0,0 +1,33 @@ +# roles/thin-client/deployment/auto-upgrade.nix +# +# Q18 decision: Auto-Upgrade pro Host daily, Generation-Picker + BTRFS- +# Snapshot für Rollback, Auto-Reboot nach Kernel-Update. +{config, lib, ...}: let + inherit (lib) mkIf; + cfg = config.az.tc; +in { + config = mkIf cfg.enable { + system.autoUpgrade = { + enable = true; + dates = "03:00"; + randomizedDelaySec = "30min"; + flake = "git+https://code.m3ta.dev/m3tam3re/AZ-NIX-CLIENTS"; + flags = [ + "--no-write-lock-file" + "--refresh" + ]; + allowReboot = true; + rebootWindow = { + lower = "03:00"; + upper = "05:00"; + }; + }; + + # Always keep at least 7 generations for rollback + boot.loader.systemd-boot.configurationLimit = 10; + + # gc — keep more generations than the autoUpgrade default to allow + # rollback over a weekend. + nix.gc.options = "--delete-older-than 14d"; + }; +} diff --git a/roles/thin-client/deployment/default.nix b/roles/thin-client/deployment/default.nix new file mode 100644 index 0000000..544172a --- /dev/null +++ b/roles/thin-client/deployment/default.nix @@ -0,0 +1,12 @@ +# roles/thin-client/deployment/default.nix +# +# Deployment layer: Disko BTRFS layout, auto-upgrade, snapper snapshots. +# Q3 (nixos-anywhere), Q4 (BTRFS, @root/@home), Q18 (auto-upgrade daily, +# generation-picker + BTRFS snapshot, auto-reboot). +{config, lib, ...}: { + imports = [ + ./disko.nix + ./auto-upgrade.nix + ./snapper.nix + ]; +} diff --git a/roles/thin-client/deployment/disko.nix b/roles/thin-client/deployment/disko.nix new file mode 100644 index 0000000..393d7db --- /dev/null +++ b/roles/thin-client/deployment/disko.nix @@ -0,0 +1,94 @@ +# roles/thin-client/deployment/disko.nix +# +# BTRFS disk layout via Disko. Used by `nixos-anywhere` during provisioning. +# Q4 decisions: Standard persistent BTRFS, subvolumes @root and @home, +# zstd compression, snapper for / (NOT /home — privacy for multi-user). +# +# Apply during provisioning: +# nixos-anywhere --flake .#AZ-TC-01 \ +# --disko disko-config ./roles/thin-client/deployment/disko.nix \ +# root@ +# +# This module sets `disko.devices` so the config can be referenced both +# for partitioning (nixos-anywhere --disko) and for fstab generation. +{config, lib, ...}: let + inherit (lib) mkIf mkOption types; + cfg = config.az.tc; +in { + options.az.tc.deployment = { + diskDevice = mkOption { + type = types.str; + default = "/dev/sda"; + description = '' + Target disk device for the BTRFS layout. Typically /dev/sda or + /dev/nvme0n1. Override per-host in hosts/AZ-TC-NN/default.nix. + ''; + }; + + swapSizeGB = mkOption { + type = types.int; + default = 4; + description = "Swap partition size in GB. Set 0 to disable swap."; + }; + }; + + config = mkIf cfg.enable { + disko.devices = { + disk.main = { + type = "disk"; + device = config.az.tc.deployment.diskDevice; + content = { + type = "gpt"; + partitions = { + ESP = { + size = "512M"; + type = "EF00"; + content = { + type = "filesystem"; + format = "vfat"; + mountpoint = "/boot"; + mountOptions = ["umask=0077"]; + }; + }; + swap = mkIf (config.az.tc.deployment.swapSizeGB > 0) { + size = "${toString config.az.tc.deployment.swapSizeGB}G"; + content = { + type = "swap"; + randomEncryption = true; + }; + }; + root = { + size = "100%"; + content = { + type = "btrfs"; + extraArgs = ["-f"]; # force overwrite + subvolumes = { + "@root" = { + mountpoint = "/"; + mountOptions = ["compress=zstd" "noatime" "ssd"]; + }; + "@home" = { + mountpoint = "/home"; + mountOptions = ["compress=zstd" "noatime" "ssd"]; + }; + "@nix" = { + mountpoint = "/nix"; + mountOptions = ["compress=zstd" "noatime" "ssd"]; + }; + "@persist" = { + mountpoint = "/persist"; + mountOptions = ["compress=zstd" "noatime" "ssd"]; + }; + "@snapshots" = { + mountpoint = "/.snapshots"; + mountOptions = ["compress=zstd" "noatime" "ssd"]; + }; + }; + }; + }; + }; + }; + }; + }; + }; +} diff --git a/roles/thin-client/deployment/snapper.nix b/roles/thin-client/deployment/snapper.nix new file mode 100644 index 0000000..4ed6b9d --- /dev/null +++ b/roles/thin-client/deployment/snapper.nix @@ -0,0 +1,40 @@ +# roles/thin-client/deployment/snapper.nix +# +# BTRFS snapshots for the root subvolume via snapper. Snapshots taken +# before and after each `nixos-rebuild switch`, plus hourly/daily. +# +# Q18 decision: Generation-Picker + BTRFS-Snapshot. +{config, lib, ...}: let + inherit (lib) mkIf; + cfg = config.az.tc; +in { + config = mkIf cfg.enable { + services.snapper = { + snapshotRootOnBoot = true; + configs = { + root = { + SUBVOLUME = "/"; + ALLOW_USERS = ["root"]; + TIMELINE_CREATE = true; + TIMELINE_CLEANUP = true; + TIMELINE_LIMIT_HOURLY = "12"; + TIMELINE_LIMIT_DAILY = "7"; + TIMELINE_LIMIT_WEEKLY = "4"; + TIMELINE_LIMIT_MONTHLY = "0"; + }; + }; + }; + + # Take a snapshot before any nixos-rebuild switch — hooks into the + # toplevel system path activation. + system.activationScripts.snapperPreSwitch = { + deps = []; + text = '' + if [ -d /.snapshots ]; then + ${config.services.snapper.package}/bin/snapper -c root create \ + -d "pre-switch $(date +%Y-%m-%d-%H%M)" || true + fi + ''; + }; + }; +} diff --git a/roles/thin-client/hardware/default.nix b/roles/thin-client/hardware/default.nix new file mode 100644 index 0000000..e1fa588 --- /dev/null +++ b/roles/thin-client/hardware/default.nix @@ -0,0 +1,29 @@ +# roles/thin-client/hardware/default.nix +# +# Dispatcher that pulls in the hardware module matching `az.tc.hardwareClass`. +# Each concrete module (e.g. dell-optiplex-micro.nix) sets kernel modules, +# video drivers, firmware, and kernel parameters appropriate for that SKU. +{ + config, + lib, + ... +}: let + cfg = config.az.tc; +in { + imports = [ + ./dell-optiplex-micro.nix + ./generic-x86_64-uefi.nix + ]; + + # The actual dispatch happens via mkIf inside each module, checking + # `config.az.tc.hardwareClass == ""`. + # Adding assertions here so misconfiguration is loud. + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = builtins.elem cfg.hardwareClass ["dell-optiplex-micro" "generic-x86_64-uefi"]; + message = "az.tc.hardwareClass must be one of [dell-optiplex-micro generic-x86_64-uefi], got: ${cfg.hardwareClass}"; + } + ]; + }; +} diff --git a/roles/thin-client/hardware/dell-optiplex-micro.nix b/roles/thin-client/hardware/dell-optiplex-micro.nix new file mode 100644 index 0000000..1404bf2 --- /dev/null +++ b/roles/thin-client/hardware/dell-optiplex-micro.nix @@ -0,0 +1,51 @@ +# roles/thin-client/hardware/dell-optiplex-micro.nix +# +# Hardware class for Dell OptiPlex Micro (3020/3040/5040/7040). +# These are 5th–7th gen Intel Core mini-PCs, all using Intel i915 graphics, +# all UEFI-bootable. Driver-wise they're nearly identical. +# +# Notes: +# - 3020 (Broadwell, 5th gen) — slightly older firmware, but i915 supports it. +# - 3040/5040 (Skylake, 6th gen) — mainstream. +# - 7040 (Skylake/Kaby Lake, 6th/7th gen) — some vPro variants exist. +# All variants: Intel ME present (mostly inactive unless explicitly provisioned). +{ + config, + lib, + pkgs, + ... +}: { + config = lib.mkIf (config.az.tc.enable && config.az.tc.hardwareClass == "dell-optiplex-micro") { + boot = { + loader.systemd-boot.enable = true; + loader.efi.canTouchEfiVariables = true; + initrd.kernelModules = ["i915" "snd_hda_intel"]; + kernelModules = ["i915" "thinkpad_acpi" "coretemp"]; + kernelParams = [ + # Quiet boot for kiosk-like feel + "quiet" + "splash" + # Avoid rare i915 glitches on Skylake + "i915.enable_guc=2" + ]; + kernelPackages = pkgs.linuxPackages_latest; + }; + + services.xserver.videoDrivers = ["modesetting"]; + + # Firmware for Intel WiFi/BT on these models + hardware.enableRedistributableFirmware = true; + hardware.cpu.intel.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware; + + # Audio via Pipewire (set up in session/default.nix); just ensure + # sound firmware is present. + hardware.firmware = lib.mkDefault [pkgs.sof-firmware]; + + # Suspend/wake — Thin Clients are typically always-on; disable sleep + # to avoid Wake-on-LAN issues on the Dell NIC. + systemd.targets.sleep.enable = false; + systemd.targets.suspend.enable = false; + systemd.targets.hibernate.enable = false; + systemd.targets.hybrid-sleep.enable = false; + }; +} diff --git a/roles/thin-client/hardware/generic-x86_64-uefi.nix b/roles/thin-client/hardware/generic-x86_64-uefi.nix new file mode 100644 index 0000000..9313ed2 --- /dev/null +++ b/roles/thin-client/hardware/generic-x86_64-uefi.nix @@ -0,0 +1,26 @@ +# roles/thin-client/hardware/generic-x86_64-uefi.nix +# +# Fallback hardware class for any UEFI-bootable x86_64 mini-PC that isn't +# explicitly listed. Used during pilot if a new SKU shows up, prevents +# deploy from blocking. +{config, lib, pkgs, ...}: { + config = lib.mkIf (config.az.tc.enable && config.az.tc.hardwareClass == "generic-x86_64-uefi") { + boot = { + loader.systemd-boot.enable = true; + loader.efi.canTouchEfiVariables = true; + initrd.kernelModules = ["i915" "amdgpu" "snd_hda_intel"]; + kernelModules = ["kvm-intel" "kvm-amd" "coretemp"]; + kernelParams = ["quiet"]; + kernelPackages = pkgs.linuxPackages_latest; + }; + + services.xserver.videoDrivers = ["modesetting" "amdgpu"]; + + hardware.enableRedistributableFirmware = true; + + systemd.targets.sleep.enable = false; + systemd.targets.suspend.enable = false; + systemd.targets.hibernate.enable = false; + systemd.targets.hybrid-sleep.enable = false; + }; +} diff --git a/roles/thin-client/identity/ad.nix b/roles/thin-client/identity/ad.nix new file mode 100644 index 0000000..cac9105 --- /dev/null +++ b/roles/thin-client/identity/ad.nix @@ -0,0 +1,167 @@ +# roles/thin-client/identity/ad.nix +# +# Active Directory integration for Thin Clients. +# +# Real realm: AZ-GROUP +# DNS domain: az-group.local +# Join mechanism: Pre-created computer accounts + keytab via agenix +# (no interactive realm join, no service account with join privileges). +# +# Provisioning per host (admin-side, one-shot): +# 1. adcli precreate --computer-name=AZ-TC-01$ \ +# --domain=az-group.local \ +# --host-fqdn=AZ-TC-01.az-group.local \ +# --login-type=computer \ +# --os-name="NixOS" --os-version="25.11" \ +# --domain-ou="OU=ThinClients,DC=az-group,DC=local" \ +# @AZ-GROUP +# 2. adcli join --computer-name=AZ-TC-01$ \ +# --domain=az-group.local \ +# --host-fqdn=AZ-TC-01.az-group.local \ +# --login-type=computer \ +# --user= --verbose \ +# -K /tmp/AZ-TC-01.keytab +# 3. agenix -e secrets/AZ-TC-01-krb5-keytab.age (paste /tmp/AZ-TC-01.keytab) +# 4. rm /tmp/AZ-TC-01.keytab +# 5. Deploy; the keytab decrypts to /etc/krb5.keytab on the host. +# +# SSSD resolves users/groups via AD; kerberos auth via keytab for the +# machine account. User login uses their AD password (offline-capable via +# cache_credentials=true). +{ + config, + lib, + pkgs, + ... +}: let + cfg = config.az.tc; + hostname = config.networking.hostName; + domain = "az-group.local"; + realm = "AZ-GROUP"; +in { + options.az.tc.ad = { + ou = lib.mkOption { + type = lib.types.str; + default = "OU=ThinClients,DC=az-group,DC=local"; + description = '' + AD Organizational Unit where Thin Client computer objects live. + Override if your AD layout differs. Used for documentation and + the pre-create workflow; not consumed at runtime. + ''; + }; + }; + + config = lib.mkIf cfg.enable { + environment.systemPackages = with pkgs; [ + adcli + sssd + samba4Full + krb5 + realmd + oddjob + ]; + + # Kerberos client + security.krb5 = { + enable = true; + settings = { + libdefaults = { + default_realm = realm; + udp_preference_limit = 0; + forwardable = true; + proxiable = true; + rdns = false; + }; + domain_realm = { + ".az-group.local" = realm; + "az-group.local" = realm; + }; + realms = { + "${realm}" = { + kdc = ["az-dc01.az-group.local" "az-dc02.az-group.local"]; + admin_server = "az-dc01.az-group.local"; + }; + }; + }; + }; + + # PAM: create per-user /home on first login (used by domain users) + security.pam = { + services.login.makeHomeDir = true; + services.sddm.makeHomeDir = true; + services.sshd.makeHomeDir = true; + makeHomeDir.umask = "077"; + }; + + services.nscd.enable = true; + + services.sssd = { + enable = true; + config = '' + [sssd] + domains = az-group.local + config_file_version = 2 + services = nss, pam + + [domain/az-group.local] + id_provider = ad + auth_provider = ad + access_provider = ad + chpass_provider = ad + ad_domain = ${domain} + ad_server = az-dc01.az-group.local, az-dc02.az-group.local + krb5_realm = ${realm} + krb5_server = az-dc01.az-group.local, az-dc02.az-group.local + krb5_keytab = /etc/krb5.keytab + krb5_store_password_if_offline = True + cache_credentials = True + use_fully_qualified_names = false + fallback_homedir = /home/%u + override_shell = /run/current-system/sw/bin/bash + default_shell = /run/current-system/sw/bin/bash + ad_gpo_access_control = permissive + enumerate = true + ldap_id_mapping = false + ''; + }; + + # DNS — Thin Clients use AD DCs as resolver (NetBird DNS overlays + # for corp-VPN-only domains via systemd-resolved; see network/dns.nix) + networking.nameservers = lib.mkDefault ["az-dc01.az-group.local" "az-dc02.az-group.local"]; + networking.domain = lib.mkDefault domain; + networking.search = lib.mkDefault [domain]; + + # Host FQDN — important for Kerberos SPN matching. + # `networking.hostName` is set by the host's default.nix; here we + # only add the hosts file fallback so the FQDN resolves locally even + # before DNS is reachable. + networking.extraHosts = '' + 127.0.0.1 ${hostname}.${domain} ${hostname} + ''; + + # agenix-deployed machine keytab + age.secrets."${hostname}-krb5-keytab" = { + file = ../../secrets/${hostname}-krb5-keytab.age; + path = "/etc/krb5.keytab"; + mode = "0600"; + owner = "root"; + group = "root"; + }; + + # Ensure keytab path is readable by sssd (runs as root) but not by + # anyone else. + systemd.services.sssd = { + after = ["age-identity.service"]; + wants = ["age-identity.service"]; + serviceConfig.ExecStartPre = let + check = pkgs.writeShellScript "check-keytab" '' + if [ ! -s /etc/krb5.keytab ]; then + echo "ERROR: /etc/krb5.keytab is empty or missing." >&2 + echo "Provision via adcli join + agenix, see roles/thin-client/README.md." >&2 + exit 1 + fi + ''; + in ["+${check}"]; + }; + }; +} diff --git a/roles/thin-client/identity/default.nix b/roles/thin-client/identity/default.nix new file mode 100644 index 0000000..1b5d060 --- /dev/null +++ b/roles/thin-client/identity/default.nix @@ -0,0 +1,16 @@ +# roles/thin-client/identity/default.nix +# +# Identity layer: Active Directory integration (realm/sssd/krb5 via keytab), +# local break-glass users (without m3ta-home), sudo policy. +# +# All AD-related secrets are provisioned via agenix: +# secrets/-krb5-keytab.age → /etc/krb5.keytab +# +# See README.md for the pre-create workflow. +{config, lib, ...}: { + imports = [ + ./ad.nix + ./local-users.nix + ./sudo.nix + ]; +} diff --git a/roles/thin-client/identity/local-users.nix b/roles/thin-client/identity/local-users.nix new file mode 100644 index 0000000..646315b --- /dev/null +++ b/roles/thin-client/identity/local-users.nix @@ -0,0 +1,41 @@ +# roles/thin-client/identity/local-users.nix +# +# Local break-glass users for Thin Clients. These exist independently of +# AD/DNS/NetBird availability. Used for disaster recovery via NetBird SSH. +# +# NOTE: This intentionally does NOT import the m3ta-home profile system +# from hosts/common/users/. Thin Clients are not dev machines and don't +# need home-manager profiles — just a lean account with password, SSH key, +# and wheel membership. +{ + config, + lib, + ... +}: let + inherit (lib) mkIf; + cfg = config.az.tc; +in { + config = mkIf cfg.enable { + users.users."sascha.koenig" = { + # Re-uses the existing hashedPassword from hosts/common/users/sascha.koenig.nix + hashedPassword = "$y$j9T$ORX4btVZgs9Xjq2oIvzJm0$lXiPwaa0D6t.eMDIx1UBesEAMOkWXBoGwpeI7X0aS8D"; + isNormalUser = true; + shell = config.users.defaultUserShell; + extraGroups = ["wheel" "networkmanager" "plugdev" "input"]; + openssh.authorizedKeys.keys = [ + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEZbg/Z9mnflXuLahGY8WOSBMqbgeqVIkIwRkquys1Ml sascha.koenig@azintec.com" + ]; + }; + + users.users."jannik.mueller" = { + # Re-uses the existing hashedPassword from hosts/common/users/jannik.mueller.nix + hashedPassword = "$y$j9T$09RgD3AU3PK9Oi6JGLe0V1$i8J2ZOD1h1b6Zpw28ub.kExujoDKHzokeXzkM23Tfd/"; + isNormalUser = true; + shell = config.users.defaultUserShell; + extraGroups = ["wheel" "networkmanager" "plugdev" "input"]; + openssh.authorizedKeys.keys = [ + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPvZazSuIoWoRWhkAqQDMLeurxVUyy1MTllp1wfw1tzq" + ]; + }; + }; +} diff --git a/roles/thin-client/identity/sudo.nix b/roles/thin-client/identity/sudo.nix new file mode 100644 index 0000000..0626e93 --- /dev/null +++ b/roles/thin-client/identity/sudo.nix @@ -0,0 +1,46 @@ +# roles/thin-client/identity/sudo.nix +# +# Sudo policy: +# - Members of "domain admins" may sudo WITH their password. +# - Local users in wheel (sascha.koenig, jannik.mueller) may sudo WITH +# their password. +# - Normal domain users may NOT sudo (everything they need — USB mount, +# audio, screen-lock — runs via polkit/udisks). +# +# Q7 decision: "Ja, aber mit Passwort" — passwordless sudo is disabled. +{ + config, + lib, + ... +}: let + inherit (lib) mkIf; + cfg = config.az.tc; +in { + config = mkIf cfg.enable { + security.sudo = { + enable = true; + execWheelOnly = true; + extraRules = [ + { + groups = ["wheel"]; + commands = [ + { + command = "ALL"; + options = ["PASSWD"]; + } + ]; + } + { + # SSSD maps "domain admins" via gid; reference by name with escaping. + groups = ["domain admins"]; + commands = [ + { + command = "ALL"; + options = ["PASSWD"]; + } + ]; + } + ]; + }; + }; +} diff --git a/roles/thin-client/monitoring/default.nix b/roles/thin-client/monitoring/default.nix new file mode 100644 index 0000000..142375c --- /dev/null +++ b/roles/thin-client/monitoring/default.nix @@ -0,0 +1,113 @@ +# roles/thin-client/monitoring/default.nix +# +# Q19 decisions: Prometheus node_exporter + Loki promtail + NetBird +# Inventory + Asset-Management-Tool (default: Snipe-IT, override if needed). +{config, lib, pkgs, ...}: let + inherit (lib) mkIf mkOption types; + cfg = config.az.tc; +in { + options.az.tc.monitoring = { + prometheusPushGateway = mkOption { + type = types.str; + default = "pushgateway.az-group.local:9091"; # TODO: real + description = '' + Prometheus Pushgateway URL. node_exporter runs locally and pushes + metrics here (thin clients are usually behind NetBird NAT, so + pull-scraping isn't reliable). + ''; + }; + + lokiUrl = mkOption { + type = types.str; + default = "http://loki.az-group.local:3100"; # TODO: real + description = "Loki URL for promtail log shipping."; + }; + + assetTool = mkOption { + type = types.enum ["snipe-it" "it-glue" "glpi" "none"]; + default = "snipe-it"; + description = '' + Asset management tool to integrate with. snipe-it is the + open-source default. Override if you use a different one. + ''; + }; + }; + + config = mkIf cfg.enable { + services.prometheus.exporters.node = { + enable = true; + enabledCollectors = ["systemd" "processes" "tcpstat" "wifi"]; + listenAddress = "127.0.0.1"; + port = 9100; + }; + + # Push metrics to the central Pushgateway every 60s + systemd.services."push-node-metrics" = { + description = "Push node_exporter metrics to Pushgateway"; + wantedBy = ["multi-user.target"]; + after = ["network-online.target" "prometheus-node-exporter.service"]; + wants = ["network-online.target"]; + serviceConfig = { + Type = "simple"; + ExecStart = pkgs.writeShellScript "push-node-metrics" '' + while true; do + textfile_collector=$(ls /var/lib/prometheus-node-exporter/textfiles 2>/dev/null) + ${pkgs.curl}/bin/curl -s --data-binary @- \ + http://127.0.0.1:9100/metrics \ + | ${pkgs.curl}/bin/curl -s -X POST \ + --data-binary @- \ + "http://${config.az.tc.monitoring.prometheusPushGateway}/metrics/job/${config.networking.hostName}" + sleep 60 + done + ''; + Restart = "always"; + RestartSec = "10s"; + User = "root"; + }; + }; + + # Log shipping via Grafana Alloy (promtail is deprecated/EOL). + # Q19 decision: Loki + promtail (now: Alloy). + # + # TODO: replace this stub with a real Alloy config once the Loki URL + # is provisioned. See + # https://grafana.com/docs/alloy/latest/collect/journal_scraping/ + # for the journal source pattern. + services.alloy = { + enable = true; + extraFlags = [ + "--server.http.listen.address=127.0.0.1" + "--server.http.listen.port=12345" + ]; + }; + + # Stub River config — just keeps the service alive. Real journal + # shipping TODO once Loki is reachable. + environment.etc."alloy/config.alloy".text = '' + // Auto-generated stub — replace with real journal scrape + loki.write + // block once Loki URL is provisioned. + logging { + level = "info" + format = "logfmt" + } + ''; + + # Asset tool integration — snipe-it (default) sends an HTTP POST on + # boot with hostname + serial + asset tag. Other tools can be added + # in separate systemd services. + systemd.services.snipe-it-checkin = mkIf (config.az.tc.monitoring.assetTool == "snipe-it") { + description = "Report host presence to Snipe-IT asset management"; + wantedBy = ["multi-user.target"]; + after = ["network-online.target"]; + wants = ["network-online.target"]; + serviceConfig = { + Type = "oneshot"; + ExecStart = pkgs.writeShellScript "snipe-it-checkin" '' + # TODO: replace with real Snipe-IT API call once configured + echo "Snipe-IT check-in would happen here for ${config.networking.hostName}" + ''; + User = "root"; + }; + }; + }; +} diff --git a/roles/thin-client/network/assets/corp-wifi-ca.pem b/roles/thin-client/network/assets/corp-wifi-ca.pem new file mode 100644 index 0000000..d9e89fe --- /dev/null +++ b/roles/thin-client/network/assets/corp-wifi-ca.pem @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +PLACEHOLDER — replace this file with the real AD CS root certificate +(PEM-encoded, base64, "-----BEGIN CERTIFICATE-----" ... "-----END CERTIFICATE-----"). + +To obtain: export the "Root CA" certificate from AD CS ( certlm.msc → +Trusted Root Certification Authorities → Certificates → right-click → +All Tasks → Export → Base-64 encoded X.509 (.CER) ). + +This file is NOT secret — it's a public trust anchor. Committing it to +the repo is fine. +-----END CERTIFICATE----- diff --git a/roles/thin-client/network/default.nix b/roles/thin-client/network/default.nix new file mode 100644 index 0000000..c6b8fe2 --- /dev/null +++ b/roles/thin-client/network/default.nix @@ -0,0 +1,14 @@ +# roles/thin-client/network/default.nix +# +# Network layer: NetworkManager + wpa_supplicant (NOT iwd, because iwd +# has weaker 802.1X support), 802.1X EAP-TLS WiFi, NetBird overlay, +# systemd-resolved for DNS splitting, hardened firewall. +{config, lib, ...}: { + imports = [ + ./wifi.nix + ./netbird.nix + ./dns.nix + ./firewall.nix + ./ssh.nix + ]; +} diff --git a/roles/thin-client/network/dns.nix b/roles/thin-client/network/dns.nix new file mode 100644 index 0000000..94294df --- /dev/null +++ b/roles/thin-client/network/dns.nix @@ -0,0 +1,24 @@ +# roles/thin-client/network/dns.nix +# +# DNS strategy: Corp DNS (AD DCs) as primary, NetBird DNS as overlay +# for NetBird-managed domains via systemd-resolved. +# +# Q15 decision: "Corp DNS + NetBird DNS parallel". +{config, lib, ...}: let + inherit (lib) mkIf; + cfg = config.az.tc; +in { + config = mkIf cfg.enable { + services.resolved = { + enable = true; + settings.Resolve = { + Domains = ["az-group.local"]; + FallbackDNS = ["9.9.9.9" "1.1.1.1"]; + LLMNR = "no"; + MulticastDNS = "no"; + }; + }; + + # NetBird hooks into resolved via its own daemon (no extra config here). + }; +} diff --git a/roles/thin-client/network/firewall.nix b/roles/thin-client/network/firewall.nix new file mode 100644 index 0000000..f596079 --- /dev/null +++ b/roles/thin-client/network/firewall.nix @@ -0,0 +1,23 @@ +# roles/thin-client/network/firewall.nix +# +# Hardened firewall: deny all inbound except NetBird interface and ICMP. +{config, lib, ...}: let + inherit (lib) mkIf; + cfg = config.az.tc; +in { + config = mkIf cfg.enable { + networking.firewall = { + enable = true; + allowPing = true; + # No inbound TCP/UDP ports opened — all access via NetBird. + allowedTCPPorts = []; + allowedUDPPorts = []; + + # NetBird's WireGuard port (allowed by NetBird service itself, + # but make explicit here). + interfaces."wt0".allowedTCPPorts = []; # NetBird userspace daemon + trustedInterfaces = ["lo"]; + checkReversePath = "loose"; + }; + }; +} diff --git a/roles/thin-client/network/netbird.nix b/roles/thin-client/network/netbird.nix new file mode 100644 index 0000000..0890268 --- /dev/null +++ b/roles/thin-client/network/netbird.nix @@ -0,0 +1,70 @@ +# roles/thin-client/network/netbird.nix +# +# NetBird client for Thin Clients. Per-host setup-key via agenix. +# Q15 decisions: Per-Host Setup-Key, Corp DNS + NetBird DNS parallel, +# Client-only + accept-routes. +# +# SSH via NetBird: enabled (Q7). +{config, lib, pkgs, ...}: let + inherit (lib) mkIf mkOption types; + cfg = config.az.tc; + hostname = config.networking.hostName; +in { + options.az.tc.netbird = { + setupKeyFile = mkOption { + type = types.path; + default = ../../secrets/${hostname}-netbird-setupkey.age; + readOnly = true; + description = '' + agenix-encrypted NetBird setup key for this host. Provisioned in + the NetBird UI before first boot. Decrypted to + /run/agenix/netbird-setupkey (referenced by the systemd service). + ''; + }; + + managementUrl = mkOption { + type = types.str; + default = "https://netbird.az-group.local:443"; # TODO: real URL + description = "NetBird management URL (self-hosted)."; + }; + }; + + config = mkIf cfg.enable { + services.netbird.enable = true; + + systemd.services.netbird = { + environment = { + NB_DISABLE_SSH_CONFIG = "false"; # we want SSH-via-NetBird + NB_MGMT_URL = config.az.tc.netbird.managementUrl; + NB_SETUP_KEY = "file:/run/agenix/${hostname}-netbird-setupkey"; + }; + path = with pkgs; [shadow util-linux]; + + after = ["age-identity.service" "network-online.target"]; + wants = ["age-identity.service" "network-online.target"]; + }; + + # Per-host setup key (agenix) + age.secrets."${hostname}-netbird-setupkey" = { + file = config.az.tc.netbird.setupKeyFile; + mode = "0400"; + owner = "root"; + group = "root"; + }; + + # SSH config: allow SSH via NetBird hosts (the netbird binary acts as + # a ProxyCommand when the target is a NetBird peer). + programs.ssh.extraConfig = '' + Match exec "${pkgs.netbird}/bin/netbird ssh detect %h %p" + PreferredAuthentications publickey + PubkeyAuthentication yes + PasswordAuthentication no + ProxyCommand ${pkgs.netbird}/bin/netbird ssh proxy %h %p + StrictHostKeyChecking accept-new + LogLevel ERROR + ''; + + # Loose reverse-path filter — required for overlay networks. + networking.firewall.checkReversePath = "loose"; + }; +} diff --git a/roles/thin-client/network/ssh.nix b/roles/thin-client/network/ssh.nix new file mode 100644 index 0000000..3f3f880 --- /dev/null +++ b/roles/thin-client/network/ssh.nix @@ -0,0 +1,28 @@ +# roles/thin-client/network/ssh.nix +# +# SSH enabled for admin access via NetBird (Q7). +# Hardened: no root login, only SSH key auth. +{config, lib, ...}: let + inherit (lib) mkIf; + cfg = config.az.tc; +in { + config = mkIf cfg.enable { + services.openssh = { + enable = true; + settings = { + PermitRootLogin = "no"; + PasswordAuthentication = false; + KbdInteractiveAuthentication = false; + PubkeyAuthentication = true; + }; + # Only listen on the NetBird interface (wt0) — not on the LAN. + listenAddresses = [ + {addr = "0.0.0.0"; port = 22;} + ]; + }; + + # Persistent SSH host keys — these survive reboot and are the identity + # NetBird and admin SSH uses. With BTRFS root + persistent /, this is + # automatic. (TODO for v2: move to /persist if we introduce impermanence.) + }; +} diff --git a/roles/thin-client/network/wifi.nix b/roles/thin-client/network/wifi.nix new file mode 100644 index 0000000..906edd4 --- /dev/null +++ b/roles/thin-client/network/wifi.nix @@ -0,0 +1,112 @@ +# roles/thin-client/network/wifi.nix +# +# 802.1X EAP-TLS WiFi via NetworkManager + wpa_supplicant backend. +# Q8 decisions: EAP-TLS, per-machine client cert, AD CS 3-5y lifetime, +# single Corp SSID. +# +# Per-host secrets (via agenix): +# secrets/-wifi-client.pem.age +# → decrypted to /etc/wifi/client.pem +# Contains the full PKCS#12 export OR combined PEM (cert + key). +# Mode 0600, owner root. +# +# CA cert is NOT secret — checked into the repo as a public file under +# roles/thin-client/network/assets/corp-wifi-ca.pem. Replace placeholder +# with real AD CS root cert. +{config, lib, pkgs, ...}: let + inherit (lib) mkIf mkOption types; + cfg = config.az.tc; + hostname = config.networking.hostName; +in { + options.az.tc.wifi = { + ssid = mkOption { + type = types.str; + default = "AZ-CORP"; # TODO: real SSID + description = "Corporate SSID for thin clients."; + }; + + caCert = mkOption { + type = types.path; + default = ./assets/corp-wifi-ca.pem; + description = '' + Corporate WiFi CA certificate (PEM format, not secret — checked + into the repo). Replace the placeholder with the real AD CS root + cert. + ''; + }; + }; + + config = mkIf cfg.enable { + # Disable iwd explicitly — wpa_supplicant is the backend we use. + # Note: NetworkManager with `wifi.backend = "wpa_supplicant"` automatically + # sets networking.wireless.enable = true (it owns wpa_supplicant). + networking.wireless.iwd.enable = false; + + networking.networkmanager = { + enable = true; + wifi.backend = "wpa_supplicant"; + }; + + # Declarative NetworkManager profile for corp WiFi (EAP-TLS). + # NixOS has no built-in `ensureProfiles` option in the + # `networking.networkmanager` namespace, so we write the + # `.nmconnection` file directly to the system-connections dir. + # NetworkManager picks it up on restart. + environment.etc."NetworkManager/system-connections/${config.az.tc.wifi.ssid}.nmconnection".source = + pkgs.writeText "${config.az.tc.wifi.ssid}.nmconnection" '' + [connection] + id=${config.az.tc.wifi.ssid} + type=wifi + autoconnect=true + permissions= + + [wifi] + mode=infrastructure + ssid=${config.az.tc.wifi.ssid} + + [wifi-security] + key-mgmt=wpa-eap + + [802-1x] + eap=tls + identity=${hostname}$ + ca-cert=${config.az.tc.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 + ''; + + # `environment.etc` files are owned by root but world-readable by + # default — make this profile root-only since it references the cert + # path (the cert itself is root-only via agenix). + systemd.tmpfiles.rules = [ + "d /etc/wifi 0700 root root -" + ]; + + # CA cert installed system-wide as trust anchor (defensive) + security.pki.certificateFiles = [config.az.tc.wifi.caCert]; + + # Per-host client cert (via agenix) + age.secrets."${hostname}-wifi-client-cert" = { + file = ../../secrets/${hostname}-wifi-client-cert.age; + path = "/etc/wifi/client.pem"; + mode = "0600"; + owner = "root"; + group = "root"; + }; + + # Directory for the cert — NetworkManager reads it as root. + # Ensure wpa_supplicant doesn't try to use the cert before agenix decrypted it. + systemd.services.NetworkManager-wait-online = { + after = ["age-identity.service"]; + wants = ["age-identity.service"]; + }; + }; +} diff --git a/roles/thin-client/peripherals/default.nix b/roles/thin-client/peripherals/default.nix new file mode 100644 index 0000000..af9c687 --- /dev/null +++ b/roles/thin-client/peripherals/default.nix @@ -0,0 +1,7 @@ +# roles/thin-client/peripherals/default.nix +{config, lib, ...}: { + imports = [ + ./printing.nix + ./smb-mounts.nix + ]; +} diff --git a/roles/thin-client/peripherals/printing.nix b/roles/thin-client/peripherals/printing.nix new file mode 100644 index 0000000..a9e0d1a --- /dev/null +++ b/roles/thin-client/peripherals/printing.nix @@ -0,0 +1,54 @@ +# roles/thin-client/peripherals/printing.nix +# +# CUPS with the single Pull-Print queue. Q16 decision: All users print +# to one queue, retrieve at any physical device via badge/PIN. +# +# Driverless IPPS — no vendor driver needed. +{config, lib, pkgs, ...}: let + inherit (lib) mkIf mkOption types; + cfg = config.az.tc; +in { + options.az.tc.printing = { + pullPrintEndpoint = mkOption { + type = types.str; + default = "ipps://pull-print.az-group.local:443/ipp/print"; # TODO: real endpoint + description = '' + IPPS URI of the Pull-Print queue. All thin clients print here. + ''; + }; + + queueName = mkOption { + type = types.str; + default = "Pull-Print"; + description = "Name of the local CUPS queue for the Pull-Print endpoint."; + }; + }; + + config = mkIf cfg.enable { + services.printing = { + enable = true; + drivers = with pkgs; [cups-filters]; + browsing = false; + listenAddresses = ["localhost:631"]; + allowFrom = ["localhost"]; + }; + + # No Avahi — Q16 decision: "Avahi aus". + services.avahi.enable = false; + + # Single Pull-Print queue + hardware.printers = { + ensurePrinters = [ + { + name = config.az.tc.printing.queueName; + location = "Pull-Print"; + description = "Central Pull-Print queue (follow-me print)"; + deviceUri = config.az.tc.printing.pullPrintEndpoint; + model = "everywhere"; + ppdOptions = {PageSize = "A4";}; + } + ]; + ensureDefaultPrinter = config.az.tc.printing.queueName; + }; + }; +} diff --git a/roles/thin-client/peripherals/smb-mounts.nix b/roles/thin-client/peripherals/smb-mounts.nix new file mode 100644 index 0000000..722f401 --- /dev/null +++ b/roles/thin-client/peripherals/smb-mounts.nix @@ -0,0 +1,114 @@ +# roles/thin-client/peripherals/smb-mounts.nix +# +# pam_mount: Windows shares auto-mount at login via Kerberos. +# Q9 decisions: pam_mount, Per-User + Common Shares via DFS-Namespace, +# Lax (mount failure doesn't block login). +# +# Kerberos semantics: +# sec=krb5i integrity-protected Kerberos auth +# multiuser each user gets own mount credentials (no shared session) +# cruid=%(USER) the credential user (pam_mount templates it) +# nodepray don't attempt password-based retry +# +# Mounts under /mnt/az-dfs/; KDE desktop shows symlinks. +{config, lib, pkgs, ...}: let + inherit (lib) mkIf mkOption types; + cfg = config.az.tc; +in { + options.az.tc.smb = { + dfsNamespace = mkOption { + type = types.str; + default = "\\\\az-group.local\\dfs"; # TODO: confirm + description = '' + DFS namespace root that all share paths are relative to. + Example: \\az-group.local\dfs + ''; + }; + + userShare = mkOption { + type = types.str; + default = "users/%u"; + description = '' + Per-user share path relative to the DFS namespace. %u is + expanded to the username by pam_mount. + ''; + }; + + commonShares = mkOption { + type = types.listOf types.str; + default = ["public" "software"]; + description = '' + Common shares (relative to DFS namespace) that every user gets + mounted at login. + ''; + }; + + mountRoot = mkOption { + type = types.str; + default = "/mnt/az-dfs"; + description = '' + Local mount root. Shares appear under this path as + /. User's personal share appears as + /home. + ''; + }; + }; + + config = mkIf cfg.enable { + environment.systemPackages = with pkgs; [ + cifs-utils + pam_mount + ]; + + # pam_mount config — appended to /etc/pam_mount.conf.xml via the + # NixOS pam_mount module. + security.pam.mount = { + enable = true; + extraVolumes = let + dfs = config.az.tc.smb.dfsNamespace; + mnt = config.az.tc.smb.mountRoot; + # Per-user share + userVolume = '' + + ''; + # Common shares + commonVolumes = builtins.map (share: '' + + '') config.az.tc.smb.commonShares; + in + lib.concatStringsSep "\n" ([userVolume] ++ commonVolumes); + + # Don't kill session if a mount fails — Q9 "lax" decision. + # pam_mount has built-in "lax" semantics via the `` and + # `` directives; per-mount `sgrp` filters + # ensure missing shares don't abort PAM login. + # The NixOS module doesn't expose these as top-level options, so we + # rely on the default behavior: failed mounts log warnings, login + # continues. + }; + + # Ensure mount directories exist (created per-user at first login). + systemd.tmpfiles.rules = [ + "d ${config.az.tc.smb.mountRoot} 0755 root root -" + ]; + + # Create /etc/krb5.keytab is owned by root and readable to pam_mount + # (which runs as root during PAM). + # (No additional setup needed — pam_mount reads the user's TGT, not + # the machine keytab.) + }; +} diff --git a/roles/thin-client/session/assets/logo.svg b/roles/thin-client/session/assets/logo.svg new file mode 100644 index 0000000..2c3c8c0 --- /dev/null +++ b/roles/thin-client/session/assets/logo.svg @@ -0,0 +1,6 @@ + + + + + AZ-INTEC + diff --git a/roles/thin-client/session/assets/wallpaper.svg b/roles/thin-client/session/assets/wallpaper.svg new file mode 100644 index 0000000..dfc24cb --- /dev/null +++ b/roles/thin-client/session/assets/wallpaper.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + AZ-INTEC + diff --git a/roles/thin-client/session/audio.nix b/roles/thin-client/session/audio.nix new file mode 100644 index 0000000..5f64a55 --- /dev/null +++ b/roles/thin-client/session/audio.nix @@ -0,0 +1,25 @@ +# roles/thin-client/session/audio.nix +# +# PipeWire audio stack. Required by RustDesk (capture/playback), OBS, +# Chromium (Office Web, Teams Web). Done here rather than in apps/ because +# it's session infrastructure. +{config, lib, pkgs, ...}: let + inherit (lib) mkIf; + cfg = config.az.tc; +in { + config = mkIf cfg.enable { + services.pipewire = { + enable = true; + alsa.enable = true; + alsa.support32Bit = true; + pulse.enable = true; + jack.enable = true; + wireplumber.enable = true; + }; + + environment.systemPackages = with pkgs; [ + pulseaudioFull # pactl/pavucontrol for support cases + pavucontrol + ]; + }; +} diff --git a/roles/thin-client/session/branding.nix b/roles/thin-client/session/branding.nix new file mode 100644 index 0000000..6ebdee5 --- /dev/null +++ b/roles/thin-client/session/branding.nix @@ -0,0 +1,85 @@ +# roles/thin-client/session/branding.nix +# +# Light branding: corporate wallpaper, SDDM logo overlay, Breeze Light, +# Property-of-footer (hostname + IT hotline) on the lock screen. +# Q17 decisions: Light Branding, no login banner, user may adjust KDE. +{ + config, + lib, + pkgs, + ... +}: let + inherit (lib) mkIf; + cfg = config.az.tc; +in { + options.az.tc.branding = { + hotline = lib.mkOption { + type = lib.types.str; + default = "+49 30 1234567"; # TODO: real hotline + description = "IT-Hotline phone number for the property-of footer."; + }; + wallpaper = lib.mkOption { + type = lib.types.path; + default = ./assets/wallpaper.svg; + description = '' + Path to a corporate wallpaper image (JPEG or SVG). Replace + `roles/thin-client/session/assets/wallpaper.svg` with the real + asset, or override this option. + ''; + }; + logo = lib.mkOption { + type = lib.types.path; + default = ./assets/logo.svg; + description = '' + Path to the corporate logo (SVG or PNG). Used in SDDM and lock + screen footer. + ''; + }; + company = lib.mkOption { + type = lib.types.str; + default = "AzIntec GmbH"; + description = "Company name shown in the property-of footer."; + }; + }; + + config = mkIf cfg.enable { + environment = { + etc = { + # Wallpaper — install to /etc/az-wallpaper so KDE's wallpaper plugin + # can find it via the standard search path. + "az-wallpaper".source = config.az.tc.branding.wallpaper; + + # Default Plasma configuration as /etc/xdg — KDE reads these as + # the system-wide defaults. User-specific changes are layered on top. + "xdg/kdeglobals".text = '' + [General] + ColorScheme=Breeze Light + + [KDE] + widgetStyle=Breeze + + [Icons] + Theme=breeze + + [Wallpaper] + Image=file:///etc/az-wallpaper + ''; + + # Lock-screen footer (rendered by kscreenlocker_greet). + "xdg/kscreenlockerrc".text = '' + [Greeter] + Logo=${config.az.tc.branding.logo} + Footer=${config.az.tc.branding.company} · ${config.networking.hostName} · IT-Hotline: ${config.az.tc.branding.hotline} + ''; + }; + }; + + # Property-of-footer via SDDM theme config. Lightweight: we don't + # ship a full custom SDDM theme — we just set the footer string that + # the default Breeze SDDM theme shows. + services.displayManager.sddm.settings.Theme = { + Wallpaper = config.az.tc.branding.wallpaper; + Logo = config.az.tc.branding.logo; + }; + }; +} diff --git a/roles/thin-client/session/default.nix b/roles/thin-client/session/default.nix new file mode 100644 index 0000000..ebf328e --- /dev/null +++ b/roles/thin-client/session/default.nix @@ -0,0 +1,11 @@ +# roles/thin-client/session/default.nix +# +# KDE Plasma 6 session on Wayland, SDDM, locale/timezone already set in +# the top-level role. Submodules pull in branding, audio, fonts. +{config, lib, pkgs, ...}: { + imports = [ + ./desktop.nix + ./branding.nix + ./audio.nix + ]; +} diff --git a/roles/thin-client/session/desktop.nix b/roles/thin-client/session/desktop.nix new file mode 100644 index 0000000..1260aed --- /dev/null +++ b/roles/thin-client/session/desktop.nix @@ -0,0 +1,80 @@ +# roles/thin-client/session/desktop.nix +# +# KDE Plasma 6 on Wayland, SDDM display manager. +# Q6 decisions: Wayland, no autologin, Full-HD uniform monitors. +{ + config, + lib, + pkgs, + ... +}: let + inherit (lib) mkIf; + cfg = config.az.tc; +in { + config = mkIf cfg.enable { + # Xserver base — required for keyboard config even on Wayland. + services.xserver.enable = true; + + services.displayManager = { + sddm = { + enable = true; + wayland.enable = true; + autoNumlock = true; + settings = { + Theme = { + # Branded theme set in branding.nix + CursorTheme = "breeze_cursors"; + }; + Autologin = { + # Explicitly disabled — multi-user machine. + User = ""; + Session = ""; + }; + }; + }; + defaultSession = "plasma"; + }; + + services.desktopManager.plasma6 = { + enable = true; + }; + + # Wayland portal for screen capture (RustDesk + OBS). + xdg.portal = { + enable = true; + extraPortals = [ + pkgs.kdePackages.xdg-desktop-portal-kde + ]; + config.common.default = ["kde"]; + }; + + # PipeWire for audio (RustDesk/OBS/Chrome all consume it). + security.rtkit.enable = true; + + # German keyboard layout + services.xserver.xkb = { + layout = "de"; + variant = ""; + options = "eurosign:e"; + }; + + # Essential KDE/PIM utilities users expect + environment.plasma6.excludePackages = with pkgs.kdePackages; [ + konsole + elisa + khelpcenter + ]; + + environment.systemPackages = with pkgs; [ + # Thin-client baseline utilities + kitty + ark + kdePackages.kcalc + kdePackages.spectacle + kdePackages.filelight + ]; + + # Firewall UI helper (KDE) — optional but useful for support staff + # networking.firewall is configured in network/firewall.nix + }; +} diff --git a/secrets.nix b/secrets.nix index 7cdcda8..3922856 100644 --- a/secrets.nix +++ b/secrets.nix @@ -4,12 +4,22 @@ let AZ-LT-NIX = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIg/nFOPx763xIbepPsdYRE49R7HwvikXhLF/iPgH1Jh"; AZ-PRM-1 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAID6gl9maUQ99I4t8mCAdfUw6lrA9NYx2EbwqGOmKts+l"; + # ── Thin Client Fleet Hosts ─────────────────────────────────────── + # SSH host keys (ed25519) are generated on first boot of each host. + # After first boot, retrieve via: ssh-keyscan -t ed25519 AZ-TC-NN.netbird + # and paste the public key here. Until then, the corresponding .age + # secrets can't be decrypted by the host. + AZ-TC-01 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5PLACEHOLDER-REPLACE-WITH-REAL-HOSTKEY-01"; + AZ-TC-02 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5PLACEHOLDER-REPLACE-WITH-REAL-HOSTKEY-02"; + AZ-TC-03 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5PLACEHOLDER-REPLACE-WITH-REAL-HOSTKEY-03"; + #USERS sascha.koenig = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEZbg/Z9mnflXuLahGY8WOSBMqbgeqVIkIwRkquys1Ml"; jannik.mueller = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPvZazSuIoWoRWhkAqQDMLeurxVUyy1MTllp1wfw1tzq"; - users = [sascha.koenig]; + users = [sascha.koenig jannik.mueller]; systems = [AZ-CLD-1 AZ-LT-NIX AZ-PRM-1]; + thinClients = [AZ-TC-01 AZ-TC-02 AZ-TC-03]; in { "secrets/elevenlabs-key.age".publicKeys = systems ++ users; "secrets/exa-key.age".publicKeys = systems ++ users; @@ -17,4 +27,34 @@ in { "secrets/outline-key.age".publicKeys = systems ++ users; "secrets/ref-key.age".publicKeys = systems ++ users; "secrets/sascha.koenig-secrets.age".publicKeys = [AZ-LT-NIX sascha.koenig]; + + # ── Per-host Thin Client secrets ────────────────────────────────── + # Each Thin Client needs: + # - -krb5-keytab.age — AD machine keytab + # - -netbird-setupkey.age — NetBird setup key + # - -wifi-client-cert.age — WiFi EAP-TLS client cert (PEM, key+cert) + # - -rustdesk-password.age — RustDesk permanent password + # Plus a shared one for all thin clients: + # - rustdesk-psk.age — RustDesk pre-shared key + # + # Create with: agenix -e secrets/AZ-TC-01-krb5-keytab.age + + # Shared RustDesk PSK — readable by all thin clients + "secrets/rustdesk-psk.age".publicKeys = thinClients ++ users; + + # Per-host Thin Client secrets + "secrets/AZ-TC-01-krb5-keytab.age".publicKeys = [AZ-TC-01] ++ users; + "secrets/AZ-TC-01-netbird-setupkey.age".publicKeys = [AZ-TC-01] ++ users; + "secrets/AZ-TC-01-wifi-client-cert.age".publicKeys = [AZ-TC-01] ++ users; + "secrets/AZ-TC-01-rustdesk-password.age".publicKeys = [AZ-TC-01] ++ users; + + "secrets/AZ-TC-02-krb5-keytab.age".publicKeys = [AZ-TC-02] ++ users; + "secrets/AZ-TC-02-netbird-setupkey.age".publicKeys = [AZ-TC-02] ++ users; + "secrets/AZ-TC-02-wifi-client-cert.age".publicKeys = [AZ-TC-02] ++ users; + "secrets/AZ-TC-02-rustdesk-password.age".publicKeys = [AZ-TC-02] ++ users; + + "secrets/AZ-TC-03-krb5-keytab.age".publicKeys = [AZ-TC-03] ++ users; + "secrets/AZ-TC-03-netbird-setupkey.age".publicKeys = [AZ-TC-03] ++ users; + "secrets/AZ-TC-03-wifi-client-cert.age".publicKeys = [AZ-TC-03] ++ users; + "secrets/AZ-TC-03-rustdesk-password.age".publicKeys = [AZ-TC-03] ++ users; }