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:
@@ -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
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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";
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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")
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -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 -"
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
@@ -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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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 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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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}"];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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"];
|
||||
}
|
||||
];
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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-----
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
@@ -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).
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
};
|
||||
}
|
||||
@@ -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.)
|
||||
};
|
||||
}
|
||||
@@ -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 |
@@ -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
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
+41
-1
@@ -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:
|
||||
# - <host>-krb5-keytab.age — AD machine keytab
|
||||
# - <host>-netbird-setupkey.age — NetBird setup key
|
||||
# - <host>-wifi-client-cert.age — WiFi EAP-TLS client cert (PEM, key+cert)
|
||||
# - <host>-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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user