This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
krg-infra is the KastnerRG infrastructure monorepo, replacing the old Ansible
infrastructure at fabricant-prod
and waiter. Layers are split by
configuration tool (not by guest/host role — some NixOS machines are physical):
nix/— every machine configured by NixOS (the flake): physical hosts (waiter) and Proxmox VMs (krg-prod, e4e-prod, krg-ldap). Machines are composed from profile modules — no per-host playbooks.ansible/— the Proxmox/Debian hypervisor hosts those VMs run on.terraform/— the OpenTofu layer: things driven through a Terraform/ Web-API provider rather than NixOS or Ansible. One root module (own state + creds) per target — not one combined config (separate blast radius). Targets:e4e-nas/(Synology DSM),authentik/(SSO config),openbao/(secrets-engine structure + AppRole auth),grafana/(dashboards/datasources/SSO) — all built out (real.tf, not stubs).authentik/+openbao/+grafana/manage the config of services deployed elsewhere (Authentik & Grafana = compose on krg-prod; OpenBao = krg-vault), not their deployment. e4e-nas is a hybrid: thesynologyprovider only covers Container Manager / packages / tasks / files / VMs; identity (AD join), shares/ACLs, firewall, SSH, snapshots and DSM updates have no API and live in the runbookdocs/e4e-nas-dsm.md(DSM is a proprietary appliance — SSH-level edits don't survive DSM updates, so prefer UI settings).
This whole rebuild is incident-response driven: a Proxmox host's root SSH was
dictionary-attacked. The hypervisors had no config management — ansible/ closes
that gap (secure + up to date by default), and the old AD (inside the blast
radius) is being rebuilt clean as a new Samba AD forest on krg-ldap.
Target feature set (from the old Ansible repos):
- fabricant-prod: production services — Traefik, Authentik (SSO), Grafana/Prometheus/Loki, Blackbox, PostgreSQL, Outline, MLflow, Label Studio, node/IPMI exporters, firewall, unattended upgrades. (These are the lab-wide tools now on krg-prod; E4E project-specific services go on e4e-prod.)
- waiter: research/compute at 137.110.161.67 — NVIDIA CUDA + Container Toolkit, FPGA tooling (Vivado, Vitis, Verilator; opt-in), XRDP+XFCE desktop (opt-in, gated on FPGA), CrowdSec, native node/IPMI exporters + DCGM exporter (Docker) — blackbox now lives on krg-prod — and ZFS-on-root with ZFS auto-snapshots (replacing the old btrfs/snapper). The legacy Ubuntu setup used btrfs + Docker-based node/blackbox; the rebuild moved those to the patterns above.
Every live change against managed infrastructure (NAS, NixOS hosts, Proxmox, etc.) flows through IaC. When something needs to change on a managed machine, the fix is:
- Extend the spec (
spec/e4e-nas/*.yml,nix/...,terraform/...). - Extend the role / module / resource that consumes it (apply_*.py subcommand, NixOS module, terraform resource).
- Commit + push.
- Apply via the appropriate orchestrator (
ansible-playbook,nixos-rebuild,tofu apply).
Do not propose live mutations as the fix itself:
- ❌ "Click here in DSM web → Apply"
- ❌ "Run
synowebapi --exec ... method=set ...on the NAS to fix it" - ❌ "Edit
/etc/...directly via SSH" - ❌ "Manually create the bucket / share / cert / firewall rule"
Even when the goal is to "just unblock today," the right framing is "extend the role NOW, it's the same 5 minutes either way." UI-driven or shell-driven fixes are invisible to the spec → they're drift the moment they land (ADR 0001: git is truth, UI = drift).
Discovery is the only exception. Read-only probes, Chrome DevTools
network captures of a wizard click to learn the API shape, synowebapi --exec ... method=get, log greps, etc. are fine — flag them as
discovery, then port the findings into the role / spec / module before
applying. Capturing a wizard's POST shape is research; clicking the
wizard's "Save" as the fix delivery is not.
This applies broadly to nix/, ansible/, terraform/ work — not just the NAS. The Synology surface had the most run-ins with it because DSM has the richest "click here to fix it" affordance, but the rule is global.
The flake lives in nix/. Run from the repo root with the ./nix ref shown
below, or cd nix and drop the prefix.
# Validate the flake (Nix syntax + module type checking)
nix flake check ./nix
# Build a system config without deploying
nix build ./nix#nixosConfigurations.krg-prod.config.system.build.toplevel
nix build ./nix#nixosConfigurations.waiter.config.system.build.toplevel
# Inspect a config value
nix eval ./nix#nixosConfigurations.krg-prod.config.networking.hostName
# Deploy to the current machine
sudo nixos-rebuild switch --flake ./nix#krg-prod
# Deploy remotely (new nixos-rebuild: --sudo, not --use-remote-sudo)
nixos-rebuild switch --flake ./nix#krg-prod --target-host krg-admin@krg-prod.ucsd.edu --sudo --ask-sudo-password
nixos-rebuild switch --flake ./nix#waiter --target-host krg-admin@137.110.161.67 --sudo --ask-sudo-password
# Update flake inputs (run inside nix/)
cd nix && nix flake update # or: nix flake update nixpkgs
# Format .nix files (alejandra, via treefmt). This is the SAME formatter the
# `nix flake check` gate enforces (checks.formatting), so always format before
# committing — unformatted Nix fails CI.
cd nix && nix fmt
# --- Proxmox hosts (ansible/) ---
cd ansible
ansible-galaxy collection install -r requirements.yml
ansible-playbook playbooks/site.yml --check # dry run
ansible-playbook playbooks/site.ymlkrg-infra/
CLAUDE.md README.md LICENSE .github/workflows/build.yml
nix/ # NixOS machines (physical + Proxmox guests)
flake.nix # inputs + nixosConfigurations (krg-prod, e4e-prod, waiter, kastner-ml, krg-ldap, krg-vault, krg-deploy, krg-nat)
keys/admins.json # SHARED admin SSH keys — read by nix + ansible
networks/trusted.json # SHARED trusted nets / Proxmox IPSets — read by nix + ansible + PVE cluster.fw
modules/
docker.nix users.nix nix-ld.nix impermanence.nix
zfs.nix # ZFS support + auto-scrub + auto-snapshot retention (krg.zfs); WHICH datasets/cadence is per-dataset via com.sun:auto-snapshot props. NixOS hosts run the timers; fabricant (Proxmox) runs the same tool via ansible nfs_server
nfs-home.nix # mount /home from NFS (krg.nfsHome) — AD user homes off the local ZFS root (enables impermanence)
scratch.nix scratch/ # /scratch/<lab> on PLAIN ZFS (krg.scratch) — scratchpool (striped HDD + NVMe special/L2ARC); daily cold-file overflow to NFS + self-service scratch-restore (modules/scratch/*.py). Replaced autotier (FUSE; crashed under concurrent training reads)
local-cache.nix # node-local /local/<user> (krg.localCache) — IDE servers + cache class OFF the NFS /home (durable NVMe dataset, no FUSE/NFS)
samba-ad.nix # Samba AD domain controller (samba4Full daemon, krb5.conf, DNS/resolver, AD ports)
sssd-ad-client.nix # SSSD AD client (krg.adClient) — every host joins KRG.LOCAL; key-only SSH with keys served from AD (sss_ssh_authorizedkeys + OpenSSH-LPK)
ad-group-sync.nix # generic AD-group → local-group bridge (krg.adGroupSync) — re-derives a fixed-GID local group's members from one+ AD groups via a oneshot+10-min timer. Consumed by nvidia.nix (cudaAccessGroups → GPU device group) and docker.nix (accessGroups → docker daemon group)
security/{firewall,crowdsec,crowdsec-bouncer,oec-qualys-trellix}.nix # firewall is the single switch; crowdsec is the ban layer
services/{compose-stack,node-exporter,ipmi-exporter}.nix
hardware/{nvidia,fpga}.nix desktop/xrdp.nix
profiles/
base.nix # every host: SSH hardening, auto-upgrade, OEC + CrowdSec + node-exporter + in-guest firewall; isVM enables qemu-guest-agent
server.nix # krg-prod / e4e-prod role (docker, compose, ipmi exporter)
compute.nix # waiter role (physical; NVIDIA/FPGA/XRDP/ZFS)
directory.nix # krg-ldap role: Samba AD DC (realm KRG.LOCAL)
hosts/{krg-prod,e4e-prod,waiter,krg-ldap}/{default,hardware-configuration}.nix
users/admin.nix # local break-glass admin (krg-admin/e4e-admin); home /var/lib/<account> (OFF /home, so an NFS /home mount can't shadow it); keys from keys/admins.json; human users come from Samba AD
docker-compose/ # compose stacks mounted by the flake
krg-prod/ # the lab-wide services stack (Traefik, Authentik, Grafana, …) wired into nix/hosts/krg-prod/default.nix
dcgm-exporter/ # standalone NVIDIA DCGM exporter, wired by modules/hardware/nvidia.nix (krg.nvidia.dcgmExporter) — there is no per-host waiter/ dir, just this
ansible/ # Proxmox hypervisor hosts (Debian/PVE)
ansible.cfg requirements.yml
inventory/
hosts.yml # the Proxmox hosts (group: proxmox) — currently one host, "fabricant"
group_vars/{all,proxmox}.yml # next to the inventory (so ansible-playbook loads it): all.yml = generic baseline (keys/trusted nets via the shared files); proxmox.yml = PVE-specific
host_vars/fabricant.yml # fabricant-ONLY vars (NFS shares, ZFS limits, host.fw rules)
playbooks/site.yml # all hosts → base; proxmox group → proxmox_firewall; fabricant → zfs_limits + nfs_server
roles/
base/ # THE baseline: OS basics (timezone, packages incl tmux, unattended upgrades, sysctl) + composes the security/monitoring roles below (import_role, ordered: krg_admin → ssh_hardening → crowdsec → monitoring → oec)
krg_admin/ # key-only sudo krg-admin (mirrors nix/users/admin.nix)
ssh_hardening/ # disable password auth, root key-only (the breach fix)
crowdsec/ # community CTI + local scenarios — the fleet brute-force/ban layer
monitoring/ # node + ipmi exporters (systemd) — on every host via base
oec_qualys_trellix/ # campus-mandated Qualys + Trellix (set oec_installer) — via base
proxmox_firewall/ # PVE cluster.fw + per-guest <vmid>.fw + per-node host.fw (host rules eval before cluster rules; proxmox group, separate play)
zfs_limits/ # quota/reservation on EXISTING ZFS datasets — caps VM storage so user data wins (fabricant ONLY play)
nfs_server/ # NFSv4 exports on ZFS datasets under <pool>/nfs (fabricant ONLY play; NFS tcp/2049 opened via fabricant host.fw); ALSO schedules zfs-auto-snapshot (systemd timers) for the opted-in shares — Proxmox has no NixOS services.zfs.autoSnapshot, so retention here MIRRORS nix krg.zfs.autoSnapshot, scoped opt-in via --default-exclude
terraform/ # OpenTofu layer — one root module (own state+creds) per target
README.md .gitignore # layer conventions; .gitignore is top-level (covers all targets: *.tfstate*, *.tfvars)
e4e-nas/ # Synology DSM (synology-community/synology): containers/packages/scheduler.tf + FileStation folders; Garage CONFIG lives in the ansible role (ADR 0007), here = just the dirs/folders it needs
authentik/ # BUILT (goauthentik/authentik): SSO apps/providers/flows/groups/LDAP-outpost (applications_{krg,e4e}.tf, data.tf, groups.tf, ldap.tf, outpost.tf) + writes generated OIDC client secrets to OpenBao (vault_secrets.tf, roster_secrets.tf)
openbao/ # BUILT (was "vault/"; openbao provider): KV-v2 mount + AppRole auth + per-role policies (krg-deploy, krg-prod) — structure not values (main.tf)
grafana/ # BUILT (grafana/grafana): dashboards/datasources/folders/teams/SSO — reads grafana-oidc from OpenBao (sso.tf)
temporal/ # BUILT (temporal provider): namespaces/config on the lab-wide Temporal cluster (temporalio/auto-setup on krg-prod, workflows.krg.ucsd.edu) — declared here, not via one-off tctl
secrets/ # BUILT (openbao provider): mints the OIDC client-secrets + DB passwords krg-prod services need at startup into OpenBao (secret/krg-prod/authentik-managed/*) — runs early (before P2 vault-agent renders consume them); fixes the deploy-DAG ordering
spec/e4e-nas/ # declarative DSM source of truth (shares/groups/smb-globals/nfs-exports/acls/users/garage) — consumed by ansible synology_* roles; seeded from docs/e4e-nas-dsm.md
docs/
creating-a-user.md # AD user-creation runbook
e4e-nas-dsm.md # DSM runbook: the NAS settings with no API (AD join, shares/ACLs, firewall, SSH, snapshots, updates) — → break-glass once spec/ seeded
krg-prod-iac.md # krg-prod IaC architecture + NAS standup plan (how the spec maps onto this repo)
adr/ # Architecture Decision Records — 0001 IaC-source-of-truth, 0002 Garage, 0003 Garage-on-NAS, 0004 VM-IO-budget, 0005 repo-integration/OpenTofu/krg-deploy, 0006 no-OEC-on-DSM, 0007 DSM-ansible-not-terraform, 0008 e4e-prod-tenant-platform, 0009 lab-internal-PKI-AD, 0010 AD-structure-as-IaC, 0011 cross-layer-deploy-ordering, 0012 endpoint-device-management, 0013 SSO-Authentik-front-door, 0014 proxmox-auth-ldap-outpost, 0015 fleet-wide-container-logs-Loki (Proposed), 0016 developed-apps-one-trust-tier, 0017 incus-nat-self-serve-platform (re-realizes 0008), 0018 monitoring-long-running-vms, 0019 proxmox-to-incus-migration, 0020 tenant-deploy-contract-mkTenant, 0021 tenant-TLS-vault-agent-and-secret-zero, 0022 tenant-runners-github-app-broker (Proposed), 0023 tenant-temporal-client-cert (Proposed)
Naming note:
fabricantnow refers only to the Proxmox host (hypervisor, in the ansible inventory). The old nixfabricantservices config was split intokrg-prod(lab-wide tools) ande4e-prod(E4E project services).
Each layer owns the firewall concern it's best at, so they don't drift:
- In-guest NixOS firewall (
krg.firewall) — on EVERY host, VMs included. It owns which ports a service exposes (e.g.samba-ad.nixdeclares the AD port set) and gives CrowdSec a backend (the countermeasure to the dictionary attack that drove this rebuild).profiles/base.nixsets itmkDefault true. - Proxmox host firewall (
ansible/) — additive perimeter. Owns which sources may reach a VM, plus containment. Services →ucsd/sealab; compute (waiter) → public SSH (protected by key-only + CrowdSec). Does not replace the in-guest layer.
Values needed by both nix and ansible live in one file each, under nix/ (the
flake can only read its own subtree); ansible reads them across the repo:
nix/keys/admins.json— admin SSH public keys (not secret).nix/networks/trusted.json— trusted nets / Proxmox IPSets + monitoring host. Edit these, not the per-layer copies.
Services that were native systemd in Ansible (node_exporter, ipmi_exporter) use native NixOS services.prometheus.exporters.* modules. Everything else stays as Docker Compose stacks managed by krg.composeStacks.
The compose-stack module runs each stack as a systemd oneshot service with docker compose --project-directory <workingDir> -f <nix-store-path> up -d. The --project-directory flag makes Docker Compose resolve relative volume paths (like ./.secrets/foo.txt) against the working directory (e.g. /var/lib/krg/krg-prod/), not the Nix store. The compose files stay read-only in the store; runtime data (databases, secrets, config) lives in the working directory.
compose.yml uses Docker Compose's include: directive to pull in sub-stacks. For this to work when the compose files are in the Nix store, the entire nix/docker-compose/krg-prod/ directory must be in the same store path. Always reference the directory, not individual files:
# In nix/hosts/krg-prod/default.nix — correct pattern
let composeDir = ../../docker-compose/krg-prod; in
{
krg.composeStacks.krg-prod.composeFiles = [ "${composeDir}/compose.yml" ];
}
# The whole docker-compose/krg-prod/ directory is copied to the store,
# so include: can find compose.authentik.yml etc. alongside compose.yml.- Run
nixos-generate-config --show-hardware-configon the target; save asnix/hosts/<name>/hardware-configuration.nix - Create
nix/hosts/<name>/default.niximporting the appropriate profile plus anykrg.composeStacks - Add the host to
nix/flake.nixundernixosConfigurations git addthe new files (a flake only sees git-tracked files),nix flake check ./nix, then deploy withnixos-rebuild switch --flake ./nix#<name> --target-host ...
Every third-party application surfaced in Authentik must ship an app-tile icon.
An iconless tile renders as a generic placeholder and reads as a broken/untrusted
link on the user dashboard. When you add an app to terraform/authentik/applications_*.tf,
add its icon in the same change: drop the SVG in nix/docker-compose/krg-prod/authentik/media-icons/
and set meta_icon = "krg-icons/<file>.svg". If no clean upstream logo exists yet,
record it in that dir's README.md "Not yet iconed" list so the gap is tracked, not
forgotten. See nix/docker-compose/krg-prod/authentik/media-icons/README.md for the wiring.
Secrets are not managed by Nix yet (future: OpenBao/HashiCorp Vault on krg-vault). Before starting each compose stack, manually create the required files in the working directory. Each host's default.nix lists required secrets in a comment. (Vaultwarden is still in use — the earlier "drop Bitwarden" decision was reversed; it serves vaultwarden.krg.ucsd.edu.)
krg-prod secrets in /var/lib/krg/krg-prod/.secrets/:
authentik_postgres_admin_password.txtauthentik_admin_password.env(AUTHENTIK_SECRET_KEY=...andAUTHENTIK_POSTGRESQL__PASSWORD=...)authentik_traefik_token.envgf_admin_password.txtoutline_secrets.envmlflow.env(POSTGRES_PASSWORD,OIDC_*variables)
The .secrets/ directories are in .gitignore.
The grafana/prometheus/loki compose services mount config from the working directory. Before starting the krg-prod stack, populate:
/var/lib/krg/krg-prod/grafana/— Grafana config/var/lib/krg/krg-prod/loki/loki-config.yaml— Loki config/var/lib/krg/krg-prod/loki/config.alloy— Grafana Alloy log-shipper config (replaced Promtail, EOL 2026-03-02; tails/var/log+ the systemd journal)/var/lib/krg/krg-prod/prometheus/prometheus.yml— Prometheus scrape config (targets the renamed hosts; the deadansible_deploy_monitor:9000job and the not-yet-provisionedkastner-mltargets are the remaining cleanups)/var/lib/krg/krg-prod/blackbox-exporter/blackbox.yml— copy fromnix/docker-compose/krg-prod/blackbox-exporter/blackbox.yml
- DNS/URL migration for krg-prod — DONE (machine + CNAME rename plan). Lab-wide services consolidated onto
*.krg.ucsd.edu:auth.fabricant→auth.krg, Grafanadashboard.waiter/grafana.krg→**monitoring.krg.ucsd.edu**,wiki.fabricant→docs.krg,roster.e4e→roster.krg,github-sync.e4e→github.roster.krg,workflows.fishsense.e4e→workflows.krg,vault.e4e-gateway→vaultwarden.krg. Updated across Traefik router rules (authentik/grafana/outline), the compose.yml traefik aliases, AuthentikAUTHENTIK_HOST, OutlineURL+ OIDC URIs,grafana.iniroot_url, and the Prometheus probe + scrape targets (fabricant-prod.ucsd.edu→krg-prod.ucsd.edu). s3 (s3.krg.ucsd.edu) is served by the fabricant hypervisor's storage pool, not krg-prod's Traefik (architecture decision — the pool lives there). E4E project APIs (api.fishsense,api.smartfin) move to e4e-prod. Still external (DNS layer, not this repo): publishing the actual CNAME records. - [~] SSSD AD client integration — built into the baseline on both layers so every host joins Samba AD (
KRG.LOCAL) and humans log in with their AD accounts (onlynix/users/admin.nixbreak-glass stays local). nix:krg.adClient(nix/modules/sssd-ad-client.nix), enabled inprofiles/base.nix(the DC setsisDomainController). ansible:roles/ad_client, composed into thebaserole. Both: algorithmic ID mapping (auto uid/gid), key-only SSH with keys served from AD (sss_ssh_authorizedkeys+ the OpenSSH-LPKsshPublicKeyschema extension), access restricted toDomain Adminsby default (widen per host). User-creation runbook:docs/creating-a-user.md. Status (perdocs/joining-a-host-to-the-domain.md): krg-ldap provisioned + keytab exported; waiter joined 2026-05-21; fabricant + krg-prod + e4e-prod still pending domain join + on-box validation. Compute hosts widen viakrg.adClient.allowedGroups(waiter:Domain Admins+Waiter). Do NOT import the old domain's password hashes — they're compromised; users get new passwords. - Real SSH public keys added to
nix/keys/admins.json(chris, dzuberi, dzube, shperry for krg-admin; chris, shperry, treez for e4e-admin — all ed25519). Shared by both layers. - Replace placeholder
hardware-configuration.nixfiles for the unbuilt hosts (waiter's is real; krg-prod/e4e-prod/krg-ldap pending first deploy). - [~] Qualys Cloud Agent + Trellix HX (xagt): nix module
nix/modules/security/oec-qualys-trellix.nix(enabled for all hosts viabase.nix); Ansible counterpartoec_qualys_trellixrole built and composed into thebaserole (runs on every host; setoec_installerto the vendor archive, else it no-ops). Installer archive at/var/lib/krg/oec/oec-qualystrellixinstallers-linux.tgz(NOT in git — live credentials). Both sides still need on-box validation. - [~] Samba AD domain controller (
krg-ldap, VMID 100 on thefabricantProxmox host):nix/modules/samba-ad.nix, enabled vianix/profiles/directory.nix(new forestKRG.LOCAL,SAMBA_INTERNALDNS). Provisioned on-box (samba-tool domain provision) and keytab exported to members (samba-tool domain exportkeytab— seedocs/joining-a-host-to-the-domain.md). Still pending: populate AD users/groups (no real principals yet — see [[krg-local-ad-principals-pending]]), and lab-group widening per host. - [~] Proxmox host hardening (
ansible/): thebaserole IS the baseline — OS basics +krg_admin+ssh_hardening+crowdsec+monitoring(node + ipmi exporters) +oec_qualys_trellix, composed in order viaimport_role(secure + up to date + monitored + enrolled by default).proxmox_firewall(cluster.fwtemplated fromtrusted.json+ per-guest<vmid>.fw, e.g.100.fwfor krg-ldap) is a separateproxmox-group play — it fixes the live cluster.fw finding (SSH + exporters currently open to+dc/public). All roles built; inputs filled:inventory/hosts.ymlhasfabricant(running inansible_connection: localmode on the PVE host itself), real admin keys are innix/keys/admins.json, andnix/networks/trusted.jsonhas real CIDRs/IPSets. Pending: pointoec_installerat the vendor archive on fabricant + on-box validation of the full play. - nix in-guest service-SSH restriction (read
ucsd/sealabfromtrusted.jsonso service hosts restrict 22 in-guest too). - [~] Docker published-port firewall bypass. Docker DNATs published ports through the FORWARD path, bypassing
krg.firewall's nftables INPUT rules — so the in-guest firewall can't govern container ports. Done:krg.docker.defaultPublishAddress(nix/modules/docker.nix, default127.0.0.1) binds unspecified publishes to loopback fleet-wide, so DBs/exporters (authentik Postgres, loki, blackbox) stay off the external interface; intentionally-public ports opt in with0.0.0.0:(Traefik 80/443, dcgm 9400). It also tightens ad-hocdocker run -p X:Yon compute boxes to loopback (use-p 0.0.0.0:…or an SSH tunnel to expose). Still open: ports bound to0.0.0.0for remote scraping — notably dcgm 9400 on physical/public waiter — remain world-reachable;krg.firewall.monitoringPorts = [9400](nvidia.nix) does NOT enforce against the Docker-forwarded port. Needs aDOCKER-USER/nftables FORWARD rule restricting 9400 tomonitoringSourceIp— must match on the external ingress interface (eno1on waiter) so container-to-container traffic isn't dropped; wants on-box validation. - Fleet-wide container logs → Loki/Grafana (ADR 0015, Proposed). Two gaps: (1) Docker has no
log-driverset (nix/modules/docker.nix) so it defaults tojson-file— container app stdout lands in/var/lib/docker/containers/*-json.log, not the journal Alloy reads, so even on krg-prod the Loki datasource is empty of container content (theloki/config.alloycomment claiming otherwise is wrong); (2) Alloy is a krg-prod-only compose service and Loki is loopback-only, so the rest of the fleet has no shipper and no path to Loki. Plan: set the daemonlog-driver = journaldfleet-wide (A), promote Alloy to a native per-host module inbase.nix+ an ansible role with ahostlabel (B), expose a Loki push endpoint via Traefik 443 authed by mTLS over the fleet OpenBao PKI — not a raw port (Docker-FORWARD bypass above), not a shared basic-auth secret (C), and add Loki retention/compactor before fan-in (D). [ADR 0017] reshapes it: the Incus/NAT platform's instances (developed-app tenants and self-serve VMs) are extra appliance-like / user-owned log sources whose shipper lives in the Incus golden template (notbase.nix), and whose ephemeral root / TTL-reap makes central shipping the only forensic record — for OEC-exempt ephemeral self-serve VMs it's a named compensating control of the 0017 §7 ITS carve-out, not just observability (part E). Loki stays single-tenant with atenant/instancelabel (NOTX-Scope-OrgID) — instances are semi-trusted and the Incus VM is the boundary, so cross-instance read isolation is out of the threat model; per-stream rate limits cover noisy-neighbor, org-id reserved per-instance if retention/quota/scoped-view is ever needed. Persistent instances gated on secret-zero-into-persistent-instances (0017 §7); ephemeral self-serve VMs use a template-issued write-only push identity, so they ship first. Sequence A(on krg-prod)→D→C→B→E. (Supersedes the earlier nested-microVM/krg.tenantsframing, retired by 0017.) - TOTP 2FA on the PVE realm; PVE web-UI brute-force jail (needs
filter.d/proxmox.conf); PVE patching + persistence hunting (post-breach). - Add Vault for secrets management (replacing manual
.secrets/population). - waiter NFS
/home+ impermanence — DONE + validated on-box (2026-05-21)./homeis served from fabricant (rpool/nfs/home, ansiblenfs_server) and mounted on waiter viakrg.nfsHome(modules/nfs-home.nix), moving user homes OFF the rolled-back root.krg.impermanence.enable = true(PR #12), validated across two reboots (rollback fires each boot;/persistpreserves keytab/host-keys/machine-id + the break-glass admin home/var/lib/<account>;/home, AD, 0 failed units). Two boot bugs were fixed to get here: the/homemount is now a plainnofailmount (NOTx-systemd.automount, which wedged early boot — PR #9), andmodules/impermanence.nixreseeds/usr/bin/envin initrd to survive systemd 258's empty-/usrPID1 freeze (PR #11). Recovery if a blanked root ever freezes: GRUBboot.debug1mounts→ recreate/usr/bin/env. (Docker has its ownnvmepool/dockerdataset, durable regardless.) - Gate AD logins on the NFS
/homemount (impermanence data-loss window) — DONE (PR #13). The window: with impermanence + thenofail/homemount, if fabricant (NFS) is down at boot/homeis left unmounted; an SSSD-cached AD login then triggerspam_mkhomedirto create an ephemeral home on the rolled-back root, wiped on the next reboot. Closed bykrg.nfsHome.requireMountForLogin(default on) innix/modules/nfs-home.nix: apam_execaccount check (added to sshd + login) denies any user whose home is under the NFS mountpoint when that mount is not active, relaying the reason to the user; break-glasskrg-admin(home/var/lib/<account>, off/home) matches nothing and logs in normally, so the box stays recoverable while NFS is down. Enabled on waiter (it doesn't override the default). (The newerkrg.scratchper-user dirs apply the same fail-closed guard for/scratch: created only while the mount is active.) - Second AD DC (remove the krg-ldap SPOF). Today every host's login depends on the single
krg-ldapVM on thefabricanthypervisor. Plan: stand up a second Samba AD DC on another Proxmox host before go-live. When it lands, let members fail over — either drop the pinnedkrg.adClient.server/serverIpso SSSD uses DNS SRV autodiscovery, or extend the module to list both DCs (and pin both in/etc/hosts). Until then, the SSSD offline cache (cache_credentials=true) + local break-glasskrg-adminare the only continuity if krg-ldap is down. - [~] e4e-nas (Synology DSM) under IaC management — PR #8 (built, pending bring-up). Three-layer surface: terraform/e4e-nas/ (community
synologyprovider for Container Manager / packages / scheduler / VMs — scaffolded, resources commented pending provider-attr verification + DSM API creds); spec/e4e-nas/*.yml (20 declarative spec files — shares, ACLs, SMB, NFS, users, groups, DSM web/system, security, notifications, app portal, SSH, AD, dsm-updates, security-advisor, external-access, quotas, snapshots, hyper-backup); ansible/synology/ (self-contained subtree — 20 roles + composer + own ansible.cfg/inventory/group_vars;rm -rf ansible/synology/is the decommission). Roles wrap synowebapi/synouser/synoshare/synoacltool viascript:+raw(DSM py3.8 is below ansible's module floor). synology_base composer enforces a load-bearing order: dsm_system → users → ssh → security → external_access → dsm_web → services → notifications → security_advisor → dsm_updates → ad (AD last, anti-lockout). Hardening: e4e-admin break-glass + key-only SSH + Telnet/SFTP off, QuickConnect/UPnP off, autoblock 3/24h/permanent, SMB3-min/NTLMv1-off, FTP/AFP off, SNMPv3 (krg-monitor), TLS Modern profile, Security Advisor weekly, hotfix-security DSM auto-updates. Per-share Btrfs snapshot retention; Hyper Backup declarative (jobs:[] today — destination decision open). AD join to KRG.LOCAL (winbind, idmap reconciled to match SSSD's algorithmic range so the fabricant-prod NFS export works). ADR 0006 (no Qualys/Trellix on DSM — DSM-native Security Advisor replaces it; compliance carve-out filing owed to UCSD ITS). 160/160 tests green; reviewed by Copilot + a full code review (4577021512), all H/M/Low items addressed. Bring-up gates (docs/e4e-nas-dsm.md): pre-flight live captures (Hyper Backup jobs + AppPortal lists + network — declarative sync deletes anything not in spec), .dss config backup off-box, rig validation to flip OUT_KEYS field-name mismatches, Phase 1 (Mode 2 reset → wizard → re-enter static IP → e4e-admin + NOPASSWD sudo) → Phase 3--check --diffthen incremental apply. Follow-ups (own PRs): #75secret/e4e-nas/users/*AppRole in OpenBao +synouser --setpwtask, #77 NAS in Grafana (SNMP exporter + dashboard), #85 synology-apply systemd timer on krg-deploy. -
/scratch— GREENFIELD ZFS-native rebuild — DEPLOYED on waiter. Supersedes the autotier design (PR #14): autotier (FUSE) crashed (SIGABRT) under concurrent training reads — it wrote a RocksDB record per file open/close — and is unmaintained; removed entirely. Design (docs/scratch-greenfield.md,nix/modules/scratch.nix,nix/hosts/waiter/disko-config.nix): onescratchpool= 2× HDD striped data (~29 TiB, no redundancy, regenerable) + NVMe special vdev (metadata-only, striped) + NVMe L2ARC (striped).nvmepool(RAIDZ1, 4× NVMeospartitions) keeps OS//tools//local./scratch/krgis a plain ZFS mount (no FUSE): ZFS serves hot reads from ARC→L2ARC→HDD in-kernel. Lab isolation: real3770(setgid+sticky)Kastner Research Group(no FUSE, no o+x hack; sticky stops members deleting each other's per-user dirs). Snapshots OFF on scratch-krg (regenerable + they'd pin blocks the overflow frees); cold copies on fabricant NFS stay snapshotted.relatimeso the mover can pick least-recently-accessed files. Per-user dirs via the samepam_exechook, guarded on the mount. Overflow (capacity backstop + TTL GC, OUT of the read path): dailyscratch-overflowtimer (Python,modules/scratch/scratch-overflow.py) demotes files to fabricant NFS (/srv/scratch-cold/krg) for two reasons, both by last-access (relatime): a TTL sweep moves anything idle >maxIdleDays(180 = 6mo on waiter) every run regardless of fullness (GC of abandoned data; active files never evicted), and a capacity sweep moves the coldest files whenscratchpool>85% (down to 75%, floored at minAgeDays=14). Fail-closed (copy→fsync→verify size+sha256→atomic symlink-swap;RequiresMountsForboth mounts so it won't run if NFS is down; a local file is never unlinked until its NFS copy verifies; manifest records the sweep reason);scratch-restore(Python) pulls a file back self-service. Tooling is Python on purpose (correctness-critical but researcher-maintainable, no compile, no new toolchain; round-trip unit-tested). Concurrency knobs (waiter, 377 GiB RAM): ARC cap 96 GiB (~25%),earlyoom(systemd-oomd off),smartd(striped pool = no redundancy → need disk-failure warning). Post-deploy fixes: PR #66 (fix(scratch): re-assert /scratch lab perms after tmpfiles-resetup) bound thekrg-scratch-perms-<lab>oneshot tosystemd-tmpfiles-resetup.serviceafter the nightly nixos-upgrade left/scratch/krgasdrwxr-xr-x root rootin the wild. ansiblenfs_serverexportsrpool/nfs/scratch-krg(no_root_squash) as the cold overflow target; the oldbulkcleanup (zfs destroy rpool/nfs/bulk) still stands. e4e later: add aprojects.e4e(scratchpool/scratch-e4e is reserved scaffolding) with e4e-nas as its overflow target. Follow-up (accepted, documented in docs/scratch-greenfield.md): a residual restore TOCTOU — a symlinked parent-dir component swapped afterscratch-restore's containment checks could redirect the publish; only bites an admin running restore as ROOT on a crafted/scratchpath (normal restores run as the owner; bytes are the user's own). Full fix = per-componentopenat/dir_fdtraversal; deferred as disproportionate vs the tool's simple/maintainable goal (Copilot review round 15, left as a tracked follow-up not a blocker). - node-local
/local/<user>fast cache — DEPLOYED on waiter. The counterpart to scratch:krg.localCache(nix/modules/local-cache.nix) moves regenerable, hot, NODE-local per-user state OFF the NFS/homeonto a plain durable NVMe dataset (nvmepool/local→/local, legacy mount, off the@blankrollback — so NO/persistbind needed, like/var/lib/docker). Two kinds move: (1) IDE remote servers~/.vscode-server+~/.cursor-servervia a login-time symlink into/local/<user>/; (2) the cache class —XDG_CACHE_HOME,HF_HOME,TORCH_HOME,CONDA_PKGS_DIRS,npm_config_cache— viaenvironment.shellInitexports computed withid -un(only when/local/<user>exists). Only caches move; conda envs/real data stay in/home. Why not/home: small-file/watch-heavy IDE + cache I/O is exactly NFS's worst case (inotify doesn't cross NFS → polling watchers), and it's regenerable so it doesn't deserve durable network home space. Why not/scratch: scratch DEMOTES cold files to NFS when the pool fills (opposite of what you want for a dev cache) —/localis the deliberately boring pure-NVMe path that never overflows. Per-user dir auto-created by apam_execsession hook (order 13500,optionalso it never blocks login), guarded on/localbeing mounted; the symlink is never created over an existing real path (so an existing~/.vscode-serveron NFS is untouched). On waiter (krg.localCache.enable + perUser.enable). MIGRATION: existing users opt in once withrm -rf ~/.vscode-server(then next login symlinks it). Post-deploy fix: PR #68 (fix(local-cache): self-heal dangling IDE-server symlinks) made the login hook recover when/localis reprovisioned (e.g. disko repartition) and the home-side symlink is left dangling — observed in the wild asmkdir: cannot create directory '~/.vscode-server': File existson Remote-SSH connect.