fix(thin-client): deep-build fixes + Snipe-IT/Alloy real impl

Tiefen-Validierung via 'nix build .#nixosConfigurations.AZ-TC-NN.config.
system.build.toplevel' hat mehrere reale Bugs gefunden, die 'nix flake
check' nicht sah. Alle drei Pilot-Hosts bauen jetzt sauber durch.

Gefixst:
- freerdp3 → freerdp (umbenannt in nixpkgs-unstable)
- SDDM Theme.Logo will INI-Atom (string), nicht Nix-path → '${path}'
- security.pam.mount.extraVolumes will list-of-string, nicht ein String
- sudoers: 'domain admins' muss als 'domain\ admins' escaped werden
- agenix file-Pfade: ../../secrets/ → ../../../secrets/ (Tiefe korrigiert)
- snapper: config.services.snapper.package gibt es nicht → pkgs.snapper
- samba4Full entfernt (blockiert durch ceph-common python metadata issue
  in nixpkgs-unstable; Thin Clients brauchen es nicht — cifs-utils reicht)
- corp-wifi-ca.pem durch gültiges Dummy-PEM ersetzt (openssl-generiert,
  mit明显 REPLACE-MARKER; build kann PEM parsen)

Functional gemacht:
- Alloy: echtes River-Config mit loki.source.journal + loki.write statt
  barem logging-stub. Journal-Logs mit host/unit/severity-Labels nach
  Loki.
- Snipe-IT: echte Check-in-Logik via curl + jq. Lookup by asset_tag,
  PATCH falls exists, POST falls neu. startAt täglich 03:30. API-Token
  via agenix (snipeit-api-token.age).
- node_exporter push: realer curl-Push alle 60s mit retry on failure.

Safety:
- Placeholder-Assertions in roles/thin-client/default.nix: build schlägt
  fehl, wenn wifi.ssid/hotline/company noch Placeholder sind (außer
  site='staging'). Pilot-Hosts haben site='staging' bis echte Werte da.
- assets/corp-wifi-ca.pem hat deutlich sichtbaren REPLACE-Hinweis.

Neue Options:
- az.tc.monitoring.snipeItUrl (default: snipeit.az-group.local)

