feat: +thin-client role + AZ-TC-01..03 pilot

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).
This commit is contained in:
m3ta-chiron
2026-07-29 08:34:01 +02:00
parent ea0c4ccf22
commit 98dc2917e8
42 changed files with 2281 additions and 1 deletions
+214
View File
@@ -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@<target-ip>
```
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
```
+42
View File
@@ -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";
};
};
}
+119
View File
@@ -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
];
};
}
+14
View File
@@ -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
];
}
+85
View File
@@ -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"
}
'';
};
};
};
}
+45
View File
@@ -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=<URL>`, 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")
];
};
}
+96
View File
@@ -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 -"
];
};
}
+82
View File
@@ -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";
};
};
}
+114
View File
@@ -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').";
}
];
};
}
@@ -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";
};
}
+12
View File
@@ -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
];
}
+94
View File
@@ -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@<target-ip>
#
# 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"];
};
};
};
};
};
};
};
};
};
}
+40
View File
@@ -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
'';
};
};
}
+29
View File
@@ -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 == "<this class>"`.
# 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}";
}
];
};
}
@@ -0,0 +1,51 @@
# roles/thin-client/hardware/dell-optiplex-micro.nix
#
# Hardware class for Dell OptiPlex Micro (3020/3040/5040/7040).
# These are 5th7th 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;
};
}
@@ -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;
};
}
+167
View File
@@ -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" \
# <admin>@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=<admin> --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}"];
};
};
}
+16
View File
@@ -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/<hostname>-krb5-keytab.age → /etc/krb5.keytab
#
# See README.md for the pre-create workflow.
{config, lib, ...}: {
imports = [
./ad.nix
./local-users.nix
./sudo.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"
];
};
};
}
+46
View File
@@ -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"];
}
];
}
];
};
};
}
+113
View File
@@ -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";
};
};
};
}
@@ -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-----
+14
View File
@@ -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
];
}
+24
View File
@@ -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).
};
}
+23
View File
@@ -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";
};
};
}
+70
View File
@@ -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";
};
}
+28
View File
@@ -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.)
};
}
+112
View File
@@ -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/<hostname>-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"];
};
};
}
@@ -0,0 +1,7 @@
# roles/thin-client/peripherals/default.nix
{config, lib, ...}: {
imports = [
./printing.nix
./smb-mounts.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;
};
};
}
@@ -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/<share>; 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
<mountRoot>/<share-name>. User's personal share appears as
<mountRoot>/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 = ''
<volume
fstype="cifs"
server="${dfs}"
path="${config.az.tc.smb.userShare}"
mountpoint="${mnt}/home"
options="sec=krb5i,cruid=%(USER),uid=%(USER),gid=%(USER),multiuser,nodev,nosuid,mfsymlinks"
user="*"
/>
'';
# Common shares
commonVolumes = builtins.map (share: ''
<volume
fstype="cifs"
server="${dfs}"
path="${share}"
mountpoint="${mnt}/${share}"
options="sec=krb5i,cruid=%(USER),uid=%(USER),gid=%(USER),multiuser,nodev,nosuid,ro,mfsymlinks"
user="*"
/>
'') 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 `<luserconf>` and
# `<mkmountpoint enable="...">` 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.)
};
}
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Placeholder AzIntec logo. Replace with real corporate logo. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 80" width="200" height="80">
<rect width="200" height="80" fill="#1e3a8a"/>
<text x="100" y="50" font-family="Arial,sans-serif" font-size="24" font-weight="bold" fill="white" text-anchor="middle">AZ-INTEC</text>
</svg>

After

Width:  |  Height:  |  Size: 388 B

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Placeholder wallpaper. Replace with real corporate wallpaper (JPEG preferred). -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1920 1080" width="1920" height="1080">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#0f172a"/>
<stop offset="100%" stop-color="#1e3a8a"/>
</linearGradient>
</defs>
<rect width="1920" height="1080" fill="url(#bg)"/>
<text x="960" y="540" font-family="Arial,sans-serif" font-size="72" font-weight="bold" fill="white" text-anchor="middle" opacity="0.3">AZ-INTEC</text>
</svg>

After

Width:  |  Height:  |  Size: 626 B

+25
View File
@@ -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
];
};
}
+85
View File
@@ -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;
};
};
}
+11
View File
@@ -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
];
}
+80
View File
@@ -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
};
}