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
+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"];
}
];
}
];
};
};
}