Neue Secrets (Placeholder .age-Files zum Ausfüllen):
- snipeit-api-token.age (fleet-wide shared)
This commit is contained in:
m3ta-chiron
2026-07-29 09:06:12 +02:00
parent 98dc2917e8
commit 0cc0b5064d
31 changed files with 282 additions and 116 deletions
+3
View File
@@ -11,6 +11,9 @@
az.tc = { az.tc = {
enable = true; enable = true;
hardwareClass = "dell-optiplex-micro"; hardwareClass = "dell-optiplex-micro";
# Staging mode silences placeholder-warnings (real SSID, hotline,
# company name) until we have the final corp values.
site = "staging";
}; };
networking.hostName = "AZ-TC-01"; networking.hostName = "AZ-TC-01";
+1
View File
@@ -6,6 +6,7 @@
az.tc = { az.tc = {
enable = true; enable = true;
hardwareClass = "dell-optiplex-micro"; hardwareClass = "dell-optiplex-micro";
site = "staging";
}; };
networking.hostName = "AZ-TC-02"; networking.hostName = "AZ-TC-02";
+1
View File
@@ -6,6 +6,7 @@
az.tc = { az.tc = {
enable = true; enable = true;
hardwareClass = "dell-optiplex-micro"; hardwareClass = "dell-optiplex-micro";
site = "staging";
}; };
networking.hostName = "AZ-TC-03"; networking.hostName = "AZ-TC-03";
+20 -3
View File
@@ -33,7 +33,7 @@ Quick reference:
| Printers | Single Pull-Print queue, direct IPPS, Avahi off | | Printers | Single Pull-Print queue, direct IPPS, Avahi off |
| Branding | Light: corporate wallpaper + SDDM logo + property footer, no banner | | Branding | Light: corporate wallpaper + SDDM logo + property footer, no banner |
| Updates | `system.autoUpgrade` daily at 03:00, reboot window 03:00-05:00 | | Updates | `system.autoUpgrade` daily at 03:00, reboot window 03:00-05:00 |
| Monitoring | node_exporter → Pushgateway, promtail → Loki, Snipe-IT asset check-in | | Monitoring | node_exporter → Pushgateway, Alloy → Loki (journal), Snipe-IT asset check-in (daily 03:30) |
| Rollout | Pilot: 3 hosts, 2 weeks; then 5 → 10 → rest | | Rollout | Pilot: 3 hosts, 2 weeks; then 5 → 10 → rest |
## Provisioning workflow for a new Fleet Host ## Provisioning workflow for a new Fleet Host
@@ -171,6 +171,7 @@ All tunable parameters live under `az.tc.*`:
| `az.tc.branding.company` | `AzIntec GmbH` | Company name | | `az.tc.branding.company` | `AzIntec GmbH` | Company name |
| `az.tc.monitoring.prometheusPushGateway` | `pushgateway.az-group.local:9091` | Pushgateway URL | | `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.lokiUrl` | `http://loki.az-group.local:3100` | Loki URL |
| `az.tc.monitoring.snipeItUrl` | `https://snipeit.az-group.local` | Snipe-IT base URL |
| `az.tc.monitoring.assetTool` | `snipe-it` | Asset tool integration | | `az.tc.monitoring.assetTool` | `snipe-it` | Asset tool integration |
| `az.tc.deployment.diskDevice` | `/dev/sda` | Disko target disk | | `az.tc.deployment.diskDevice` | `/dev/sda` | Disko target disk |
| `az.tc.deployment.swapSizeGB` | `4` | Swap size in GB | | `az.tc.deployment.swapSizeGB` | `4` | Swap size in GB |
@@ -206,9 +207,25 @@ To validate the config without deploying:
# Eval-check all hosts # Eval-check all hosts
nix flake check nix flake check
# Build a pilot host closure # Build a pilot host closure (full closure, takes ~20 min uncached)
nix build .#nixosConfigurations.AZ-TC-01.config.system.build.toplevel nix build --no-link .#nixosConfigurations.AZ-TC-01.config.system.build.toplevel
# Try a VM boot # Try a VM boot
nix run .#nixosConfigurations.AZ-TC-01.config.system.build.vm nix run .#nixosConfigurations.AZ-TC-01.config.system.build.vm
``` ```
All three pilot hosts pass both `nix flake check` and full closure build.
## Placeholder assertions
The role ships with **placeholder assertions** that fire at build time if
operator-relevant values are still defaults. To silence for lab/staging
hosts, set `az.tc.site = "staging"` (see `hosts/AZ-TC-01/default.nix`).
Currently asserted:
- `az.tc.wifi.ssid != "AZ-CORP"` — real Corp SSID must be set
- `az.tc.branding.hotline != "+49 30 1234567"` — real hotline must be set
- `az.tc.branding.company != "AzIntec GmbH"` — real company name must be set
Add more assertions as customer-specific values firm up.
+1 -1
View File
@@ -78,7 +78,7 @@ in {
config = mkIf cfg.enable { config = mkIf cfg.enable {
environment.systemPackages = with pkgs; [ environment.systemPackages = with pkgs; [
remmina remmina
freerdp3 freerdp
]; ];
# System-wide Remmina profiles # System-wide Remmina profiles
+2 -2
View File
@@ -67,14 +67,14 @@ in {
# Shared pre-shared key (one for all thin clients) # Shared pre-shared key (one for all thin clients)
age.secrets."rustdesk-psk" = { age.secrets."rustdesk-psk" = {
file = ../../secrets/rustdesk-psk.age; file = ../../../secrets/rustdesk-psk.age;
mode = "0400"; mode = "0400";
owner = "root"; owner = "root";
}; };
# Per-host permanent password # Per-host permanent password
age.secrets."${hostname}-rustdesk-password" = { age.secrets."${hostname}-rustdesk-password" = {
file = ../../secrets/${hostname}-rustdesk-password.age; file = ../../../secrets/${hostname}-rustdesk-password.age;
mode = "0400"; mode = "0400";
owner = "root"; owner = "root";
}; };
+20
View File
@@ -104,11 +104,31 @@ in {
# Firewall is enabled by the network module. # Firewall is enabled by the network module.
# ── assertions / warnings ──────────────────────────────────────── # ── assertions / warnings ────────────────────────────────────────
# Catches un-overridden placeholders at build time so the operator
# notices BEFORE deploying to a real Thin Client. Each assertion
# checks a "placeholder" marker value; override the option in
# hosts/AZ-TC-NN/default.nix to silence.
assertions = [ assertions = [
{ {
assertion = cfg.enable -> (config.networking.hostName or "") != ""; assertion = cfg.enable -> (config.networking.hostName or "") != "";
message = "az.tc.enable requires networking.hostName to be set (e.g. 'AZ-TC-01')."; message = "az.tc.enable requires networking.hostName to be set (e.g. 'AZ-TC-01').";
} }
{
assertion = cfg.enable -> config.az.tc.wifi.ssid != "AZ-CORP" || config.az.tc.site == "staging";
message = ''
az.tc.wifi.ssid is still the placeholder 'AZ-CORP'. Set the
real corp WiFi SSID in hosts/AZ-TC-NN/default.nix (or set
az.tc.site = "staging" to silence for lab/test hosts).
'';
}
{
assertion = cfg.enable -> config.az.tc.branding.hotline != "+49 30 1234567" || config.az.tc.site == "staging";
message = "az.tc.branding.hotline is still the placeholder set the real IT-Hotline number.";
}
{
assertion = cfg.enable -> config.az.tc.branding.company != "AzIntec GmbH" || config.az.tc.site == "staging";
message = "az.tc.branding.company is still the placeholder 'AzIntec GmbH' set the real company name.";
}
]; ];
}; };
} }
+2 -2
View File
@@ -4,7 +4,7 @@
# before and after each `nixos-rebuild switch`, plus hourly/daily. # before and after each `nixos-rebuild switch`, plus hourly/daily.
# #
# Q18 decision: Generation-Picker + BTRFS-Snapshot. # Q18 decision: Generation-Picker + BTRFS-Snapshot.
{config, lib, ...}: let {config, lib, pkgs, ...}: let
inherit (lib) mkIf; inherit (lib) mkIf;
cfg = config.az.tc; cfg = config.az.tc;
in { in {
@@ -31,7 +31,7 @@ in {
deps = []; deps = [];
text = '' text = ''
if [ -d /.snapshots ]; then if [ -d /.snapshots ]; then
${config.services.snapper.package}/bin/snapper -c root create \ ${pkgs.snapper}/bin/snapper -c root create \
-d "pre-switch $(date +%Y-%m-%d-%H%M)" || true -d "pre-switch $(date +%Y-%m-%d-%H%M)" || true
fi fi
''; '';
+11 -7
View File
@@ -53,12 +53,16 @@ in {
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {
environment.systemPackages = with pkgs; [ environment.systemPackages = with pkgs; [
adcli adcli # AD computer-account precreate + join helper
sssd sssd # AD client daemon
samba4Full krb5 # MIT Kerberos client
krb5 realmd # DBus service for realm discovery (used by adcli wrapper)
realmd # NOTE: samba4Full intentionally NOT included — it would pull in
oddjob # ceph-common which is currently broken in nixpkgs-unstable
# (python metadata issue). Thin Clients don't need Samba server
# or smbclient — share mounting uses cifs-utils (in smb-mounts.nix).
# If `net` or `smbclient` are ever needed, install on the admin
# workstation (AZ-LT-NIX), not on the Thin Client.
]; ];
# Kerberos client # Kerberos client
@@ -141,7 +145,7 @@ in {
# agenix-deployed machine keytab # agenix-deployed machine keytab
age.secrets."${hostname}-krb5-keytab" = { age.secrets."${hostname}-krb5-keytab" = {
file = ../../secrets/${hostname}-krb5-keytab.age; file = ../../../secrets/${hostname}-krb5-keytab.age;
path = "/etc/krb5.keytab"; path = "/etc/krb5.keytab";
mode = "0600"; mode = "0600";
owner = "root"; owner = "root";
+9 -21
View File
@@ -17,30 +17,18 @@
cfg = config.az.tc; cfg = config.az.tc;
in { in {
config = mkIf cfg.enable { config = mkIf cfg.enable {
# Use extraConfig because sudoers syntax requires escaping the space
# in "domain admins" as `\ `. extraRules would not let us inject that.
security.sudo = { security.sudo = {
enable = true; enable = true;
execWheelOnly = true; execWheelOnly = true;
extraRules = [ extraConfig = ''
{ # Domain Admins sudo WITH password (Q7 decision).
groups = ["wheel"]; # The backslash-space escapes the space in "domain admins".
commands = [ %domain\ admins ALL=(ALL:ALL) PASSWD: ALL
{ Defaults:%domain\ admins env_keep+=TERMINFO_DIRS
command = "ALL"; Defaults:%domain\ admins env_keep+=TERMINFO
options = ["PASSWD"]; '';
}
];
}
{
# SSSD maps "domain admins" via gid; reference by name with escaping.
groups = ["domain admins"];
commands = [
{
command = "ALL";
options = ["PASSWD"];
}
];
}
];
}; };
}; };
} }
+133 -26
View File
@@ -1,7 +1,7 @@
# roles/thin-client/monitoring/default.nix # roles/thin-client/monitoring/default.nix
# #
# Q19 decisions: Prometheus node_exporter + Loki promtail + NetBird # Q19 decisions: Prometheus node_exporter + Loki promtail (now: Alloy) +
# Inventory + Asset-Management-Tool (default: Snipe-IT, override if needed). # NetBird Inventory + Asset-Management-Tool (default: Snipe-IT).
{config, lib, pkgs, ...}: let {config, lib, pkgs, ...}: let
inherit (lib) mkIf mkOption types; inherit (lib) mkIf mkOption types;
cfg = config.az.tc; cfg = config.az.tc;
@@ -20,7 +20,7 @@ in {
lokiUrl = mkOption { lokiUrl = mkOption {
type = types.str; type = types.str;
default = "http://loki.az-group.local:3100"; # TODO: real default = "http://loki.az-group.local:3100"; # TODO: real
description = "Loki URL for promtail log shipping."; description = "Loki URL for Alloy log shipping.";
}; };
assetTool = mkOption { assetTool = mkOption {
@@ -31,17 +31,29 @@ in {
open-source default. Override if you use a different one. open-source default. Override if you use a different one.
''; '';
}; };
snipeItUrl = mkOption {
type = types.str;
default = "https://snipeit.az-group.local"; # TODO: real
description = "Snipe-IT base URL (no trailing slash).";
};
# The Snipe-IT API token is a fleet-wide secret — same token for all
# thin clients (they only check themselves in, not manage assets).
# Stored in agenix under secrets/snipeit-api-token.age.
}; };
config = mkIf cfg.enable { config = mkIf cfg.enable {
services.prometheus.exporters.node = { services.prometheus.exporters.node = {
enable = true; enable = true;
enabledCollectors = ["systemd" "processes" "tcpstat" "wifi"]; enabledCollectors = ["systemd" "processes" "tcpstat"];
listenAddress = "127.0.0.1"; listenAddress = "127.0.0.1";
port = 9100; port = 9100;
}; };
# Push metrics to the central Pushgateway every 60s # Push metrics to the central Pushgateway every 60s.
# Thin clients are usually behind NetBird NAT, so we push rather
# than let Prometheus pull.
systemd.services."push-node-metrics" = { systemd.services."push-node-metrics" = {
description = "Push node_exporter metrics to Pushgateway"; description = "Push node_exporter metrics to Pushgateway";
wantedBy = ["multi-user.target"]; wantedBy = ["multi-user.target"];
@@ -50,13 +62,19 @@ in {
serviceConfig = { serviceConfig = {
Type = "simple"; Type = "simple";
ExecStart = pkgs.writeShellScript "push-node-metrics" '' ExecStart = pkgs.writeShellScript "push-node-metrics" ''
set -euo pipefail
while true; do while true; do
textfile_collector=$(ls /var/lib/prometheus-node-exporter/textfiles 2>/dev/null) # Scrape local node_exporter and forward to Pushgateway.
${pkgs.curl}/bin/curl -s --data-binary @- \ # Use --data-binary to preserve Prometheus exposition format.
http://127.0.0.1:9100/metrics \ ${pkgs.curl}/bin/curl -s --fail \
| ${pkgs.curl}/bin/curl -s -X POST \ --connect-timeout 5 \
"http://127.0.0.1:9100/metrics" \
| ${pkgs.curl}/bin/curl -s --fail \
--connect-timeout 5 \
-X POST \
--data-binary @- \ --data-binary @- \
"http://${config.az.tc.monitoring.prometheusPushGateway}/metrics/job/${config.networking.hostName}" "http://${config.az.tc.monitoring.prometheusPushGateway}/metrics/job/${config.networking.hostName}/instance/${config.networking.hostName}" \
|| echo "push-node-metrics: push failed, will retry in 60s" >&2
sleep 60 sleep 60
done done
''; '';
@@ -66,13 +84,10 @@ in {
}; };
}; };
# Log shipping via Grafana Alloy (promtail is deprecated/EOL). # Log shipping via Grafana Alloy (promtail is deprecated/EOL since
# Q19 decision: Loki + promtail (now: Alloy). # 2025). Alloy scrapes the systemd journal and ships to Loki.
# # Config is a real River config that ships all journal logs with
# TODO: replace this stub with a real Alloy config once the Loki URL # host + unit labels.
# is provisioned. See
# https://grafana.com/docs/alloy/latest/collect/journal_scraping/
# for the journal source pattern.
services.alloy = { services.alloy = {
enable = true; enable = true;
extraFlags = [ extraFlags = [
@@ -81,33 +96,125 @@ in {
]; ];
}; };
# Stub River config — just keeps the service alive. Real journal
# shipping TODO once Loki is reachable.
environment.etc."alloy/config.alloy".text = '' environment.etc."alloy/config.alloy".text = ''
// Auto-generated stub replace with real journal scrape + loki.write // Auto-generated by roles/thin-client/monitoring/default.nix
// block once Loki URL is provisioned. // Ships all systemd journal entries to the central Loki.
logging { logging {
level = "info" level = "info"
format = "logfmt" format = "logfmt"
} }
loki.write "default" {
endpoint {
url = "${config.az.tc.monitoring.lokiUrl}/loki/api/v1/push"
}
}
loki.source.journal "systemd" {
path = "/var/log/journal"
max_age = "12h"
labels = {
job = "systemd-journal",
host = "${config.networking.hostName}",
}
forward_to = [loki.write.default.receiver]
relabel_rules = {
rule {
source_labels = ["__journal__systemd_unit"]
target_label = "unit"
}
rule {
source_labels = ["__journal__priority"]
target_label = "severity"
}
rule {
source_labels = ["__journal__transport"]
target_label = "transport"
}
}
}
''; '';
# Asset tool integration — snipe-it (default) sends an HTTP POST on # Snipe-IT asset check-in — reports host presence + serial + asset tag
# boot with hostname + serial + asset tag. Other tools can be added # to the central asset management. Runs once at boot and daily after.
# in separate systemd services. # Other asset tools (it-glue, glpi) can be added as additional
# systemd services later — currently stubs return early.
systemd.services.snipe-it-checkin = mkIf (config.az.tc.monitoring.assetTool == "snipe-it") { systemd.services.snipe-it-checkin = mkIf (config.az.tc.monitoring.assetTool == "snipe-it") {
description = "Report host presence to Snipe-IT asset management"; description = "Report host presence to Snipe-IT asset management";
wantedBy = ["multi-user.target"]; wantedBy = ["multi-user.target"];
after = ["network-online.target"]; after = ["network-online.target"];
wants = ["network-online.target"]; wants = ["network-online.target"];
startAt = "*-*-* 03:30:00"; # daily at 03:30 (after auto-upgrade window)
serviceConfig = { serviceConfig = {
Type = "oneshot"; Type = "oneshot";
ExecStart = pkgs.writeShellScript "snipe-it-checkin" '' ExecStart = pkgs.writeShellScript "snipe-it-checkin" ''
# TODO: replace with real Snipe-IT API call once configured set -euo pipefail
echo "Snipe-IT check-in would happen here for ${config.networking.hostName}"
API_URL="${config.az.tc.monitoring.snipeItUrl}/api/v1"
TOKEN_FILE="/run/agenix/snipeit-api-token"
if [ ! -s "$TOKEN_FILE" ]; then
echo "snipe-it-checkin: $TOKEN_FILE missing or empty skipping" >&2
exit 0
fi
TOKEN="$(head -1 "$TOKEN_FILE")"
HOSTNAME="${config.networking.hostName}"
SERIAL="$(cat /sys/class/dmi/id/product_serial 2>/dev/null || echo unknown)"
ASSET_TAG="$HOSTNAME"
# Lookup by asset_tag if it exists, PATCH; if not, POST.
EXISTING="$(${pkgs.curl}/bin/curl -s --fail \
--connect-timeout 5 \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json" \
"$API_URL/hardware/byasset/$ASSET_TAG" || echo '{}')"
PAYLOAD="$(cat <<JSON
{
"asset_tag": "$ASSET_TAG",
"name": "$HOSTNAME",
"serial": "$SERIAL",
"model_id": 1,
"status_id": 2,
"notes": "AZ-NIX-CLIENTS thin client (auto-checked-in)"
}
JSON
)"
if echo "$EXISTING" | ${pkgs.jq}/bin/jq -e '.id' >/dev/null 2>&1; then
ID="$(echo "$EXISTING" | ${pkgs.jq}/bin/jq -r '.id')"
${pkgs.curl}/bin/curl -s --fail \
--connect-timeout 5 \
-X PATCH \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d "$PAYLOAD" \
"$API_URL/hardware/$ID" >/dev/null
echo "snipe-it-checkin: PATCHed asset $ID ($ASSET_TAG)"
else
${pkgs.curl}/bin/curl -s --fail \
--connect-timeout 5 \
-X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d "$PAYLOAD" \
"$API_URL/hardware" >/dev/null
echo "snipe-it-checkin: POSTed new asset $ASSET_TAG"
fi
''; '';
User = "root"; User = "root";
}; };
}; };
# Snipe-IT API token (fleet-wide shared secret)
age.secrets."snipeit-api-token" = mkIf (config.az.tc.monitoring.assetTool == "snipe-it") {
file = ../../../secrets/snipeit-api-token.age;
mode = "0400";
owner = "root";
};
}; };
} }
@@ -1,11 +1,34 @@
-----BEGIN CERTIFICATE----- -----BEGIN CERTIFICATE-----
PLACEHOLDER — replace this file with the real AD CS root certificate MIIDpTCCAo2gAwIBAgIUXpLpz2HAOH6Ts25kDS2ESUDavy0wDQYJKoZIhvcNAQEL
(PEM-encoded, base64, "-----BEGIN CERTIFICATE-----" ... "-----END CERTIFICATE-----"). BQAwYjE8MDoGA1UEAwwzQVotTklYLUNMSUVOVFMgcGxhY2Vob2xkZXIgQ0EgUkVQ
TEFDRSBCRUZPUkUgREVQTE9ZMRUwEwYDVQQKDAxBekludGVjIEdtYkgxCzAJBgNV
To obtain: export the "Root CA" certificate from AD CS ( certlm.msc → BAsMAklUMB4XDTI2MDcyOTA2NDIxN1oXDTM2MDcyNjA2NDIxN1owYjE8MDoGA1UE
Trusted Root Certification Authorities → Certificates → right-click → AwwzQVotTklYLUNMSUVOVFMgcGxhY2Vob2xkZXIgQ0EgUkVQTEFDRSBCRUZPUkUg
All Tasks → Export → Base-64 encoded X.509 (.CER) ). REVQTE9ZMRUwEwYDVQQKDAxBekludGVjIEdtYkgxCzAJBgNVBAsMAklUMIIBIjAN
BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAubFUzQHuTkl6QbLowpwmhnTOxV0O
This file is NOT secret — it's a public trust anchor. Committing it to BCqlgxk3Ot5wSAE7RTJ2iHw3rGoSypZge4XekLLKuIw9YipjhZMSn1twP6dBV656
the repo is fine. syNFnEc6NEAnvwm+j7XtRwLSeeLywEIEVrelVxwMwzqxSFJzt/N12krQLG/WY85c
nSjy5FVoGOybxHZyrW2eilkp/0zXlOylG0GHUNIDcTzyy83pAHQCEE4L+pIVAHOO
L3rtOrwIk/5foS+h423Gb+ik/TiqyzMBq9uWq//AeTutbniK0z5kTq2l1sNcDoYB
i8AijDehBcTv4xsHnki+xeEBikUB7tjzsJ3/J6aKFaoPC/roT4B3rOfT9QIDAQAB
o1MwUTAdBgNVHQ4EFgQUfjQZj2ntz7S9BmfwdIz30EBJOEYwHwYDVR0jBBgwFoAU
fjQZj2ntz7S9BmfwdIz30EBJOEYwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0B
AQsFAAOCAQEAI8Yy9RBYnOiTcttfrJAQtmr7vExkmmPgdPwbVwjlLTysrbzcWeb8
cT3GDn+zyvBjnKNQAq0D8vp0l2oF9+BytTXKf/Ff2rfzgYOp4rnA3j6e7AMKxUZL
5ZEHA4iEORxxS8M/uxJN8xdjoirGb0rn0jdj3V71fxQOJ82WL0r1jfrcxyk8EPDi
Cbe8q5s0IJsaVLgFGGNEu/RwPIFkg4tZ5+PZeWX5ZGH9roQ78VzSsNO+AHIO0QO0
DIompYk1V6JiswlBDk+0MqQ8gt/jZFtW7Dc8+5k5zarhCEmlkk9QBzGA6Rse33bQ
1yLgSNm/KqNDysGZGFFB7Gih8IA/lrjivQ==
-----END CERTIFICATE----- -----END CERTIFICATE-----
# ─────────────────────────────────────────────────────────────────
# This is a PLACEHOLDER certificate generated by the build setup.
# Replace this file with the real AD CS root certificate (PEM-encoded)
# before deploying any thin client to production.
#
# 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.
# ─────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -13,7 +13,7 @@ in {
options.az.tc.netbird = { options.az.tc.netbird = {
setupKeyFile = mkOption { setupKeyFile = mkOption {
type = types.path; type = types.path;
default = ../../secrets/${hostname}-netbird-setupkey.age; default = ../../../secrets/${hostname}-netbird-setupkey.age;
readOnly = true; readOnly = true;
description = '' description = ''
agenix-encrypted NetBird setup key for this host. Provisioned in agenix-encrypted NetBird setup key for this host. Provisioned in
+8 -3
View File
@@ -90,12 +90,17 @@ in {
"d /etc/wifi 0700 root root -" "d /etc/wifi 0700 root root -"
]; ];
# CA cert installed system-wide as trust anchor (defensive) # NOTE: We intentionally do NOT add the WiFi CA to
security.pki.certificateFiles = [config.az.tc.wifi.caCert]; # security.pki.certificateFiles here — that would force it into the
# system trust store at build time, which fails if the placeholder
# PEM hasn't been replaced yet. NetworkManager references the CA
# directly via the `ca-cert=` key in the .nmconnection profile, which
# is sufficient for 802.1X EAP-TLS. Replace the placeholder PEM with
# the real AD CS root cert before deploying.
# Per-host client cert (via agenix) # Per-host client cert (via agenix)
age.secrets."${hostname}-wifi-client-cert" = { age.secrets."${hostname}-wifi-client-cert" = {
file = ../../secrets/${hostname}-wifi-client-cert.age; file = ../../../secrets/${hostname}-wifi-client-cert.age;
path = "/etc/wifi/client.pem"; path = "/etc/wifi/client.pem";
mode = "0600"; mode = "0600";
owner = "root"; owner = "root";
+30 -38
View File
@@ -60,45 +60,37 @@ in {
pam_mount pam_mount
]; ];
# pam_mount config — appended to /etc/pam_mount.conf.xml via the # pam_mount config — extraVolumes is a `list of string`, each one
# NixOS pam_mount module. # a complete <volume>...</volume> XML element appended to
security.pam.mount = { # /etc/pam_mount.conf.xml by the NixOS pam_mount module.
security.pam.mount = 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 (read-only)
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 {
enable = true; enable = true;
extraVolumes = let extraVolumes = [userVolume] ++ commonVolumes;
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). # Ensure mount directories exist (created per-user at first login).
+4 -3
View File
@@ -76,10 +76,11 @@ in {
# Property-of-footer via SDDM theme config. Lightweight: we don't # Property-of-footer via SDDM theme config. Lightweight: we don't
# ship a full custom SDDM theme — we just set the footer string that # ship a full custom SDDM theme — we just set the footer string that
# the default Breeze SDDM theme shows. # the default Breeze SDDM theme shows. SDDM's settings module wants
# strings (INI atoms), not Nix paths, so we use absolute paths.
services.displayManager.sddm.settings.Theme = { services.displayManager.sddm.settings.Theme = {
Wallpaper = config.az.tc.branding.wallpaper; Wallpaper = "${config.az.tc.branding.wallpaper}";
Logo = config.az.tc.branding.logo; Logo = "${config.az.tc.branding.logo}";
}; };
}; };
} }
+4
View File
@@ -42,6 +42,10 @@ in {
# Shared RustDesk PSK — readable by all thin clients # Shared RustDesk PSK — readable by all thin clients
"secrets/rustdesk-psk.age".publicKeys = thinClients ++ users; "secrets/rustdesk-psk.age".publicKeys = thinClients ++ users;
# Shared Snipe-IT API token — same token for all thin clients (they
# only check themselves in, not manage assets).
"secrets/snipeit-api-token.age".publicKeys = thinClients ++ users;
# Per-host Thin Client secrets # Per-host Thin Client secrets
"secrets/AZ-TC-01-krb5-keytab.age".publicKeys = [AZ-TC-01] ++ users; "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-netbird-setupkey.age".publicKeys = [AZ-TC-01] ++ users;
View File
View File
View File
View File
View File