# System Setup

# System Change log

- 2026-10-20 Tuesday
  - **Forgejo Two-Factor Authentication (`git.drheck.dev`) Cipher Migration Fix (`ul`)**:
    - Resolved `HTTP 500 Internal Server Error` on `POST /user/two_factor` (`UserSignIn: chacha20poly1305: message authentication failed`) caused by legacy Gitea `AES-CFB` (`md5(SECRET_KEY)`) + double-Base64 ciphertext remaining in `two_factor.secret` after the Gitea $\longrightarrow$ Forgejo `16.0.6` migration.
    - Decrypted the existing 64-character Base32 TOTP seed without requiring authenticator re-enrollment, re-sealed `two_factor.secret` using Forgejo `16.0.6`'s `modules/keying` HKDF-SHA256 (`totp` subkey) + `XChaCha20-Poly1305` AEAD with `ColumnAndID("secret", id)` big-endian associated data, verified all 27 `forgejo doctor check --all` checks (`OK`), and refreshed PAR2 backups.
  - **Docker Package Removal, Root NVMe Reclamation & ZFS `data/podman` Dataset Migration (`ul`)**:
    - Uninstalled `docker` (`1:29.9.1-1`), `docker-compose` (`5.6.0-1`), and `containerd` (`2.4.2-1`) via `pacman -Rns` and purged `/var/lib/docker`, `/var/lib/containerd`, and `/etc/docker` (`43 GB` freed on `/`).
    - Provisioned dedicated ZFS dataset `data/podman` (`compression=lz4`, `xattr=sa`, `acltype=posix`, `dnodesize=auto`, `atime=off`, `mountpoint=/home/docker-data/.local/share/containers`) and migrated Podman's `67 GB` image/layer store from the root NVMe onto the 160 TB ZFS pool via two-pass `rsync -aHAX`.
    - Cleaned `39 GB` of obsolete package tarballs from `/var/cache/pacman/pkg` (`paccache -rk2` + `paccache -ruk0`) and enabled weekly `paccache.timer`.

- 2026-10-19 Monday
  - **Complete 30-Service / 40-Container Rootless Podman Cutover & Docker Decommissioning (`ul`)**:
    - Decommissioned `docker.service`, `docker.socket`, and `containerd.service` completely (`inactive` / disabled), removed all 10 leftover Docker kernel bridges (`docker0` + `br-*`), archived legacy artifacts into `/data/backups/legacy_docker_archive/`, and replaced `docker-prune.timer` with rootless `/etc/systemd/system/podman-prune.{service,timer}` (`User=docker-data`).
    - Migrated all remaining 19 services (29 containers) alongside the initial 11 standalone services—totaling **all 30 containerized services (41 containers)**—to rootless Podman Quadlets (`docker-data`, `UID 1100`, subordinate range `docker-data:624288:65536`, `Pull=never` offline boot resilience, `/home/docker-data/.config/containers/systemd/*.{container,network}`).
    - Migrated shared Valkey to native host `valkey.service` bound strictly to `127.0.0.1:6379` with per-service ACL users (`authentik`, `immich`, `nextcloud`, `vikunja`, `degoog`), `default` disabled, and dangerous admin commands disabled; locked host `postgresql.service` strictly to `127.0.0.1:5432` and upgraded the legacy MD5 role password to `SCRAM-SHA-256`.
    - Configured host HAProxy Layer-4 TCP passthrough (`/etc/haproxy/conf.d/ldap.cfg`) relaying ports `389`/`636` to rootless `authentik-ldap` (`:3389`/`:6636`) and removed `/etc/nginx/certs:/certs:ro` from all Authentik containers so zero host TLS/mTLS keys (`pikvm_client.key`, `studio_secret.conf`) are mounted inside containers.
    - Remediated security & operational findings uncovered during migration:
      - Enabled `YOURLS_PRIVATE=true`, rotated weak `yourls` credentials (`admin_pass_9213` / `yourls_db_pass_9213`), and gated `https://drheck.dev/yourls-api.php` behind Authentik ForwardAuth while keeping short-link `301` redirects public.
      - Removed `/home/luke:/host_home:ro` mount from `subocr`, restricted Collabora WOPI wildcard `https://.*` strictly to `cloud\.drheck\.dev`, set `DISABLE_SIGN_UP=true` and `0770` permissions on `wipstudio`, and archived the deleted upstream `minio/minio` image at `/data/docker/wipstudio/minio_image_backup.tar.xz`.
      - Fixed silent data-loss gaps in `/home/luke/git/backups/ul/scripts/`: pointed `backup/nextcloud` + `restore/nextcloud` at live `nextcloud_db` (instead of stale `nextcloud`) + `config.php`; migrated `penpot` Postgres & assets out of `/var/lib/docker/volumes` onto ZFS `/data/docker/penpot/{postgres_data,assets}` and added `assets` to `backup/penpot`; added `pg_dumpall -U funkwhale` to `backup/funkwhale`; added `authentik-*.container` to `backup/authentik`; and expanded `backup/systemd` to archive `/etc/containers`, `/etc/haproxy`, `/etc/nftables.conf`, and `/home/docker-data/.config/containers/systemd`.
      - Fixed `HTTP 500` ForwardAuth errors on secondary hostname aliases (`music.drheck.dev` $\longrightarrow$ `navidrome.drheck.dev`, `scontroll.drheck.dev` $\longrightarrow$ `scontrol.drheck.dev`) and OIDC callback host mismatches (`vault.drheck.dev` $\longrightarrow$ `pw.drheck.dev`, `bookstack.drheck.dev` $\longrightarrow$ `wiki.drheck.dev`) with dedicated TLS `301` redirects across `/etc/nginx/sites-available/`.
      - Pruned a stale `aardvark-dns` entry in `/run/user/1100/containers/networks/aardvark-dns/penpot_net` (`10.89.13.2` ahead of `10.89.13.5`) that caused `penpot-backend` Lettuce Redis timeouts, fixed root-owned cache generation in `/home/luke/bin/read-bookstack-page` (`-u abc -e HOME=/config`), and updated `/usr/local/bin/{update-authentik-cert.sh,mc-bluemap-provision,degoog-update}`, `/data/minecraft/ipctl`, `/etc/nftables.conf`, and all 43 PAR2-verified service backups.
  - **Backup Architecture Decoupling (`ul`)**:
    - Refactored `/home/luke/git/backups/ul/scripts/run_all_backups` so default invocations execute fast local snapshots and `par2` verification only (`--all` runs the full local + cloud + ISO pipeline).
    - Extracted standalone `/home/luke/git/backups/ul/scripts/stage_cloud_archive`, `/home/luke/git/backups/ul/scripts/upload_cloud_archive`, and `/home/luke/git/backups/ul/scripts/stage_iso_archive` scripts and updated [Backup Process](https://wiki.drheck.dev/books/system-setup/page/backup-process).
  - **amux Synchronous Command Waiting (`ul`)**:
    - Implemented native event-driven command completion waiting in `amux` (`/home/luke/git/amux`, installed at `/usr/local/bin/tmux-custom`) via `read-pane -w` (`-S <token>`, `-e <var>`, `-E <file>`, `-O <offset>`, `-t <timeout>`), eliminating polling loops.
    - Added `atx-exec` helper to `/home/luke/.agent_profile` and updated [amux - Agent modified tmux](https://wiki.drheck.dev/books/system-setup/page/amux-agent-modified-tmux) and [SSH & Agent Command Tracking Guide](https://wiki.drheck.dev/books/system-setup/page/ssh-command-tracking-guide).
  - **Gitea $\longrightarrow$ Forgejo Migration (`ul`)**:
    - Migrated [git.drheck.dev](https://git.drheck.dev) from Gitea (`28.1.0-1`) to Forgejo (`16.0.6-1`), renaming ZFS dataset `data/gitea` to `data/forgejo` (`/data/forgejo`), PostgreSQL database/role to `forgejo`, configuration to `/etc/forgejo/app.ini`, and working state to `/var/lib/forgejo` (`User=git`).
    - Added `/etc/tmpfiles.d/forgejo.conf` (`git:git` override) to prevent pacman `30-systemd-tmpfiles.hook` from resetting `/var/lib/forgejo` and `/etc/forgejo/app.ini` ownership to `forgejo:forgejo` during package upgrades.
    - Updated Nginx (`/etc/nginx/sites-available/forgejo`), Authentik OIDC provider/application (`Forgejo` / `forgejo`), `pass` entries (`forgejo/*`), `repo_tools` (`forgejo-create`, `forgejo-delete`, `forgejo-Visibility`, `gpush`), and backup/restore scripts (`/home/luke/git/backups/ul/scripts/{backup,restore}/forgejo`).
  - **Kasm Decommissioning & Rootless Podman Quadlet Migration (`ul`)**:
    - Decommissioned `kasm` (`kasm.drheck.dev`, `/data/docker/kasm`, PostgreSQL `kasm_db`, ULA IPv6 `:ca53`, Authentik OIDC app, Nginx config, Unbound DNS, and `/data/kasm_profiles`) and removed obsolete `/data/docker/sunshine` container stack.
    - Configured rootless Podman (`6.1.4-1`) with `passt` user-mode networking, subordinate UID/GID ranges (`docker-data:100000:65536`), ZFS-backed graphroot (`/data/podman/docker-data/storage`), and systemd user lingering (`loginctl enable-linger docker-data`).
    - Migrated 11 standalone single-container services (`audiomass`, `bentopdf`, `excalidraw`, `tldraw`, `wick`, `webrcade`, `drawpile`, `navidrome`, `vaultwarden`, `sopro`, `demucs`) from rootful Docker Compose to rootless Podman systemd Quadlets (`/home/docker-data/.config/containers/systemd/*.container`).
    - Updated `/home/luke/bin/status_all`, backup/restore scripts, [Service List](https://wiki.drheck.dev/books/system-setup/page/service-list), [Service Update Process](https://wiki.drheck.dev/books/system-setup/page/service-update-process), and authored [podman](https://wiki.drheck.dev/books/system-setup/page/podman).

- 2026-10-10 Saturday
  - **Minecraft Server Control Plane, Live Console, Seed Studio & Text/Icon Studio (`ul`, `danknasty`)**:
    - Built and deployed `mc-manager` (`/usr/local/bin/mc-manager`, `mc-manager.service`), `mc-runner` (`/usr/local/bin/mc-runner`, `minecraft@<slug>.service` PTY wrapper with `/run/mc-manager/<slug>.sock`), and `mc-seed-render` (`/usr/local/bin/mc-seed-render`, native `cubiomes` biome & structure map generator) at [mc.drheck.dev](https://mc.drheck.dev) behind Nginx and Authentik ForwardAuth.
    - Added **Minecraft Text, MOTD, Color Gradient & 8-Bit Pixel Icon Studio** to both the **+ New World** wizard and existing server **Configuration** panel (Unicode font styles, Java-safe symbols, `§` RGB gradient interpolator, live server-list preview, and 64×64 8-bit pixel badge/icon generator).
    - Updated `/etc/haproxy/minecraft.lua` and HAProxy backend rules so all Multiplayer Server List pings (`state == 1`) query `mc-manager` (`BuildLiveStatusJSON`), overlaying live `motd` (`server.properties`) and `favicon` (`server-icon.png`) onto running server status responses without requiring a server restart.
    - Updated `/data/minecraft/ipctl` to authenticate programmatically to `danknasty` using short-lived (5-minute) SSH user certificates issued by `step-ca` (`webshell-svc` provisioner) and trusted via `/etc/sshd_config.d/10-drheck-certs.conf` on `danknasty`.
    - Externalized all dynamic `ipctl` state out of code-reviewed base configs into `/etc/systemd/network/20-wired.network.d/minecraft.conf`, `/etc/haproxy/conf.d/minecraft_backends.cfg`, `/etc/haproxy/minecraft_hosts.map`, and `/var/unbound/etc/unbound.minecraft.conf` (included from `unbound.conf` and parsed recursively by `update_dns.py`, `dns_danger_sync.py`, and `verify_dns.sh`), with tracked mirrors in `/data/minecraft/.network/`.
    - Provisioned `desertworld.drheck.dev` (`fd77:7777:5010::1337:3c:d00d:2/64`) and updated [Minecraft Worlds](https://wiki.drheck.dev/books/system-setup/page/minecraft-worlds) and [Service List](https://wiki.drheck.dev/books/system-setup/page/service-list).

- 2026-09-27 Sunday
  - **amux (Agent-Modified tmux) & Persistent Session Infrastructure (`ul`)**:
    - Created custom tmux fork (`amux`) built from `/home/luke/git/tmux` and installed at `/usr/local/bin/tmux-custom`.
    - Implemented native `write-pane` (`writep`) command supporting `-b <base64>` and `-f <file>` with `-e` (auto-enter) to inject scripts directly into PTY buffers with zero quote escaping or keystroke lag.
    - Built structured audit logging directly into `cmd-write-pane.c` using `sd_journal_send` (`SYSLOG_IDENTIFIER=amux`, `AMUX_PANE_ID`, `AMUX_WINDOW_NAME`, `AMUX_ARCHIVE_PATH`).
    - Configured automatic pre-execution script archival to persistent storage in `/data/amux/scripts/` (`YYYY-MM-DD_HHMM.SS_<window_name>.sh`).
    - Configured dedicated `atx` agent session in `/home/luke/bin/tmux_control` on socket `/tmp/tmux-1000/tmux_luke_atx`, styled with orange status bar (`#ff8800`), `C-b` prefix, and persistent agent environment in `~/.agent_profile`.
    - Added backup and restore scripts for amux (`/home/luke/git/backups/ul/scripts/backup/amux` and `/restore/amux`).
    - Documented architecture and audit procedures on [amux - Agent modified tmux](https://wiki.drheck.dev/books/system-setup/page/amux-agent-modified-tmux) and [SSH & Agent Command Tracking Guide](https://wiki.drheck.dev/books/system-setup/page/ssh-command-tracking-guide).
  - **Minecraft Datapack Enhancements (`pokelovers`) (`ul`)**:
    - Installed 5 verified Minecraft 26.3 datapacks in `/data/minecraft/poke lovers/world/datapacks/`:
      - `fast_leaf_decay` (rapid leaf decay upon log breakage)
      - `graves` (death chest/grave saving inventory and XP)
      - `invisframes` (invisible item frames via shears while sneaking)
      - `player-drops-head` (player head drops on death)
      - `shulker-drops-two-shells` (guaranteed 2 shulker shells)
    - Broadcasted in-game countdown notices to online players prior to executing live `/reload`.
  - **BlueMap 3D Web Map & Authentik SSO Integration (`pokelovers`) (`ul`)**:
    - Deployed BlueMap 5.28 standalone CLI renderer running as `bluemap-pokelovers.service` in watch mode (`-w -u`), serving live world updates and Server-Sent Events (SSE) on port 8100.
    - Configured NGINX reverse proxy on port 443 with wildcard SSL (`*.drheck.dev`) in `/etc/nginx/sites-available/pokelovers`.
    - Gated `https://pokelovers.drheck.dev` behind Authentik ForwardAuth proxy provider and application on embedded outpost, protecting map coordinates and player builds behind authentication.

- 2026-09-26 Saturday
  - **Zero-Config Minecraft HAProxy SNI Stream Routing (`ul`, `danknasty`)**:
    - Decommissioned application-level proxy (Velocity) to preserve native vanilla Diffie-Hellman encryption and Microsoft `online-mode=true` authentication.
    - Deployed HAProxy 3.4.5 on dual-stack endpoints (`10.0.0.113:25565` and `[fd77:7777:5010::1337:f1ac:d00d:3c]:25565`).
    - Implemented `/etc/haproxy/minecraft.lua` using non-draining buffer peeking (`txn.req:dup()`) to inspect the raw unencrypted Minecraft handshake packet (`0x00`), dynamically extracting the SNI virtual host (`pokelovers.drheck.dev`).
    - Routed traffic via `/etc/haproxy/minecraft_hosts.map` directly to dedicated internal ULA addresses on standard port 25565 (`[fd77:7777:5010::1337:3c:d00d:1]:25565`).
    - Implemented native fallback responder (`mc_offline`) returning custom protocol packets when backends are stopped:
      - Multiplayer Server List MOTD: `"[Server] is currently offline. Ping Luke"`
      - Multiplayer Join Screen: Immediate disconnect with red bold error: `"[Server] is currently offline. Ping Luke"`
  - **Network & DNS Automation (`ipctl`) (`ul`, `danknasty`)**:
    - Developed `/data/minecraft/ipctl` (symlinked in `/home/luke/bin/ipctl`).
    - Automates ULA IPv6 allocation (`fd77:7777:5010::1337:3c:d00d:<hex>/64`) on `eth77` with `systemd-networkd` reload.
    - Automates HAProxy backend and map configuration reload.
    - Integrated SSH agent validation checking for key `ultra-arikui` (`SHA256:7Xr0vCxgfpPhbKE3hN9fE2IC9y4x3Kj+kYmXwzgJY3s`).
    - Implemented 7-step automated DNS provisioning over SSH to `danknasty`: updates `~/git/router_config/unbound.conf`, validates via `unbound-checkconf`, deploys to `/var/unbound/etc/`, restarts `unbound`, verifies local resolution, and triggers `dns_danger_sync.py --force` to push public A and AAAA records to deSEC.io.
    - Created dedicated backup and restore scripts in `/home/luke/git/backups/ul/scripts/backup/minecraft_router` and `/restore/minecraft_router`.
    - Documented entire architecture and quickstart HOWTO on HeckWiki page 136 ([Minecraft Worlds](https://wiki.drheck.dev/books/system-setup/page/minecraft-worlds)).
  - **Firewall Configuration for Minecraft Port 25565 (`danknasty`)**:
    - Configured `mc_ipv4` (`10.0.0.113`), `mc_gu` (`$gu_prefix:1337:f1ac:d00d:3c`), and `mc_ula` (`$ula_prefix:1337:f1ac:d00d:3c`) in `~/git/router_config/pf.conf`.
    - Added inbound port 25565 forwarding rules for IPv4 NAT and IPv6 ULA redirection.
    - Validated with `pfctl -nf`, deployed to `/etc/pf.conf`, loaded into the kernel, and verified reachability externally from `rng`.


- 2026-09-21 Monday
  - **Native Sunshine & Gamescope Migration on ul**:
    - Decommissioned legacy `steam-headless` container; migrated to bare-metal Gamescope DRM compositor + Sunshine host on AMD Radeon AI PRO R9700 (`1002:7551`, Navi 48).
    - Injected AOC 1080p EDID on `DP-1` via mkinitcpio & systemd-boot loader options.
    - Configured Steam library paths directly to 151TB ZFS pool (`/data/games/steam/steamapps`).
    - Resolved PS5 DualSense controller disconnection: disabled Steam proprietary HIDAPI polling (`SDL_JOYSTICK_HIDAPI_PS5=0`, `SDL_JOYSTICK_HIDAPI=0`) to prevent `hid_read failure` timeout on virtual `uhid` devices.
    - Added `-e` (`--steam`) integration flag to Gamescope launch script to ensure proper Wayland focus relaying to Steam, enabling Steam Input in-game instead of dropping to Desktop mouse layout.
    - Documented complete architecture, launch scripts, and controller gotchas on HeckWiki page 130 ([Sunshine Remote Setup](https://wiki.drheck.dev/books/systems/page/sunshine-remote-setup)).
- 2026-09-21 Monday
  - **Steam Storage Migration to 151TB ZFS Pool (`/data/games/steam`) (`ul`)**:
    - Discovered root NVMe filesystem at 85% utilization (71GB free).
    - Consolidated all Steam games, runtimes, manifests, and shader caches from temporary docker volumes to `/data/games/steam/steamapps/` on the 151TB ZFS pool.
    - Replaced `~/.local/share/Steam/steamapps` and `~/.steam/steam/steamapps` with permanent atomic symlinks pointing to `/data/games/steam/steamapps/`.
    - Cleaned `libraryfolders.vdf` ensuring 100% of game downloads route directly to `/data/games/steam/` with zero bytes hitting the root SSD.
  - **TTY1 Getty Deactivation & Display Console Fix (`ul`)**:
    - Disabled `getty@tty1.service` (`systemctl disable --now getty@tty1.service`) to prevent Linux console login prompts from broadcasting over KMS on `DP-1`.

- 2026-09-20 Sunday
  - **PS5 DualSense Controller Hardware Support & uhid Activation (`ul`)**:
    - Identified PlayStation 5 DualSense controller emulation failure in Sunshine due to missing `/dev/uhid` (User HID) device node.
    - Loaded `uhid` kernel module and made persistent across reboots in `/etc/modules-load.d/uhid.conf`.
    - Configured udev rule in `/etc/udev/rules.d/85-sunshine-uinput.rules` granting `input` group permissions to both `/dev/uinput` and `/dev/uhid` (`MODE="0660", GROUP="input"`).
  - **Native Gamescope + Headless DRM Stack Migration (`ul`)**:
    - Decommissioned legacy `steam_headless` Docker container to eliminate Xorg CPU dummy-driver bottleneck (`xf86-video-dummy`) and software rasterization (15 FPS).
    - Installed native Arch Linux gaming stack: `gamescope`, `steam`, `vulkan-radeon`, `lib32-vulkan-radeon`, `pipewire-pulse`, `wireplumber`, `seatd`, `socat`, and `sunshine-bin` (AUR).
    - Granted real-time scheduling capabilities to Gamescope (`setcap cap_sys_nice=eip /usr/bin/gamescope`) and rootless DRM frame capture to Sunshine (`setcap cap_sys_admin,cap_sys_nice=eip /usr/bin/sunshine`).
    - Configured `seatd` daemon with group `video` override in `/etc/systemd/system/seatd.service.d/group.conf` to allow Gamescope DRM session management without an active logind seat.
    - Configured Gamescope launcher script `/home/luke/bin/launch-gamescope-steam.sh` driving connector `DP-1` via `--backend drm` with RADV Vulkan acceleration on the AMD Radeon AI PRO R9700.
  - **Headless Virtual Display via Boot-Time EDID Injection (`ul`)**:
    - Resolved `*ERROR* No EDID found on connector: DP-1` kernel spam on headless AMD GPU.
    - Sourced verified 1080p 144Hz CEA-861 DisplayPort EDID (`aoc_1080p.bin`) in `/usr/lib/firmware/edid/`.
    - Packaged EDID into early initramfs via `FILES=(/usr/lib/firmware/edid/aoc_1080p.bin)` in `/etc/mkinitcpio.conf` and regenerated boot image via `mkinitcpio -P`.
    - Configured bootloader kernel parameters in `/boot/loader/entries/arch.conf`: `drm.edid_firmware=DP-1:edid/aoc_1080p.bin video=DP-1:1920x1080@60e`.
    - Pinned Sunshine KMS capture explicitly to `adapter_name = /dev/dri/card1` and `output_name = DP-1`, preventing BMC graphics capture (`VGA-1`).
  - **Dual-Stack IPv4/IPv6 `socat` Bridge (`ul`)**:
    - Bound native Sunshine directly to dedicated IPv6 ULA address `[fd77:7777:5010:0:1337:f1ac:d00d:4799]`.
    - Deployed `/usr/local/bin/sunshine-ipv4-bridge.sh` and systemd unit `/etc/systemd/system/sunshine-ipv4-bridge.service` listening on `10.0.0.112` and forwarding all GameStream TCP and UDP ports bidirectionally to IPv6 to prevent asymmetric routing drops on dual-stack LANs.
  - **Client Authentication & Stale Certificate Remediation (`ul`)**:
    - Diagnosed `Warning: SSL Verification error :: Client certificate identity is not enabled` (LizardByte issue #5696) caused by duplicate client certificates with conflicting UUIDs in `sunshine_state.json`.
    - Cleared stale device records in `sunshine_state.json` allowing clean one-time client pairing across desktop and TV clients.
  - **NetworkManager D-Bus Mock for Steam (`ul`)**:
    - Diagnosed Steam Gamepad UI offline status (`An internet connection is required to load this content` / slashed Wi-Fi icon) caused by absence of NetworkManager on `systemd-networkd` host.
    - Implemented lightweight Python D-Bus daemon `/usr/local/bin/fake-networkmanager.py` and service `/etc/systemd/system/fake-networkmanager.service` reporting `NM_STATE_CONNECTED_GLOBAL` (70) and `NM_CONNECTIVITY_FULL` (4) to satisfy Steam network checks.
  - **Documentation**:
    - Authored and published comprehensive architecture and setup runbook on HeckWiki: [sunshine remote setup](https://wiki.drheck.dev/books/system-setup/page/sunshine-remote-setup) (Page 130).

- 2026-09-19 Saturday
  - **Sunshine Network Tuning & Steam Container Bootstrap (`ul`)**:
    - Diagnosed container web assets and API communication issues on `josh5/steam-headless`.
    - Configured Sunshine CORS origins (`csrf_allowed_origins = https://sunshine.drheck.dev`) and dual-stack network settings in `sunshine.conf`.
    - Bootstrapped Steam runtime inside container and configured Steam Big Picture startup commands.
    - Authored and published initial HeckWiki guide: [Steam Streaming](https://wiki.drheck.dev/books/computer/page/steam-streaming) (Page 129).
    - Updated LDAP group enrollment documentation (`ldap-sssd-client-enrollment-guide`, Page 25) with corrected repository clone instructions.

- 2026-09-18 Friday
  - **Immich Machine Learning AMD ROCm Migration (`ul`)**:
    - Migrated `immich_machine_learning` from NVIDIA CUDA to native AMD ROCm: pulled `ghcr.io/immich-app/immich-machine-learning:v3.1.0-rocm`.
    - Updated `/data/docker/immich/.env` (`IMMICH_MACHINE_LEARNING_GPU_TYPE=rocm`).
    - Recreated Immich stack and verified PyTorch ROCm device detection (`torch.cuda.is_available() = True` on AMD Radeon AI PRO R9700 `gfx1201`).
    - Verified VA-API hardware video acceleration on `/dev/dri/renderD128` inside `immich_server`.
  - **Sunshine & Steam-Headless Initial Deployment (`ul` & `danknasty`)**:
    - Provisioned dedicated internal IPv4 (`10.0.0.112`) and IPv6 ULA (`fd77:7777:5010:0:1337:f1ac:d00d:4799`).
    - Updated `unbound.conf` in `~/git/router_config` on `danknasty` with `sunshine.drheck.dev` A and AAAA records; deployed via `./sync_unbound`.
    - Synced public DNS records via `dns_danger_sync.py` on deSEC.io.
    - Configured `/etc/nginx/sites-available/sunshine` with Authentik ForwardAuth and deployed reverse proxy.
    - Provisioned `sunshine` application and ProxyProvider in Authentik embedded outpost.
    - Deployed `josh5/steam-headless` container in `/data/docker/sunshine/docker-compose.yml`.

- 2026-09-17 Thursday
  - **Tmux & SSH Auth Environment Maintenance (`ul`)**:
    - Re-initialized Tmux Plugin Manager (`tpm`) and checked plugin compatibility (`check_tmux_version.sh`).
    - Maintained persistent SSH agent socket symlinks.

- 2026-09-15 Tuesday
  - **System Package Maintenance & Initramfs Rebuild (`ul`)**:
    - Upgraded system packages via `paru -Syu`.
    - Rebuilt `initramfs-linux-lts.img` via ALPM hooks.
    - Verified systemd service health.

- 2026-09-12 Saturday
  - **System Package Maintenance & LTS Kernel Update (`ul`)**:
    - Upgraded system packages via `paru -Syu` / `pacman -Syu`.
    - Kernel update to `6.18.52-1-lts` and regenerated `initramfs-linux-lts.img`.
    - Re-verified NVIDIA kernel module conftest compilation checks for `580.178.04`.
    - Media library maintenance: sorted and updated Adventure Time subtitle filenames.

- 2026-09-08 Tuesday - 2026-09-11 Friday
  - **System Maintenance & Driver Toolchain Compilation (`ul`)**:
    - Executed routine system maintenance and package updates across `ul`.
    - Kernel module conftest compilation and pacman keyring verification.

- 2026-09-05 Saturday
  - **OpenBSD Router Reinstallation & CrowdSec Perimeter Restoration (`danknasty`)**:
    - Reinstalled and configured `crowdsec-firewall-bouncer` connecting to `ul`'s LAPI over IPv6 ULA (`http://[fd77:7777:5010:0:1337:f1ac:d00d:c70d]:8080/`).
    - Restored OpenBSD PF firewall tables (`<crowdsec-blacklists>`, `<crowdsec6-blacklists>`, `<bruteforce>`) with rate limiting (`max-src-conn-rate 3/30`), loading ~23,880 blacklisted attacker IPs directly into kernel tables.
    - Verified remote syslog forwarding of `auth,authpriv.info` over IPv6 UDP to `ul`'s CrowdSec syslog parser.
  - **Router Network Mapping & Dynamic IPv6 DNS Cron Jobs (`danknasty`)**:
    - Aligned network interface roles after hardware reinstallation (`em0` WAN, `em1` LAN with delegated `/64` prefix).
    - Updated interface configurations across `/etc/hostname.em0`, `/etc/hostname.em1`, `/etc/dhcp6leased.conf`, `/etc/rad.conf`, and `/etc/dhcpd.conf`.
    - Deployed dynamic deSEC.io IPv6 DNS updater (`/etc/update_dns.py`) and keepalive monitor (`/etc/ipv6_keepalive.sh`).
    - Restored root crontab on `danknasty` to run `/etc/update_dns.py` every minute and `/etc/ipv6_keepalive.sh` every 5 minutes.
    - Triggered manual sync: verified all 43 domain AAAA records updated on deSEC and verified public IPv6 reachability via `rng` to `https://auth.drheck.dev/`.
  - **Router Disaster Recovery & Reinstall Survival Automation (`danknasty`)**:
    - Created single-command idempotent provisioning script `~/git/router_config/bootstrap.sh` (`doas ./bootstrap.sh`): installs package dependencies, copies system configs, sets file permissions and ownership, restores root crontab, applies sysctl, and starts all services.
    - Created zero-touch OpenBSD post-install hook `~/git/router_config/install.site` to automatically pull repository and execute `bootstrap.sh` inside `chroot /mnt` at OS installation completion.
    - Added `/etc/sysctl.conf` (`net.inet.ip.forwarding=1`, `net.inet6.ip6.forwarding=1`) to repo with automated activation via `sysctl -f`.
    - Added package manifests to repo: `pkglist` (generated via `pkg_info -mz` for native OpenBSD `pkg_add -I -l` restoration) and `packages.txt` (human-readable catalog with descriptions).
    - Added `/etc/dhcpd.conf` (DHCPv4 server) and `/var/unbound/etc/unbound.conf` (DNS resolver) deployment and service management into `bootstrap.sh`.
    - Backed up all router configuration templates into `~/git/router_config` (`boot.conf`, `ttys`, `doas.conf`, `crontab.root`, `dhcp6leased.conf`, `rad.conf`, `pf.conf`, `syslog.conf`, `rc.conf.local`, `mygate`, `crowdsec-firewall-bouncer.yaml`).
  - **Nextcloud Landing Page & occ Runbook Guide (`ul`)**:
    - Configured post-authentication redirect on `cloud.drheck.dev` to default directly to `/apps/files/files` (`occ config:system:set defaultapp files`, `occ config:app:set core defaultpage /apps/files/files`).
    - Authored and published comprehensive [Nextcloud occ Guide](https://wiki.drheck.dev/books/system-setup/page/nextcloud-occ) on HeckWiki documenting command-line administration, alias setups, user management, and maintenance mode operations.

- 2026-09-04 Friday
  - **Danknasty Router Hardware & Platform Diagnostics (`danknasty`)**:
    - Identified router hardware platform: Broachlink NOAH4C V10 (Intel Atom E3845 quad-core @ 1.91GHz, 4GB RAM, 4x Intel I210 Gigabit Ethernet ports `em0`-`em3`).
    - Enabled RJ45 serial console access on `com0` at 115200 baud via `/etc/boot.conf` (`stty com0 115200`, `set tty com0`) and `/etc/ttys` (`tty00 std.115200 vt220 on secure`).
    - Diagnosed periodic SSH responsiveness latency: traced kernel system CPU lockups and interrupt activity using `vmstat -i`, `sensorsd`, and `bsd.re-config`.
  - **Split-Horizon Unbound Authoritative LAN Resolution (`danknasty`)**:
    - Analyzed Unbound configuration on `danknasty` acting as authoritative LAN resolver (`local-zone` / `local-data` with `aa` flag set in LAN responses).
    - Evaluated DNS high-availability options: Git-based Unbound configuration replication vs authoritative secondary migration to NSD.

- 2026-09-03 Thursday
  - **SSH CA Documentation Modularization (HeckWiki)**:
    - Cleanly partitioned SSH Certificate Authority documentation into two focused reference runbooks under the `LDAP+PAM+SSH CA` book:
      1. [SSH CA setup](https://wiki.drheck.dev/books/ldappamssh-ca/page/ssh-ca-setup) (Page 26): Core architecture, Ed25519 CA keys, Vaultwarden storage, signing principles, and Certificate Revocation Lists (KRL).
      2. [SSH Client and Host Onboarding Guide](https://wiki.drheck.dev/books/ldappamssh-ca/page/ssh-client-and-host-onboarding-guide) (Page 112): Step-by-step out-of-band bootstrap and verification workflows for onboarding new clients and servers without circular SSH dependencies.

- 2026-09-02 Wednesday
  - **OpenBSD Router IPv6 Dynamic DNS Rewrite (`update_dns.py`) (`danknasty`)**:
    - Diagnosed HTTP 400 bulk update failures on deSEC.io caused by regex string parsing errors and duplicate records in `unbound.conf`.
    - Replaced legacy bash/awk script (`update_dns.sh`) with robust Python 3 implementation (`update_dns.py`) using Python's native `ipaddress` library and typed dictionary deduplication.
    - Added unit test suite `test_update_dns.py` with 9 unit tests verifying zone record parsing, address synthesis, and deduplication.
    - Removed duplicate `libresprite.drheck.dev` entry and cleaned up interleaved records in `unbound.conf`.
    - Deployed `/etc/update_dns.py` (0755 root:wheel), removed `/etc/update_dns.sh`, and signed/committed all changes to `~/git/router_config`.
  - **CrowdSec Log Synchronization & False-Positive Ban Remediation (`ul` & `danknasty`)**:
    - Investigated repetitive `/var/log/authlog` session spam on `danknasty`.
    - Identified redundant SSH polling in `crowdsec-sync-authlog` on `ul` running via `crowdsec-export-bans.timer` every 60s.
    - Disabled unnecessary SSH scraping, relying on native real-time UDP IPv6 syslog forwarding (`auth,authpriv.info`) from `danknasty` to CrowdSec on `ul`.
    - Resolved false-positive ban on VPS `rng` (`66.186.37.205`) in `<crowdsec-blacklists>` PF table caused by unprivileged TCP connect scans.
  - **SSH CA Inventory Tracking & Streamlined Key Signing (`ul`)**:
    - Implemented key archival and inventory management in `/usr/local/bin/sign-host-key` and `/usr/local/bin/sign-user-key` archiving signed public keys in `/etc/ssh/ca_inventory/{hosts,users}` with mandatory labeling for revocation tracking.
    - Built revocation utility `/usr/local/bin/revoke-ssh-key`.
    - Documented out-of-band onboarding workflow on HeckWiki.

- 2026-08-31 Monday
  - **System Package Maintenance & Toolchain Updates (`ul`)**:
    - Executed system upgrades via `paru` / `pacman`.
    - Updated Haskell libraries (`haskell-uuid-types`, `haskell-vector`, `haskell-witherable`), `shellcheck`, and system info documentation.
    - Updated Opencode CLI, `csvtools`, and `nethack-git`.

- 2026-08-30 Sunday
  - **Demucs Stem Separation Deployment (`https://demucs.drheck.dev/`)**:
    - Added `demucs.drheck.dev` DNS records to `unbound.conf` on `danknasty` (`~/git/router_config`).
    - Built and deployed container running Demucs v4 web service (`app.py`, HTML5 Multi-Track Mixer) on dedicated ULA IPv6 (`fd77:7777:5010:0:1337:f1ac:d00d:de3:8000`).
    - Supports `htdemucs_ft` (Fine-tuned 4-Stem), `htdemucs` (4-Stem), `htdemucs_6s` (6-Stem with Piano & Guitar), and `mdx_extra`.
    - Features synchronized multi-track audio playback with per-stem Solo, Mute, Volume faders, and ZIP export.
    - Provisioned Authentik ForwardAuth `demucs` ProxyProvider and Application in Authentik Embedded Outpost.
    - Configured `/etc/nginx/sites-available/demucs` reverse proxy with 512MB max body size and 600s read timeout.
    - Added firewall isolation reject rule in `/etc/nftables.conf`.
    - Created `demucs` backup and restore scripts with `par2` parity in `~/git/backups`.

- 2026-08-29 Saturday
  - **Sopro V2 TTS Deployment (`https://sopro.drheck.dev/`)**:
    - Enhanced web interface and backend API to expose configurable acoustic solver `Steps` (1-64 steps).
    - Added `sopro.drheck.dev` DNS records to `unbound.conf` on `danknasty` (`~/git/router_config`).
    - Built and deployed container running `soprotts serve --device cpu --host 0.0.0.0 --port 7860 --no-open` on dedicated ULA IPv6 (`fd77:7777:5010:0:1337:f1ac:d00d:509:7860`).
    - Provisioned Authentik ForwardAuth `sopro` ProxyProvider and Application in Authentik Embedded Outpost.
    - Configured `/etc/nginx/sites-available/sopro` reverse proxy with WebSocket support and pushed to `~/git/nginx`.
    - Added firewall isolation reject rule in `/etc/nftables.conf` and pushed to `~/git/configs`.
    - Created `sopro` backup and restore scripts with `par2` parity in `~/git/backups`.
  - **BentoPDF Deployment (`https://bentopdf.drheck.dev/`)**:
    - Added `bentopdf.drheck.dev` DNS records to `unbound.conf` on `danknasty` (`~/git/router_config`).
    - Deployed `ghcr.io/alam00000/bentopdf:latest` container on dedicated ULA IPv6 (`fd77:7777:5010:0:1337:f1ac:d00d:bdf:8080`).
    - Provisioned Authentik ForwardAuth `bentopdf` ProxyProvider & Application in Authentik Embedded Outpost.
    - Configured `/etc/nginx/sites-available/bentopdf` reverse proxy and pushed to `~/git/nginx`.
    - Added firewall isolation reject rule in `/etc/nftables.conf` and pushed to `~/git/configs`.
    - Created `bentopdf` backup and restore scripts with `par2` parity in `~/git/backups`.
  - **SSL Certificate Automation & SSSD TLS**:
    - Fixed expired LDAP `CertificateKeyPair` inside Authentik database via `ak shell` and restarted `authentik-ldap`.
    - Deployed `/usr/local/bin/update-authentik-cert.sh` automated renewal hook in `acme.sh` configuration with `/etc/sudoers.d/acme_ssl` NOPASSWD grant.
  - **Docker `.env` & Secret File Hardening**:
    - Stripped POSIX extended ACLs (`setfacl -b`) across all `.env` files and SSH keys in `/data/docker/`.
    - Restricted permissions to `0640 root:docker-data` and parent directories to `0750`.
  - **Universal Admin Account Transition**:
    - Transferred administrative roles to `akadmin` / `admin` across Authentik, Gitea, Immich, Anchor, BookStack, Nextcloud, Kavita, Navidrome, and RomM.
    - Demoted `luke` to standard user on web applications while preserving `wheel` (sudo) access on host OS.
  - **PostgreSQL Authentication Hardening**:
    - Removed obsolete `0.0.0.0/0` and `::/0` wildcard rules for non-existent `infisical` role from `/data/postgres/data/pg_hba.conf`.
  - **`nftables` Egress Isolation (`chain output`)**:
    - Added reject rules (`meta skuid != { 0, 33, 1101 } reject`) in `chain output` for all 26 active ULA IPv6 backend services to prevent unprivileged local processes on `ul` from bypassing Authentik ForwardAuth.
    - Tracked `/etc/nftables.conf` in `~/git/configs/nftables.conf`.
  - **Nginx Hash Table Performance Tuning**:
    - Added `variables_hash_max_size 2048;` and `variables_hash_bucket_size 128;` to `/etc/nginx/nginx.conf` and pushed to `~/git/nginx`.
  - **Authentik 2026.8.0 Outpost & OIDC Remediation**:
    - Populated missing `authentication_flow` (`default-authentication-flow`) and `invalidation_flow` (`default-invalidation-flow`) UUIDs across all 19 `ProxyProvider` database records, fixing Rust Embedded Outpost deserialization crash and restoring ForwardAuth across all services.
    - Updated OpenID `email` ScopeMapping expression to enforce `email_verified = True` for Better-Auth (WIPStudio) / SSO client logins.
  - **Cockpit Removal**:
    - Uninstalled `cockpit` and `cockpit-docker` packages; purged `/etc/motd.d/cockpit`, `/etc/issue.d/cockpit.issue`, and `/run/cockpit`.
  - **Arch `.pacnew` Merge & Maintenance**:
    - Adopted updated upstream makepkg drop-ins and TPM2 FAPI JSON profiles.
    - Tuned `/etc/pacman.conf` with `DownloadUser = alpm` and `ParallelDownloads = 5`.
    - Cleaned all lingering `.pacnew` files in `/etc/`.
  - **BMO95 Display Mirroring (`~/git/audio_config`)**:
    - Installed `wl-mirror` on `bmo95` and configured Sway (`common/sway/config`) to mirror DisplayPort `DP-3` to HDMI `HDMI-A-1` (TV) via zero-copy DMA-BUF.
    - Consumed Sway configuration into `~/git/audio_config` and committed changes.
  - **Studio Control (`scontrol`) UI & Toggle Controls**:
    - Added `POST /api/power/{device}/{action}` supporting individual Sub-GHz RF outlet toggling (`pa`, `sub`, `rack`, `computer`, `tv`, `neewer`, `mrc`, `all`) and `POST /api/atx/{action}` for PiKVM motherboard ATX button clicks.
    - Added **Individual Power Controls** view with dedicated toggle buttons and view-switcher button to `scontrol.drheck.dev`.
    - Checked in and pushed to `https://git.drheck.dev/luke/scontrol.git`.

  - **Kavita Arch Linux / AUR Upgrade & Database Migrations (`ul`)**:
    - Checked SQLite database integrity on `/opt/kavita/config/kavita.db` (`PRAGMA integrity_check`).
    - Created safety database backup at `/opt/kavita/config/kavita.db.pre_0.9.1_upgrade`.
    - Restarted `kavita.service` following `paru` upgrade to Kavita `v0.9.1.0`.
    - Executed automatic backup (`/opt/kavita/config/temp/migration/0.9.0.2`) and applied database migrations (`MetadataSettingFieldRenumber`, `ManualMigrateOriginalNameBackfill`, `ManualMigrateNormalizedOriginalNameBackfill`, `ManualMigrateLastReadDates`, `ManualMigrationExternalSeriesMetadataIds`, `ManualMigrationCorrectAuditStatus`).
    - Verified service initialization and HTTP 200 on `http://[fd77:7777:5010:0:1337:f1ac:d00d:7ead]:5000/`.
  - **Full Docker Fleet Update & Service Migrations (`ul`)**:
    - **Authentik**: Created PostgreSQL database backup at `/tmp/authentik_pre_upgrade.dump`. Upgraded `server`, `worker`, and `ldap` to `2026.8.0`. Applied blueprint and Django migrations across all applications. Verified `email_verified: True` in `ak shell` scope mapping and tested `https://auth.drheck.dev/`.
    - **Infra (Valkey)**: Upgraded shared Redis/cache container to `valkey/valkey:8.1-alpine` (v8.1.9). Verified active session reconnection for Authentik, Immich, and Nextcloud.
    - **Immich**: Upgraded `immich-server` and `immich-machine-learning` to latest `v3.1.0` builds with GPU acceleration. Verified `https://photos.drheck.dev/`.
    - **Kasm Workspaces**: Upgraded manager container to `lscr.io/linuxserver/kasm:latest` (Kasm 1.19.0). Initialized internal services and verified `https://kasm.drheck.dev/`.
    - **BookStack**: Upgraded `bookstack` (v26.05.4) and internal `mariadb:latest`. Verified Laravel migrations and `https://wiki.drheck.dev/`.
    - **Penpot**: Upgraded `frontend`, `backend`, `exporter` (v2.17.2), `postgres:15.19`, and `redis:7`. Verified `https://penpot.drheck.dev/`.
    - **Anchor**: Upgraded image tag to `ghcr.io/zhfahim/anchor:0.16.0`. Executed database schema migrations and verified `https://keep.drheck.dev/`.
    - **Vaultwarden**: Upgraded to `vaultwarden/server:latest` (v1.37.2). Verified `https://pw.drheck.dev/`.
    - **Navidrome**: Upgraded to `deluan/navidrome:latest` (v0.63.2). Verified web and Subsonic API endpoints on `https://navidrome.drheck.dev/` and `https://music.drheck.dev/`.
    - **WebShell / Step-CA**: Upgraded `smallstep/step-ca:latest` (v0.30.2). Verified HTTPS on port 9000.
    - **WIPStudio / MinIO**: Upgraded storage backend to `minio/minio:latest`.
    - **YOURLS**: Upgraded `yourls:latest` (1.10.5) and internal `mariadb:10.11`.
    - **Drawpile**: Upgraded server to `drawpile/drawpile-srv:2` (2.3.0). Verified TCP/WebSocket listeners on ports 27750-27752.
    - **Stateless Web Services**: Upgraded `wick` (`nginx:alpine`), `webrcade` (`webrcade:latest`), `excalidraw` (`excalidraw:latest`), and `tldraw` (`ratneo/tldraw:latest`).
    - **Offsite Cloud Backups**: Executed full service backups and synced to Google Drive and Proton Drive via rclone (`2026-08-29_112824`).


- 2026-08-28 Friday
  - **Funkwhale Audio Streaming & Range Request Fix (`ul`)**:
    - Diagnosed audio playback failures and stream disconnects on Funkwhale API endpoints (`/api/v1/listen/.../?upload=...` and transcoded audio streams).
    - Resolved Nginx reverse proxy byte-range streaming failure (`206 Partial Content`) by updating `/data/docker/funkwhale/funkwhale_nginx.template` and volume mounting custom `/data/docker/funkwhale/mime.types` inside the container.
    - Recreated container and verified byte-range audio playback via `curl -i -r 0-65535` returning HTTP `206 Partial Content`.
    - Updated Funkwhale backup and restore scripts (`/home/luke/git/backups/ul/scripts/backup/funkwhale` and `restore/funkwhale`) to include `mime.types` and `funkwhale_nginx.template` in backup tarballs (verified with `shellcheck`).

- 2026-08-27 Thursday
  - **System Package Maintenance & Kernel Audit (`ul`)**:
    - Executed system package upgrades via `paru -Syu` / `pacman -Syu`.
    - Updated mirrorlist and restarted `reflector.service`.
    - Verified kernel image and module consistency using `kernel-check` against `/boot/vmlinuz-linux-lts`.

- 2026-08-26 Wednesday
  - **Studio Control Gateway & ATX Power Automation (`ul` & `pikvm-studio`)**:
    - Integrated ATX motherboard power button controls (`kvmd-cli atx`) into `studio-power` CLI and synchronized executable between `ul` and `pikvm-studio`.
    - Resolved container DNS and IPv6 resolution for `bmo95.drheck.dev` inside `scontrol` Docker container on `infra_net` bridge.
    - Updated and redeployed `app.py` in `/data/docker/scontrol` and committed changes to `/home/luke/git/scontrol`.

- 2026-08-24 Monday
  - **Studio PiKVM Hardware & Perimeter Ingress Security (`ul` & `pikvm-studio`)**:
    - Configured dual-hostname architecture: `studio-kvm-ssh.drheck.dev` (direct hardware & SSH) and `studio-kvm-web.drheck.dev` (Authentik-gated Web UI).
    - Implemented kernel-level outbound firewall isolation in `/etc/nftables.conf` (`chain output`) on `ul` blocking non-root, non-nginx (`http`, UID 33) users from connecting to PiKVM ports 80/443.
    - Configured Pre-Shared Proxy Secret Header (`X-Proxy-Secret`) in `/etc/nginx/certs/studio_secret.conf` (mode `0600`, owned by `http:http`).
    - Configured Mutual TLS (mTLS) with internal CA and client certificate authentication (`/etc/nginx/certs/pikvm_ca.crt`, `pikvm_client.crt`, `pikvm_client.key`).
    - Updated Authentik `ProxyProvider` and `Application` (`Studio PiKVM`) with implicit consent (`default-provider-authorization-implicit-consent`) and group `studio` access restriction.
    - Set `kvmd.auth.enabled: false` in `/etc/kvmd/override.yaml` (committed in `/home/luke/git/pikvm_studio`).
    - Updated [Studio PiKVM & Studio Control Gateway](https://wiki.drheck.dev/books/system-setup/page/studio-pikvm) documentation.
  - **Studio Control Gateway Deployment (`scontrol.drheck.dev`) (`ul`)**:
    - Developed and deployed asynchronous FastAPI orchestration service (`scontrol`) in `/data/docker/scontrol` binding to dedicated ULA IPv6 address `[fd77:7777:5010:0:1337:f1ac:d00d:5c01]:8000`.
    - Implemented tactile mobile-optimized dark UI with dynamic state buttons, live monospace console, and status indicators.
    - Generated dedicated service ed25519 key (`/data/docker/scontrol/id_scontrol`) registered in `luke@studio-kvm-ssh.drheck.dev:~/.ssh/authorized_keys`.
    - Integrated real-time Server-Sent Events (SSE) streaming pipeline (`/api/stream/on` and `/api/stream/off`):
      1. RF Power Outlets: switches all 7 studio devices via `studio-power all on` over SSH.
      2. ATX Motherboard: queries PiKVM `/api/atx` and pulses motherboard power button if off.
      3. Studio PC Boot: probes DNS hostname `bmo95.drheck.dev` with a 100-second boot timeout.
      4. Soundcards & Audio: audits ALSA/JACK multi-channel recording interfaces.
    - Created Nginx ForwardAuth reverse proxy configuration `/etc/nginx/sites-available/scontrol` (symlinked to `/etc/nginx/sites-enabled/scontrol`) with SSE buffering bypass.
    - Configured Authentik `ProxyProvider` and `Application` (`Studio Control`) bound to Authentik Group **`studio`**.
    - Created Git repository at `/home/luke/git/scontrol` tracking `build.sh` (verified with `shellcheck`), `Dockerfile`, `entrypoint.sh`, `app.py`, `docker-compose.yml`, `templates/index.html`, and `README.md`.
    - Created, tested, and verified backup/restore scripts (`scontrol_backup` and `scontrol_restore` in `/home/luke/git/backups/ul/scripts/`) with `par2` 10% redundancy.
  - **Studio PC (`bmo95`) Boot & Audio Stack Audit**:
    - Resolved `libkmod: ERROR: conf_files_filter_out: Cannot stat directory entry: /etc/modprobe.d/no_intel_sound.conf` by replacing early-boot symlink with a direct regular file (`blacklist snd_hda_intel`).
    - Verified 4x Echo AudioFire 12 FireWire interfaces (`hw:AudioFire12,0` .. `hw:AudioFire12_3,0`), Emagic Unitor8 USB MIDI interface, `jackd`, `zita-a2j`/`zita-j2a` bridges, and autostarting `ardour.service` DAW instance.
  - **SSH Command Tracking Enhancement (`ul`)**:
    - Upgraded `/usr/local/bin/audit-log` helper script to automatically decode hexadecimal kernel byte strings into plaintext and sort rotated log files (`audit.log.*`) in chronological order.
    - Updated [SSH Command Tracking Guide](https://wiki.drheck.dev/books/system-setup/page/ssh-command-tracking-guide) documentation.

- 2026-08-22 Saturday
  - **Sub-GHz RF Studio Power Automation (`pikvm-studio`)**:
    - Integrated Great Scott Gadgets YARD Stick One (`1d50:605b`, CC1111) on `pikvm-studio` to switch 433.92 MHz RF wireless power relays.
    - Reverse-engineered 33-bit Princeton / Fosmon PWM baseband protocol ($T_e = 291.27\text{ }\mu\text{s}$, 3433.22 baud).
    - Identified and resolved critical CC1111 24.0 MHz reference crystal mismatch (recalculated `FREQ = 0x121473` from 26.0 MHz default `0x10B071`, resolving a 33 MHz carrier transmission error).
    - Resolved double sync gap bug and configured 30-frame contiguous burst streaming (630 bytes, 1.468 s) to satisfy superheterodyne receiver AGC settling.
    - Installed `/usr/local/bin/studio-power` CLI tool managing all 7 studio devices and whole-studio batch switching.
    - Built standalone C direct transceiver utilities (`yard_tx.c`, `flipper_replay.c`, `cc1111_rfcat.h`) with `libusb-1.0`.
    - Created, configured, and pushed Gitea repository at [studio_yardstick](https://git.drheck.dev/luke/studio_yardstick).
    - [Documentation](https://wiki.drheck.dev/books/setup-detail/page/studio-power-automation)
  - **PiKVM GPG / Pass Pinentry TUI Fix (`pikvm-studio`)**:
    - Resolved `gpg: public key decryption failed: Screen or window too small` when executing `gpush pass` in subshell pipes.
    - Configured `/home/luke/.gnupg/gpg-agent.conf` with `pinentry-program /usr/bin/pinentry-tty` and `allow-loopback-pinentry`.
    - Configured `export GPG_TTY="$(tty)"` and `gpg-connect-agent updatestartuptty /bye` in `~/.bashrc` on `pikvm-studio`.
  - **48-Channel FireWire Synchronous Audio Stack (`bmo95`)**:
    - Consolidated 4x Echo AudioFire12 interfaces into a sample-accurate 48-channel synchronous recording engine on Linux 6.13 PREEMPT_RT kernel.
    - Established hardware color mapping (`Red` Master `fc79`, `Blue` Slave 1 `9815`, `Green` Slave 2 `8525`, `Yellow` Slave 3 `94e8`) with 75Ω BNC Word Clock daisy-chain wiring.
    - Configured master `jackd` bound to Red with synchronous `zita-a2j` / `zita-j2a` duplex ALSA bridges (locked at $\rho = 1.0000000000$).
    - Conducted 10-minute continuous 1 kHz stress test proving absolute phase synchronization (0.000 ms / 0 samples drift across 28.8M samples).
    - Deployed systemd user services (`jack.service`, `zita-*-{blue,green,yellow}.service`) and canonical port alias mapping (`Red_01..12`, `Blue_01..12`, `Green_01..12`, `Yellow_01..12`).
    - [Documentation](https://wiki.drheck.dev/books/setup-detail/page/48-channel-firewire-audio-stack)
  - **Studio Hardware Documentation ([Setup Detail](https://wiki.drheck.dev/books/setup-detail)) (`ul`)**:
    - Published comprehensive hardware register maps, wiring diagrams, and automation runbooks for the recording studio, audio stack, and RF power systems under the new [Setup Detail](https://wiki.drheck.dev/books/setup-detail) book.
  - **BookStack KaTeX TeX Math & Typography Integration (`ul`)**:
    - Globally enabled **KaTeX 0.16.11** client-side LaTeX math rendering across BookStack via `app-custom-head`.
    - Implemented a CSP `strict-dynamic` compliant nonced JavaScript loader to dynamically load KaTeX assets without violating browser security policies.
    - Published the [Meta: Wiki Capabilities, TeX Math & Typography Reference](https://wiki.drheck.dev/books/wiki/page/meta-wiki-capabilities-tex-math-typography-reference) guide documenting TeX math syntax, Japanese Furigana (`<ruby>`), callout alert banners, and collapsible accordions.
    - Updated the [Agent Guide: Updating BookStack Wiki Pages](https://wiki.drheck.dev/books/system-setup/page/agents-guide-to-wiki) with typography standards, CLI helper workflows, and operational gotchas.
    - Verified automated backup and restore scripts (`/home/luke/git/backups/ul/scripts/{backup,restore}/bookstack`) properly archive and restore database settings and media with `par2` parity.

- 2026-08-20 Thursday
  - pikvm-studio (PiKVM V4 Plus) Configuration & Dotfile Management:
    - 1080p60 Video Pass-Through & Capture Optimization:
      - Imported and applied the `v4plus.no-1920x1200` EDID preset via `kvmd-edidconf` to lock capture to uncropped 1920x1080 @ 60Hz.
      - Enabled `gpu_freq=700` in `/boot/config.txt` for H.264 60fps hardware encoding boost.
      - Configured `h264_boost: true` and `desired_fps: 60` in `/etc/kvmd/override.yaml`.
    - Chezmoi Configuration Repository Setup:
      - Installed and configured `chezmoi` on `pikvm-studio` (`~/.config/chezmoi/chezmoi.yaml`) with `sourceDir: ~/git/pikvm_studio` and `destDir: /`.
      - Established clean 2-stage signed Git history: baseline vanilla OS configuration followed by 1080p60 optimizations.
      - Connected and pushed repository to Gitea (`https://git.drheck.dev/luke/pikvm_studio.git`) via `gpush`.
  - SSH Auth Socket & GPG Agent Fixes:
    - Resolved SSH key retention issue in `/home/luke/bin/auth_socket.sh` by prioritizing existing live agent sockets and testing responsiveness with `ssh-add -l` rather than relying on obsolete PID suffix parsing.
    - Cleaned up stale lockfiles in `~/.gnupg/` on `pikvm-studio` and configured `pinentry-program /usr/bin/pinentry-curses` in `~/.gnupg/gpg-agent.conf`.
    - Successfully committed and pushed updated password store entry (`gitea/token/pikvm_studio`) via `gpush pass`.
  - HeckWiki Documentation & Host Maintenance:
    - Authored and verified HeckWiki documentation pages for [pikvm-studio](https://wiki.drheck.dev/books/system-setup/page/pikvm-studio) and [automation](https://wiki.drheck.dev/books/system-setup/page/automation).
    - Verified LTS kernel update (`kernel-check`), performed planned system reboot on `ul`, and verified all running systemd services and Docker containers via `status_all`.
- 2026-08-19 Wednesday
  - VPS rng Assessment & Infrastructure Monitoring Plan:
    - Inspected VPS `rng` (`rng.drheck.dev`) hardware resources, memory, disk, network, and listening services.
    - Authored and published the [Infrastructure Monitoring & Alerting Plan](https://wiki.drheck.dev/books/monitoring/page/infrastructure-monitoring-alerting-plan) on HeckWiki detailing metrics, alerting strategy, and agent deployment.
  - PiKVM Network Configuration:
    - Verified dual-stack IPv4/IPv6 reachability and latency response (`fping`) for `pikvm-studio`.
    - Added host entry and identity mapping in `~/.ssh/config` for direct SSH connectivity.
  - System Package & Media Maintenance:
    - Upgraded host packages via `paru -Syu` including Java runtime environment updates.
    - Built and installed latest `nethack-git` (5.0.0 release) from AUR.
    - Restarted `plexmediaserver` and monitored service journal logs.
- 2026-08-18 Tuesday
  - ul NVIDIA Driver Upgrade & Container CDI Fix:
    - Diagnosed Immich Outage: Immich server (`immich_server`) and machine learning (`immich_machine_learning`) exited (127) after host NVIDIA driver upgrade (`580.173.02` → `580.178.04`). The OCI runtime failed mounting `/usr/lib/libEGL_nvidia.so.580.173.02` due to obsolete paths in `/etc/cdi/nvidia.yaml`.
    - Regenerated CDI Spec & Restored Services: Ran `nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml` to update the CDI specification for `580.178.04`, restarted the Immich container stack, and verified healthy container status and restored upstream proxying via Nginx (`photos.drheck.dev`).
    - Automated via Pacman Hook: Created `/etc/pacman.d/hooks/nvidia-cdi.hook` with a `PostTransaction` trigger on `nvidia*` and `*nvidia*` packages to automatically regenerate `/etc/cdi/nvidia.yaml` on future `paru`/`pacman` driver updates.
  - Opencode Wiki Skill & Knowledge Integration:
    - Embedded HeckWiki / BookStack management rules and helper workflows into global Opencode instructions (`~/.config/opencode/INSTRUCTIONS.md`).
    - Created dedicated skill `~/.config/opencode/skill/update-wiki/SKILL.md` for seamless automated wiki editing and querying.
  - Funkwhale Album Track Ordering Fix:
    - Diagnosed track numbering and sort discrepancy in Funkwhale where album and artist views displayed tracks out of sequence.
    - Directly updated track `position` attributes and calibrated `creation_date` timestamps in the Funkwhale database via Django management shell (`/app/api/manage.py shell`) for Album 10 so both default chronological sorting and album sequence sorting correctly align.
- 2026-08-17 Monday
  - bmo95 Arch Linux Boot & dmesg Diagnostics & Resolution:
    - Resolved FireWire Audio Crash Loop & Massive dmesg Spam:
      - Diagnosed >50,000 repeating kernel errors (`snd_fireworks fw1.0: oPCR0: plug is already in use`) and FireWire CMP stack trace warnings (`sound/firewire/cmp.c:300` / `cmp.c:193`).
      - Fixed missing ALSA period count in `zita-j2a.service` and `zita-a2j.service` by adding `-n 3` (required by `snd_fireworks` driver) and fixed client naming (`-j Up` / `-j Up_In`).
      - Cleaned up `set-port-aliases.sh` to remove conflicting `pkill -9` invocations and background spawns that were killing services mid-handshake and wedging the FireWire CMP isochronous plug state.
      - Fixed hardware clock synchronization sequence: moved clock configuration into `firewire-ready.service` (`wait-for-firewire.sh`) using dynamic GUID-to-node matching (`0x00148602c583fc79` -> Internal Master, `0x001486a296a59815` -> Word Clock Slave) with a 2-second bus settling delay before starting JACK.
      - Verified full 24-channel capture/playback routing stably running in JACK with zero kernel errors on boot.
    - Udev Rules & System Tuning:
      - Fixed udev substitution syntax in `50-firewire-irq.rules` (piping `pgrep` into `xargs` to avoid invalid `$()` substitution error in udevd).
      - Fixed quote syntax and `power/control` attribute assignment in `50-usb-midi.rules` for low-latency Emagic Unitor8 USB MIDI operation.
      - Configured `WIRELESS_REGDOM="US"` in `/etc/conf.d/wireless-regdom`.
    - Wireless Regulatory Database: Installed `wireless-regdb` package to resolve `Direct firmware load for regulatory.db failed with error -2`.
    - PCIe Correctable AER Timeouts: Added `pci=noaer` to `GRUB_CMDLINE_LINUX_DEFAULT` in `/home/luke/git/audio_config/common/grub` and regenerated GRUB config to eliminate spurious correctable error logging from the ASMedia USB 3.1 controller.
    - ASUS ACPI SuperIO Hardware Monitor Conflict: Added `acpi_enforce_resources=lax` to GRUB kernel parameters to resolve ACPI OpRegion conflict on ASUS PRIME Z270-A motherboard.
- 2026-08-16 Sunday
  - bmo95 Audio Workstation Infrastructure & Low-Latency Tuning:
    - Multi-Profile Audio Config Repo: Established `/home/luke/git/audio_config` git repository tracking audio profiles (`jack-alsa`, `pipewire`), metadata mappings (`filedata.json`), and custom management scripts (`apply-profile`, `consume`).
    - Sound Card & Clock Synchronization: Configured dual Echo AudioFire12 interfaces with forced internal clock on primary card (Node 1 / Bottom card) and Word Clock sync on secondary card (Node 2 / Top card).
    - ALSA + JACK2 Engine Setup: Configured JACK2 server via ALSA backend (48kHz / 512 period / 3 buffer) for low-latency audio processing.
    - Systemd User Service Modularization: Modularized JACK audio services into individual systemd user units (`jack.service`, `firewire-ready.service`, `zita-j2a.service`, `zita-a2j.service`, `a2jmidid.service`, `jack-aliases.service`, `patchage.service`, `ardour.service`).
    - Custom Port Aliases & Metadata: Scripted automatic routing and pretty-name metadata (`Down_01`..`Down_12` and `Up_01`..`Up_12`) for hardware inputs and outputs.
    - Realtime System & Kernel Tuning: Configured GRUB kernel parameter `threadirqs`, udev rules for FireWire IRQ thread scheduling (`50-firewire-irq.rules`), HPET/RTC audio timers, sysctl swappiness, and PAM realtime privileges.
    - Software & Documentation: Enabled Ardour 9 integration (`use-jack-pretty-names`) and added comprehensive architecture and routing documentation (`README.md`).
  - System SSH Audit Logging Setup on bmo95:
    - Installed `audit` package, enabled `auditd`, and configured `/etc/audit/rules.d/execve.rules` with strict `auid >= 1000` human session command tracking.
    - Deployed `/usr/local/bin/audit-log` execution tracking script.
- 2026-08-12 Wednesday
  - BookStack Wiki & Nginx Reverse Proxy SSO Integration:
    - Reverse Proxy Routing: Configured Nginx `/login` redirect behavior.
    - Unauthenticated Homepage Auto-Redirect: Implemented DOMContentLoaded JavaScript trigger in BookStack's `app-custom-head` setting.
    - Ensured unauthenticated visitors accessing `https://wiki.drheck.dev/` are automatically forwarded to Authentik (`/login`), while keeping deep links (e.g. `/books/...`) publicly viewable.
    - this is for convenience, not security. I just don't want to click in "login" if I visit [wiki/](http://wiki/)
- 2026-08-10 Monday
  - Opencode CLI Agent Configuration & Setup:
    - Configured Opencode settings (`opencode.json` / `opencode.jsonc`) and system instructions.
    - Added instruction mandate to comment bash tool command batches.
    - Deployed global Opencode skills under `~/.config/opencode/skill/`.
  - SSH Audit Logging & Command Tracking:
    - Configured Linux `auditd` rules on `ul` for `auid >= 1000` system call executions.
    - Configured OpenBSD process accounting (`acct`) and verbose SSH payload auditing on `danknasty`.
    - Implemented unified `audit-log` CLI helper scripts across both machines.
    - Documented SSH command tracking guide and created `daily-change-list` skill.
- 2026-08-09 Sunday
  - [documenting services](https://wiki.drheck.dev/books/system-setup/page/service-list)
    - in this process saw that kavita permissions were messed up, fixed
    - saw that moebius is like halfway removed but don't know why, leading to the realization that I need to track commands.
      - [SSH Command Tracking](https://wiki.drheck.dev/books/system-setup/page/ssh-command-tracking-guide)
      - now I remember why it was removed, because it sucks. The "web service" is just a viewer for multiple drawers connecting with thier own clients.
    - some updates to the crowdsec blocks. realized it has crowed sourced blocking rules, not sure about implementing them though.
- 2026-08-08 Saturday
  - setting up navidrome, and other audio related software.
  - put albums up on funkwhale
  - power outtage caused IPv6 netblock to change, the IPv6 update scripts still suck. Tried to make them better
  - had AI write a script to pull down missing artwork for Jellyfin
  - noticed collation version mismatch when poking around in posgresql.
  - created a short-link service
    - drheck.dev/$short_link
    - editable at drheck.dev/admin
  1. Check Which Databases Have Collation Version Mismatches
    ```bash
    sudo -u postgres bash -c 'for db in $(psql -At -c "SELECT datname FROM pg_database WHERE datistemplate = false;"); do
      echo "=== DATABASE: $db ==="
      psql -d "$db" -c "SELECT 1;" 2>&1
    done'
    ```
    
  2. Run amcheck to Detect Corrupted B-Tree Indexes
    ```bash
    sudo -u postgres bash -c '
    for db in spacebar immich nextcloud_db anchor authentik postgres gitea nextcloud; do
      echo "=== AMCHECK: $db ==="
      psql -d "$db" -c "
          CREATE EXTENSION IF NOT EXISTS amcheck;
          SELECT c.relname, bt_index_check(c.oid, true)
          FROM pg_index i
          JOIN pg_class c ON c.oid = i.indexrelid
          JOIN pg_namespace n ON n.oid = c.relnamespace
          WHERE c.relkind = '\\\\\\\\''i'\\\\\\\\'' AND i.indisready AND                i.indisvalid AND n.nspname = '\\\\\\\\''public'\\\\\\\\'';" 2>&1
    done'
    ```
    
  3. Reindex Databases and Refresh Collation Versions
    ```bash
    sudo -u postgres bash -c '
    for db in spacebar immich nextcloud_db anchor authentik postgres gitea nextcloud; do
      echo "=== REINDEXING & REFRESHING: $db ==="
      psql -d "$db" -c "REINDEX DATABASE \\\\\\\\"$db\\\\\\\\";"
      psql -d "$db" -c "ALTER DATABASE \\\\\\\\"$db\\\\\\\\" REFRESH COLLATION VERSION;"
    done'
    ```

  4. Verify All Warnings Are Cleared
  ```bash
  sudo -u postgres bash -c '
  for db in $(psql -At -c "SELECT datname FROM pg_database WHERE datistemplate = false;"); do
    echo "=== VERIFY: $db ==="
    psql -d "$db" -c "SELECT 1;" 2>&1
  done'
  ```
  
- 2026-08-05 Wednesday
  - updates to crowdsec
    - parsing more SSH errors
    - generated a table of banned IPs but it's not very interesting. Live world view would be cooler
- 2026-08-04 Tuesday
  - docker updates
    - anchor: 0.13.0 → 0.14.0
    - authentik: 2026.5.5 → 2026.5.6
    - bookstack: Updated to latest image
    - immich: v3.0.3 → v3.1.0
    - infra (valkey): Updated to latest image build
    - kasm: Updated to latest image
    - nextcloud: 34.0.1-apache → 34.0.2-apache
    - penpot: Frontend, backend, exporter, postgres, and redis updated
    - romm: Updated to latest image (5.1.0)
    - vaultwarden: Updated to latest image
    - webrcade: Updated to latest image
    - wick: Updated to latest image
  - installed crowdsec
    - review of publicly accesible service logs (eg: wiki) shows no bot traffic (yet)!
    - review of nginx logs straight from the public IPs shows bot traffic looking for common vulnerabilities, exploits, and staged malware.
    - [documentation](https://wiki.drheck.dev/books/system-setup/page/crowdsec-openbsd-pf-perimeter-security-setup)
- 2026-08-03 Monday
  - pull all new docker images and prepare for update
  - in preparation for more users, ensure unencrypted backups aren't world readable
  - gdrive backup credentials failed again
    - finally cleaned up google cloud project, renamed properly and created two separate subprojects, google sso, and rclone for ul
    - redid the certs for rclone, tested good
  - ran a backup to repare for docker update and all the online backups had no files except the instructions!
  - setting all the backups to root rendered the rclone script useless.
  - Trying all backup files owned by my user with a plan
    - create a dedicated backup user
    - create a new gpg key for backups
    - backup gpg key
    - move rclone credentials to backup user
    - chown all backups to 700 backup user
    - Profit!
- 2026-08-02 Sunday
  - Removed old unused "web shells" like the old ttyd, whatever else. Also warpgate.
  - Made webshell awesome. Picked a single font, created a service to show ANSI art on login
  - made big improvements to ansview
    - first render is completely in memory, remove all jumps and relative and exact positions. This allows the final product to be rendered correctly when not placed at the first screen column.
    - due to this success, added an auto wide mode, if artwork can fit on the screen multiple times horizontally, do it
    - also truncate the artwork if viewing on a smaller screen rather than an awful wrap. This is great for the phone shell.
- 2026-08-01 Saturday
  - Created webshell, and spent a lot time trying to make it customizable per user. This was a failure.
- 2026-07-31 Friday
  - Set up moebius in KASM, generated a [KASM setup guide](https://wiki.drheck.dev/books/system-setup/page/kasm-container-build).
- 2026-07-30 Thursday
  - giving up on warpgate for now, trying ttyd again with step-ca to tie it into OIDC. I really want an authentik OIDC enabled direct web -> shell.
- 2026-07-28 Tuesday
  - got warpgate to stop crashing but I'm not sure how I can use it for a web based terminal.. we'll see
- 2026-07-26 Sunday
  - accidentally blocked the git users with the new firewall rules, allowed it to access the gitea service and we are able to upload again
  - started to set up [warpgate](https://github.com/warp-tech/warpgate) as it looks like it can provide a shell that can login with OIDC
- 2026-07-25 Saturday
  - replaced gemini-cli with opencode, it's way better
  - went through each docker service moving off of the default 1000 UID
  - removed broken web "shells"
    - Made a process to generate short lived ssh certs for web users to get a shell without typing a password but it felt really insecure and I think you could impersonate other users, so I killed it.
- 2026-07-23 Thursday
  - Set nginx to cache large uploads on the ZFS array as / was filling up with video uploaded from immich
  - Set permanent redirects on short names in the browser for convenience, eg: http://photos -> https://photos.drheck.dev/
- 2026-07-22 Wednesday
  - Configured rclone for Google drive and Proton drive. [Added](https://git.drheck.dev/luke/backups/commit/c0d835fc574ddc78ae6b1d9840c42de10dbd45d1) this to the backup scripts. Now we have automated on-line backups.
  - created rclone client config in Google's [cloud console](https://console.cloud.google.com/)
- 2026-07-21 Tuesday
  - transmission would timeout quickly since exposed behind authentik. After some research I modifed the nginx config to start forwarding `$host`, `$remote_addr`, `$proxy_add_x_forwarded_for`, and `$scheme`.
- 2026-07-20 Monday
  - IPv6 netblock was wrong again. This time an IPv6 netblock change was detected then the network went out. This caused the API call to desec.io to fail. We were writing the new IPv6 netblock unconditionally though so no more updates were going to happen. Script is updated [1](https://git.drheck.dev/luke/router_config/commit/5eb5286403f3239676983bd71b6a446003775e69) [2](https://git.drheck.dev/luke/router_config/commit/ec44c1cafef9abff1db9f524d072993b47cbf6da)
  - also added logging to the desec.io call in change 2 ^
- 2026-07-19 Sunday
  - Tested LDAP users
    - could log in but did not have the ability to choose the window environment
    - [LDAP](https://wiki.drheck.dev/books/ldappamssh-ca/page/ldap-sssd-client-enrollment-guide) guide updated to fix this problem
- 2026-07-17 Friday 
    - [LDAP](https://wiki.drheck.dev/books/ldappamssh-ca/page/ldap-sssd-client-enrollment-guide) guide updated to add `systemd` to `shadow` for NSS. This was breaking the graphical boot environment on Mitsuki's laptop.
    - [write-up](https://wiki.drheck.dev/books/system-setup/page/nss-gdm-issue)

- 2026-07-16 Thursday 
    - [router updates](https://git.drheck.dev/luke/router_config/commit/06b21af8156cca40ed5ff8f8848896b894687b8b)
        - Remote IPv6 was not updated in public DNS. The router update script was failing to execute in cron 
            - shebang was /usr/local/bin/bash but
            - cron executed /bin/ksh $script... (just openbsd things!)
            - fixed
        - also router IPv6 [update script updated](https://git.drheck.dev/luke/router_config/src/branch/main/update_dns.sh#L6) to use persistent storage for IPv6 address as a reboot would remove the older address
        - bad firewall rule forwarded all IPv6 traffic arriving on 22 to ul 
            - fixed
- 2026-07-13 Monday 
    - created [ldap enable](https://wiki.drheck.dev/books/ssh-certificate-authority/page/ldap-enable) doc and test on desktop 
        - works!
    - completed SSH [cert setup](https://wiki.drheck.dev/books/ldappamssh-ca/page/ssh-ca-setup) for deskop 
        - works!
    - drafted a process to [auto generate ssh keys and sign them with authentik](https://wiki.drheck.dev/books/ldappamssh-ca/page/auto-generate-signed-ssh-keys)
- 2026-07-12 Sunday 
    - created "admin" specific firefox profile on desktop
    - cleared all old disparate passwords for the old individual service passwords
    - fixed www server for mitsuki
    - checked in [kanji service](https://kanji.drheck.dev) in [gitea](https://git.drheck.dev/luke/kanji)
        - added bounding boxes
        - changed the stroke order color to be more legible and not run into the background
- 2026-07-11 Saturday 
    - created this server change log
    - added "backup_all" script to run each service backup
    - finally [checked in](https://git.drheck.dev/luke/backups) backup/restore scripts
    - separated backup output into current and all previous to simplify disk backup
    - modified nginx config for immich, upload cap 30GB -> 100GB


# NSS + GDM Issue

##### 1. GDM Requests a Dynamic User

When GDM starts up it attempts to launch the graphical login screen. For security isolation GDM requests a session for a systemd dynamic user named `gdm-greeter` from `systemd-logind`.
   * Systemd dynamically allocated a UID (60578 in this case) and registered the username gdm-greeter to it.
   * Logind triggers the creation of the user's slice and runtime directory
     ```
     systemd[1]: Created slice User Slice of UID 60578.
     systemd-logind[426]: New session '1' of user 'gdm-greeter' with class 'greeter' and type 'wayland'.
     ```

##### 2. Systemd Spawns the User Manager and Invokes PAM
  
To run the background GNOME session, systemd attempts to spawn the per-user systemd instance (user@60578.service).

Before launching the service, systemd must authenticate and initialize the user's environment using the PAM  stack configured in `/etc/pam.d/systemd-user` (which inherits `/etc/pam.d/system-auth`).

##### 3. pam_sss.so Gracefully Rejects the Dynamic User

First, the account block in the PAM stack runs pam_sss.so:
   ```
   account    sufficient                  pam_sss.so
   ```
   * pam_sss queries the SSSD daemon: "Do you have a user named gdm-greeter?"
   * SSSD queries your LDAP server and finds nothing. SSSD replies: "No, this user is unknown to me."
   * This generates the log entry:
   ```
   1   (systemd)[508]: pam_sss(systemd-user:account): Access denied for user gdm-greeter: 10 (User not known to the underlying authentication module)
   ```
   * Because pam_sss is configured as sufficient, its failure is non-fatal. PAM ignores this rejection and falls through to the next module in the stack: pam_unix.so.

##### 4. pam_unix.so Spawns unix_chkpwd

To verify the local account, pam_unix.so spawns the setuid helper binary /usr/bin/unix_chkpwd to check the user's password and shadow database info:
```
1 account    required                    pam_unix.so
```

##### 5. The Silent NSS Database Lookup Failure
Inside the C code of /usr/bin/unix_chkpwd, the program makes a standard glibc library call to look up the shadow password entry for the username:

   ```
   1 getspnam("gdm-greeter"); 
   ```
  To resolve this call, glibc reads your `/etc/nsswitch.conf` file to see which databases are registered under the shadow: line. It saw:

   ```
   1 shadow: compat sss
   ```

Glibc began querying the modules in order:

   1. compat (local files): It searched /etc/shadow for an entry starting with gdm-greeter:. Since gdm-greeter is a dynamic user, no such line exists in /etc/shadow. This returns:
  ```
  NSS_STATUS_NOTFOUND.
  ```
   2. sss (SSSD): It asked SSSD if it has a cached shadow record for gdm-greeter. SSSD does not. This returns NSS_STATUS_NOTFOUND.

  Because systemd was missing from that line, glibc reached the end of the databases and stopped. It never queried nss-systemd (which is the only place where GDM's dynamic users are registered).

  As a result, getspnam("gdm-greeter") returned NULL (No such entry).

##### 6. PAM Crashes, Locking Out the Session
   * unix_chkpwd realized it was completely blind to this user and threw an error:
   ```
   unix_chkpwd[509]: could not obtain user info (gdm-greeter)
   ```
   * Because unix_chkpwd failed, pam_unix.so returned a fatal error to PAM.
   * Because pam_unix.so is required, the entire PAM transaction aborted:
   ```
   (systemd)[508]: PAM PAM failed: Authentication service cannot retrieve authentication info
   ```
   * Systemd saw the PAM initialization failure and immediately aborted spawning the user manager:
   ```
   (systemd)[508]: user@60578.service: Failed to set up PAM session: Operation not permitted
   (systemd)[508]: user@60578.service: Failed at step PAM spawning /usr/lib/systemd/systemd: Operation not permitted
   systemd[1]: Failed to start User Manager for UID 60578.
   ```

##### 7. Crash and Lockout

Without a running systemd --user manager for UID 60578, the user-level D-Bus broker (org.freedesktop.systemd1) was never launched.

When the GDM Wayland session tried to initialize gnome-session, it attempted to talk to the systemd user manager over D-Bus. This lookup failed, causing the greeter worker to throw an unhandled assertion and crash (dumping core):
   ```
   gnome-session-i[518]: Failed to start unit gnome-session@gnome.target: GDBus.Error:org.freedesktop.DBus.Error.NameHasNoOwner: Name "org.freedesktop.systemd1" does not exist
   systemd-coredump[527]: Process 518 (gnome-session-i) of user 60578 dumped core.
   ```

GDM attempted to restart this sequence six times, failing every time, until it reached its failure threshold:
   ```
   gdm[455]: Gdm: GdmLocalDisplayFactory: maximum number of display failures reached. Giving up.
   ```

At this point, GDM completely gave up on opening any graphical interfaces on your screen, leaving the system stuck in the console/tty mode.

By adding systemd back to the shadow: line in /etc/nsswitch.conf, the glibc call getspnam("gdm-greeter") was able to query nss-systemd, retrieve the dynamic user's shadow record, pass the PAM stack, start the user manager, and successfully launch the login screen.

# Web-based Shell

# Web based SSH Instant Access

This is a browser-based SSH terminal service (`webshell.drheck.dev`) authenticated via Authentik Single Sign-On (SSO) and short-lived OpenSSH certificates signed by Smallstep (`step-ca`), running under rootless Podman Quadlets (`docker-data`, `UID 1100`).

---

## 1. Architectural Overview

```
[ Browser Client ]
        │
        ▼ (HTTPS / WebSocket)
[ Nginx Reverse Proxy (/etc/nginx/sites-available/webshell) ]
        │
        ├── auth_request ──► [ Authentik Embedded Outpost ] (SSO Auth & Claims)
        │
        ▼ (HTTP / WebSocket + X-Authentik-Username Header)
[ Webshell Rootless Container (ttyd + login-wrapper.sh) on eth77 fd77:...:771d:7681 ]
        │
        ├── Step SSH Certificate Sign ──► [ Step-CA Container on webshell_net (step-ca:9000) ]
        │
        ▼ (SSH with Ephemeral Ed25519 Certificate over IPv6)
[ Host ul OpenSSH Daemon on [fd77:7777:5010:0:1337:f1ac:d00d:337]:22 ]
```

### Key Components
1. **`step-ca`**: Smallstep Certificate Authority issuing 5-minute ephemeral Ed25519 user certificates (`step-ca.container` on `webshell.network`).
2. **`webshell`**: Debian Bookworm container running static `ttyd` 1.7.4, `step` CLI 0.25.0, and `login-wrapper.sh` (`webshell.container` on `webshell.network`).
3. **Authentik**: Provides OIDC integration for `step-ca` and ForwardAuth single sign-on (`webshell.drheck.dev`).
4. **Nginx**: Terminates TLS and proxies HTTP/WebSocket connections while passing authenticated identity headers (`X-Authentik-Username`).
5. **OpenSSH (`sshd`)**: Configured to trust `step-ca` signed user certificates for passwordless OS login on host `ul`.

---

## 2. Network & ULA IPv6 Allocation

Every web-facing service on host `ul` binds to a unique ULA IPv6 address on interface `eth77` (`fd77:7777:5010:0:1337:f1ac:d00d:.../64`).

* **`step-ca` IPv6**: `fd77:7777:5010:0:1337:f1ac:d00d:ca` (Port `9000`, also used locally by `/data/minecraft/ipctl`)
* **`webshell` IPv6**: `fd77:7777:5010:0:1337:f1ac:d00d:771d` (Port `7681`)
* **Private Bridge Network**: `webshell.network` (`webshell_net`, serving `step-ca` container DNS via `aardvark-dns`)
* **Host `ul` ULA IPv6 SSH Target**: `fd77:7777:5010:0:1337:f1ac:d00d:337` (Port `22`)

---

## 3. Step 1: Rootless Podman Quadlet Configuration (`step-ca` & `webshell`)

### A. Quadlet Unit Definitions (`/data/docker/webshell/`)
Both Quadlets are symlinked into `/home/docker-data/.config/containers/systemd/` and managed by `user@1100.service`:

**`/data/docker/webshell/step-ca.container`:**
```ini
[Unit]
Description=Smallstep SSH & X.509 CA (Rootless Podman)
After=network-online.target
Wants=network-online.target

[Container]
Pull=never
ContainerName=step-ca
Image=docker.io/smallstep/step-ca:latest
Network=webshell.network
AddHost=auth.drheck.dev:169.254.1.2
PublishPort=[fd77:7777:5010:0:1337:f1ac:d00d:ca]:9000:9000
EnvironmentFile=/data/docker/webshell/.env
Volume=/data/docker/webshell/step-ca:/home/step:rw

[Service]
Restart=always
RestartSec=5

[Install]
WantedBy=default.target
```

**`/data/docker/webshell/webshell.container`:**
```ini
[Unit]
Description=Webshell Browser SSH Terminal (Rootless Podman)
After=network-online.target step-ca.service
Requires=step-ca.service

[Container]
Pull=never
ContainerName=webshell
Image=localhost/webshell:latest
Network=webshell.network
PublishPort=[fd77:7777:5010:0:1337:f1ac:d00d:771d]:7681:7681
Environment=STEP_CA_URL=https://step-ca:9000
Volume=/data/docker/webshell/login-wrapper.sh:/usr/local/bin/login-wrapper.sh:ro
Volume=/data/docker/webshell/known_hosts:/etc/ssh/ssh_known_hosts:ro
Volume=/etc/passwd:/etc/ul_passwd:ro
Volume=/data/docker/webshell/step-ca/certs/root_ca.crt:/etc/step-ca/root_ca.crt:ro
Volume=/data/docker/webshell/step-ca/secrets/webshell_svc_pwd:/etc/step-ca/webshell_svc_pwd:ro
Volume=/data/docker/webshell/index.html:/etc/ttyd/index.html:ro
Exec=-W -H X-Authentik-Username -t theme={"background":"#000000"} -I /etc/ttyd/index.html /usr/local/bin/login-wrapper.sh

[Service]
Restart=always
RestartSec=5

[Install]
WantedBy=default.target
```

### B. CA Directory Permissions (Rootless User Namespace)
Inside `docker-data`'s rootless user namespace (`docker-data:624288:65536`), the `step` user inside `smallstep/step-ca` maps cleanly to an isolated unprivileged subordinate UID on the host with zero world permissions (`o-rwx`).

---

## 4. Step 2: Host `ul` OpenSSH Daemon Trust & Web Session Banner Setup

To allow `sshd` on host `ul` (`[fd77:7777:5010:0:1337:f1ac:d00d:337]:22`) to accept certificates signed by `step-ca`, enforce strict `known_hosts` checking, and serve a dedicated login banner exclusively for web sessions:

1. Extract the CA user public key:
   ```bash
   (cd /tmp && sudo -u docker-data HOME=/home/docker-data XDG_RUNTIME_DIR=/run/user/1100 podman exec step-ca step certificate inspect --format json /home/step/certs/user_ca_key.pub)
   ```
2. Append the public key (`ecdsa-sha2-nistp256 ...` or `ssh-ed25519 ...`) to `/etc/ssh/drheck_ca_user_key.pub`.
3. Verify that `/etc/ssh/sshd_config.d/10-drheck-certs.conf` contains:
   ```text
   TrustedUserCAKeys /etc/ssh/drheck_ca_user_key.pub
   HostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub

   Match Address fd77:7777:5010:771d::/64
       Banner /etc/ssh/webshell_banner.txt
   ```
4. Web Session Banner & Rotation Script:
   `/etc/profile.d/webshell_banner.sh` executes when a web session connects. It randomly selects an ANSI art file from `/data/nfo/ansi_list.txt` (42,000+ files), renders it via `/usr/local/bin/ansview --wide-dupe` across terminal width, right-aligns the file path, and exports `$LAST_ANSI` for the `/usr/local/bin/bad_ansi` removal command.

   The static fallback banner is stored at `/etc/ssh/webshell_banner.txt`.
5. Generate `/data/docker/webshell/known_hosts` from host `ul`'s public host key:
   ```bash
   echo "fd77:7777:5010:0:1337:f1ac:d00d:337 $(cat /etc/ssh/ssh_host_ed25519_key.pub)" > /data/docker/webshell/known_hosts
   chmod 644 /data/docker/webshell/known_hosts
   ```
6. Reload OpenSSH daemon:
   ```bash
   sudo systemctl reload sshd
   ```

---

## 5. Step 3: Web Terminal Backend (`ttyd` + `login-wrapper.sh`) Setup

### A. Dockerfile (`/data/docker/webshell/Dockerfile`)
<p class="callout warning"><strong>Rootless Podman <code>ttyd</code> Listener Note:</strong> Do <strong>NOT</strong> pass <code>-6 -i ::</code> under rootless Podman. When rootless Podman forwards traffic from host <code>[fd77:...:771d]:7681</code> into <code>webshell.network</code>, the forwarded socket arrives over container IPv4 (<code>10.89.X.X:7681</code>). Omitting <code>-6 -i ::</code> allows <code>ttyd</code> to bind all interfaces inside the container cleanly instead of rejecting IPv4 forwards with <code>502 Bad Gateway</code>.</p>

```dockerfile
FROM debian:bookworm-slim

ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
    ca-certificates curl openssh-client bash procps libnss-sss \
    && sed -i 's/passwd: files/passwd: files sss/' /etc/nsswitch.conf \
    && rm -rf /var/lib/apt/lists/*

# Install static ttyd 1.7.4 & step CLI 0.25.0
RUN curl -sSL https://github.com/tsl0922/ttyd/releases/download/1.7.4/ttyd.x86_64 -o /usr/local/bin/ttyd \
    && chmod +x /usr/local/bin/ttyd \
    && curl -sSL https://dl.step.sm/gh-release/cli/docs-step-cli/v0.25.0/step_linux_0.25.0_amd64.tar.gz | tar -xz -C /tmp \
    && mv /tmp/step_0.25.0/bin/step /usr/local/bin/step \
    && chmod +x /usr/local/bin/step \
    && rm -rf /tmp/step_0.25.0

COPY login-wrapper.sh /usr/local/bin/login-wrapper.sh
RUN chmod +x /usr/local/bin/login-wrapper.sh

EXPOSE 7681
ENTRYPOINT ["/usr/local/bin/ttyd"]
CMD ["-W", "-H", "X-Authentik-Username", "-t", "theme={\"background\": \"#000000\"}", "-I", "/etc/ttyd/index.html", "/usr/local/bin/login-wrapper.sh"]
```

### B. Backend Login Wrapper (`/data/docker/webshell/login-wrapper.sh`)
```bash
#!/usr/bin/env bash
set -euo pipefail

# Resolve username across CGI-style environment variables exported by ttyd/proxy
TARGET_USER="${X_AUTHENTIK_USERNAME:-${TTYD_USER:-${HTTP_X_AUTHENTIK_USERNAME:-${AUTHENTIK_USERNAME:-}}}}"

if [[ -z "${TARGET_USER}" ]]; then
  echo "Authentication Error: Missing Authentik identity." >&2
  exit 1
fi

# Strict alphanumeric regex check
if [[ ! "${TARGET_USER}" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]]; then
  echo "Authentication Error: Invalid username format." >&2
  exit 1
fi

# Retrieve numeric UID from host passwd database
TARGET_UID=$(awk -F: -v u="${TARGET_USER}" '$1 == u {print $3}' /etc/ul_passwd 2>/dev/null || true)
if [[ -z "${TARGET_UID}" ]]; then
  echo "Authentication Error: User does not exist on target host." >&2
  exit 1
fi

# Reject system accounts (UID < 1000), admin, and docker* accounts
if (( TARGET_UID < 1000 )) || [[ "${TARGET_USER}" == "admin" ]] || [[ "${TARGET_USER}" == docker* ]]; then
  echo "Authentication Error: Access to privileged or system account is forbidden." >&2
  exit 1
fi

KEY_DIR="$(mktemp -d -t ssh_XXXXXX)"
KEY_PATH="${KEY_DIR}/id_ed25519"

cleanup() {
  rm -rf "${KEY_DIR}"
}
trap cleanup EXIT INT TERM

# Generate ephemeral Ed25519 keypair silently
ssh-keygen -q -t ed25519 -f "${KEY_PATH}" -N "" >/dev/null 2>&1

# Sign user certificate via JWK provisioner (--sign flag is mandatory for non-interactive execution)
if command -v step >/dev/null 2>&1 && [[ -n "${STEP_CA_URL:-}" ]] && [[ -f "/etc/step-ca/webshell_svc_pwd" ]]; then
  step ssh certificate "${TARGET_USER}" "${KEY_PATH}.pub"     --sign     --provisioner="webshell-svc"     --provisioner-password-file="/etc/step-ca/webshell_svc_pwd"     --ca-url="${STEP_CA_URL}"     --root="/etc/step-ca/root_ca.crt"     --not-after=5m     --force >/dev/null 2>&1 || true
fi

# Login passwordless to host ul SSH daemon over local ULA IPv6 with strict known_hosts checking
exec ssh -o StrictHostKeyChecking=yes          -i "${KEY_PATH}"          "${TARGET_USER}@fd77:7777:5010:0:1337:f1ac:d00d:337" "$@"
```

---

## 6. Step 4: Authentik Single Sign-On Provisioning (`ak shell`)

To configure Authentik ForwardAuth without encountering `500 Internal Server Error`, `Redirect URI Error`, or `ERR_TOO_MANY_REDIRECTS`, execute the following Python provisioning script in `authentik-server`:

```bash
(cd /tmp && sudo -u docker-data HOME=/home/docker-data XDG_RUNTIME_DIR=/run/user/1100 podman exec -i authentik-server ak shell) << 'EOF'
from authentik.core.models import Application
from authentik.providers.proxy.models import ProxyProvider, ProxyMode
from authentik.providers.oauth2.models import RedirectURI, RedirectURIMatchingMode, RedirectURIType
from authentik.outposts.models import Outpost

SERVICE_NAME = "webshell"
EXTERNAL_HOST = f"https://{SERVICE_NAME}.drheck.dev"

# 1. Inherit default scope property mappings from a working provider template
template_provider = ProxyProvider.objects.get(name="cockpit")

# 2. Create ProxyProvider with complete OAuth, redirect URI, and cookie settings
provider, _ = ProxyProvider.objects.get_or_create(
    name=SERVICE_NAME,
    defaults={
        "external_host": EXTERNAL_HOST,
        "mode": ProxyMode.FORWARD_SINGLE,
        "authorization_flow_id": "f9ad5326-f668-4d1f-861d-43b47f0a002e",
        "cookie_domain": "drheck.dev",
        "grant_types": ["authorization_code", "client_credentials", "password"],
        "redirect_uris": [
            RedirectURI(
                matching_mode=RedirectURIMatchingMode.STRICT,
                url=f"{EXTERNAL_HOST}/outpost.goauthentik.io/callback?X-authentik-auth-callback=true",
                redirect_uri_type=RedirectURIType.AUTHORIZATION
            ),
            RedirectURI(
                matching_mode=RedirectURIMatchingMode.STRICT,
                url=f"{EXTERNAL_HOST}?X-authentik-auth-callback=true",
                redirect_uri_type=RedirectURIType.AUTHORIZATION
            ),
        ]
    }
)

provider.property_mappings.set(template_provider.property_mappings.all())
provider.save()

# 3. Create Application
app, _ = Application.objects.get_or_create(
    slug=SERVICE_NAME,
    defaults={
        "name": SERVICE_NAME.capitalize(),
        "meta_launch_url": f"{EXTERNAL_HOST}/",
        "open_in_new_tab": True,
        "provider": provider,
    }
)

# 4. Mandatory: Assign provider to authentik Embedded Outpost
outpost = Outpost.objects.get(name="authentik Embedded Outpost")
if provider not in outpost.providers.all():
    outpost.providers.add(provider)
    outpost.save()

print(f"Successfully provisioned {SERVICE_NAME} and attached to Embedded Outpost.")
EOF
```

---

## 7. Step 5: Nginx Reverse Proxy Configuration (`/etc/nginx/sites-available/webshell`)

```nginx
map $http_upgrade $proxy_target_connection_webshell {
    default upgrade;
    ""      "";
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name webshell.drheck.dev;

    ssl_certificate     /etc/nginx/certs/fullchain.pem;
    ssl_certificate_key /etc/nginx/certs/privkey.pem;

    client_max_body_size 64M;

    # Authentik ForwardAuth Subrequest Configuration
    auth_request     /outpost.goauthentik.io/auth/nginx;
    error_page       401 = @goauthentik_proxy_signin;
    auth_request_set $auth_cookie $upstream_http_set_cookie;
    add_header       Set-Cookie $auth_cookie;
    auth_request_set $authentik_username $upstream_http_x_authentik_username;
    auth_request_set $authentik_email $upstream_http_x_authentik_email;

    # Per-User Custom Fonts Route
    location /user-fonts/ {
        auth_request     /outpost.goauthentik.io/auth/nginx;
        auth_request_set $authentik_username $upstream_http_x_authentik_username;
        alias /home/$authentik_username/.config/webshell/fonts/;
        types {
            font/woff2 woff2;
            font/ttf ttf;
            font/otf otf;
        }
        add_header Cache-Control "public, max-age=86400";
    }

    location / {
        proxy_pass http://[fd77:7777:5010:0:1337:f1ac:d00d:771d]:7681;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $proxy_target_connection_webshell;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Authentik-Username $authentik_username;
        proxy_set_header X-Authentik-Email $authentik_email;
    }

    location /outpost.goauthentik.io {
        proxy_pass              https://auth.drheck.dev/outpost.goauthentik.io;
        proxy_set_header        Host auth.drheck.dev;
        proxy_set_header        X-Original-URL $scheme://$http_host$request_uri;
        proxy_set_header        X-Forwarded-Host $http_host;
        proxy_set_header        X-Forwarded-Proto $scheme;
        add_header              Set-Cookie $auth_cookie;
        auth_request_set        $auth_cookie $upstream_http_set_cookie;
        proxy_pass_request_body off;
        proxy_set_header        Content-Length "";
    }

    location @goauthentik_proxy_signin {
        internal;
        add_header Set-Cookie $auth_cookie;
        return 302 /outpost.goauthentik.io/start?rd=$scheme://$http_host$request_uri;
    }
}
```

---

## 8. Step 6: Backup & Restore Scripts

Every service on host `ul` uses `shellcheck`-verified backup and restore scripts located in `/home/luke/git/backups/ul/scripts/` with 10% `par2` redundancy:

* `/home/luke/git/backups/ul/scripts/backup/webshell`: Archives `/data/docker/webshell/` (`.env`, `step-ca.container`, `webshell.container`, `webshell.network`, `step-ca/`) to `webshell_data.tar.xz`, and system configuration/scripts (`/etc/ssh/webshell_banner.txt`, `/etc/ssh/drheck_ca_user_key.pub`, `/etc/ssh/sshd_config.d/10-drheck-certs.conf`, `/etc/nginx/sites-available/webshell`, `/etc/profile.d/webshell_banner.sh`, `/usr/local/bin/bad_ansi`, `/data/nfo/ansi_list.txt`) to `webshell_system_config.tar.xz`, generating `.par2` recovery files via `par2 create -r10`.
* `/home/luke/git/backups/ul/scripts/restore/webshell`: Verifies archives with `par2 verify`, restores configurations into `/data/docker/webshell/` and `/`, and restarts `step-ca.service` and `webshell.service`.

---

## 9. Initial Assumptions & Attempts vs. Actual Working Configuration

| Technical Area | Initial Assumption / Attempt | Actual Working Configuration & Root Cause | Prevention Standard |
| :--- | :--- | :--- | :--- |
| **Outpost Assignment** (`500 Internal Server Error`) | Creating `ProxyProvider` in Authentik automatically activates ForwardAuth routing. | Creating a provider does not assign it to an Outpost. Unassigned hostnames return `404 Not Found` from `/outpost.goauthentik.io/auth/nginx`, which Nginx converts to `500 Internal Server Error`. | Explicitly attach providers to `authentik Embedded Outpost` (`outpost.providers.add(provider)`) before enabling Nginx traffic. |
| **Redirect URIs** (`Redirect URI Error`) | Instantiating `ProxyProvider` sets default authorization callback URLs. | The Django ORM defaults `redirect_uris` to `[]`. Authentik rejects callback URIs against an empty database list. | Explicitly define `RedirectURI` objects for both `/outpost.goauthentik.io/callback` and root `/` in `ProxyProvider.redirect_uris`. |
| **Cookie Domain & Scopes** (`ERR_TOO_MANY_REDIRECTS`) | Providers operate without defining `cookie_domain`, `grant_types`, or `property_mappings`. | Without `property_mappings`, the OAuth request omits critical scopes (`ak_proxy`). Without explicit `cookie_domain='drheck.dev'`, Authentik refuses to grant ForwardAuth proxy tokens, causing an endless redirect loop. | Clone `property_mappings` from `cockpit` and explicitly set `cookie_domain='drheck.dev'` and `grant_types`. |
| **CGI Env Mapping** (`Missing Authentik identity`) | `ttyd -H X-Authentik-Username` exports `X_AUTHENTIK_USERNAME` directly to child scripts. | `ttyd` transforms HTTP headers into standard CGI environment variables (`HTTP_X_AUTHENTIK_USERNAME`). | Always check CGI-style fallback variations: `TARGET_USER="${X_AUTHENTIK_USERNAME:-${TTYD_USER:-${HTTP_X_AUTHENTIK_USERNAME:-${AUTHENTIK_USERNAME:-}}}}"`. |
| **Rootless Port Forwarding vs. `-6 -i ::`** (`502 Bad Gateway`) | Keeping legacy Docker `"-6", "-i", "::"` flags works identically in rootless Podman. | Under rootless Podman, `PublishPort=[fd77:...:771d]:7681:7681` forwards traffic into `webshell_net` over IPv4 (`10.89.X.X:7681`). Passing `-6 -i ::` restricts `ttyd` to IPv6-only and rejects forwarded connections. | Omit `-6 -i ::` in `webshell.container` `Exec=` so `ttyd` listens on all interfaces inside the container namespace. |
| **Container Script Updates** (`old script execution`) | Modifying `login-wrapper.sh` on host `ul` and restarting runs the updated script only if mounted. | Restarting only re-reads bind-mounted files, not `COPY`ed image layers. | Bind-mount operational scripts (`Volume=/data/docker/webshell/login-wrapper.sh:/usr/local/bin/login-wrapper.sh:ro`) in `webshell.container`. |
| **CA Secret Privilege Restrictions** | Administrative or personal user accounts own the CA keys and JWK provisioner secret. | Standard unprivileged or administrative (`wheel`) user accounts should not have read access to CA private keys or provisioner passwords. | Isolate `/data/docker/webshell/step-ca` inside `docker-data`'s rootless subordinate UID namespace with `o-rwx` permissions. |
| **SSH Client Host Warnings** (`Warning: Permanently added...`) | Invoking `ssh` with `/dev/null` as known_hosts prints key addition warnings on every login. | Connecting without a populated known_hosts file causes OpenSSH to log a warning to stderr whenever an ephemeral host entry is added. | Mount a static `/etc/ssh/ssh_known_hosts` file generated from `/etc/ssh/ssh_host_ed25519_key.pub` and enforce `StrictHostKeyChecking=yes`. |
| **Cockpit Login MOTD Banner** (`Web console: https://ul...:9090`) | Cockpit's MOTD issue updater (`update-issue`) injects `:9090` URLs into `/etc/motd.d/`. | Cockpit's `cockpit-issue.service` inspects local interfaces and writes direct port URLs to `/run/cockpit/issue`, which `/etc/motd.d/cockpit` symlinks into login greetings. | Remove `/etc/motd.d/cockpit` and `/etc/issue.d/cockpit.issue` symlinks and mask `cockpit-issue.service` (`systemctl mask cockpit-issue.service`). |
| **Dynamic Host UID Verification** | Hardcoding system username lists (`root|admin|...`) in wrapper scripts to block system accounts. | Hardcoded string checks are fragile and fail to account for newly created local or system accounts. | Mount host `/etc/passwd` as `/etc/ul_passwd:ro` and dynamically validate that `TARGET_UID >= 1000` via `awk`. |
| **Web-Session-Only Login Banner** | Printing greetings via script `echo` or editing server-wide `/etc/motd`. | Script `echo` output is lost before terminal attachment, while `/etc/motd` changes affect standard SSH logins. | Configure `Match Address fd77:7777:5010:771d::/64` in `/etc/ssh/sshd_config.d/10-drheck-certs.conf` with `Banner /etc/ssh/webshell_banner.txt`. |


# Customization

# Webshell Configuration Guide

Per-user web terminal preferences are stored in `~/.config/webshell/`.

---

## Directory Structure

```text
~/.config/webshell/
├── config.json      # Theme, font size, and cursor settings
└── fonts/           # Dir for local fonts
    └── font.woff2   # (Optional) Local custom font file (.woff2, .ttf, or .otf)
```

---

## `config.json` Options

| Key | Type | Default | Description |
| :--- | :--- | :--- | :--- |
| `fontFamily` | String | `'JetBrains Mono', monospace` | Font family stack |
| `fontSize` | Number | `15` | Font size in pixels |
| `cursorStyle` | String | `"block"` | Cursor shape (`"block"`, `"underline"`, or `"bar"`) |
| `cursorBlink` | Boolean | `true` | Enable or disable cursor blinking |
| `theme` | Object | *See below* | Color palette overrides |

---

## Theme Options (`theme` object)

| Key | Description |
| :--- | :--- |
| `background` | Terminal background color (e.g. `"#000000"`) |
| `foreground` | Default text color (e.g. `"#e0e0e0"`) |
| `cursor` | Cursor color (e.g. `"#ffffff"`) |
| `black` – `white` | Standard 8 ANSI colors |
| `brightBlack` – `brightWhite` | High-intensity 8 ANSI colors |

---

## Example `~/.config/webshell/config.json`

```json
{
  "fontFamily": "'JetBrains Mono', 'Fira Code', monospace",
  "fontSize": 15,
  "cursorStyle": "block",
  "cursorBlink": true,
  "theme": {
    "background": "#000000",
    "foreground": "#e0e0e0",
    "cursor": "#00ff00",
    "green": "#00ff00",
    "cyan": "#00ffff"
  }
}
```

------

# Using Local Font Files

## Location

Place your font file in `~/.config/webshell/` as one of the following:

* `font.woff2` *(Create your own if needed! See below)*
* `font.ttf`
* `font.otf`

The webshell will automatically encode and load your font file into the web browser upon login.

## Determining Font Family Name from a Font File

fc-scan will return the font family name for you. It is possible to return multiple aliases so you can use the first one to simplify the process.

```bash
$ fc-scan GoogleSansCodeNerdFontMono-SemiBoldItalic.ttf --format "%{family}\n" | cut -d, -f1
GoogleSansCode Nerd Font Mono
```

## Use woff2 files instead of ttf or otf

These are normal font files, but compressed so it will be nicer for web use:

```shell
# one at a time
for f in *.ttf *.otf; do woff2_compress "$f"; done
# use all those cores!
parallel woff2_compress ::: *.[ot]tf
```

On 181 of my fonts the size dropped from 381M to 220M.

# KASM Container Build

<p class="callout warning"><strong>Archived / Decommissioned (2026-10-19):</strong> Kasm Workspaces on <code>ul</code> has been decommissioned. This page is retained as an archival reference for container packaging and KasmVNC configuration.</p>

This guide covers the technical workflow for packaging desktop GUI applications into custom Docker images for Kasm Workspaces (version **1.19.0+**), registering them in the database and web interface, configuring advanced features, and understanding display server architecture (X11 vs. Wayland).

---

## 1. Selecting a Base Image

Kasm Workspaces runs applications inside containerized desktop environments streamed via KasmVNC. Always extend an official Kasm 1.19 base image to ensure compatibility with session management, audio forwarding, clipboard synchronization, and dynamic resizing:

| Base Image | Base OS | Desktop Environment | Recommended Use Case |
| :--- | :--- | :--- | :--- |
| `kasmweb/terminal:1.19.0-rolling-weekly` | Ubuntu 24.04 (Noble) | XFCE4 + Terminal | GUI utilities, editors, lightweight tools |
| `kasmweb/core-ubuntu-noble:1.19.0` | Ubuntu 24.04 (Noble) | Minimal XFCE4 Core | Clean custom application desktop builds |
| `kasmweb/core-debian-bookworm:1.19.0` | Debian 12 (Bookworm) | Minimal XFCE4 Core | Debian-focused software packaging |
| `kasmweb/desktop:1.19.0-rolling-weekly` | Ubuntu 24.04 (Noble) | Full XFCE4 + Apps | Full desktop environments |

---

## 2. Dockerfile Packaging Workflow

### Key Packaging Rules
1. **User Isolation**: Official Kasm base images run under non-root user `1000:1000` (`kasm-user`). Switch to `USER root` for package installation and explicitly switch back to `USER 1000` at the end of the Dockerfile.
2. **Ubuntu 24.04 Package Renaming (`libasound2t64`)**: In Ubuntu 24.04 (Noble), the 64-bit `time_t` transition renamed ALSA runtime packages (e.g. `libasound2` $\rightarrow$ `libasound2t64`). Use `libasound2t64` (or `libasound2*`) to prevent dependency resolution issues on 24.04 bases.
3. **Autostart Configuration**: To launch an application automatically when the workspace opens, copy an XFCE `.desktop` shortcut into `/home/kasm-user/.config/autostart/`.
4. **Custom Startup Scripts**: For applications requiring pre-launch environment variables or wrapper logic, place a script in `/dockerstartup/custom_startup.sh`.

### Example Dockerfile (`kasmweb/moebius:latest`)
```dockerfile
FROM kasmweb/terminal:1.19.0-rolling-weekly

USER root

# Install required X11, GTK3, and audio runtime dependencies (Ubuntu 24.04 Noble)
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
        wget curl libnotify4 libnss3 libxss1 libxtst6 xdg-utils \
        libsecret-1-0 libgbm1 libasound2t64 && \
    wget -q -O /tmp/Moebius.deb https://github.com/blocktronics/moebius/releases/latest/download/Moebius.deb && \
    apt-get install -y /tmp/Moebius.deb && \
    rm -rf /var/lib/apt/lists/* /tmp/Moebius.deb && \
    mkdir -p /home/kasm-user/Desktop /home/kasm-user/.config/autostart && \
    cp /usr/share/applications/moebius.desktop /home/kasm-user/Desktop/moebius.desktop && \
    cp /usr/share/applications/moebius.desktop /home/kasm-user/.config/autostart/moebius.desktop && \
    chmod +x /home/kasm-user/Desktop/moebius.desktop && \
    chown -R 1000:1000 /home/kasm-user/Desktop /home/kasm-user/.config

USER 1000
```

### Building inside Standalone Kasm Containers
When running standalone Kasm Workspaces (`lscr.io/linuxserver/kasm`), Docker images must be built inside the Kasm container engine (`kasm`):
```bash
sudo docker exec -i kasm docker build -t kasmweb/moebius:latest - < Dockerfile
```

---

## 3. Registering the Workspace in the Web Interface

### Automated Database Registration (`kasm_db`)
In standalone Kasm deployments, workspaces can be registered directly in the PostgreSQL database (`kasm_db`). Each entry in the `images` table defines the container parameters, and `group_images` grants access to user groups.

> [!IMPORTANT]
> Unused foreign-key UUID columns (`session_banner_id`, `server_id`, `zone_id`, `filter_policy_id`, `server_pool_id`) must be explicitly set to `NULL`. Empty strings (`''`) cause PostgreSQL UUID syntax errors.

```sql
-- 1. Insert Workspace definition into images table
INSERT INTO images (
    image_id, cores, description, docker_registry, docker_token, docker_user,
    image_src, enabled, available, friendly_name, hash, memory_bytes, name,
    session_banner_id, session_banner_force_disabled, x_res, y_res, run_config,
    volume_mappings, persistent_profile_path, restrict_to_network, restrict_network_names,
    allow_network_selection, restrict_to_server, server_id, exec_config,
    restrict_to_zone, zone_id, filter_policy_id, filter_policy_force_disabled,
    categories, require_gpu, gpu_count, gpu_graphics_acceleration, gpu_video_encoding,
    hidden, notes, override_egress_gateways, image_type, server_pool_id, link_url,
    cpu_allocation_method, uncompressed_size_bytes, launch_config, rdp_client_type,
    is_remote_app, remote_app_icon, remote_app_name, remote_app_program, remote_app_args,
    created_at, updated_at, enforce_workspace_persistence
) VALUES (
    'a1111111-60eb-4000-a000-0000000060eb', 2,
    'Moebius ANSI and ASCII Art Editor full desktop client.',
    'https://index.docker.io/v1/', NULL, NULL,
    'img/thumbnails/terminal.png', true, true, 'Moebius', NULL, 2902458368,
    'kasmweb/moebius:latest', NULL, false, 1280, 720, '{"hostname": "moebius"}',
    '{}', '/data/kasm_profiles/{username}/', false, '[]', false, false, NULL, '{}',
    false, NULL, NULL, false, '["Design"]', false, 0, '["MESA"]', '["SW"]',
    false, NULL, false, 'Container', NULL, NULL, 'Inherit', 1500000000, '{}', NULL,
    false, NULL, NULL, NULL, NULL, NOW(), NOW(), true
) ON CONFLICT (image_id) DO NOTHING;

-- 2. Assign image to default All Users group (68d557ac-4cac-42cc-a9f3-1c7c853de0f3)
INSERT INTO group_images (group_image_id, group_id, image_id)
VALUES (
    'a2222222-60eb-4000-a000-0000000060eb',
    '68d557ac-4cac-42cc-a9f3-1c7c853de0f3',
    'a1111111-60eb-4000-a000-0000000060eb'
) ON CONFLICT (group_image_id) DO NOTHING;
```

### Manual UI Registration
1. Navigate to **Kasm Admin Panel** -> **Workspaces** -> **Add Workspace**.
2. **Workspace Type**: `Container`
3. **Friendly Name**: `Moebius`
4. **Docker Image**: `kasmweb/moebius:latest`
5. **Cores / Memory**: `2` / `2768` (MB)
6. **Persistent Profile Path**: `/data/kasm_profiles/{username}/`
7. Click **Save**.

---

## 4. Advanced Configuration Options

When packaging complex software, evaluate these key operational options:

### 1. Persistent Profiles (`persistent_profile_path`)
* **Purpose**: Retains user files, application settings, and browser profiles across session restarts.
* **Configuration**: Set `persistent_profile_path` to `/data/kasm_profiles/{username}/`.
* **Behavior**: When a session launches, Kasm bind-mounts the user's persistent directory to `/home/kasm-user/`. Set `enforce_workspace_persistence = true` to prevent unmanaged ephemeral sessions.

### 2. GPU Hardware Acceleration (AMD, Intel & NVIDIA)
* **Purpose**: Offloads WebGL, 3D rendering, compute, and video stream encoding to host GPUs.
* **Configuration for AMD & Intel GPUs**:
  * Pass `/dev/dri` and `/dev/kfd` device nodes in `run_config`:
    ```json
    {
      "devices": [
        "/dev/dri:/dev/dri",
        "/dev/kfd:/dev/kfd"
      ],
      "group_add": ["render", "video"]
    }
    ```
  * **Graphics Acceleration**: Set `gpu_graphics_acceleration: ["MESA"]` (RADV Vulkan / OpenGL).
  * **Hardware Video Encoding**: Set `gpu_video_encoding: ["VAAPI", "SW"]` for hardware H.264/HEVC/AV1 encoding.
  * Set `require_gpu = true` and `gpu_count = 1`.
* **Configuration for NVIDIA GPUs**:
  * Ensure NVIDIA Container Toolkit is enabled on the host.
  * Set `gpu_graphics_acceleration: ["NVIDIA"]` and `gpu_video_encoding: ["NVENC", "SW"]`.

### 3. Bidirectional Audio & Microphone Forwarding
* **Purpose**: Streams 48 kHz stereo audio output and enables low-latency WebRTC microphone input.
* **Configuration**:
  * Kasm base images include ALSA and PulseAudio (`pulseaudio -D`) bridged to WebRTC / WebSocket audio codecs. Ensure packages install `libasound2t64` and `pulseaudio-utils`.
  * Enable microphone pass-through in `launch_config` if recording or voice chat is required:
    ```json
    {
      "allow_microphone": true
    }
    ```

### 4. Gamepad & Controller Passthrough
* **Purpose**: Passes browser HTML5 Gamepad API inputs directly into container `/dev/input/js*` virtual joystick devices.
* **Configuration**: In `launch_config`, enable gamepad forwarding:
  ```json
  {
    "allow_gamepad": true
  }
  ```

### 5. Network Isolation & Security (`restrict_to_network`)
* **Purpose**: Restricts container egress or connects workspaces to internal backend VLANs/docker networks.
* **Configuration**: Set `restrict_to_network = true` and specify target Docker network names in `restrict_network_names`:
  ```json
  ["infra_net", "isolated_workspaces"]
  ```

---

## 5. Display Server Architecture: X11 vs. Wayland

### 1. Current KasmVNC Standard (X11)
* **Architecture**: Official Kasm Workspaces desktop containers (`kasmweb/desktop`, `kasmweb/terminal`) operate on the **X11 display server protocol** using `Xvfb` / `Xorg` virtual framebuffers on display `:1`.
* **Why X11 is Default**:
  * **Input Injection**: Keyboard and mouse event simulation across web browsers maps reliably to X11 input extensions (`libxtst6`).
  * **Dynamic Resolution Resizing**: Automatic desktop resizing when browser windows resize relies on `xrandr` / X11 Resize and Rotate extensions.
  * **Clipboard Synchronization**: Web-to-desktop clipboard sharing uses standard X11 selection buffers (`PRIMARY` and `CLIPBOARD`).

### 2. Wayland & Xwayland Support
* **Can Kasm run Wayland?**: Yes. Modern KasmVNC releases support Wayland compositors (such as Sway or Weston) via **Xwayland** and virtual DRM/KMS backend framebuffers.
* **Limitations in Wayland Mode**:
  * **Clipboard & Capture**: Pure Wayland restricts client-to-client screen capture and clipboard access by design. Applications running natively under Wayland require `xdg-desktop-portal-wlr` or compositor-specific protocols to share clipboards with KasmVNC.
  * **Application Compatibility**: Most packaged Linux GUI applications (Electron, GTK3, Qt5) run under X11 or Xwayland by default. For production packaging in Kasm Workspaces, **X11 remains the recommended display server** unless testing specialized Wayland-only compositors.

# GPG Keys

### Current

#### Luke

https://keys.openpgp.org/search?q=lukeisgreat@gmail.com

```
pub   rsa4096 2019-11-30 [SC]
      9F9AC4567EDC8E2B86E5CB9475D6600BEF4E8E8F
uid   Luke Tidd <lukeisgreat@gmail.com>
sub   rsa4096 2019-11-30 [E]
```


```
-----BEGIN PGP PUBLIC KEY BLOCK-----
Comment: 9F9A C456 7EDC 8E2B 86E5  CB94 75D6 600B EF4E 8E8F
Comment: Luke Tidd <lukeisgreat@gmail.com>

xsFNBF3ifkcBEADU/riY8J3FRkZ+2qVvMQPHiwqEmZYCeSEtCsFxuXamEfRMhPEO
bD2B6DXsHWXYw4g14Q0TF0A5OezSIXUt3au0d9AiSSFJtlXdUCAber5CGfQT44IU
UgjBSww3FPTp9QJ7t7eUbNluablI4bt82piFJEgc0619bXmUMjZOyQps7xeFdRD0
FKNnSs7rgNgvZdTHwOvoqy0JKky/YKLgTU4jRieuVNT1mdR+IxfzbOzS7mJxa1Ur
RX/FbrDp6+INMPFWoHOJaspc5jAcTtUAkjREyZmgn/Ki/KAe8A6agFseK7gKX3SS
XIPhAgHfRbS9xr9vG5+QOlsksKqlEzGOxjgRGtloXBkk4H2oOJNgQfGccKl4ADel
PZ3tXJGZlphZ/86kHFt1XNgr+/wsFpE5XCMiHiFo63t6jGEIQr2QgfJNZSTv1Lrr
B1gt8lakaTHEOAdJafb0VAGFQPxNiiGYP6bfh9dXeKWOg0sdyBWCS0hHv29neEal
rwrYTLNeEVDSxbMgw+dG53CKWZTkvRuPgGK+hcjPXOmByqFJzUJSHCHwpH073ret
UgP8SGpkcC+7Sv3HP/qBe4UNAXrQnSXi2oPdHejaLXryCxEmLx+1mycGTkhkfXQ2
XFZsLWm8FSbXsnzELFPsB4gxZqOlL1k6zEk5gyW/KH+IkFulf/pRf9QQgQARAQAB
zSFMdWtlIFRpZGQgPGx1a2Vpc2dyZWF0QGdtYWlsLmNvbT7CwZQEEwEIAD4CGwMF
CwkIBwIGFQoJCAsCBBYCAwECHgECF4AWIQSfmsRWftyOK4bly5R11mAL706OjwUC
aUVLcwUJDUQArAAKCRB11mAL706OjyQyD/9jlYzHboeOyabPqkscgGunDhBnNjrA
uWSd2tyhP3/vRtv3vY8Vzp0VNdxXDD16oKmCZgj85qmpCl4ZhctZ1enyhDJKKeK/
JdwrdvZIL1atNufkzl3mko5wSISRK0dnDfOWhTOtsDFScIsQyBCOe41eU0eg3Jp/
2SlEu3kQC6WIUXk5CeziHlPCMfoK3GS/i7caKwixelE2Ju35yDzTnrBIhN3Zykpj
Fy/qn5M7TDlZZU+JzV6kKu7Q3rTnWkDJMcwv7a20P0chhZnonxKN/p5ktlgYMcWn
KXkt9lGv3Kssu5CIAO9qIRTZMck0j2gxI1QCcFtRE+ZwPV2ToX12YunQAWbAhwEP
scfPOYb08cNPCoZy7xvrRsY+hlxx47sc9iHvxHdqzf6B36QDyzEoJ6hSgHQYXiV5
A0dSLFDwJWxx4VfzYO9UDaKm6FwT0ocD/eZ+ZfPS4dcae0ftFBnj/S+Y7pxW3skU
TywRZKsu+XQakL4chGtOi42Ii8sQMTWc50Qfc82ynnXDqlnD8s+VUcUmCYBhN4J+
QFuzIS4Dk7m8Zp6gSM8Vbr5ZYAsLkSm4ekAZcus8RBxZhODWo4l18G4EWxmLlJyz
H/I5ztbnuJlUT20ZbKBjth8xT/X8SHJQPqtZ1sT1iPZGuoqEZpU81pIfLAi8Bey+
5eon0j1/mFKnu8LBlAQTAQgAPgIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgBYh
BJ+axFZ+3I4rhuXLlHXWYAvvTo6PBQJe4kecBQkC4PzVAAoJEHXWYAvvTo6PnvwP
/3jg9RcHaDxnTgi/J8HaXE6AtLgZ05Qku+TW/nYE+gyL3/EuHk8TfwyqiuBv85oa
oMHR9b3IP3fUZ5cdWVi/npEMm5/Tfv19DzYzVw84cFxGBDO2GnwshW9HnhBn56UG
YDJ1J/8u9VutkC33JirAqd1DCAWn1KZOV6M9AFxvF5lSPXwMEXRiQr9A8rVjR8oc
6mTQKMluW6D918F6v+4MqtB7eamsMvym2ixNnyW3SuqPrvevEZRPmOv61g3e/vS8
00tBlcjAveE85rTgjZKdyXkUTD8FhPzqXW6pHx6AzhiqSS4FLwk9yLtMjC85RHvQ
Texoc5WS9SwVPXhKpEc4QXIS66L5jf4mJNqdyUAeAqrbbRqcCQsSlMe3SU+9CMS0
coa4piedhO6wirFnH8sWxN2CQVCRIwrZqZDlNBEaNQinyBWmr0c+VN0EQwdv4p2c
ImYpb1GhUE/FsuW2TC6EJppITz3ipTBvD1MYBdpho1TzizAQympMKmTJMrdup2FM
zfVTs7q8pXDQIQCwZjYismLUIhRVLp0YJldm/uTg/UhN/R7JrQ9bmbAKjcfXcXIw
1t4ORsApEO+GkAk6YTO759CqrZw0Cg5OoaVZprg83yT+77Y9GE+OEkpzbj8n8bLo
C81vgMzDkMvkk/n2klm4O+lKCd3QyK00sRhSShmIqtv9zsFNBF3ifkcBEADNeG+l
45CuegVep4ti1q6J3AAj1kDeZfNYF5IMCNmgC2mLoxQzxGUd2aPdr1zOFqo2B2TP
m/FvXooZMa4Iocue+/BIpxUqJNGqCEv6B3/EzUQzomOSiy8yxm6fvMpBhS32+KKq
AdhRyq32dUBdEwy20+7EcYi2sFGdJXaqf0NAM5VXp1UHx/4Q0SCdezswi8WU1a03
cDYfAc/GF1ZPucYjCa9yB3stbBTHc3VBgcbcWmpRB2Lv063e3NswLyqrKMWBxYZq
HK+h/deYMRh3fmmL4OeUmzT2e1CvJxBEl9OsTo1Q1NAnByRhee+3AwLwLsA3a4jz
NDz4igGhLVFS0yEWc7xQtYePfQi/0uXLezujAxrNT7ugAKyPzxtIG3Qto2O0Q+zP
5BzOFZ8XyCuRCPojd2pN6u2/Z2dQpUtwH+fbQ8c5Xui356asqBhpBCjH/nV360bd
WgKNpmgnSbIGkqoUr2LjXZFA2Qt+N+HvUFl+zEm11OwFuuXvu6waaSiCkkevuA+E
Ec/hsVujUkj437vF2S3hQsNcmuHtfEg4ld/d1Be4lu86tUAhsTjeyQYKhHBP5dKj
qkUJwrkVP+f0zRVigedof5LG38XGxcv2KOmW2y9Pg+BIncPLHYK9pg4vJ1qvvTp2
nThVOfT7tKpiOhkbeQOvhI+DJLCedvXkcYperwARAQABwsF8BBgBCAAmAhsMFiEE
n5rEVn7cjiuG5cuUddZgC+9Ojo8FAmlFS3MFCQ1EAKwACgkQddZgC+9Ojo/uiw/+
I52aAPI7S2AFX8BLD6rHCzzqNKx822e36yK3AUny6UUv51ou5YeDpO3H+O8U8bZX
+L4GRT1VDUBR/B75AlBdZXtu9RTQ5AfXJYgoK8j796jH3cNTgN5iOrREjYCrFrqc
V0etLOrCVXvhsBbOuznLgDoN+n3tQnme0zghB+RINoWZaOji9Ut3FaOWxzcoB6dC
Swtg1kZqxhWN1VKtwSbNGX1Q9uqrtR6AoRbtG0CX8S9CDFEidDTonngspY5ICj6i
Pq6Wf9DgOqT+fpwDGjV9gjM6etY6TV1vA1pLfwf7JB04faFN4KxnN7UHPvd6R2qa
NJ7QfOsGHsTh5sugV2IOAuazl/xUhn3iyftSQ1F2Q+9vRhWW0AgOhWEQzVuFYq8O
IB4BnzT5rt6rrsLyKHVOgc6T+3lVP8o3c2s8TXCcbYnbtiy6dlAwrLfSpxcAPXWi
K4M4rS5DoxCkgt/sTdMT9BqYVCJjR7ud8Ffn76OfIM6EqYwENAPQqZPHgFE0fzOR
kV9thvsvMne/a1zr6Ys1RXK3Yfv+EyxuLvH4houZ0vHfMgzrxOop5/X7u+2TcPaX
XWG+U8t+SWngnYDTq93Nwg82rNazKftlYFR8TuMCYedJc+BC1+guhGkqxImA/GlZ
0wBvFkvp73MrOoPaVxHgK8f/TGZK9tggNwIo5vTOeYvCwXwEGAEIACYCGwwWIQSf
msRWftyOK4bly5R11mAL706OjwUCXuJH3QUJAuD9FgAKCRB11mAL706Oj2YQD/9q
Jzkn1G/palkUfAA6lFisfSY7Ej0INmxJZpI/RHOiOipcZvh2PWzAfTh1w0yLtLw+
D+vEedRpGVKvqS+CjUAKyV6Z3uP48cy93FPnLi/kiDq2eCkB+GZYTNjP2IDsLR5Z
YFj/qK5olpmNff3BOMIRR55QIWT0ETgI2oG1KSJ/iqQYBxpQQ51gyWqroP8XaX0W
0Dcii4pscXL/Mj6okDO5QlN7o7pQOGShkWcsqmIbGJ3DOdArGZpi3Cm6zdBarsKg
mgKE8XIVZ3XGynUh0K74TIZMGqErX3/RX6Tg8qb2T0vnSwagN8hpI5EyxGquwD0G
FUyXtvW6xi2jjCQUzfE59QCpkxOL4vIsf3fRmP9UtlSgf8MhDaTR5Fjqs8C3YoHZ
lAK16NmDP+rjkxdlhiPsKRS4I3AOStbtORMPB4ig4MRzRNFUtxZJgFN0yEkf42QL
QOBMude+jIsi+zrwI1NqimpqTyKw248s9n+5Fir5rGYj9y3cfBFCqqfz5JqjGknf
UiFXaG7MrWFz9S/Dy8for+IFUjHrs22TMGJGZL89gblbGHFqQIjFxTsj/f1ehijk
G/WOWDXRMukLUBLk/R3N/sQz9lfWIGi4XdMHk2UU32OQKZU2BgB/xCNQ7U52LnlI
Ga1nOpOXmBUUtXKfOgJmGU17aKNMa5SGS72XN4kWPA==
=0mJb
-----END PGP PUBLIC KEY BLOCK-----
```

----


### Obsolete

#### Luke

https://pgp.mit.edu/pks/lookup?op=get&search=0xAD7D030C881A4A29

```
Type bits/keyID     Date       User ID

pub  1024D/881A4A29 2004-12-26 *** KEY REVOKED *** [not verified]
                               Luke Tidd <c60@bellsouth.net>
Public Key Server -- Get "0xad7d030c881a4a29"
```

```
-----BEGIN PGP PUBLIC KEY BLOCK-----
Version: SKS 1.1.6
Comment: Hostname: pgp.mit.edu

mQGiBEHOIAIRBACJI1tNSFOEJ0F2xPOpbtnaxqiwoseGKK6oNAFN5k8VntQVQrir5n9A8ocp
uFPsB47dsHyBn7X8bFuR1Umz7tgO09lrvfulCvGRLpmSkm2E1VbOEj9IBekbng8ByzjGVLOQ
RCAWoqxzdGLSxcTUGutTNRaA0RJms1CJeqQze2PYNwCggeJa5orn6FhZZcmVaaRx4iIxKkcD
/1ICPOhXlO1Q0Q1zWQYjuMJIstA7pGeb+wMmN2JEHgra+ClpBU2ZafkHBz+SPR1K85F8ufZY
deXcvc3gruBtYrKdlucRbelMT0gMWOtw/2f+mX5n0zvbzJQXIJN+rRJfqs9VVN5emRisvAWy
60Gja/pGBTzkwhM1+4Qt7S1iyQ4qA/9diGL6Ft0oph0zMYZSKAYIArkyEB19UrwX1mhenxsw
f7WXrOoW/Vq3y6TBldtfGTclBqpMwgpbfOr5HtYoer007izKIQVzyI2UfEPkCdSc7mlvy3RY
rejVMFwgwZ4rUy5miAqmcPYoz8b2ECE4KCt0M0eW0vfk+vMp7Jb4f4N0xYhJBCARAgAJBQJB
7yRNAh0AAAoJEK19AwyIGkopkNEAmgKSZ+rREOPVofe3aNP3KTrxEEh8AJ0UnNPfYJUY8Xk8
GspO0SfenryshrQdTHVrZSBUaWRkIDxjNjBAYmVsbHNvdXRoLm5ldD6IXgQTEQIAHgUCQc4g
AgIbAwYLCQgHAwIDFQIDAxYCAQIeAQIXgAAKCRCtfQMMiBpKKbYhAJ9kqM6c2GGCosftce/Y
nNDyfkqYegCfa/7irE2R2MKtm8x9O/v5qSMALC65BA0EQc4hbBAQAIHmEr8VvBJ838gTAv++
8snvnrckpTfLenSUdG5IbbcJrJyWEwMC3V3i669RMpsDO0t8Z9mSa8B12A8pN3g1DqHJAJv6
WRnH3FOOkcI54ypBTUJBYhp8FYOxFEEi7B0a8c4w3/Zic3mXdnvbkHa3n6OnJ11ugFrIVJUz
wmynfk69QmaHnY9st0NC/TqRLmcq2iWEbdk76GBA0stiitjAIYwSEkRXZzMYyq3QwJM0FLfg
U2xuves+PjfnXbxiEi9l13FSXerhgZWxVkdGIp+3fmuBqa1dUrJLsMLM8IalEKiAue9vFqgH
cF/ZVBKpCQnyAZLih9DBzvRF8kiqTgayyjIN+oF7kYIZuwbIBjP1PwFF/ILwKUMIG5uTdyJ1
C0yQj3HC/R7qjB1QRP7pQVvUrjgI6W7oT0t0Cy8thchW5PJmR48zQIepOQodc/EKsLL2l2ii
BdTcXdTBsJbOzelMIMnOJaedOqLEZvNFFCIGmoPumm+mOVXAzzOY2R48n4oTE1xwkk3clZ6G
2Lhk0yvdGYsW6+TrhXx53O+JSzOqz4C96SKclIhrVmzJAi+gmIX1giiWnW3jwgrAFFvEoLFa
4egF//beOPVaBFMHe6bXabMLsG4dst+f3O6h1tJk79VTurTHLb4JVrSg7nOJ+Ye03BXFpGzG
bftEIwJR5cHn6lurAAMFD/wMQsd/3DgkrlO5diVnBvVuLVrCewKj49aP9j69zsc18BLFcLKu
I7/JJBOhiyvAeYiWXF1Z2rdzkHA32eolkNoRyN2zBh+17U9BZxXtiLlqf3y+YmpqzJexn4t1
yXLh23EcHIdSwcQtAH2VRFf+MQHlQY9Z7omQRGILyS6dvBLz0iHHR/XdtMk+6dxlQ+7a/r6s
rPAsct5nKa0D8UhYeUzE7xSp7RvH9fBQBM+XRb5Zk/gF7acj5lklO0a4CdLHcDXlxGyLrdyg
EZo4HdvK4E2xYm/bnLRMh8uFACWlKOwyi+dpd20R2l6V+dSEvgsRR+WR9PGCel+QlL1tjV6p
WKnGvK3WbGPNvNanto1wn0HlbtMBwKUgy3+DzAkpjdjrrJy1tgSnfy+XBw5PkvICx16Za6hT
cTcyTxmpwK13UZ9M6+oXEDOaFCF33U+HrR5EL9VMkRpkwJ1qzIwjCjsdlkvEQmN9eZNnakr6
9SZXDVHcQDE3OqmzycutBMmyVRJupcnE+GEHe4eWvEcf+4C9lEcyMxSdI/Pn71UWJvODqOVL
wma+4Md83L2JBvUHRSgLuI8AY9cfx9R7EoBa8gEtlRP+CqWpStxl597ZR2qJYFk/gtL4hh3N
t0dotxone2fozW1xn7cJnz8qhpchABW1GtpWntawuzwNpCVAT87TeUbbz4hJBBgRAgAJBQJB
ziFsAhsMAAoJEK19AwyIGkopb4wAniDf4xRYStRvlozVTmUnqYl2hj+PAJ4tXfl2456EOvqp
5/XNFKNEpAmuaA==
=z7x2
-----END PGP PUBLIC KEY BLOCK-----
```

# CrowdSec & OpenBSD PF Perimeter Security Setup

## 1. Overview & Architectural Concept

This document outlines the distributed intrusion detection and perimeter enforcement setup across the local infrastructure (`drheck.dev`).

```
                              [ Public Internet (WAN) ]
                                          │
                                          ▼
                         ┌─────────────────────────────────┐
                         │   danknasty (OpenBSD Router)    │
                         │ ─────────────────────────────── │
                         │ • OpenBSD PF Kernel Firewall    │
                         │ • Kernel SSH Rate Limiter       │
                         │ • Syslog SSH Forwarder (UDP 514)│
                         │ • CrowdSec PF Firewall Bouncer  │
                         └────────────────┬────────────────┘
                                          │ Drops banned IPs at WAN edge
                                          │ Stream bans via IPv6 LAN ([fd77:...:c70d]:8080)
                                          ▼
                         ┌─────────────────────────────────┐
                         │      ul (Arch Linux Server)     │
                         │ ─────────────────────────────── │
                         │ • Dedicated IPv6: ...:c70d/64   │
                         │ • Nginx Web Reverse Proxy       │
                         │ • Remote Syslog Ingestion (514) │
                         │ • CrowdSec LAPI Decision Engine │
                         │ • 53-Hour Ban Policy Engine     │
                         │ • Public Active Enriched JSON   │
                         │ • Append-Only NDJSON Stream     │
                         └─────────────────────────────────┘
```

### Design Philosophy & Conventions
1. **Dedicated Service IPv6 Address (`ul`):**
   * Per local network convention (`20-wired.network`), CrowdSec LAPI listens on a dedicated IPv6 ULA:
     `fd77:7777:5010:0:1337:f1ac:d00d:c70d` on port `8080` (LAPI) and UDP port `514` (Syslog Ingestion).
2. **Offload All Heavy Detection to Powerful Hardware (`ul`):**
   * All heavy log analysis, regex pattern matching, SQLite/LAPI database operations, and scenario evaluations run on the Arch Linux server (`ul`).
   * **Nginx Web Probes & Direct IP Hits:** Parses Nginx access logs to detect scanner bots (e.g., `.env` harvesting, WordPress probes, webshell attempts, open proxy CONNECT attempts, and direct IP connection attempts).
   * **Router SSH Probes, Invalid Users & Root Attempts:** Receives OpenBSD SSH authentication logs (`/var/log/authlog`) forwarded over UDP IPv6 syslog from `danknasty` to detect SSH brute-force attempts, instant invalid username bans, and instant root attempt bans.
   * **53-Hour Ban Policy:** Issues a global **53-hour ban** for any detected web probe, direct IP scan, or SSH brute-force/invalid user/root attempt.
3. **Perimeter Enforcement at Gateway (`danknasty`):**
   * The OpenBSD router (`danknasty`) runs only the lightweight CrowdSec PF Bouncer (`crowdsec_firewall_bouncer`) and native `syslogd`.
   * Banned IPs are pulled from `ul` and injected into OpenBSD PF's kernel `<crowdsec-blacklists>` table. Banned packets are dropped at the network interface before entering the local network.
4. **Zero-Overhead Edge SSH Protection (`danknasty`):**
   * OpenBSD PF tracks TCP connection rates directly in kernel state space (`max-src-conn-rate 3/30`).
   * Any bot opening more than 3 SSH connections in 30 seconds is added to the OpenBSD PF `<bruteforce>` table and dropped with **0% CPU and ~0 MB RAM usage**.
5. **LAN Subnet & Dynamic IPv6 Whitelisting:**
   * Private subnets (`10.0.0.0/24`, `fd77:7777:5010::/64`), loopbacks (`127.0.0.1/32`, `::1/128`), and the dynamic public ISP IPv6 prefix (auto-detected via `/usr/local/bin/crowdsec-update-ipv6-whitelist`) are whitelisted on `ul` so internal LAN users and SSH typos can never trigger bans.
6. **Rich Context & OSINT Enriched Export (`ul`):**
   * Every banned IP is automatically enriched with Reverse DNS (PTR), Country, Region, City, Coordinates (Lat/Lon), Timezone, ISP, Organization, and Autonomous System (ASN) details.
   * **Active Array File:** `/var/log/crowdsec-bans-public.json` (`chmod 644`).
   * **Append-Only NDJSON Stream:** `/var/log/crowdsec-bans.json` (`chmod 644`).

---

### Detection Latency & Pipeline Timeline

| Protection Layer | Target Threat | Latency | Mechanism / Action |
| :--- | :--- | :--- | :--- |
| **Layer 1: OpenBSD PF Kernel Limiter** | SSH Connection Spikes | **Instant (Nanoseconds / ~0s)** | In-kernel TCP state tracking (`max-src-conn-rate 3/30`) instantly moves abusive IPs into `<bruteforce>` table. |
| **Layer 2: CrowdSec Distributed Ban** | Web Probes, SSH Invalid Users & Root Attempts | **~5 seconds (Max 10s)** | Syslog/Nginx logs stream to `ul`. `ul` triggers 53h ban. `danknasty` bouncer pulls decision and injects IP into OpenBSD PF `<crowdsec-blacklists>`. |

#### CrowdSec Distributed Pipeline Timeline:
1. **Event Generation:** `sshd-session` or Nginx emits log event (`< 1 ms`).
2. **Log Forwarding:** UDP IPv6 syslog or Nginx log tailer streams log to `ul` (`< 1 ms`).
3. **Scenario Trigger & Ban Creation:** CrowdSec parses line, leaks scenario bucket, and writes 53h ban to LAPI database (`~20–50 ms`).
4. **Router Bouncer Polling:** `danknasty` bouncer polls `ul` LAPI over IPv6 LAN (`update_frequency: 10s`, average `~5 seconds`).
5. **Kernel Table Injection:** Bouncer executes `pfctl -t crowdsec-blacklists -T add <IP>` (`~10 ms`).

---

## 2. Implementation & Configuration Details

### A. Central Decision Engine on `ul`

* **Interface Configuration (`/etc/systemd/network/20-wired.network`):**
  ```ini
  # crowdsec
  Address=fd77:7777:5010::1337:f1ac:d00d:c70d/64
  ```

* **Listen URI (`/etc/crowdsec/config.yaml`):**
  Bound to dedicated IPv6 ULA `[fd77:7777:5010:0:1337:f1ac:d00d:c70d]:8080`:
  ```yaml
  api:
    server:
      listen_uri: "[fd77:7777:5010:0:1337:f1ac:d00d:c70d]:8080"
  ```

* **Nginx Log Acquisition (`/etc/crowdsec/acquis.d/nginx.yaml`):**
  ```yaml
  filenames:
    - /var/log/nginx/*.log
    - /var/log/nginx/access.log
  labels:
    type: nginx
  ```

* **Router SSH Syslog Ingestion (`/etc/crowdsec/acquis.d/danknasty-ssh.yaml`):**
  ```yaml
  source: syslog
  listen_addr: "fd77:7777:5010:0:1337:f1ac:d00d:c70d"
  listen_port: 514
  labels:
    type: syslog
  ```

* **LAN & Dynamic IPv6 Whitelist Override (`/etc/crowdsec/parsers/s02-override/00-lan-whitelist.yaml`):**
  ```yaml
  name: custom/lan-whitelist
  description: "Whitelist internal LAN subnets and dynamic public IPv6 prefix"
  filter: "evt.Meta.source_ip != nil"
  whitelist:
    reason: "Exempt internal LAN subnets and dynamic public IPv6 prefix"
    cidr:
      - "10.0.0.0/24"
      - "fd77:7777:5010::/64"
      - "127.0.0.1/32"
      - "::1/128"
      - "2603:3001:626:9d80::/64" # Auto-updated by crowdsec-update-ipv6-whitelist
  ```

* **Custom Honeypot Scenario (`/etc/crowdsec/scenarios/honeypot-endpoints.yaml`):**
  ```yaml
  type: leaky
  name: custom/honeypot-endpoints
  description: "Instant trigger on bot/scanner endpoints"
  filter: |
    evt.Parsed.request in ["/.env", "/cgi-bin/authLogin.cgi", "/IndoSec.php", "/alfa.php", "/luxe.php", "/BDKR28WP.php"] || evt.Parsed.request matches "(?i)^/(wp-admin|wp-login|wp-includes|\\.env|cgi-bin|alfa\\.php|1\\.php|222\\.php|8\\.php|shell\\.php)"
  capacity: 1
  leakspeed: 1h
  groupby: evt.Meta.source_ip
  blackhole: 1m
  labels:
    service: nginx
    type: honeypot_scan
    remediation: true
  ```

* **Custom SSH Invalid User Scenario (`/etc/crowdsec/scenarios/ssh-invalid-user.yaml`):**
  Instant 53-hour ban for any attempt to log in using an invalid username:
  ```yaml
  type: leaky
  name: custom/ssh-invalid-user
  description: "Instant 53h ban for any SSH login attempt using an invalid user"
  filter: |
    evt.Meta.log_type == "ssh_failed-auth" && (
      evt.Parsed.sshd_invalid_user != "" ||
      evt.Meta.target_user != ""
    )
  capacity: 1
  leakspeed: 1h
  groupby: evt.Meta.source_ip
  blackhole: 1m
  labels:
    service: ssh
    type: bruteforce
    remediation: true
  ```

* **Custom SSH Root Attempt Scenario (`/etc/crowdsec/scenarios/ssh-root-login-attempt.yaml`):**
  Instant 53-hour ban for any attempt targeting the `root` account:
  ```yaml
  type: leaky
  name: custom/ssh-root-login-attempt
  description: "Instant 53h ban for any SSH login attempt targeting user root"
  filter: |
    evt.Meta.log_type == "ssh_failed-auth" && (
      evt.Meta.target_user == "root" ||
      evt.Parsed.sshd_invalid_user == "root" ||
      evt.Parsed.sshd_user == "root"
    )
  capacity: 1
  leakspeed: 1h
  groupby: evt.Meta.source_ip
  blackhole: 1m
  labels:
    service: ssh
    type: bruteforce
    remediation: true
  ```

* **Direct IP Access Scenario (`/etc/crowdsec/scenarios/direct-ip-access.yaml`):**
  Instant 53-hour ban for any HTTP/HTTPS connection made directly by IP address or invalid host header:
  ```yaml
  type: leaky
  name: custom/direct-ip-access
  description: "Instant 53h ban for any HTTP/HTTPS connection made directly to IP address or non-domain host"
  filter: |
    evt.Meta.log_type == "http_access-log" && (
      evt.Meta.http_status in ["444", "400", "403"] ||
      evt.Parsed.status in ["444", "400", "403"] ||
      evt.Parsed.target_fqdn matches "^([0-9]{1,3}\\.){3}[0-9]{1,3}$" ||
      evt.Parsed.http_host matches "^([0-9]{1,3}\\.){3}[0-9]{1,3}$"
    )
  capacity: 1
  leakspeed: 1h
  groupby: evt.Meta.source_ip
  blackhole: 1m
  labels:
    service: nginx
    type: direct_ip_scan
    remediation: true
  ```

* **53-Hour Ban Policy (`/etc/crowdsec/profiles.yaml`):**
  ```yaml
  name: default_ip_remediation
  filters:
   - Alert.Remediation == true && Alert.GetScope() == "Ip"
  decisions:
   - type: ban
     duration: 53h
  notifications:
   - file_default
  on_success: break
  ---
  name: default_range_remediation
  filters:
   - Alert.Remediation == true && Alert.GetScope() == "Range"
  decisions:
   - type: ban
     duration: 53h
  notifications:
   - file_default
  on_success: break
  ```

* **File Notification Plugin (`/etc/crowdsec/notifications/file.yaml`):**
  Appends real-time events upon trigger to `/var/log/crowdsec-notifications.log`:
  ```yaml
  type: file
  name: file_default
  log_level: info
  group_wait: 0s
  group_threshold: 1
  format: |
    {{range . -}}
    {{$alert := . -}}
    {{range .Decisions -}}
    {"timestamp":"{{$alert.CreatedAt}}","ip":"{{.Value}}","action":"{{.Action}}","duration":"{{.Duration}}","reason":"{{.Scenario}}","country":"{{$alert.Source.Cn}}","as_name":"{{$alert.Source.AsName}}"}
    {{end -}}
    {{end -}}
  log_path: "/var/log/crowdsec-notifications.log"
  rotate:
    enabled: false
  ```

* **Host Firewall Rules (`/etc/nftables.conf`):**
  ```nftables
  ip6 saddr fd77:7777:5010::/64 ip6 daddr fd77:7777:5010:0:1337:f1ac:d00d:c70d tcp dport 8080 accept comment "Allow CrowdSec LAPI from local IPv6 LAN"
  ip6 saddr fd77:7777:5010::/64 ip6 daddr fd77:7777:5010:0:1337:f1ac:d00d:c70d udp dport 514 accept comment "Allow CrowdSec Syslog Ingestion from local IPv6 LAN"
  ```

* **Automated OSINT & Threat Pipeline (`/usr/local/bin/crowdsec-export-bans`):**
  Runs every 60 seconds (`crowdsec-export-bans.timer`):
  1. Updates dynamic public IPv6 whitelist (`/usr/local/bin/crowdsec-update-ipv6-whitelist`).
  2. Ingests all router SSH auth failures in real time via native UDP IPv6 syslog (`auth.info;authpriv.info` -> port 514), triggering instant sub-second bans (`custom/ssh-invalid-user` via `type: trigger`).
  3. Updates `/var/log/crowdsec-bans-public.json` (active JSON array) and appends newly discovered ban events with full GeoIP/ASN/rDNS metadata to `/var/log/crowdsec-bans.json` (`chmod 644`).

---

### B. Perimeter Enforcement & Syslog Forwarding on `danknasty` (Router)

* **Syslog Forwarding Configuration (`/etc/syslog.conf`):**
  ```syslog
  auth.info;authpriv.info					@[fd77:7777:5010:0:1337:f1ac:d00d:c70d]:514
  ```
  *Note:* OpenBSD `syslogd(8)` requires the `:514` port suffix when enclosing IPv6 addresses in square brackets `@[...]`. Without `:514`, `syslogd` silently skips UDP forwarding.

* **Bouncer Configuration (`/etc/crowdsec-firewall-bouncer/crowdsec-firewall-bouncer.yaml`):**
  ```yaml
  mode: pf
  retry_initial_connect: true
  update_frequency: 10s
  api_url: http://[fd77:7777:5010:0:1337:f1ac:d00d:c70d]:8080/
  api_key: <hunter2>
  deny_action: DROP
  blacklists_ipv4: crowdsec-blacklists
  blacklists_ipv6: crowdsec6-blacklists
  pf:
    anchor_name: ""
  ```

* **Router Firewall Rules (`/etc/pf.conf`):**
  ```pf
  # CrowdSec Blacklist Tables
  table <crowdsec-blacklists> persist
  table <crowdsec6-blacklists> persist
  block drop quick from <crowdsec-blacklists>
  block drop quick from <crowdsec6-blacklists>

  # Table for SSH Brute-force Auto-Blocking
  table <bruteforce> persist
  block drop quick from <bruteforce>

  # SSH rules with stateful connection tracking
  pass in quick on $ext proto tcp to self port ssh \
      flags S/SA keep state \
      (max-src-conn 10, max-src-conn-rate 3/30, overload <bruteforce> flush global)
  ```

---

## 3. Interaction, Operations & Data Format

### A. Enriched Public JSON Data Schema

The exported JSON files (`/var/log/crowdsec-bans-public.json` and `/var/log/crowdsec-bans.json`) contain rich OSINT, network, and threat context for each banned IP:

```json
{
  "decision_id": 4,
  "alert_id": 4,
  "ip": "119.30.119.3",
  "ip_version": "IPv4",
  "origin": "crowdsec",
  "reason": "crowdsecurity/netgear_rce",
  "action": "ban",
  "duration": "52h59m18s",
  "country": "Pakistan",
  "country_code": "PK",
  "region": "Punjab",
  "city": "Sialkot",
  "latitude": 32.426,
  "longitude": 74.3644,
  "timezone": "Asia/Karachi",
  "isp": "Mobilink GSM, Pakistan Mobile Communication Ltd.",
  "org": "Linkdotnet Telecom Limited",
  "as_name": "AS58470 IX Peering for Mobilink and Link Direct International.",
  "rdns": "static-host119-30-119-3.link.net.pk",
  "target_uri": "/setup.cgi?next_file=netgear.cfg&todo=syscmd&cmd=rm+-rf+/tmp/*...",
  "user_agent": "-",
  "method": "GET",
  "status_code": "301",
  "created_at": "2026-08-05T02:44:21Z"
}
```

---

### B. Viewing Active System State & OpenBSD PF Kernel Tables

#### 1. View Active Bans on `ul` (CrowdSec LAPI)
```bash
# View all active decisions (untruncated):
sudo cscli decisions list -a

# Query if a specific IP is banned in CrowdSec:
sudo cscli decisions list --ip <IP_ADDRESS>
```

#### 2. View Active Enriched JSON Array
```bash
cat /var/log/crowdsec-bans-public.json
```

#### 3. Tail the Append-Only Ban Stream (NDJSON)
```bash
tail -f /var/log/crowdsec-bans.json
```

#### 4. View Active Bouncers & Connection Status
```bash
sudo cscli bouncers list
```

#### 5. View Detection Metrics & Statistics
Shows lines read via Nginx logs and remote router syslog:
```bash
sudo cscli metrics
```

#### 6. View Active OpenBSD PF Kernel Tables on Router (`danknasty`)
```bash
# List all blocked IPs in CrowdSec's PF Radix Tree table:
SSH_AUTH_SOCK=/home/luke/.local/ssh_auth_sock/ssh_auth_sock ssh danknasty "doas pfctl -t crowdsec-blacklists -T show"

# View packet & byte drop statistics for the CrowdSec PF table:
SSH_AUTH_SOCK=/home/luke/.local/ssh_auth_sock/ssh_auth_sock ssh danknasty "doas pfctl -v -t crowdsec-blacklists -T show"

# Test if a specific IP is currently blocked in the OpenBSD PF kernel table:
SSH_AUTH_SOCK=/home/luke/.local/ssh_auth_sock/ssh_auth_sock ssh danknasty "doas pfctl -t crowdsec-blacklists -T test <IP_ADDRESS>"

# View OpenBSD SSH kernel brute-force bans:
SSH_AUTH_SOCK=/home/luke/.local/ssh_auth_sock/ssh_auth_sock ssh danknasty "doas pfctl -t bruteforce -T show"
```

---

### C. Manual Interactivity & Ban Management

#### 1. Manually Ban an IP for 53 Hours
```bash
sudo cscli decisions add --ip <IP_ADDRESS> --duration 53h --reason "Manual administrative ban"
```

#### 2. Remove a Ban (Unban an IP)
```bash
sudo cscli decisions delete --ip <IP_ADDRESS>
```

#### 3. Force Immediate Synchronization & OSINT Enrichment
```bash
sudo /usr/local/bin/crowdsec-export-bans
```

#### 4. Inspect Recent Detection Alerts
```bash
sudo cscli alerts list
sudo cscli alerts inspect <ALERT_ID>
```

#### 5. Flush Banned IP Tables on the Router (Emergency Reset)
```bash
SSH_AUTH_SOCK=/home/luke/.local/ssh_auth_sock/ssh_auth_sock ssh danknasty "doas pfctl -t crowdsec-blacklists -T flush"
```

---

### D. Whitelisting Local Trusted Networks

Internal LAN subnets and your dynamic public IPv6 prefix are protected from accidental bans via `/etc/crowdsec/parsers/s02-override/00-lan-whitelist.yaml`:

```yaml
name: custom/lan-whitelist
description: "Whitelist internal LAN subnets from all CrowdSec scenarios"
filter: "evt.Meta.source_ip != nil"
whitelist:
  reason: "Exempt internal LAN subnets"
  cidr:
    - "10.0.0.0/24"
    - "fd77:7777:5010::/64"
    - "127.0.0.1/32"
    - "::1/128"
    - "2603:3001:626:9d80::/64"
```

After modifying whitelists or scenarios, reload CrowdSec:
```bash
sudo systemctl reload crowdsec
```

### E. Adding More Scanner Endpoints

`/etc/crowdsec/scenarios/honeypot-endpoints.yaml`

Current Configuration:
```
type: leaky
name: custom/honeypot-endpoints
description: "Instant trigger on bot/scanner endpoints"
filter: |
  evt.Parsed.request in ["/.env", "/cgi-bin/authLogin.cgi", "/IndoSec.php", "/alfa.php", "/luxe.php", "/BDKR28WP.php"] || evt.Parsed.request matches "(?i)^/(wp-admin|wp-login|wp-includes|\\.env|cgi-bin|alfa\\.php|1\\.php|222\\.php|8\\.php|shell\\.php)"
capacity: 1
leakspeed: 1h
groupby: evt.Meta.source_ip
blackhole: 1m
labels:
  service: nginx
  type: honeypot_scan
  remediation: true
```

How to Add More Scanner Endpoints

1. Add new exact paths to the evt
1. Parsed.request in [...] list (e.g. "/setup.php", "/config.json"), or append regex patterns to evt.Parsed.request matches "(?i)...".
1. Reload Crowd
```
$ sudo cscli scenarios reload
$ sudo systemctl reload crowdsec
```


# Service List

<p class="callout info"><strong>Runtime Architecture Note:</strong> All 30 containerized applications on <code>ul</code> run under <strong>rootless Podman Quadlets</strong> (<code>docker-data</code>, <code>UID 1100</code>) fronted by Nginx TLS + Authentik SSO on dedicated <code>eth77</code> ULA IPv6 addresses. See <a href="https://wiki.drheck.dev/books/system-setup/page/podman">podman</a> for full operational reference.</p>

---

### [Anchor](https://keep.drheck.dev)

Anchor is a fast, self-hosted collaborative note-taking and checklist application integrated with Authentik OIDC Single Sign-On and backed by PostgreSQL on `ul`.

---

### [AudioMass](https://audiomass.drheck.dev)

AudioMass is a full-featured, open-source web-based audio waveform editor and recorder. Running entirely inside the browser using Web Audio and WebAssembly, it provides users with a desktop-class digital audio editing workspace without requiring local software installation. Users can load audio files, record microphone input, view real-time visual spectrums, cut, copy, paste, trim, and mix audio tracks.

The tool includes an extensive suite of built-in DSP effects and processing tools, such as equalizers, gain adjusters, compressors, pitch shifters, delay, reverb, and noise gates. It allows quick audio editing, podcast clips creation, or sound snippet modifications with instant rendering and export in WAV or MP3 formats directly from the browser.

[![audiomass main view](https://wiki.drheck.dev/uploads/images/gallery/2026-08/scaled-1680-/20260808-19h14m13s-grim.png)](https://wiki.drheck.dev/uploads/images/gallery/2026-08/20260808-19h14m13s-grim.png)
---

### [Authentik Identity Provider](https://auth.drheck.dev)

Authentik is the central OpenID Connect (OIDC), ForwardAuth proxy, and LDAPS (`ldap.drheck.dev:636`) Single Sign-On identity provider powering unified authentication across web apps and Linux workstations on `drheck.dev`.

---

### [BentoPDF](https://bentopdf.drheck.dev)

BentoPDF is a privacy-preserving, browser-based PDF manipulation toolkit gated behind Authentik ForwardAuth for merging, splitting, compressing, rotating, watermarking, and converting PDF documents with zero external cloud uploads.

---

### [BookStack](https://wiki.drheck.dev)

BookStack is a wiki platform designed to make storing and organizing information simple and structured. BookStack breaks content down into an intuitive real-world book hierarchy: Shelves, Books, Chapters, and Pages. This visual structure ensures that technical documentation, standard operating procedures, personal notes, and knowledge bases remain easy to search, navigate, and maintain.

BookStack offers both WYSIWYG rich text and Markdown editors, built-in version control with page revisions, image management, cross-linking, and granular role-based access controls.

Single Sign-On support: yes, with SSO can visit the site and be signed in directly.

[![bookstack](https://wiki.drheck.dev/uploads/images/gallery/2026-08/scaled-1680-/20260808-19h19m36s-grim.png)](https://wiki.drheck.dev/uploads/images/gallery/2026-08/20260808-19h19m36s-grim.png)

---

### [Camera Hub (`cam`)](https://cam.drheck.dev)

DrHeck Camera Hub provides live low-latency video and MP3 audio streaming, two-way RTSP ONVIF backchannel talk, YOLOv8n + hardware AI event capture timelines, 5X optical zoom controls, floodlight/IR controls, and a Google TV 10-foot UI for the PoE Reolink `RLC-811A` cameras, restricted to the `camera_access` group.

---

### [Console Games (Arcade Launcher)](https://arcade.drheck.dev/content/launcher.html)

Console Games is a web-based retro gaming launcher and front-end interface built on top of webЯcade. It provides end users with an interactive, TV- and controller-friendly library for browsing and launching classic console video games directly in modern web browsers using WebAssembly emulation cores.

Users can connect gamepad controllers via USB or Bluetooth, save game states, customize controller mappings, and stream classic retro titles across desktop computers, mobile devices, and smart TVs without installing native emulators or software dependencies.

[![arcade](https://wiki.drheck.dev/uploads/images/gallery/2026-08/scaled-1680-/20260808-19h21m12s-grim.png)](https://wiki.drheck.dev/uploads/images/gallery/2026-08/20260808-19h21m12s-grim.png)

---

### [Degoog Search](https://search.drheck.dev)

Degoog is a self-hosted privacy metasearch engine at `https://search.drheck.dev` (`https://s.drheck.dev`) backed by host Valkey caching without tracking or ad profiling.

---

### [Demucs Stem Splitter](https://demucs.drheck.dev)

Demucs is a GPU-accelerated music source separation studio running on the AMD Radeon AI PRO R9700 (`ROCm`), splitting songs into isolated vocals, drums, bass, guitar, piano, and accompaniment stems.

---

### [Drawpile](https://drawpile.drheck.dev)

Drawpile is a free, open-source collaborative drawing program that allows multiple artists to draw on the same canvas simultaneously over the internet. The server component hosted on drheck.dev manages shared session state, brush strokes, layer ordering, and user permissions for real-time multiplayer digital art sessions.

Users can host private or public drawing rooms, sketch together in real time, record and playback drawing sessions, lock specific layers to prevent accidental edits, and chat with co-creators. Authentik integration ensures authenticated users can easily jump into ongoing canvas sessions or manage drawing servers.

This is just the server, create a room and connect with your client.

---

### [Excalidraw](https://excalidraw.drheck.dev)

Excalidraw is a virtual, hand-drawn style collaborative whiteboard application designed for quick sketching, architecture diagrams, wireframes, and visual brainstorming. Its distinctive hand-drawn aesthetic removes the pressure of high-fidelity graphics, encouraging rapid ideation and clear visual communication.

Featuring real-time end-to-end encryption, infinite canvas navigation, rich shape libraries, customizable arrows, text blocks, and exported PNG/SVG diagrams, Excalidraw allows end users to draft system architectures, mind maps, and workflow diagrams quickly within their browser.

[![excalidraw](https://wiki.drheck.dev/uploads/images/gallery/2026-08/scaled-1680-/20260808-19h25m09s-grim.png)](https://wiki.drheck.dev/uploads/images/gallery/2026-08/20260808-19h25m09s-grim.png)

---

### [Funkwhale](https://funkwhale.drheck.dev/)

Funkwhale is a self-hosted, decentralized audio and music streaming platform built on open web protocols (ActivityPub). It allows end users to import, organize, stream, and share their personal music collections and podcasts across devices while connecting with audio libraries on other federated instances.

End users can create custom playlists, follow public audio channels, listen to high-quality audio streams in the web application or via Subsonic-compatible mobile apps, and share music tracks with friends without depending on centralized commercial streaming services.

[![funkwhale](https://wiki.drheck.dev/uploads/images/gallery/2026-08/scaled-1680-/20260808-20h54m34s-grim.png)](https://wiki.drheck.dev/uploads/images/gallery/2026-08/20260808-20h54m34s-grim.png)

---

### [Forgejo](https://git.drheck.dev/)

Forgejo is a self-hosted, community-governed lightweight software forge that provides complete software development workflows similar to GitHub. It offers source code hosting, Git repository management, pull requests, code reviews, issue tracking, wikis, and organization management in a fast, low-footprint application.

Integrated with Authentik via Single Sign-On (OIDC), users can log into Forgejo with their unified credentials to push and pull Git repositories, manage SSH keys, configure webhook integrations, run automated CI/CD pipelines, and collaborate on software projects.

[![gitea](https://wiki.drheck.dev/uploads/images/gallery/2026-08/scaled-1680-/20260809-12h04m49s-grim.png)](https://wiki.drheck.dev/uploads/images/gallery/2026-08/20260809-12h04m49s-grim.png)

---

### [Grill Monitor (`FireBoard 2`)](https://grill.drheck.dev)

Local cloud-free BLE-to-Wi-Fi telemetry and multi-channel temperature graphing dashboard (`grill.drheck.dev` / `fireboard.drheck.dev`) for the hardware-switched Seeed XIAO ESP32-C3 + FireBoard 2 6-probe smoker thermometer.

---

### [Immich](https://photos.drheck.dev)

Immich is a high-performance, self-hosted photo and video management solution designed as a privacy-respecting alternative to Google Photos or Apple Photos. It provides seamless background photo backup from mobile devices (iOS and Android) as well as an intuitive web interface for browsing, searching, and sharing media libraries.

Immich includes machine-learning features such as face recognition, object detection, spatial map views based on EXIF GPS data, album sharing, and multi-user support with quota management. SSO authentication via Authentik allows smooth, secure onboarding across all web and mobile client applications.

[![immich](https://wiki.drheck.dev/uploads/images/gallery/2026-08/scaled-1680-/20260809-12h05m52s-grim.png)](https://wiki.drheck.dev/uploads/images/gallery/2026-08/20260809-12h05m52s-grim.png)

---

### [Jellyfin](https://jellyfin.drheck.dev/)

Jellyfin is a free, open-source media system that puts users in control of managing and streaming their personal movies, TV shows, music, and live TV broadcasts. Unlike commercial alternatives, Jellyfin contains no tracking, premium paywalls, or central server dependencies, running entirely self-hosted.

Jellyfin automatically fetches metadata, artwork, and subtitle tracks for media libraries, transcoding video streams on-the-fly for smooth playback across web browsers, smart TVs, Roku, Fire TV, and mobile devices. Authentik LDAP integration allows unified credentials for all household members.

[![jellyfin](https://wiki.drheck.dev/uploads/images/gallery/2026-08/scaled-1680-/20260809-12h08m08s-grim.png)](https://wiki.drheck.dev/uploads/images/gallery/2026-08/20260809-12h08m08s-grim.png)

---

### [Kanji Practice](https://kanji.drheck.dev/)

Kanji Practice is a specialized web application built for Japanese language learners to practice stroke order. There are two modes. In the first, each stroke will be animated in the correct order in a single pane, or there is an view showing each character being assembled one stroke at a time in successive panes.

 [![kanji](https://wiki.drheck.dev/uploads/images/gallery/2026-08/scaled-1680-/20260809-12h09m06s-grim.png)](https://wiki.drheck.dev/uploads/images/gallery/2026-08/20260809-12h09m06s-grim.png)

---


### [Kavita](https://read.drheck.dev)

Kavita digital library designed specifically for reading and managing digital comics, manga, light novels, and traditional e-books. It automatically organizes files (EPUB, CBZ, CBR, PDF) into series, volumes, and chapters with cover art and metadata tagging.

Kavita includes built-in responsive web readers tailored for mobile devices, tablets, and desktop screens, offering custom reading modes (continuous scroll, dual page, manga right-to-left mode), bookmark sync, reading progress tracking, and user library permissions.

[![kavita](https://wiki.drheck.dev/uploads/images/gallery/2026-08/scaled-1680-/20260809-12h26m50s-grim.png)](https://wiki.drheck.dev/uploads/images/gallery/2026-08/20260809-12h26m50s-grim.png)

---
### [LibreSprite](https://libresprite.drheck.dev)
LibreSprite is an open-source animated sprite editor and pixel art tool compiled to WebAssembly. Running 100% client-side in the browser, it provides a full-featured digital drawing and animation workspace for creating 2D game assets, animated GIFs, and pixel graphics without requiring local software installation.

Users can draw pixel artwork, construct frame-by-frame sprite animations, manage custom color palettes, and export finished graphics as sprite sheets or animated images, bringing classic pixel art editing into a cloud-hosted web environment.

---

### [Minecraft Control Plane](https://mc.drheck.dev)

Minecraft Control Plane (`mc-manager`) is the unified web administration and world provisioning studio for all Minecraft servers hosted on `ul`. Protected behind Authentik ForwardAuth, it provides real-time server lifecycle management (`On-Demand` wake-on-join via HAProxy, `Always On`, and `Manual`), a live WebSocket PTY server console (`mc-runner`), interactive `cubiomes` 2D biome & structure seed previews, and a built-in **Minecraft Text, MOTD, Color Gradient & 8-Bit Pixel Icon Studio** with live Multiplayer Server List ping updates.

It also integrates with automated network, HAProxy SNI, and DNS provisioning (`ipctl` using short-lived `step-ca` SSH certificates and external config files) as well as 3D world maps such as [PokeLovers BlueMap](https://pokelovers.drheck.dev).

---

### [Navidrome](https://navidrome.drheck.dev/)

Navidrome (`navidrome.drheck.dev` / `music.drheck.dev`) is a modern, lightweight, open-source music server and streamer that gives users complete freedom to listen to their personal music collection from anywhere. Built with high performance in mind, Navidrome indexes large music libraries effortlessly with low CPU and memory overhead.

Navidrome features a responsive web UI and full compatibility with Subsonic/Airsonic mobile clients (such as DSub, amposer, and Symfonium). Users can stream music in original quality or on-the-fly transcoded formats, create playlists, mark favorites, and share music with family members.

[![navidrome](https://wiki.drheck.dev/uploads/images/gallery/2026-08/scaled-1680-/20260809-13h46m51s-grim.png)](https://wiki.drheck.dev/uploads/images/gallery/2026-08/20260809-13h46m51s-grim.png)

---

### [Nextcloud & Collabora Online](https://cloud.drheck.dev)

Nextcloud is a productivity and cloud storage platform that provides file storage and access, documents, calendars, contacts, and communication tools. It serves as a privacy-focused alternative to proprietary cloud services like Google Drive and Microsoft 365.

End users can sync files across desktop and mobile clients, edit office documents collaboratively in real time via Collabora Online (`office.drheck.dev`), schedule events, manage task lists, and share files securely via public password-protected web links. Authentik OIDC Single Sign-On grants secure access across all Nextcloud modules.

[![nextcloud](https://wiki.drheck.dev/uploads/images/gallery/2026-08/scaled-1680-/20260809-13h48m01s-grim.png)](https://wiki.drheck.dev/uploads/images/gallery/2026-08/20260809-13h48m01s-grim.png)

---

### [Penpot](https://penpot.drheck.dev)

Penpot is the first open-source, web-based design and prototyping tool built natively on open web standards (SVG). Created as an alternative to proprietary platforms like Figma, Penpot allows design and development teams to craft UI/UX wireframes, interactive component prototypes, and vector illustrations.

Because Penpot uses SVG as its native domain model, vector designs render with absolute fidelity for web Developers. It supports real-time multiplayer collaboration, shared design systems, component libraries, layout grids, and CSS code generation. Single Sign-On via Authentik enables instant user authentication.

[![penpot](https://wiki.drheck.dev/uploads/images/gallery/2026-08/scaled-1680-/20260809-13h50m01s-grim.png)](https://wiki.drheck.dev/uploads/images/gallery/2026-08/20260809-13h50m01s-grim.png)

---

### [Pixelorama](https://pixelorama.drheck.dev)

Pixelorama is a free, open-source pixel art editor built with the Godot engine and deployed to the web for creating 2D sprites, tilesets, pixel animations, and retro game art directly in the browser.

It features a rich toolkit tailored specifically for pixel artists, including custom pencil and eraser brushes, color palettes, onion skinning for frame-by-frame animation, layer management, pixel-perfect lines, tile mode previewing, and export options to sprite sheets or animated GIFs.

[![pixelorama](https://wiki.drheck.dev/uploads/images/gallery/2026-08/scaled-1680-/20260809-14h17m33s-grim.png)](https://wiki.drheck.dev/uploads/images/gallery/2026-08/20260809-14h17m33s-grim.png)

---

### [Plex](https://plex.drheck.dev)

Plex is a comprehensive media streaming application that organizes personal video, audio, and photo collections into a clean, beautifully formatted media streaming service. Plex automatically matches files against online databases to attach movie posters, plot summaries, cast lists, trailers, and themes.

End users can stream content seamlessly across a vast ecosystem of devices, including smart TVs, streaming sticks, mobile phones, game consoles, and web browsers, with intelligent server-side video transcoding to match client device capabilities and network bandwidth.

[![plex](https://wiki.drheck.dev/uploads/images/gallery/2026-08/scaled-1680-/20260809-14h21m35s-grim.png)](https://wiki.drheck.dev/uploads/images/gallery/2026-08/20260809-14h21m35s-grim.png)

---

### [RomM (ROM Manager)](https://romm.drheck.dev)

RomM (ROM Manager) is a modern web application designed for organizing, scanning, and managing retro video game ROM libraries. RomM connects with online databases (such as IGDB and SteamGridDB) to automatically enrich game titles with high-resolution box art, screenshots, release dates, genre tags, and game descriptions.

End users can browse their game collection by console platform, search titles, manage save states and ROM variants, and launch games directly in the browser using integrated web emulators or send game files to local emulation devices.


---

### [Sopro Text-to-Speech](https://sopro.drheck.dev)

Sopro is a self-hosted neural text-to-speech synthesis web studio gated behind Authentik ForwardAuth on `ul`.

---

### [Studio Control Gateway (`scontrol`)](https://scontrol.drheck.dev)

Studio Control (`scontrol.drheck.dev` / `studio-kvm-web.drheck.dev`) orchestrates one-touch RF power sequencing, ATX motherboard ignition, and Echo AudioFire 12 / Ardour DAW readiness across the recording studio rack.

---

### [SubOCR Japanese Subtitle Study Platform](https://subocr.drheck.dev)

SubOCR extracts burned-in Japanese video subtitles via ROCm `PP-OCRv5` GPU inference on the AMD Radeon AI PRO R9700, refines dialogue via local `qwen2.5:32b`, and generates Kanji-only furigana, JLPT tags, conjugation tables, and interactive video study decks.

---

### [Sunshine Game Stream Host](https://sunshine.drheck.dev)

Bare-metal KMS/DRM Gamescope + Sunshine low-latency streaming server running directly on the AMD Radeon AI PRO R9700 with Steam libraries hosted on ZFS `/data/games/steam`.

---

### [tldraw](https://tldraw.drheck.dev)

tldraw is a collaborative, infinite-canvas drawing tool and visual whiteboarding app designed for instant sketching, diagramming, and visual note-taking. Built around speed and simplicity, tldraw provides a minimal canvas interface with immediate tool accessibility.

End users can create vector shapes, freehand ink lines, sticky notes, connective arrows, and text labels. Supporting multiplayer real-time collaboration and SVG/PNG exports, tldraw is ideal for quick technical diagrams, architecture brainstorming, and informal remote collaboration.


[![20260809_14h27m42s_grim.png](https://wiki.drheck.dev/uploads/images/gallery/2026-08/scaled-1680-/20260809-14h27m42s-grim.png)](https://wiki.drheck.dev/uploads/images/gallery/2026-08/20260809-14h27m42s-grim.png)
---

### [Transmission](https://bt.drheck.dev)

Transmission is a fast, lightweight BitTorrent client equipped with a clean web interface for managing peer-to-peer file transfers remotely. The web UI allows users to monitor active downloads and uploads, add new torrent files or magnet links, adjust bandwidth rate limits, and organize downloads into destination folders.

Protected behind Authentik ForwardAuth reverse-proxy authentication, users can safely monitor long-running torrent transfers and manage media downloads on drheck.dev from any web browser without exposing unauthenticated torrent control endpoints to the public internet.

[![20260812_21h29m26s_grim.png](https://wiki.drheck.dev/uploads/images/gallery/2026-08/scaled-1680-/20260812-21h29m26s-grim.png)](https://wiki.drheck.dev/uploads/images/gallery/2026-08/20260812-21h29m26s-grim.png)

---

### [Vaultwarden](https://pw.drheck.dev)

Vaultwarden (`pw.drheck.dev` / `vault.drheck.dev`) is a lightweight, self-hosted implementation of the Bitwarden secrets and password management server API written in Rust. It provides full compatibility with official Bitwarden desktop applications, browser extensions, mobile apps, and CLI tools while consuming minimal memory resources.

End users can securely store end-to-end encrypted login credentials, credit cards, secure notes, personal identities, and two-factor authentication (TOTP) seed keys. Enforced with Authentik OIDC Single Sign-On and strict password policies, Vaultwarden ensures seamless, secure password management across all user devices.

(secrets)

---

### [Vikunja Task Manager](https://vikunja.drheck.dev)

Vikunja is a self-hosted project, Kanban board, Gantt chart, and task management platform backed by host PostgreSQL, host Valkey caching, and Authentik OIDC Single Sign-On.

---

### [Wavacity](https://wavacity.drheck.dev)

Wavacity is an open-source web browser port of the popular Audacity audio editing application. Compiled to WebAssembly, it brings Audacity's iconic multi-track audio editing workspace directly into modern web browsers without requiring local software installation or native binaries.

Users can import multi-track audio files, perform non-destructive editing, apply audio filters, cut and align tracks, inspect frequency spectrums, and export finished master tracks in standard audio formats, carrying the familiar Audacity interface into a cloud-hosted web environment.

[![wavacity](https://wiki.drheck.dev/uploads/images/gallery/2026-08/scaled-1680-/20260809-14h29m23s-grim.png)](https://wiki.drheck.dev/uploads/images/gallery/2026-08/20260809-14h29m23s-grim.png)

---

### [webЯcade Setup](https://arcade.drheck.dev)

webЯcade is a cloud-based retro gaming ecosystem that allows users to play classic console and arcade games directly inside modern web browsers across desktop computers, mobile phones, tablets, and smart TVs.

The webЯcade setup application allows users and administrators to configure game feeds, set up cloud save game synchronization, map custom controller inputs, and manage retro emulator settings for seamless web-based retro gaming.

[![arcade](https://wiki.drheck.dev/uploads/images/gallery/2026-08/scaled-1680-/20260809-14h30m13s-grim.png)](https://wiki.drheck.dev/uploads/images/gallery/2026-08/20260809-14h30m13s-grim.png)

---

### [Webshell](https://webshell.drheck.dev/)

Webshell is a web-based terminal interface (powered by ttyd and short-lived 5-minute `step-ca` SSH certificates) that provides authenticated users with direct, browser-based command-line shell access to host environment tools.

Protected behind Authentik Single Sign-On, Webshell enables administrators and authorized users to safely perform terminal administration tasks, check system diagnostics, run scripts, and manage files from mobile devices or workstations without needing native SSH client software.

[![20260812_21h31m43s_grim.png](https://wiki.drheck.dev/uploads/images/gallery/2026-08/scaled-1680-/20260812-21h31m43s-grim.png)](https://wiki.drheck.dev/uploads/images/gallery/2026-08/20260812-21h31m43s-grim.png)

---

### [Wick Editor](https://wick.drheck.dev)

Wick Editor is an open-source, web-based tool for creating animations, games, and interactive code projects. Combining the accessibility of classic Flash software with modern web standards (HTML5/JavaScript), Wick Editor makes interactive media creation approachable directly in the browser.

Users can draw vector artwork, build timeline-based frame animations, program interactivity with beginner-friendly JavaScript code snippets, attach sound effects, and export projects as interactive HTML5 games, standalone web apps, animated GIFs, or video files.

[![wick](https://wiki.drheck.dev/uploads/images/gallery/2026-08/scaled-1680-/20260809-14h32m31s-grim.png)](https://wiki.drheck.dev/uploads/images/gallery/2026-08/20260809-14h32m31s-grim.png)

---

### [WIPStudio](https://wipstudio.drheck.dev/)

WIPStudio (Work-In-Progress Studio) is a self-hosted creative media and web development studio platform designed for managing ongoing multimedia projects, interactive web prototypes, and creative assets.

Integrated with Authentik via OpenID Connect (OIDC), WIPStudio provides authenticated creators with a unified workspace to draft visual concepts, organize project files, review media builds, and collaborate on experimental digital projects.

[![wip studio](https://wiki.drheck.dev/uploads/images/gallery/2026-08/scaled-1680-/20260809-14h52m18s-grim.png)](https://wiki.drheck.dev/uploads/images/gallery/2026-08/20260809-14h52m18s-grim.png)

---

### [YOURLS Shortener (`drheck.dev`)](https://drheck.dev/admin/)

YOURLS powers custom short links on `https://drheck.dev/<slug>`, with public HTTP `301` resolution and private admin/API management protected behind Authentik ForwardAuth (`/yourls-api.php` and `/admin/`).


# SSH Command Tracking Guide

Detail of command execution auditing and tracking across human SSH sessions and AI agent sessions (`amux`).

---

## Part 1: Arch Linux (`ul`, `bmo95`, `studio-kvm-ssh`)

The Linux Audit Framework (`auditd`) captures `execve` system calls triggered by human user sessions (`auid >= 1000`), recording all interactive and SSH payload commands while strictly filtering out background system and rootless Podman container noise.

### 1. Installation & Service Setup

Install the `audit` package and enable the `auditd` daemon:

```shell
$ sudo pacman -S --noconfirm audit
$ sudo systemctl enable --now auditd
```

### 2. Configuration Rules & Interactive User Filtering

Create `/etc/audit/rules.d/execve.rules` with Audit User ID filtering (`auid >= 1000`):

> **CRITICAL:** Do NOT include catch-all `execve` rules without `-F auid>=1000`. Background system daemons and rootless Podman containers started by `user@1100.service` (e.g. MinIO healthchecks, Authentik workers) have `auid = 4294967295` (`unset`). An unfiltered rule generates ~8 MB of log noise every 7 minutes, rotating out entire log histories within 30 minutes.

```shell
$ sudo tee /etc/audit/rules.d/execve.rules > /dev/null << 'EOF'
# Delete pre-existing rules
-D

# Set kernel buffer size
-b 8192

# Log all command executions strictly for human/login users (auid >= 1000)
-a always,exit -F arch=b64 -S execve -F auid>=1000 -k remote_commands
-a always,exit -F arch=b32 -S execve -F auid>=1000 -k remote_commands
EOF
```

Load rules into the active kernel:

```shell
$ sudo auditctl -D && sudo augenrules --load
$ sudo auditctl -e 1
```

Verify active rules:

```shell
$ sudo auditctl -l
```

#### Process UID (`uid`) vs Audit User ID (`auid`)

* **Process UID (`uid=1100` `docker-data` & subordinate `624288`–`689823`):** Controls unprivileged host kernel UIDs for rootless Podman containers.
* **Audit User ID (`auid` / `loginuid`):** Assigned exclusively by PAM when a human authenticates via SSH or TTY (`auid = 1000`). Background containers started via systemd lingering (`user@1100.service`) have `auid = 4294967295` (`unset`).
* **Result:** Filter by `auid >= 1000`.

---

## Part 2: Agent Execution Auditing (`amux` / `systemd journal`)

When AI agents or automated scripts run multi-line code via `write-pane -f <file>` or `write-pane -b <base64>` in **amux**, those commands can bypass standard `auditd` `execve` logging (because they are streamed directly into an already-running shell's PTY or an interactive Python/Node REPL).

To ensure complete, tamper-resistant auditing and forensic recovery:

### 1. Structured Journal Logging
Every call to `write-pane` in `amux` automatically logs directly into the **systemd journal** with structured fields:
- `SYSLOG_IDENTIFIER=amux`
- `AMUX_PANE_ID`: Target pane (`%0`, `%1`, etc.)
- `AMUX_WINDOW_NAME`: Hierarchical task window (e.g. `mc-pokelovers-bluemap`)
- `AMUX_SOURCE`: Original script file path or `base64`
- `AMUX_ARCHIVE_PATH`: Permanent snapshot location in `/data/amux/scripts/`
- `AMUX_PAYLOAD_SIZE`: Injected byte count

Querying agent execution logs:
```shell
# Stream live agent injections
$ journalctl -t amux -f

# Inspect detailed metadata for recent agent commands
$ journalctl -t amux -n 20 -o verbose
```

### 2. Permanent Script Archival (`/data/amux/scripts/`)
Before any byte is written to the terminal PTY, `amux` automatically saves a bit-for-bit snapshot to:
```
/data/amux/scripts/YYYY-MM-DD_HHMM.SS_<window_name>.sh
```
- **Durability:** Stored on `/data` with ownership `luke:luke`.
- **Zero Loss:** Even if an agent immediately removes a temporary file from `/dev/shm` after execution, the exact script executed is preserved permanently.
- **Disaster Recovery:** `/data/amux/scripts/` is automatically backed up during the daily backup run via `/home/luke/git/backups/ul/scripts/backup/amux`.

---

## Part 3: Python Command Inspection & Summarization (`audit-log`)

The `/usr/local/bin/audit-log` Python utility parses, decodes, and summarizes `EXECVE` records from `/var/log/audit/audit.log.*`.

### CLI Command Options & Usage

```shell
# 1. Default view: last 20 executed commands
$ audit-log

# 2. View specific number of entries (e.g. last 50 commands)
$ audit-log 50
$ audit-log -n 50

# 3. Stream all recorded command history
$ audit-log all
$ audit-log --all

# 4. Filter by specific day (YYYY-MM-DD)
$ audit-log --day 2026-08-29

# 5. Filter date range (since date / until date)
$ audit-log --since 2026-08-25 --clean

# 6. Filter repetitive background noise (tmux status bars, pollers)
$ audit-log --clean -n 30

# 7. Regex / substring search across command history
$ audit-log --search "funkwhale|podman" --clean

# 8. Deduplicate identical commands in output
$ audit-log --unique --since 2026-08-28

# 9. Generate automated daily change log summary outline
$ audit-log --daily --since 2026-08-25 --clean
```

---

## Part 4: OpenBSD (Router `danknasty`)

OpenBSD uses native kernel process accounting (`acct`) to track binary executions, alongside syslog-based `sshd` verbose logging and `doas` auditing.

### 1. Kernel Process Accounting (`acct`)

#### Enable Accounting:

```shell
# Create log file
$ doas touch /var/account/acct

# Enable accounting at boot
$ echo "accounting=YES" | doas tee -a /etc/rc.conf.local

# Start accounting daemon immediately
$ doas /usr/sbin/accton /var/account/acct
```

### 2. Real-Time Command Inspection (`audit-log`)

Query process execution history using the matching `audit-log` helper script:

```shell
# View the last 20 executed commands (Default)
$ doas audit-log

# View a specific number of recent commands (e.g. 50 entries)
$ doas audit-log 50

# View ALL recorded command history
$ doas audit-log all
```

### 3. SSH Verbose Payload & `doas` Logging

To log non-interactive SSH connections and payload commands to `/var/log/authlog`:

1. Edit `/etc/ssh/sshd_config`:
   ```text
   LogLevel VERBOSE
   ```
2. Reload `sshd`:
   ```shell
   $ doas rcctl reload sshd
   ```
3. Monitor logs:
   ```shell
   $ doas tail -f /var/log/authlog
   ```


# Agent's Guide to Wiki

## Agent Guide: Updating BookStack Wiki Pages

This guide outlines how AI agents can directly query, read, edit, and update pages in the self-hosted **BookStack Wiki** (https://wiki.drheck.dev/).

---

## 1. Golden Rules for Agents

> ### 🚨 MANDATORY RULE: READ LIVE CONTENT FIRST BEFORE EDITING
> **NEVER generate or overwrite page content from memory or scratch without first reading the current live page and its latest revisions.**
> Humans frequently make live edits in the BookStack browser interface between agent turns. Overwriting a page without reading its latest `markdown` and `page_revisions` destroys user contributions, custom links, code blocks, and embedded images.
>
> **Every page update MUST follow this sequence:**
> 1. Fetch live markdown to a file via `/home/luke/bin/read-bookstack-page <page_id> <output_file>`.
> 2. Query attached images from the `images` table.
> 3. Incorporate existing content, user edits, and image tags into the updated document.
> 4. Deploy changes with `/home/luke/bin/update-bookstack-page <page_id> <file_path>`.
> 5. Pull down the live rendered page and verify HTML and KaTeX formula output via `/home/luke/bin/verify-bookstack-page <page_id_or_slug>`.

> ### 🚨 MANDATORY RULE: PULL DOWN & VERIFY THE RENDERED PAGE & FORMULAS AFTER EVERY EDIT
> **Mathematical formulas (`$$...$$` and `$...$`), signal chains (`\longrightarrow`), matrices, and TeX output are desired and encouraged across the wiki—but once an edit is made, you MUST pull down the live rendered page and check the output.**
>
> Never assume a formula rendered properly just because the Markdown source looks right or `LENGTH(html)` is non-zero in MariaDB. BookStack's server-side CommonMark parser transforms backslash-punctuation sequences before client-side KaTeX runs, and unquoted shell expansions can corrupt either the page source or the global `app-custom-head` KaTeX loader.
>
> **After every wiki edit, you MUST:**
> 1. Run `/home/luke/bin/verify-bookstack-page <page_id_or_slug>` (also executed automatically by `update-bookstack-page` and `create-bookstack-page`), which pulls down the live rendered page over HTTPS (`https://wiki.drheck.dev/books/<book>/page/<slug>`) and validates:
>    - Every inline `<script>` and the global `app-custom-head` KaTeX auto-render loader in `<head>` (`vm.Script` syntax check + intact `$$` and `$` delimiter verification).
>    - Every `$$...$$` display formula and `$...$` inline formula in the rendered `.page-content` DOM using real KaTeX `0.16.11` (`katex.renderToString` with `throwOnError: true, strict: 'error'`).
>    - Zero unrendered TeX macros (`\text{...}`, `\longrightarrow`, `\frac{...}`, `\begin{...}`), stray `$$` delimiters, CommonMark-stripped matrix row breaks (`\\`), CommonMark-stripped percent signs (`\%`), or accidental `$` prose collisions.
> 2. Inspect the live rendered HTML of the edited section via `curl -skL https://wiki.drheck.dev/books/<book_slug>/page/<page_slug>` to confirm clean rendering.

> ### 🚨 ABSTRACT DATABASE-INTERNAL IDS FROM USER-FACING TEXT
> **Never include internal database IDs or object identifiers in user-facing page bodies, changelogs, or documentation summaries.**
> Database IDs are internal implementation details strictly for CLI helper execution (`read-bookstack-page <id>`, `update-bookstack-page <id>`). Humans navigate the wiki by title and URL slug. Always use human-readable titles, book names, and standard markdown hyperlinks (e.g. `[Setup Detail](https://wiki.drheck.dev/books/setup-detail)`), never raw numeric IDs.

> ### 🚨 DUAL STORAGE RULE (`markdown` vs `html`)
> BookStack stores page source in the `entity_page_data` table:
> - **`markdown`**: Source text loaded by BookStack when a page is opened in the browser editor.
> - **`html`**: Rendered HTML displayed to public visitors on the website.
>
> **Never update `html` alone!** Updating `html` alone causes the editor to load the outdated `markdown` column and wipe out HTML changes upon saving. Agents must update the **`markdown`** source via the official helper scripts.

---

## 2. Environment & Database Access

- **Service Host:** `ul` (`ul.drheck.dev`)
- **Rootless Podman Container (Database):** `bookstack_db` (MariaDB, running under `docker-data`, `UID 1100`)
- **Rootless Podman Container (Web/App):** `bookstack` (runs PHP-FPM as user `abc`)
- **Env File:** `/data/docker/bookstack/.env`
- **Database Name:** `bookstackapp`

> **Note on Execution Context:** The helper utilities interact directly with local rootless Podman containers under `docker-data` (`UID 1100`) on `ul` (executing `artisan` as container user `abc` with `HOME=/config` so root-owned cache/log files are never created). If the agent is running on another workstation, execute helper commands over SSH to `ul` using single-quoted outer SSH blocks or heredocs (`ssh ul << 'EOF'`) so `$` and `$$` in LaTeX formulas are never expanded by the local shell.

---

## 3. Recommended Workflow & Utilities

Official CLI utilities in `/home/luke/bin/` on host `ul` eliminate shell quoting bugs, SQL newline escaping artifacts, and cache desync:

### Step 1: Find Page ID or Book ID by Slug or Keyword
```bash
/home/luke/bin/find-bookstack-page <search_term>
```
*Output:*
```text
id	name			slug			type
31	System Change log	system-change-log	page
```

### Step 2: Export Live Markdown to a File (MANDATORY)
Export the live markdown directly to a clean file using the base64-safe reader:
```bash
/home/luke/bin/read-bookstack-page <page_id> /tmp/page_<page_id>.md
```
*(Do NOT use raw `mariadb -e "SELECT markdown..."` queries, as the MariaDB CLI client escapes newlines into literal `\n` strings).*

### Step 3: Check Attached Images & Revisions
**Check image attachments:**
```bash
(cd /tmp && sudo -u docker-data HOME=/home/docker-data XDG_RUNTIME_DIR=/run/user/1100 podman exec bookstack_db mariadb \
  -ubookstack -p"$(sudo grep DB_PASSWORD /data/docker/bookstack/.env | cut -d= -f2)" \
  bookstackapp -e "SELECT id, name, url FROM images WHERE uploaded_to=<page_id>;")
```

### Step 4: Edit Content Locally in the File
Modify `/tmp/page_<page_id>.md`, ensuring that all existing user edits, links, and image references (`![alt](url)`) are preserved.

### Step 5: Update Existing Page or Create New Page

**To update an existing page:**
```bash
/home/luke/bin/update-bookstack-page <page_id> /tmp/page_<page_id>.md
```

**To create a new page in a book:**
```bash
/home/luke/bin/create-bookstack-page <book_id> "<page_title>" <file_path>
```

These helpers Base64-encode content, invoke BookStack's native PHP `PageContent` models inside Docker as user `abc` (`artisan tinker`), compile both `markdown` and `html` columns, update revision tracking, clear the application and view caches, and automatically run `/home/luke/bin/verify-bookstack-page` against the live rendered URL.

Remember to clean up your temporary file afterwards (`rm -f /tmp/page_<page_id>.md`).

---

## 4. Mandatory Rendered-Page & Formula Verification

After every page update or creation, verify the live rendered output over HTTPS using `/home/luke/bin/verify-bookstack-page`:

```bash
/home/luke/bin/verify-bookstack-page <page_id_or_slug_or_url>
```

*Example Output:*
```text
Verified live rendered page: https://wiki.drheck.dev/books/system-setup/page/subocr-service
  Title: subocr service | Math blocks verified: 2 display ($$...$$), 7 inline ($...$)
  STATUS: PASS (0 script errors, 0 KaTeX errors, 0 unrendered TeX blocks)
```

Also inspect the rendered HTML of any modified formulas or sections directly from the live web server:
```bash
curl -skL https://wiki.drheck.dev/books/<book_slug>/page/<page_slug> | grep -C 3 "<search_term>"
```

<p class="callout warning"><strong>Why Database Length Checks Are Insufficient:</strong> Checking only <code>SELECT LENGTH(markdown), LENGTH(html) FROM entity_page_data</code> does not detect CommonMark backslash stripping (such as <code>\\</code> turned into <code>\ </code> or <code>\%</code> turned into a TeX comment <code>%</code>), Markdown <code>&lt;em&gt;</code> tags splitting a formula across DOM text nodes, or JavaScript syntax errors in <code>app-custom-head</code>. Always verify the live rendered page.</p>

---

## 5. Advanced Features & Typography Standards for Agents

BookStack on `ul` is enhanced with rich typographical, mathematical, and layout capabilities. Agents are encouraged to leverage these features:

### 5.1 KaTeX TeX Mathematical Engine (Globally Enabled)
A client-side KaTeX (`0.16.11`) rendering engine is active across all wiki pages, paired with server-side KaTeX verification in `/home/luke/bin/verify-bookstack-page`.

* **Display Equations (Block Math)**: Enclose in `$$ ... $$` on their own lines with blank lines before and after:
  ```latex
  $$\text{FREQ} = \frac{433,920,000}{24,000,000} \times 2^{16} = 1,184,883 = \mathbf{0x121473}$$
  ```
* **Inline Math**: Enclose in single `$ ... $` directly within sentences:
  `$T_e = 291.27\text{ }\mu\text{s}$`
* **Signal Chains & Architecture Pipelines**: Use `\longrightarrow` with `\text{...}` for hardware, DSP, and ML pipelines:
  ```latex
  $$\text{Video Frame } I(t) \longrightarrow \text{PP-OCRv5 (MIGraphX FP32)} \longrightarrow \text{Qwen2.5-32B (ROCm)} \longrightarrow \text{UniDic + JMdict/JLPT Study View}$$
  ```
* **Matrix & `cases` Row Breaks Require `\\\\` (Four Backslashes) or `\cr` in Markdown**:
  Because BookStack's server-side CommonMark parser unescapes `\\` into a single `\` before KaTeX runs, writing `\\` in Markdown produces `\ ` (a TeX space) in HTML and collapses multi-row matrices onto a single line. Always write `\\\\` (four backslashes) or `\cr` in Markdown so the rendered HTML contains `\\`:
  ```latex
  $$\begin{pmatrix} y_1 \\\\ y_2 \end{pmatrix} = \begin{bmatrix} a_{11} & a_{12} \\\\ a_{21} & a_{22} \end{bmatrix} \begin{pmatrix} x_1 \\\\ x_2 \end{pmatrix}$$
  ```
* **Percent Signs Inside Math Require `\\\%` in Markdown**:
  CommonMark unescapes `\%` into `%` in HTML. In TeX/KaTeX, an unescaped `%` is a comment character that swallows the `%` symbol and everything up to the closing `$`. Always write `\\\%` in Markdown (e.g. `$\le 1\\%$`) so the rendered HTML passes `\%` to KaTeX.
* **Subscript / Underscore Safety**: Do not put multiple unescaped underscores on the same line in LaTeX (e.g. `\text{DRATE_M}...\text{DRATE_E}`), as CommonMark will interpret them as Markdown italics (`<em>...</em>`) and split the DOM text node so KaTeX cannot match the closing `$`. Use hyphens (e.g. `\text{DRATE-M}`) or single subscripts (`x_1`).
* **No Backslash-Punctuation Spacing Macros (`\;`, `\,`, `\!`)**: Never use backslash-punctuation spacing commands inside Markdown math blocks. CommonMark strips backslashes before ASCII punctuation characters prior to client-side KaTeX rendering, turning `\;` into a literal `;` and `\,` into a literal `,`. Always use alphabetic TeX spacing macros (`\quad`, `\qquad`, `\text{ }`) or rely on built-in relation spacing around `\longrightarrow`.
* **Wrap Shell/Nginx Variables & Multiple Currency Amounts Carefully**: In prose outside code blocks, always wrap `$variable` names (e.g. `` `$host` ``, `` `$proxy_add_x_forwarded_for` ``) in backticks so KaTeX does not pair two `$` signs in the same paragraph as an inline math expression.

---

### 5.2 Native BookStack Callout Banners
Use BookStack's native HTML callout classes for colored, professional alert callouts:

```html
<p class="callout info"><strong>Info Note:</strong> Background context and operational instructions.</p>
<p class="callout success"><strong>Success:</strong> Empirical tests verified with zero errors.</p>
<p class="callout warning"><strong>Warning:</strong> Ensure Word Clock is 75Ω terminated on the final device.</p>
<p class="callout danger"><strong>Danger / Critical:</strong> Never run recursive find commands on host ul.</p>
```

---

### 5.3 Japanese Typography & Furigana (Ruby Text)
HTML5 Ruby annotations are fully supported in page bodies:

* **Whole-word Furigana**: `<ruby>漢字<rt>かんじ</rt></ruby>`
* **Per-character Furigana**: `<ruby>振<rt>ふ</rt>り<rt></rt>仮<rt>が</rt>名<rt>な</rt></ruby>`
* **Japanese in TeX Math**: `$$\text{周波数 } f = 433.92\text{ MHz}$$`

---

### 5.4 Collapsible Accordions (`<details>`)
Use collapsible details to tuck away long raw data logs, register dumps, or secondary tables:

```html
<details>
  <summary><strong>Click to expand technical registers</strong></summary>
  <p>Detailed register addresses and bitfield descriptions go here.</p>
</details>
```

---

### 5.5 High-Resolution Gallery Images with Lightbox Links
To embed images stored in the BookStack gallery with centering, scaling, and lightbox popup support:

```html
<p align="center">
  <a href="https://wiki.drheck.dev/uploads/images/gallery/YYYY-MM/image-name.jpg" target="_blank">
    <img src="https://wiki.drheck.dev/uploads/images/gallery/YYYY-MM/image-name.jpg" alt="Description" width="65%" style="border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); display: block; margin: 0 auto;">
  </a>
  <em style="display: block; text-align: center; margin-top: 6px; font-size: 0.9em; color: #666;">Figure 1: Descriptive caption.</em>
</p>
```

---

### 5.6 Comprehensive Capabilities Reference
For an exhaustive cheat sheet, math examples, and live test cases, refer to:
📖 **[Meta: Wiki Capabilities, TeX Math & Typography Reference](https://wiki.drheck.dev/books/wiki/page/meta-wiki-capabilities-tex-math-typography-reference)**

---

## 6. Critical Operational Gotchas for Agents

1. **Always Pull Down & Verify the Live Rendered Page**: Run `/home/luke/bin/verify-bookstack-page <page_id_or_slug>` after every edit to verify live HTTPS delivery, `<script>` syntax in `app-custom-head`, and strict KaTeX compilation of every `$$...$$` and `$...$` formula.
2. **Never Expose Internal Database IDs in Documentation**: Do not write internal numeric book or page IDs in user-facing text, changelogs, or documentation summaries. Always use human-readable titles and standard hyperlinks.
3. **Never Parse SQL Output or Update `app-custom-head` via Double-Quoted Shell Strings**: Double-quoted bash strings (`ssh ul "..."` or `mariadb -e "..."`) strip single quotes and expand `$$` into the bash PID, which corrupts both LaTeX formulas and the global `app-custom-head` KaTeX loader. Always use `/home/luke/bin/read-bookstack-page`, `/home/luke/bin/update-bookstack-page`, or hex/base64-encoded payloads.
4. **Strip Top-Level `# <Page Title>` Headers**: BookStack renders the page title as an `<h1>` header automatically. Strip `# <Page Title>` from markdown source to prevent duplicate headers.
5. **Preserve Attached Images**: Always check for image links before overwriting.
6. **Execute `artisan` as User `abc` (`-u abc -e HOME=/config -w /app/www`)**: Running `artisan` as `root` creates root-owned cache/log files in `/config/www/framework/cache` and `/config/log/bookstack/laravel.log`, causing `php-fpm` (`abc`) to return HTTP 500 errors.
7. **CommonMark Escaping Inside KaTeX**: Use `\\\\` (or `\cr`) for matrix/cases row breaks, `\\\%` for literal percent signs inside math, and `\quad`/`\qquad`/`\text{ }` instead of `\;`/`\,`/`\!`.


# Backup Process

## 1. Overview & Architecture

This server (`ul`) implements an automated, multi-tiered backup system designed around high reliability, cryptographic security, redundancy, and verified recoverability. The backup infrastructure covers 43 containerized and native host services, maintaining local PAR2-protected rollbacks and GPG-encrypted multi-cloud offsite copies.

### Key Philosophy
* **Self-Contained Service Scripts:** Each service has dedicated backup and restore scripts encapsulating its exact data dependencies (databases, ACLs, Quadlet definitions, and persistent volumes).
* **Parity & Error Correction:** Local backups utilize PAR2 (Parity Volume Set 2) with 10% redundancy to protect against bit-rot and silent disk corruption.
* **Zero-Trust Offsite Storage:** Cloud backups are client-side encrypted using GPG (AES-256 / SHA-512) before leaving local disk, preventing cloud storage providers from inspecting sensitive server state or database contents.
* **Strict Verification Before Retention Pruning:** Old backups (local and remote) are pruned **only** after a new backup run completes and passes 100% integrity verification checks (`rclone check` / `par2 verify`).

---

## 2. Directory & Data Structure

| Path | Purpose | Access Control |
| :--- | :--- | :--- |
| `/home/luke/git/backups/ul/scripts/` | Shell and Python backup/restore execution scripts (GPG-signed git repository) | Executable |
| `/data/backups/services/current/<service>/` | Most recent local backup run for each service | `700 luke:luke` |
| `/data/backups/services/archive/<service>/` | Previous local backup run(s) for point-in-time recovery | `700 root:root` |
| `/data/backups/cloud/` | Staged GPG-encrypted net archive ready for cloud upload | `700 luke:luke` |
| `/data/backups/isos/` | Staged optical media ISO images (when generating disk media) | `700 luke:luke` |

---

## 3. End-to-End Backup Workflow

Local service archive creation, cloud staging, cloud upload, and optical ISO creation are separated into distinct commands under `/home/luke/git/backups/ul/scripts/`:

```
+-----------------------------------------------------------------------------------+
| 1. Local Service Backups (`sudo ./run_all_backups`)                               |
| Loop over /ul/scripts/backup/* -> Dump DBs, tar files, generate PAR2 -> /current |
| Self-archive backup scripts themselves into /current/backup                       |
+-----------------------------------------------------------------------------------+
                     |                                           |
                     v                                           v
+-----------------------------------------+   +-------------------------------------+
| 2a. Cloud Staging                       |   | 2b. Optical ISO Staging             |
| (`./stage_cloud_archive`)               |   | (`./stage_iso_archive`)             |
| Pack /current -> GPG Encrypt -> /cloud  |   | Pack /current -> GPG ISOs -> /isos  |
+-----------------------------------------+   +-------------------------------------+
                     |                                           |
                     v                                           v
+-----------------------------------------+   +-------------------------------------+
| 3a. Cloud Upload & Remote Rotation      |   | 3b. Optical Media Burn & Verify     |
| (`./upload_cloud_archive [remotes]`)    |   | (`./burn_and_verify_iso <iso>`)     |
| Upload /cloud -> rclone check -> Rotate |   | Burn DVD/M-DISC -> Verify SHA256    |
+-----------------------------------------+   +-------------------------------------+
```

### Commands & Flags
* `sudo ./run_all_backups`: Creates local archives for all services in `/data/backups/services/current/<service>/` and archives the backup scripts themselves into `/data/backups/services/current/backup/` (default: local archives only, no cloud staging or upload).
* `sudo ./run_all_backups --upload [remotes]`: Runs all local service backups and then executes `stage_cloud_archive` and `upload_cloud_archive [remotes]`.
* `./stage_cloud_archive [--upload [remotes]]`: Stages the GPG-encrypted cloud archive in `/data/backups/cloud/` (and optionally uploads it immediately if `--upload` is passed).
* `./upload_cloud_archive [remotes]`: Uploads the staged `/data/backups/cloud/` directory to all configured cloud remotes, or to specific comma-separated remotes (e.g., `./upload_cloud_archive gdrive,protondrive`).
* `./stage_iso_archive`: Packs and GPG-encrypts `/data/backups/services/current/` into 4.7GB optical DVD ISO images in `/data/backups/isos/`.

---

## 4. Service-Level Backups (`/ul/scripts/backup/`)

Every service backup script performs atomic snapshots using the following standard pattern:

1. **Root Verification & Timestamping:** Requires root permissions (`EUID == 0`) to preserve subordinate container UID/GID ownership (`1100`, `624288`--`689823`), POSIX ACLs (`--acls`), and extended attributes (`--xattrs`). Timestamp format: `YYYY-MM-DD_HHMM.SS`.
2. **Local Archive Rotation:** Moves existing `/current/<service>/*` directories to `/archive/<service>/`.
3. **Consistent Database & Cache Snapshots:**
   * **Native Host PostgreSQL (`authentik`, `anchor`, `forgejo`, `immich`, `nextcloud_db`, `vikunja`, `wipstudio`):** Staged to `/tmp/<fn>.dump` first via `sudo -u postgres pg_dump -F c -b -v` before moving into `/data/backups/services/current/<service>/`.
   * **Containerized PostgreSQL (`penpot-postgres`, `funkwhale`):** Executed rootlessly via `(cd /tmp && sudo -u docker-data podman exec penpot-postgres pg_dump -U penpot -F c -b penpot)` and `podman exec -u funkwhale funkwhale pg_dumpall -U funkwhale`.
   * **Containerized MariaDB (`bookstack_db`, `romm-db`, `yourls-db`):** `(cd /tmp && sudo -u docker-data podman exec <db_container> mariadb-dump --single-transaction --routines --triggers ...)`.
   * **SQLite WAL Checkpoints (`navidrome`, `subocr`, `vaultwarden`):** Consistent online `.backup` or `PRAGMA wal_checkpoint(TRUNCATE)`.
   * **Native Valkey (`valkey.service`):** Triggers `BGSAVE` with dedicated read/save-only ACL user `backup` and archives `/var/lib/valkey/dump.rdb` + `/etc/valkey/`.
4. **Configuration, Quadlet & Volume Compression:**
   * Compressed using multi-threaded XZ (`XZ_OPT="-T0" tar --acls --xattrs -cJf ...`).
   * Preserves `.env`, `<service>.container`, `<service>.network`, application data/assets, and host configs (`systemd` archives `/etc/systemd`, `/etc/containers`, `/etc/haproxy`, `/etc/nftables.conf`, and `/home/docker-data/.config/containers/systemd`).
5. **PAR2 Recovery Generation:**
   * Generates 10% parity redundancy (`par2 create -r10`) directly alongside every database dump and tarball.
6. **Execution Logging:** Captures output to `backup_execution.log` inside each timestamped service backup folder.

### Backed-Up Services (43 Targets)
`acme`, `amux`, `anchor`, `audiomass`, `authentik`, `bentopdf`, `bookstack`, `cam`, `craft`, `crowdsec`, `degoog`, `demucs`, `drawpile`, `excalidraw`, `forgejo`, `funkwhale`, `grill`, `immich`, `jellyfin`, `mc_manager`, `minecraft_router`, `navidrome`, `nextcloud`, `nginx`, `penpot`, `rclone`, `romm`, `scontrol`, `sopro`, `subocr`, `sunshine`, `systemd`, `tldraw`, `transmission`, `valkey`, `vaultwarden`, `vikunja`, `wavacity`, `webrcade`, `webshell`, `wick`, `wipstudio`, `wireguard`, `yourls`.

---

## 5. Cloud Archiving & Encryption (`archive_pack.py`)

Local service backups are consolidated into a secure net package by `archive_pack.py` invoked via `stage_cloud_archive`:

* **Input Directory:** `/data/backups/services/current`
* **Output Directory:** `/data/backups/cloud/`
* **GPG Recipient:** Key fingerprint `9F9AC4567EDC8E2B86E5CB9475D6600BEF4E8E8F`
* **Cryptographic Specifications:** AES-256 cipher, SHA-512 digest.
* **Manifests & Hashes:** Generates `manifest.json`, `manifest.txt`, `SHA256SUMS`, and `DECRYPT_INSTRUCTIONS.txt` mapping original file sizes, staged sizes, SHA-256 hashes, and GPG metadata.
* **Optical Media Support:** `archive_pack.py` also supports `--mode disk` (for CD/DVD/Blu-ray ISO image staging with optional splitting and PAR2 protection via `stage_iso_archive`).

---

## 6. Cloud Orchestration & Provider Synchronization

Cloud synchronization is orchestrated by `upload_cloud_archive` calling provider scripts in `upload/`:

### Configured Providers
1. **Google Drive (`upload/gdrive`)**
2. **Proton Drive (`upload/protondrive`)**

### Upload Reliability Features
* **Exponential Backoff & Retries:** Up to 3 main attempt loops with exponential delay scaling (10s/15s initial delays). Low-level HTTP retries configured up to 20 attempts.
* **Self-Healing Draft Cleanup:** If `rclone copy` encounters mid-transfer errors or failed file copies, the upload script parses `rclone` logs and explicitly deletes failed/corrupted draft files from the remote before retrying.
* **Strict Integrity Verification:** Post-upload, `rclone check --one-way --size-only` verifies that remote file sizes match local files exactly.
* **Atomic Failure Safety Guard:** If upload or verification fails across all attempts, the script deletes the partial remote upload directory (`rclone purge`) and **skips retention rotation** to preserve existing valid cloud backups.

### Remote Retention Policy
* **Max Remote Backups:** Retains the newest **3** successful cloud backups (`MAX_BACKUPS=3`).
* **Automated Proton Trash Cleaning:** Executes custom Go binary (`proton-trash-cleaner`) to automatically purge deleted backup files from Proton Drive's cloud trash bin.

---

## 7. Restoration Practice & Recovery Procedure

Restoration scripts reside in `/home/luke/git/backups/ul/scripts/restore/`. Each service script handles full state recovery:

### Standard Restore Procedure
1. **Target Selection:** Accepts an explicit backup directory or timestamp as argument. If omitted, defaults to the latest backup in `/data/backups/services/current/<service>/`.
2. **PAR2 Integrity Check & Self-Repair:** Runs `par2 verify` on database dumps and file tarballs prior to performing any system modifications. Automatically executes `par2 repair` if corruption is detected.
3. **Interactive Confirmation:** Prompts user for explicit confirmation (`yes/no`) before stopping services or modifying state.
4. **Service Suspension:** Gracefully stops active rootless Podman Quadlet units (`sudo -u docker-data XDG_RUNTIME_DIR=/run/user/1100 systemctl --user stop <service>.service`) or native systemd units.
5. **Rollback Snapshot Creation:** Preserves current configurations in a timestamped rollback folder (`<service_dir>_rollback_<timestamp>`).
6. **File & Database Restoration:**
   * Extracts compressed tarball over service data directory with POSIX ACL/xattr and subordinate UID/GID preservation.
   * Re-creates target database schema and imports the SQL dump or PostgreSQL custom binary dump.
7. **Service Resumption:** Reloads and restarts rootless Quadlets (`sudo -u docker-data XDG_RUNTIME_DIR=/run/user/1100 systemctl --user daemon-reload && sudo -u docker-data XDG_RUNTIME_DIR=/run/user/1100 systemctl --user start <service>.service`).

---

## 8. Retention Pruning Practice (`prune_old_local_backups`)

To prevent local storage exhaustion on the server, local service backup folders are managed by `/home/luke/git/backups/ul/scripts/prune_old_local_backups`:

* **Local Retention Limit:** Retains the **2** most recent backups per service (`KEEP_COUNT=2`) across `/data/backups/services/current` and `/data/backups/services/archive`.
* **Safe Dry-Run Flag:** Supports `--dry-run` (`-n`) to inspect directories eligible for pruning without performing deletions.


# Studio PIKVM

This document details the network architecture, perimeter/host firewall projections, Authentik Single Sign-On (SSO) integration, mutual TLS (mTLS), Nginx reverse proxy configuration for the Studio PiKVM device, and the complete technical specification for the **Studio Control Gateway** (`scontrol.drheck.dev`).

---

## 1. Network & DNS Architecture

Clean separation between administrative direct terminal access and Authentik-gated browser access is maintained across dedicated DNS records and network flows:

| Hostname | Target IPs | Purpose | Protection Mechanism |
| :--- | :--- | :--- | :--- |
| **`studio-kvm-ssh.drheck.dev`** | `10.0.0.7`<br>`fd77:7777:5010:0:e65f:1ff:fee4:8911` | Direct hardware & SSH administration | SSH Key Authentication (`id_ultra` / `id_scontrol`) |
| **`studio-kvm-web.drheck.dev`** | `10.0.0.100`<br>`fd77:7777:5010:0:1337:f1ac:d00d:aaaa` | Authentik-protected browser Web UI | Authentik SSO + ForwardAuth + mTLS |
| **`scontrol.drheck.dev`** | `10.0.0.100`<br>`fd77:7777:5010:0:1337:f1ac:d00d:aaaa` | Studio One-Touch Orchestration Gateway | Authentik SSO ForwardAuth (Group `studio`) |
| **`bmo95.drheck.dev`** | `10.0.0.6`<br>`fd77:7777:5010:0:a236:9fff:fe29:bc72` | Studio Recording PC | Subnet Isolation & SSH Key Auth |

---

## 2. Security Projections & Layered Defense

### Layer 1: Outbound Gateway Isolation on Host `ul` (`nftables`)
Unprivileged SSH users logged into host `ul` are blocked by the kernel from directly connecting to PiKVM web ports (TCP 80/443) or Studio Control backend (TCP 8000). Only Nginx worker processes (`http`, UID 33), rootless Podman containers (`docker-data`, UID 1100, so `scontrol` can query the PiKVM ATX API), and administrative users (`root`, UID 0; `akadmin`, UID 1101) are permitted outbound connections:

**Rules in `/etc/nftables.conf` (`chain output`):**
```nftables
# Studio PiKVM (Direct KVM Access Protection — permits root 0, nginx 33, docker-data 1100, akadmin 1101)
ip daddr 10.0.0.7 tcp dport { 80, 443 } meta skuid != { 0, 33, 1100, 1101 } reject with tcp reset comment "Block direct local access to Studio PiKVM IPv4"
ip6 daddr fd77:7777:5010:0:e65f:1ff:fee4:8911 tcp dport { 80, 443 } meta skuid != { 0, 33, 1100, 1101 } reject with icmpx admin-prohibited comment "Block direct local access to Studio PiKVM IPv6"

# Studio Control Gateway Isolation
ip6 daddr fd77:7777:5010:0:1337:f1ac:d00d:5c01 tcp dport 8000 meta skuid != { 0, 33, 1101 } reject with icmpx admin-prohibited comment "Block direct access to Studio Control backend"
```

### Layer 2: Pre-Shared Proxy Secret Header (`X-Proxy-Secret`)
* A high-entropy pre-shared token is stored in `/etc/nginx/certs/studio_secret.conf` (mode `0600`, owned by `http:http`).
* Nginx injects this header alongside `X-KVMD-User: $authentik_username` when proxying upstream requests to PiKVM.

### Layer 3: Mutual TLS (mTLS) Client Certificate
* Dedicated internal CA certificate: `/etc/nginx/certs/pikvm_ca.crt`
* Nginx client certificate: `/etc/nginx/certs/pikvm_client.crt`
* Nginx client private key: `/etc/nginx/certs/pikvm_client.key` (mode `0600`, owned by `http:http`)
* Nginx presents this client certificate to PiKVM during TLS handshake (`proxy_ssl_certificate`).

### Layer 4: Inbound Host Firewall on PiKVM (`studio-kvm-ssh`)
On PiKVM itself, firewall rules reject all incoming HTTP/HTTPS connections unless sourced from host `ul`:
```bash
iptables -A INPUT -p tcp -m multiport --dports 80,443 -s 10.0.0.100 -j ACCEPT
iptables -A INPUT -p tcp -m multiport --dports 80,443 -j DROP

ip6tables -A INPUT -p tcp -m multiport --dports 80,443 -s fd77:7777:5010:0:1337:f1ac:d00d:aaaa -j ACCEPT
ip6tables -A INPUT -p tcp -m multiport --dports 80,443 -j DROP
```

---

## 3. Authentik SSO Configuration

* **Applications:**
  * **Studio PiKVM (`studio-kvm-web`)**: URL `https://studio-kvm-web.drheck.dev/` (with secondary alias `scontroll.drheck.dev` redirecting cleanly via `301` to `https://scontrol.drheck.dev/`)
  * **Studio Control (`scontrol`)**: URL `https://scontrol.drheck.dev/`
* **Proxy Provider:** ForwardAuth Single Mode with `cookie_domain='drheck.dev'` and explicit callback redirect URIs.
* **Authorization Flow:** `default-provider-authorization-implicit-consent` (transparent single-click launch).
* **Access Control:** Bound to Authentik Group **`studio`** (members: `luke`, `ikura`) via `PolicyBinding`. Non-members receive access denied.

---

## 4. Studio Control Gateway (`scontrol.drheck.dev`)

### 4.1 Architecture & Implementation
* **Git Repository:** `/home/luke/git/scontrol` (tracked with `.gitignore`, `build.sh`, and `Dockerfile`).
* **Deployment Location:** `/data/docker/scontrol` (`scontrol.container` and `scontrol.network`, symlinked into `/home/docker-data/.config/containers/systemd/` under rootless Podman `docker-data`, `UID 1100`).
* **Network Binding:** Dedicated ULA IPv6 address `[fd77:7777:5010:0:1337:f1ac:d00d:5c01]:8000`.
* **Dedicated Service Key:** `/data/docker/scontrol/id_scontrol` (ed25519 key registered in `luke@studio-kvm-ssh.drheck.dev:~/.ssh/authorized_keys`).

### 4.2 Building & Restarting
```bash
# Build image in rootless Podman store and restart Quadlet service
(cd /data/docker/scontrol && sudo -u docker-data HOME=/home/docker-data XDG_RUNTIME_DIR=/run/user/1100 podman build -t localhost/scontrol:latest /data/docker/scontrol)
sudo systemctl --user -M docker-data@ restart scontrol.service
```

### 4.3 Orchestration Execution Pipeline (`/api/stream/on`)
1. **RF Power Outlets (YARD Stick One on PiKVM)**:
   * Connects to `studio-kvm-ssh.drheck.dev` via SSH using `id_scontrol`.
   * Runs `sudo /home/luke/git/studio_yardstick/bin/studio-power all on` to power Moroccan Lamp (`mrc`), Recording Rack (`rack`), Studio Computer (`computer`), Subwoofer (`sub`), Main PA Speakers (`pa`), TV Monitor (`tv`), and Neewer LED Lights (`neewer`).
2. **Motherboard ATX Power Ignition**:
   * Queries PiKVM ATX state via `GET https://studio-kvm-ssh.drheck.dev/api/atx`.
   * If power rail is off, triggers `POST https://studio-kvm-ssh.drheck.dev/api/atx/click?button=power` and polls for rail confirmation.
3. **Studio Computer Boot Verification (`bmo95.drheck.dev`)**:
   * Probes DNS hostname `bmo95.drheck.dev` with a 100-second boot timeout.
4. **Soundcards & Audio Readiness**:
   * Audits 4x Echo AudioFire 12 interfaces (`hw:AudioFire12,0` through `hw:AudioFire12_3,0`) and Emagic Unitor8 USB MIDI interface.
   * Confirms `jackd`, `zita-a2j`, `zita-j2a`, `a2jmidid`, and `ardour.service` DAW readiness.

### 4.4 Strict Error Gating & Real-Time SSE Console
* If any step fails, the sequence immediately aborts and transitions the UI to `ERROR / FAILED`.
* Real-time progress is streamed live via Server-Sent Events (`/api/stream/on` and `/api/stream/off`).
* Monospace console displays colored tags for `[RF]`, `[ATX]`, `[HOST]`, `[AUDIO]`, `[SYSTEM]`, and `[ERROR]`.
* Secondary actions allow querying live hardware state (`🔄 Refresh Status`) and one-touch shutdown (`🛑 Power Off All`).


# Service Update Process

This document outlines the standard operational procedures for updating all containerized and native services hosted on `ul`. All 30 containerized services (41 containers) run under **rootless Podman Quadlets** (`docker-data`, `UID 1100`); `dockerd` and `containerd` are retired. See [podman](https://wiki.drheck.dev/books/system-setup/page/podman) for full architecture and troubleshooting details.

---

## Authentik Update Process

Authentik runs as three rootless Podman Quadlet units (`authentik-server.service`, `authentik-worker.service`, and `authentik-ldap.service`) on `authentik_net` in `/data/docker/authentik/`, backed by native loopback PostgreSQL (`169.254.1.2:5432`) and native ACL-isolated Valkey (`169.254.1.2:6379`, database `0`), with host HAProxy (`/etc/haproxy/conf.d/ldap.cfg`) forwarding ports `389`/`636` to `:3389`/`:6636`.

### 1. Determining When an Update is Available

Authentik follows Calendar Versioning (`YYYY.M.P`, e.g., `2026.8.3`).

* **Admin Web UI:** Log in to [auth.drheck.dev](https://auth.drheck.dev) as an administrator and navigate to **Admin Interface** &rarr; **System** &rarr; **Overview**.
* **GitHub Releases:** Monitor the official [Authentik Releases](https://github.com/goauthentik/authentik/releases) page for release tags, changelogs, and release notes.
* **Current Installed Version:** Check the running image tags on `ul`:
  ```bash
  # Check configured Quadlet image tags
  grep '^Image=' /data/docker/authentik/authentik-*.container

  # Check live running rootless containers
  (cd /tmp && sudo -u docker-data HOME=/home/docker-data XDG_RUNTIME_DIR=/run/user/1100 podman ps --filter name=authentik --format "table {{.Names}}\t{{.Image}}\t{{.Status}}")
  ```

* Read the [Release Notes](https://docs.goauthentik.io/releases/) for every release between your current version and the target version.
> **Do not skip minor versions** if you are multiple releases behind (e.g., upgrade one minor `YYYY.M` series at a time and allow database migrations in `authentik-worker` and `authentik-server` to finish before proceeding).

---

### 2. Update Procedure

#### Step 1: Perform Pre-Update Backup
Always create an atomic, PAR2-verified snapshot of the PostgreSQL database, configuration files, and `.container` Quadlet units first:

```bash
sudo /home/luke/git/backups/ul/scripts/backup/authentik
```
*Creates `/data/backups/services/current/authentik/<YYYY-MM-DD_HHMM.SS>/` containing `authentik_db_backup.dump` and `authentik_files_backup.tar.xz` with 10% PAR2 recovery parity.*

#### Step 2: Pull New Container Images
Because all `.container` units set `Pull=never` for offline boot resilience, pull the new target images into `docker-data`'s store before editing units:

```bash
NEW_VER="2026.8.3"
(cd /tmp && sudo -u docker-data HOME=/home/docker-data XDG_RUNTIME_DIR=/run/user/1100 podman pull "ghcr.io/goauthentik/server:${NEW_VER}")
(cd /tmp && sudo -u docker-data HOME=/home/docker-data XDG_RUNTIME_DIR=/run/user/1100 podman pull "ghcr.io/goauthentik/ldap:${NEW_VER}")
```

#### Step 3: Update Quadlet `.container` Image Tags
Update `Image=` in `/data/docker/authentik/authentik-{server,worker,ldap}.container`:

```bash
sudo sed -i "s|^Image=ghcr.io/goauthentik/server:.*|Image=ghcr.io/goauthentik/server:${NEW_VER}|" \
  /data/docker/authentik/authentik-server.container \
  /data/docker/authentik/authentik-worker.container
sudo sed -i "s|^Image=ghcr.io/goauthentik/ldap:.*|Image=ghcr.io/goauthentik/ldap:${NEW_VER}|" \
  /data/docker/authentik/authentik-ldap.container
```

#### Step 4: Reload Systemd User Manager & Restart Services
```bash
sudo systemctl --user -M docker-data@ daemon-reload
sudo systemctl --user -M docker-data@ restart authentik-worker.service authentik-server.service authentik-ldap.service
```

#### Step 5: Monitor Startup & Database Migrations
```bash
sudo -u docker-data XDG_RUNTIME_DIR=/run/user/1100 journalctl --user -u authentik-worker.service -u authentik-server.service -f
```

---

### 3. After-Update Verification Checks

1. **Container & Unit Health:**
   ```bash
   (cd /tmp && sudo -u docker-data HOME=/home/docker-data XDG_RUNTIME_DIR=/run/user/1100 podman ps --filter name=authentik)
   curl -sk -o /dev/null -w "%{http_code}\n" https://auth.drheck.dev/-/health/live/
   ```
2. **LDAPS Certificate & SSSD Online Check:**
   ```bash
   openssl s_client -connect 10.0.0.100:636 -servername auth.drheck.dev </dev/null 2>&1 | grep "Verify return code"
   sudo sssctl domain-status goauthentik.io --online
   ```
   *(If TLS certificate sync is needed at any time, run `sudo /usr/local/bin/update-authentik-cert.sh`.)*
3. **SSO Flow Check:** Open `https://auth.drheck.dev/` and test an OIDC or ForwardAuth app (e.g., `https://scontrol.drheck.dev/` or `https://pw.drheck.dev/`).

---

### 4. Rollback Procedure (If Upgrade Fails)

```bash
# Restore latest backup (or pass an explicit YYYY-MM-DD_HHMM.SS timestamp)
sudo /home/luke/git/backups/ul/scripts/restore/authentik
```

The restore script verifies PAR2 parity archives, stops `authentik-{ldap,server,worker}.service` under `docker-data`, snapshots current files to `/data/docker/authentik_rollback_<timestamp>/`, restores `.env`, Quadlets, templates, and PostgreSQL schema/data, and restarts all three units.

---

## Standard Rootless Podman Quadlet Update Workflow

Every containerized service in `/data/docker/<service>/` follows a unified backup-first workflow under `docker-data`:

### 1. Pre-Update Backup
```bash
sudo /home/luke/git/backups/ul/scripts/backup/<service>
```

### 2. Updating Registry-Backed Services

* **Rolling-Tag Services** (`audiomass`, `bentopdf`, `bookstack`, `collabora`, `degoog`, `drawpile`, `excalidraw`, `funkwhale`, `navidrome`, `penpot`, `romm`, `step-ca`, `tldraw`, `vaultwarden`, `vikunja`, `webrcade`, `wick`, `yourls`):
  ```bash
  # Pull the new image(s) into docker-data's storage
  (cd /tmp && sudo -u docker-data HOME=/home/docker-data XDG_RUNTIME_DIR=/run/user/1100 podman pull <image_name>)

  # Restart the Quadlet service unit(s)
  sudo systemctl --user -M docker-data@ restart <service>.service
  ```
* **Pinned-Tag Services** (`anchor`, `authentik`, `immich`, `nextcloud`):
  1. Pull the new pinned image tag as `docker-data`.
  2. Update `Image=` in `/data/docker/<service>/<unit>.container` (and `.env` if mirrored there).
  3. Run `sudo systemctl --user -M docker-data@ daemon-reload && sudo systemctl --user -M docker-data@ restart <unit>.service`.

### 3. Rebuilding Locally Built `Dockerfile` Services

Use `TMPDIR=/dev/shm` so multi-gigabyte builds (`demucs` and `subocr` ROCm images) stage layers in RAM:

```bash
(cd /data/docker/<service> && sudo -u docker-data HOME=/home/docker-data XDG_RUNTIME_DIR=/run/user/1100 TMPDIR=/dev/shm podman build -t localhost/<service>:latest /data/docker/<service>)
sudo systemctl --user -M docker-data@ restart <service>.service
```

Applies to: `cam`, `demucs`, `grill`, `scontrol`, `sopro`, `subocr`, `webshell`, and `wipstudio` (`localhost/wipstudio:latest`).

### 4. Service-Specific Update Notes

* **Degoog (`/data/docker/degoog`, `search.drheck.dev`):** Automated source/dependency updates run cleanly inside the container namespace via `sudo systemctl start degoog-update.service` (`/usr/local/bin/degoog-update`), preserving subordinate container UID `625287` ownership.
* **Immich (`/data/docker/immich`, `photos.drheck.dev`):** Pull both `ghcr.io/immich-app/immich-server:vX.Y.Z` and `ghcr.io/immich-app/immich-machine-learning:vX.Y.Z-rocm`, update `immich_server.container` and `immich_machine_learning.container`, reload, and restart both units. Verify via `curl -sk https://photos.drheck.dev/api/server/ping`.
* **Nextcloud & Collabora (`/data/docker/nextcloud`, `cloud.drheck.dev`, `office.drheck.dev`):** Upgrade `nextcloud:<version>-apache` one major release at a time, then run post-upgrade index maintenance:
  ```bash
  (cd /tmp && sudo -u docker-data HOME=/home/docker-data XDG_RUNTIME_DIR=/run/user/1100 podman exec -u www-data nextcloud php occ status)
  (cd /tmp && sudo -u docker-data HOME=/home/docker-data XDG_RUNTIME_DIR=/run/user/1100 podman exec -u www-data nextcloud php occ db:add-missing-indices)
  ```
* **RomM (`/data/docker/romm`, `romm.drheck.dev`):** Keep `filesystem.structure` (`default: "roms/{platform}/{game}"`, `firmware: "bios/{platform}"`) in `/data/docker/romm/config/config.yml` for RomM `5.3.0+`.
* **WIPStudio MinIO (`/data/docker/wipstudio`, `wipstudio.drheck.dev`):** Upstream removed prebuilt Docker Hub tags; a local xz image archive is preserved at `/data/docker/wipstudio/minio_image_backup.tar.xz` (`podman load -i /data/docker/wipstudio/minio_image_backup.tar.xz`), and source updates build via `/data/docker/wipstudio/Dockerfile.minio`.

---

## Native Systemd Services (`postgresql`, `valkey`, `nginx`, `haproxy`, `forgejo`, `ollama`, `podman`, `openssh`)

1. Run configuration backups first:
   ```bash
   sudo /home/luke/git/backups/ul/scripts/backup/systemd
   sudo /home/luke/git/backups/ul/scripts/backup/nginx
   sudo /home/luke/git/backups/ul/scripts/backup/forgejo
   sudo /home/luke/git/backups/ul/scripts/backup/valkey
   ```
2. Upgrade packages via `pacman`:
   ```bash
   sudo pacman -Sy --noconfirm podman netavark aardvark-dns passt nginx haproxy valkey forgejo ollama ollama-vulkan openssh
   ```
3. Restart updated host services and verify health:
   ```bash
   sudo systemctl restart nginx haproxy valkey forgejo ollama sshd
   systemctl is-active postgresql valkey nginx haproxy forgejo ollama sshd
   ```


# NextCloud OCC

`occ` (ownCloud/Nextcloud Console) is Nextcloud's command-line interface for configuration, maintenance, user administration, and system upgrades.

In this environment, Nextcloud runs inside rootless Podman (`docker-data`, `UID 1100`) as the `nextcloud` container (`nextcloud.container` systemd user Quadlet). All `occ` commands must be executed as the web server user (`www-data`) inside the container.

---

## Running `occ` in Rootless Podman

### Standard Command Pattern

```bash
(cd /tmp && sudo -u docker-data podman exec -u www-data nextcloud php occ <command>)
```

### Shell Alias

To simplify running commands across your terminal sessions, define an alias:

```bash
alias occ='(cd /tmp && sudo -u docker-data podman exec -u www-data nextcloud php occ)'
```

*(All commands below assume this alias is active.)*

---

## Getting Started: Status and Help

```bash
# Check Nextcloud version, installation status, and maintenance state
occ status

# List all available commands
occ list

# Get detailed help for any specific command
occ help config:system:set
```

---

## Practical Example: Redirecting Root `/` to Files

By default, Nextcloud directs authenticated users to the Dashboard app (`/apps/dashboard/`). You can customize the landing page using `config:system:set` and `config:app:set`:

```bash
# Set the default application to Files
occ config:system:set defaultapp --value="files"

# Configure core default page to redirect root / directly to user files
occ config:app:set core defaultpage --value="/apps/files/files"
```

To verify the current values:

```bash
occ config:system:get defaultapp
occ config:app:get core defaultpage
```

---

## System Configuration (`config:system:*`)

System configurations directly inspect or modify `config/config.php`.

### Reading & Writing Settings

```bash
# Get a configuration value
occ config:system:get trusted_domains

# Set a scalar string or boolean value
occ config:system:set defaultapp --value="files"
occ config:system:set htaccess.IgnoreFrontController --value="true" --type=bool

# Set array elements by index
occ config:system:set trusted_domains 2 --value="cloud.drheck.dev"

# Remove a setting
occ config:system:delete defaultapp
```

---

## App Configuration & Management (`config:app:*` & `app:*`)

### Managing App Settings

```bash
# List all config keys for an app
occ config:app:get core

# Get a single key
occ config:app:get core defaultpage

# Set a key value
occ config:app:set core defaultpage --value="/apps/files/files"

# Delete a setting
occ config:app:delete core defaultpage
```

### Enabling, Disabling, and Updating Apps

```bash
# List installed, enabled, and disabled apps
occ app:list

# Enable or disable an app
occ app:enable files_rightclick
occ app:disable dashboard

# Update all installed apps
occ app:update --all
```

---

## Filesystem Operations (`files:*`)

Useful when files are added, modified, or deleted externally on disk (e.g. through local mounts or background syncing).

```bash
# Rescan files for all users
occ files:scan --all

# Rescan files for a single user
occ files:scan luke

# Rescan a specific subdirectory for a user
occ files:scan --path="/luke/files/Documents"

# Clean up stale file cache entries
occ files:cleanup
```

---

## Maintenance & Database Tasks

### Maintenance Mode

```bash
# Enable maintenance mode (blocks user web access)
occ maintenance:mode --on

# Disable maintenance mode
occ maintenance:mode --off
```

### Database Optimization & Missing Indices

Nextcloud updates occasionally recommend adding missing indices or columns:

```bash
# Add missing database indices
occ db:add-missing-indices

# Add missing columns
occ db:add-missing-columns

# Add missing primary keys
occ db:add-missing-primary-keys
```

### Post-Upgrade Migration

After pulling a newer container image:

```bash
occ upgrade
```

---

## User Management (`user:*`)

```bash
# List all registered users
occ user:list

# View details for a user
occ user:info luke

# Reset user password interactively
occ user:resetpassword luke

# Enable or disable a user account
occ user:disable username
occ user:enable username
```


# sunshine remote setup

This guide documents the complete, native, headless gaming and remote streaming stack on **Arch Linux** (`ul.drheck.dev`) using **Sunshine**, **Gamescope**, and **Steam**, accelerated by the dedicated **AMD Radeon AI PRO R9700 (32GB VRAM)**.

Clients connect using the open-source **Moonlight** client across any device (Desktop, Android TV, iOS, handhelds).

---

## 1. System Architecture & Network Topology

* **Host OS**: Arch Linux (Rolling, LTS Kernel `6.18.x+`)
* **Primary Gaming GPU**: AMD Radeon AI PRO R9700 (`1002:7551`, Navi 48, DRM `/dev/dri/card1` & `/dev/dri/renderD128`)
* **Server BMC Graphics**: ASPEED AST2600 (`card0` / `VGA-1` — ignored for gaming)
* **Compositor**: Valve's **Gamescope** micro-compositor (DRM mode, Vulkan/RADV accelerated)
* **Streaming Server**: **Sunshine** (Native binary from AUR, KMS capture + VA-API hardware encoding)
* **Audio Engine**: **PipeWire** & **PipeWire-Pulse**
* **Network Binding**:
  * **Dedicated IPv6 Address**: `[fd77:7777:5010:0:1337:f1ac:d00d:4799]`
  * **Dedicated IPv4 Address**: `10.0.0.112`
  * **Admin Web Portal**: `https://sunshine.drheck.dev` (Authentik forward-auth protected, routed via Nginx)

---

## 2. Required Packages

Install the native gaming stack, graphics drivers, and audio subsystems:

```bash
# Core tools, AMD Vulkan drivers, Gamescope, Steam, and audio
sudo pacman -S --needed gamescope steam vulkan-radeon lib32-vulkan-radeon \
  pipewire pipewire-pulse wireplumber seatd socat libcap

# Sunshine binary from AUR
paru -S sunshine-bin
```

Ensure user `luke` belongs to the required hardware access groups:
```bash
sudo usermod -aG video,audio,input,render,seat luke
```

Enable systemd lingering so the user's services run 24/7 in the background without requiring an active shell login:
```bash
loginctl enable-linger luke
```

---

## 3. Headless Display via Custom Boot EDID

On a headless server with no physical monitor plugged into the AMD GPU, the `amdgpu` driver will report `disconnected` and reject mode setting. To solve this without purchasing physical HDMI/DP dummy dongles, the kernel is configured to load an **EDID firmware file at boot** to simulate a physical 1080p 144Hz monitor on `DP-1`.

### Step 1: Place Verified EDID Binary
Download and save a verified 1080p DisplayPort EDID (256-byte CEA-861 compliant binary) to the firmware directory:
```bash
sudo mkdir -p /usr/lib/firmware/edid
curl -s "https://git.linuxtv.org/v4l-utils.git/plain/utils/edid-decode/data/aoc-c24g1-dp" | sudo tee /usr/lib/firmware/edid/aoc_1080p.bin > /dev/null
```

### Step 2: Bundle EDID into Initramfs
Because `amdgpu` loads early during boot (via the `kms` hook), the EDID binary must be packaged inside the initramfs. Edit `/etc/mkinitcpio.conf`:
```ini
FILES=(/usr/lib/firmware/edid/aoc_1080p.bin)
```
Regenerate the initramfs:
```bash
sudo mkinitcpio -P
```

### Step 3: Configure Bootloader Kernel Parameters
In `/boot/loader/entries/arch.conf`, append the `drm.edid_firmware` and `video` parameters to the `options` line:
```ini
title Arch
linux /vmlinuz-linux-lts
initrd /amd-ucode.img
initrd /initramfs-linux-lts.img
options root="UUID=4496ac5e-4538-4ce4-8ca2-afc0b828ee80" rw drm.edid_firmware=DP-1:edid/aoc_1080p.bin video=DP-1:1920x1080@60e
```
*After rebooting, the kernel recognizes `DP-1` as a permanently connected 1080p DisplayPort monitor with all standard refresh rates.*

---

## 4. Dual-Stack Networking & IPv4 `socat` Bridge

In our dual-stack architecture on `ul`, Sunshine binds natively to its dedicated IPv6 ULA address. To allow IPv4 clients to connect without asymmetric routing issues, a dedicated `socat` systemd service listens on `10.0.0.112` and forwards bidirectionally to the IPv6 address.

### Bridge Script: `/usr/local/bin/sunshine-ipv4-bridge.sh`
```bash
#!/usr/bin/env bash
IPV4="10.0.0.112"
IPV6="fd77:7777:5010:0:1337:f1ac:d00d:4799"

TCP_PORTS=(47984 47989 47990 48010)
UDP_PORTS=(47998 47999 48000 48002 48010)

pids=()

term_handler() {
    for pid in "${pids[@]}"; do
        kill -TERM "$pid" 2>/dev/null
    done
    wait
    exit 0
}
trap term_handler SIGINT SIGTERM

for port in "${TCP_PORTS[@]}"; do
    socat TCP4-LISTEN:"$port",bind="$IPV4",reuseaddr,fork TCP6:["$IPV6"]:"$port" &
    pids+=($!)
done

for port in "${UDP_PORTS[@]}"; do
    socat UDP4-LISTEN:"$port",bind="$IPV4",reuseaddr,fork UDP6:["$IPV6"]:"$port" &
    pids+=($!)
done

wait
```
Make executable: `sudo chmod +x /usr/local/bin/sunshine-ipv4-bridge.sh`

### Systemd Unit: `/etc/systemd/system/sunshine-ipv4-bridge.service`
```ini
[Unit]
Description=Sunshine IPv4 to IPv6 socat Bridge
After=network.target

[Service]
Type=simple
ExecStart=/usr/local/bin/sunshine-ipv4-bridge.sh
Restart=always
RestartSec=3

[Install]
WantedBy=multi-user.target
```
Enable and start:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now sunshine-ipv4-bridge.service
```

### Nginx Reverse Proxy Config
Nginx on `ul` handles SSL termination and Authentik ForwardAuth for the web interface in `/etc/nginx/sites-available/sunshine`:
```nginx
location / {
    proxy_pass https://10.0.0.112:47990;
    proxy_ssl_verify off;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade_keepalive_sunshine;
    proxy_set_header Host $host;
    proxy_buffering off;
}
```

---

## 5. Permissions & Hardware Capabilities

### 1. Game Controller Permissions (`/dev/uinput` and `/dev/uhid`)
Sunshine emulates Xbox controllers via `uinput` and native PlayStation 5 DualSense controllers via `uhid`. Ensure `/etc/udev/rules.d/85-sunshine-uinput.rules` contains:
```udev
KERNEL=="uinput", SUBSYSTEM=="misc", GROUP="input", MODE="0660"
KERNEL=="uhid", SUBSYSTEM=="misc", GROUP="input", MODE="0660"
```
Ensure the `uhid` kernel module loads at boot:
```bash
echo "uhid" | sudo tee /etc/modules-load.d/uhid.conf
```

### 2. Session Management (`seatd`)
To allow Gamescope to open DRM display sessions without an interactive login seat, configure `/etc/systemd/system/seatd.service.d/group.conf`:
```ini
[Service]
ExecStart=
ExecStart=seatd -g video
```
Enable and start: `sudo systemctl enable --now seatd`


### 4. DualSense HIDAPI Permissions (`/dev/hidraw*`)
Steam accesses DualSense controllers via its raw HIDAPI driver on `/dev/hidraw*`. On a headless server without an interactive physical seat, the default `uaccess` tag fails to grant permissions to non-root users. Create `/etc/udev/rules.d/99-steam-controller-perms.rules`:
```udev
KERNEL=="hidraw*", KERNELS=="*054C:0CE6*", GROUP="input", MODE="0666"
KERNEL=="hidraw*", ATTRS{idVendor}=="054c", ATTRS{idProduct}=="0ce6", GROUP="input", MODE="0666"
SUBSYSTEM=="hidraw", ENV{HID_NAME}=="*DualSense*", GROUP="input", MODE="0666"
SUBSYSTEM=="hidraw", ENV{HID_NAME}=="*Wireless Controller*", GROUP="input", MODE="0666"
SUBSYSTEM=="hidraw", ENV{HID_PHYS}=="libvirtualhid/uhid/*", GROUP="input", MODE="0666"
```
Reload udev rules:
```bash
sudo udevadm control --reload-rules && sudo udevadm trigger
```

### 3. Binary Capabilities
Allow Gamescope to set real-time thread scheduling and Sunshine to capture DRM frames without root:
```bash
sudo setcap 'cap_sys_nice=eip' /usr/bin/gamescope
sudo setcap 'cap_sys_admin,cap_sys_nice=eip' /usr/bin/sunshine
```

---

## 6. Sunshine Configuration

### File: `~/.config/sunshine/sunshine.conf`
```ini
system_tray = disabled
min_log_level = info
origin_pin_allowed = lan
origin_web_ui_allowed = lan
csrf_allowed_origins = https://sunshine.drheck.dev

# Network binding
bind_address = fd77:7777:5010:0:1337:f1ac:d00d:4799
address_family = both
external_ip = 10.0.0.112

# Explicit GPU & Display pinning (prevents capturing motherboard ASPEED VGA)
adapter_name = /dev/dri/card1
output_name = DP-1
gamepad = ds5
```

### File: `~/.config/sunshine/apps.json`
```json
{
  "env": {
    "PATH": "$(PATH):$(HOME)/bin:$(HOME)/.local/bin"
  },
  "apps": [
    {
      "name": "Steam Big Picture",
      "cmd": "/home/luke/bin/launch-gamescope-steam.sh",
      "image-path": "steam.png"
    }
  ]
}
```

---

## 7. Gamescope Launch Script

Create `/home/luke/bin/launch-gamescope-steam.sh`:
```bash
#!/usr/bin/env bash
set -e

WIDTH="${SUNSHINE_CLIENT_WIDTH:-1920}"
HEIGHT="${SUNSHINE_CLIENT_HEIGHT:-1080}"
FPS="${SUNSHINE_CLIENT_FPS:-60}"

export XDG_RUNTIME_DIR="/run/user/1000"
export LIBSEAT_BACKEND="seatd"
export RADV_PERFTEST="video_encode"
export MESA_VK_DEVICE_SELECT="1002:7551"

# Force Steam to use SDL evdev instead of broken HIDAPI on virtual DualSense
export SDL_JOYSTICK_HIDAPI_PS5=0
export SDL_JOYSTICK_HIDAPI=0

# Verified DualSense mapping for Sunshine virtual pad (including PS Guide button b10)
export SDL_GAMECONTROLLERCONFIG="050057564c050000e60c000000006800,PS5 Controller,crc:5657,platform:Linux,a:b0,b:b1,x:b3,y:b2,dpleft:h0.8,dpright:h0.2,dpup:h0.1,dpdown:h0.4,leftx:a0,lefty:a1,leftstick:b11,rightx:a3,righty:a4,rightstick:b12,leftshoulder:b4,lefttrigger:a2,rightshoulder:b5,righttrigger:a5,back:b8,start:b9,guide:b10,steam:2,050000004c050000e60c000011810000,PS5 Controller,crc:519b,platform:Linux,a:b0,b:b1,x:b3,y:b2,dpleft:h0.8,dpright:h0.2,dpup:h0.1,dpdown:h0.4,leftx:a0,lefty:a1,leftstick:b11,rightx:a3,righty:a4,rightstick:b12,leftshoulder:b4,lefttrigger:a2,rightshoulder:b5,righttrigger:a5,back:b8,start:b9,guide:b10,steam:2,"

exec gamescope -e --backend drm --prefer-vk-device 1002:7551 -W "$WIDTH" -H "$HEIGHT" -r "$FPS" -- steam -bigpicture
```
Make executable: `chmod +x /home/luke/bin/launch-gamescope-steam.sh`

---

## 8. Sunshine Systemd User Service

Create `~/.config/systemd/user/sunshine.service`:
```ini
[Unit]
Description=Sunshine Game Stream Host (Headless)
After=network.target pipewire.service
Wants=pipewire.service

[Service]
Type=simple
ExecStart=/usr/bin/sunshine /home/luke/.config/sunshine/sunshine.conf
Restart=always
RestartSec=3

[Install]
WantedBy=default.target
```
Enable and start:
```bash
systemctl --user daemon-reload
systemctl --user enable --now sunshine.service
```

---


---

## 9. Dedicated Game Storage Configuration (`/data/games/steam`)

To prevent Steam games, shader caches, and downloading staging files from filling up the root NVMe SSD (`/`), the game library is permanently linked to the **151 TB ZFS storage pool** at `/data/games/steam/`:

1. **Storage Directory:**
   ```bash
   mkdir -p /data/games/steam/steamapps
   chown -R luke:luke /data/games/steam/steamapps
   ```
2. **Permanent Symlinks:**
   ```bash
   ln -sfn /data/games/steam/steamapps ~/.local/share/Steam/steamapps
   ln -sfn /data/games/steam/steamapps ~/.steam/steam/steamapps
   ```
3. **Steam Library Config (`~/.local/share/Steam/config/libraryfolders.vdf`):**
   Only library folder `"0"` (`/home/luke/.local/share/Steam`) is configured, seamlessly routing 100% of game downloads directly to `/data/games/steam/steamapps/`. Zero bytes of game files touch the root drive.

---

## 10. Troubleshooting & Gotchas

### Gotcha 1: Moonlight Pairing Fails with "Client is not authorized"
* **Symptom:** You enter the 4-digit PIN in the Sunshine web UI and it reports success, but Moonlight immediately says *"Host PC returned error: client is not authorized"*.
* **Root Cause:** A known Sunshine bug (Issue #5696). If `sunshine_state.json` accumulates duplicate device entries or certificate collisions from multiple re-pair attempts, TLS verification fails.
* **Fix:**
  1. Stop Sunshine: `systemctl --user stop sunshine.service`
  2. Clear the paired devices array:
     ```python
     python3 -c '
     import json
     p = "/home/luke/.config/sunshine/sunshine_state.json"
     with open(p) as f: d = json.load(f)
     d["root"]["named_devices"] = []
     with open(p, "w") as f: json.dump(d, f, indent=4)
     '
     ```
  3. Start Sunshine: `systemctl --user start sunshine.service`
  4. Pair your Moonlight client with a fresh PIN.

### Gotcha 2: Sunshine Startup Encoder Probe Delay ("Establishing Connection...")
* **Symptom:** When connecting right after starting/restarting Sunshine, Moonlight hangs at *"Establishing connection..."* for up to 50 seconds.
* **Explanation:** At startup, Sunshine runs sequential self-tests for all hardware codecs (H.264, HEVC, AV1 in 8-bit and 10-bit). During this probe, the streaming engine is busy and pauses client connections. Wait ~50 seconds after service start before initiating a stream.

### Gotcha 3: Motherboard ASPEED Video Hijacking Stream
* **Symptom:** The stream opens but displays the Linux text console (TTY1) with kernel messages instead of the game.
* **Root Cause:** Dual-GPU servers have an onboard ASPEED AST2600 BMC chip (`card0` / `VGA-1`). Without explicit configuration, Sunshine selects `card0`.
* **Fix:** Ensure `adapter_name = /dev/dri/card1` and `output_name = DP-1
gamepad = ds5` are explicitly set in `~/.config/sunshine/sunshine.conf`.

### Gotcha 4: PS5 DualSense Controller Layout & Android TV
* **Sony / Android TV Home Button:** Android TV consumes the physical PS button as an OS-level Home key. To access the Steam menu in Big Picture:
  * **Hold the `Start / Options` button** for 1.5 seconds, OR
  * Press **`Share + Options`** simultaneously.
* **Touchpad as Mouse:** Android TV does not pass through gamepad touchpad data over Bluetooth; use the analog stick or Moonlight's built-in mouse mode (`Hold Start`).

### Gotcha 5: Native Linux Builds vs. Proton
* For games that offer official Linux binaries (e.g. *Hollow Knight*, *VirtuaVerse*), force the native build in Steam:
  * Game page → **Properties** (Gear) → **Compatibility** → Check *"Force the use of a specific Steam Play compatibility tool"* → Select **"Steam Linux Runtime"**.
  * This runs the game natively on Linux without Wine or DXVK overhead.

### Gotcha 6: Linux Console Displaying on TV on Multi-GPU Servers
* **Symptom:** Moonlight displays a Linux text console instead of Steam Big Picture.
* **Root Cause:** On dual-GPU servers (ASPEED BMC `card0` + discrete gaming GPU `card1`), Linux `fbcon` binds console output across all framebuffers (`fb0` and `fb1`). If Gamescope is not actively running, `tty1` mirrors to `DP-1`.
* **Fix:** Keep `getty@tty1.service` enabled for the BMC/VGA console. Ensure Gamescope is active so it claims the display surface, or map `fbcon` exclusively to the BMC adapter (`fbcon=map:0`).

### Gotcha 7: Controller Registers as Keyboard & Mouse in Games (e.g. Portal)
* **Symptom:** The mouse moves on screen and clicks, but physical controller buttons are not registering in the game or act like keyboard keys.
* **Root Cause:**
  1. For PC releases of games like *Portal* and *Half-Life 2*, Steam Input defaults to a community layout called *"Keyboard (WASD) and Mouse"*.
  2. Additionally, Moonlight Android TV features an on-screen mouse emulation mode toggled by holding **Options / Start for 1.5 seconds**.
* **Fix:**
  * In Steam Big Picture: Navigate to the game page → click the **Controller Icon** → change **Current Layout** to **"Gamepad"**.
  * On your controller: Ensure Moonlight is not in mouse mode by holding **Options / Start** for 1.5s to toggle back to Gamepad mode.

### Gotcha 8: PS5 Controller Drops After 5 Seconds (`hid_read failure`)
* **Symptom:** The virtual DualSense appears in Steam Controller Settings upon connection, but after ~5 seconds it disconnects, reports `Controller device closed after hid_read failure` in `~/.local/share/Steam/logs/controller.txt`, and disappears from the UI.
* **Root Cause:** Steam has a built-in HIDAPI driver for Sony DualSense (`054c:0ce6`). When permissions are granted to `/dev/hidraw*`, Steam attempts to poll proprietary Sony hardware feature reports (gyro calibration, adaptive trigger registers). Sunshine's virtual `uhid` controller does not implement these low-level reports, causing `hid_read` to time out and Steam to terminate the controller connection.
* **Fix:** In `/home/luke/bin/launch-gamescope-steam.sh`, export:
  ```bash
  export SDL_JOYSTICK_HIDAPI_PS5=0
  export SDL_JOYSTICK_HIDAPI=0
  ```
  This forces Steam to bypass raw HIDAPI and interact with the controller via standard Linux evdev (`sdl://`), which works reliably.

### Gotcha 9: Controller Moves Mouse Cursor / "NO CONTROLLER" in Games (e.g. Hollow Knight)
* **Symptom:** Controller navigation works in Steam Big Picture, but when launching a game (e.g., *Hollow Knight*), no gamepad buttons work, the right stick or touchpad moves the desktop mouse cursor, and the in-game controller menu displays *"NO CONTROLLER"*.
* **Root Cause:**
  1. Gamescope was run without the `-e` / `--steam` flag, preventing Gamescope from relaying Wayland window focus state back to Steam.
  2. For games flagged in Steam's database with "Native Controller Support", Steam defaults Steam Input to **Disabled** for that specific game (`Opted-in Controller Mask: 1000`). When Steam Input is disabled or window focus is ambiguous, Steam drops the controller into **Desktop Layout** (`PollState Changed from 2 to 1`), translating physical buttons into mouse clicks and keyboard keys.
* **Fix:**
  1. Ensure Gamescope is executed with `-e` (`gamescope -e --backend drm ...`) in the launch script.
  2. In Steam Big Picture, navigate to the game page (e.g. *Hollow Knight*) → click the **Controller Icon** (next to Play) → set **Steam Input** to **"Enable Steam Input"**.
  3. Under Steam **Settings** → **Controller** → **Desktop Layout** → **Edit**, set the template to **"Gamepad"**. This ensures the controller continues sending standard gamepad events even if a game triggers desktop state.

### Gotcha 10: Accessing Steam Menu & Quitting Games with PS5 Controller
* **Symptom:** Pressing the physical PlayStation (PS) button does not open the Steam Overlay or allow quitting the game.
* **Explanation & Navigation Shortcuts:**
  * **Client OS Interception:** On most client devices (especially Android TV, Fire TV, and Apple TV), the operating system captures the physical PS/Home button to return to the host OS home launcher rather than forwarding it to Moonlight.
  * **Moonlight Guide Emulation:** Moonlight allows sending the Steam Guide command to the host:
    * **Hold `Start / Options` for 1.5 - 2 seconds**, OR
    * Press **`Share / Select + Options / Start`** simultaneously.
  * **Steam Chord Shortcut to Force Quit:** Once Steam receives the Guide input, hold **Guide + B** for 3 seconds to force-kill the currently running game and return directly to Steam Big Picture.
  * **Moonlight Stream Disconnect / Exit:**
    * On Android TV / Shield: Double-tap or hold **Start/Options** to trigger Moonlight's on-screen menu overlay, where you can select "Quit Current Session".
    * On Desktop Moonlight: Press `Ctrl + Alt + Shift + Q` to disconnect.



# Local models

## System Overview & Hardware Architecture

The local AI inference stack on `ul` leverages dedicated AMD hardware acceleration paired with extensive system memory and high-capacity storage:

* **GPU:** **AMD Radeon AI PRO R9700** (Navi 48, RDNA 4 / `gfx1201`)
  * **VRAM:** **32 GB GDDR6** high-bandwidth dedicated video memory
  * **Compute Runtime:** Vulkan (RADV GFX1201) / ROCm 7.2 stack
* **Host CPU:** AMD Ryzen Threadripper PRO 5955WX (16 Cores / 32 Threads @ up to 4.5 GHz)
* **Host Memory:** **128 GB DDR4 ECC RAM** (125 GiB addressable)
* **Model Storage:** `/data/ollama` on the 160 TB ZFS storage pool
* **Inference Server:** **Ollama 0.34.2** managed via systemd (`ollama.service`)

With 32 GB of dedicated GPU VRAM and 128 GB of host RAM, the system supports both **100% GPU-offloaded execution** (models up to ~27–30 GB running at maximum throughput) and **hybrid GPU/CPU offload** (models up to 70B+ spanning across VRAM and system memory).

---

## Active Model Roster & Hardware Allocation

All models are tuned with `num_ctx 32768` and verified with autonomous tool calling.

### 1. Tier 1: 100% GPU VRAM Offload (Ultra-Fast, ~40–55 tok/s)

These models fit completely inside the 32 GB GDDR6 VRAM buffer with 10–16 GB of VRAM headroom for deep 32k KV-caches:

* **`qwen3-coder:30b` (30B Active-MoE)**
  * **Quantization:** Q5_K_M (~21 GB)
  * **Architecture:** Mixture-of-Experts (30B parameters with 3B active per token).
  * **Throughput:** ~50–60 tokens/sec (100% GPU VRAM offload).
  * **Best For:** Cutting-edge software engineering, autonomous tool calling, refactoring, and multi-file code generation.
* **`deepseek-r1-abliterated:32b` (32B Uncensored)**
  * **Quantization:** Q4_K_M (~19 GB)
  * **Throughput:** ~35–45 tokens/sec (100% GPU VRAM offload).
  * **Best For:** Deep multi-step `<think>` chain-of-thought reasoning with **mathematical refusal vectors stripped**. Unfiltered, unaligned analysis for security research, reverse engineering, exploit analysis, and technical problem solving.
* **`qwen3.8:27b` (27B)**
  * **Quantization:** Q4_K_M (~16 GB)
  * **Throughput:** ~45–55 tokens/sec (100% GPU VRAM offload).
  * **Best For:** Modern general intelligence, agent execution, and command orchestration.
* **`qwen2.5:32b` (32B)**
  * **Quantization:** Q4_K_M (~19 GB)
  * **Throughput:** ~35–42 tokens/sec. Reliable fallback workhorse.
* **`deepseek-r1:32b` (32B)**
  * **Quantization:** Q4_K_M (~19 GB)
  * **Throughput:** ~35–40 tokens/sec. Standard alignment reasoning.

### 2. Tier 2: Hybrid VRAM + RAM Partitioning (~6–9 tok/s)

* **`llama3.3:70b` (70B)**
  * **Quantization:** Q4_K_M (~42 GB)
  * **Memory Partition:** 48 of 81 layers offloaded to GPU VRAM (~24 GB); remaining layers in system DDR4 ECC memory.
  * **Best For:** Frontier-grade multi-document synthesis and complex creative analysis.

---

## Censorship, Alignment & Abliteration

Standard commercial and base open weights (e.g. standard Llama releases) include safety-tuning that often produces unwanted refusals, disclaimers, or moralizing lectures on benign technical queries (e.g. penetration testing, reverse engineering, security research, or creative writing).

### What is Weight Abliteration?
**Abliteration** mathematically identifies the specific activation directions and weight vectors responsible for refusal responses and orthogonally projects them out of the model layers. 
* Unlike coarse fine-tuning, abliteration does **not** induce catastrophic forgetting or degrade benchmark reasoning capabilities.
* The model complies directly with technical, security, and administrative tasks without artificial guardrail refusals.

---

## Running Models via CLI

Ollama is running as a local service listening on `127.0.0.1:11434`.

### Interactive Chat Session
```bash
# Run Qwen 2.5 32B interactively
ollama run qwen2.5:32b

# Run DeepSeek-R1 32B reasoning model
ollama run deepseek-r1:32b

# Run Llama 3.3 70B
ollama run llama3.3:70b
```

### Management Commands
```bash
# List all locally cached models and their disk usage
ollama list

# Check currently active loaded models in VRAM/RAM
ollama ps

# Pull a new model
ollama pull <model-name>

# Remove a model
ollama rm <model-name>
```

---

## Using with OpenCode CLI (`opencode`)

You can run `opencode` directly against the local Ollama daemon using its OpenAI-compatible API interface.

### 1. Configuration in `~/.config/opencode/opencode.jsonc`

Add the local Ollama provider to your OpenCode configuration:

```jsonc
{
  "$schema": "https://opencode.ai/config.json",
  "instructions": [
    "~/.config/opencode/INSTRUCTIONS.md"
  ],
  "provider": {
    "ollama": {
      "name": "Local Ollama",
      "npm": "@ai-sdk/openai-compatible",
      "options": {
        "baseURL": "http://127.0.0.1:11434/v1",
        "apiKey": "ollama"
      },
      "models": {
        "qwen2.5:32b": {
          "name": "Qwen 2.5 32B Local",
          "limit": {
            "context": 32768,
            "output": 8192
          }
        },
        "deepseek-r1:32b": {
          "name": "DeepSeek R1 32B Local",
          "reasoning": true,
          "limit": {
            "context": 32768,
            "output": 8192
          }
        },
        "llama3.3:70b": {
          "name": "Llama 3.3 70B Local",
          "limit": {
            "context": 32768,
            "output": 8192
          }
        }
      }
    }
  }
}
```

### 2. Invoking OpenCode with Local Models

```bash
# Launch interactive session with local Qwen 32B
opencode -m ollama/qwen2.5:32b

# Run a one-off prompt
opencode run -m ollama/qwen2.5:32b "Analyze the system logs in /var/log/nginx/access.log"

# Use DeepSeek-R1 for complex architectural reasoning
opencode -m ollama/deepseek-r1:32b
```

### 3. Model Inspection & Auto-Sync Tool (`opencode_list`)

A dedicated CLI tool `/home/luke/bin/opencode_list` is installed in `$PATH` to monitor local models, their hardware execution profile, installation date/age, and capabilities:

```bash
# View all installed models with stats, age, and capabilities (newest at bottom)
opencode_list

# Sort by size or name if preferred
opencode_list --sort size
opencode_list --sort name

# Output raw JSON for scripting
opencode_list --json
```

**Auto-Synchronization:**
Whenever you pull a new model via `ollama pull <name>`, simply running `opencode_list` automatically detects the new weights, derives its capabilities, and writes the model entry directly into `~/.config/opencode/opencode.jsonc`. No manual JSON configuration is required.

---

---

## Sourcing & Discovering Models: Why NOT to Rely on Ollama's Catalog

### 1. Ollama's Library is Severely Outdated
Ollama's official online catalog (`ollama.com/library`) is manually curated by the Ollama maintainers. Official tags (e.g. `ollama pull ...`) are only created weeks or months after open-source model releases have saturated the community. 

**Never rely on Ollama's catalog to see what models exist.**

### 2. Hugging Face is the Upstream Source of Truth
All cutting-edge open weights drop first on **Hugging Face** (`https://huggingface.co/`), often within hours of release:
* **Quantizers to watch:** **`unsloth`** (`unsloth/Qwen...`), **`bartowski`**, and model creators directly (`Qwen`, `meta-llama`, `mistralai`).
* **VRAM Sizing Rule of Thumb (for 32 GB R9700):**
  * **27B – 32B Models (Q4_K_M):** ~15–18 GB file size. Consumes ~17–21 GB VRAM with full 32,768 context KV-cache. **100% GPU offload, zero system RAM penalty.**
  * **70B Models (Q4_K_M):** ~42–44 GB file size. Automatically partitions across VRAM (~24 GB) and host DDR4 RAM.

### 3. The Direct Hugging Face → Ollama Workflow
Because Ollama's internal pull tool sometimes blocks CDN redirects from Hugging Face (`hf.co`), download directly and import:

1. **Download the GGUF file to `/data/llm/models/`:**
   ```bash
   curl -L -C - -o /data/llm/models/Qwen3.8-27B-UD-Q4_K_M.gguf \
     "https://huggingface.co/unsloth/Qwen3.8-27B-GGUF/resolve/main/Qwen3.8-27B-UD-Q4_K_M.gguf"
   ```
2. **Create a clean Modelfile (`/tmp/Modelfile`):**
   ```dockerfile
   FROM /data/llm/models/Qwen3.8-27B-UD-Q4_K_M.gguf
   PARAMETER num_ctx 32768
   ```
3. **Import into Ollama:**
   ```bash
   sudo -u ollama ollama create qwen3.8:27b -f /tmp/Modelfile
   ```

---

## Tool Calling & Autonomous Agent Execution (OpenCode / Aider)

For local models to successfully execute commands, access the filesystem, and write code inside autonomous agents:

### 1. The 2k Context Window Trap (`num_ctx`)
* By default, **Ollama sets a 2,048-token context window** on all models if not specified in the Modelfile.
* When agents like OpenCode or Aider send system instructions and 15+ JSON tool schemas, the initial prompt is often **8,000+ tokens**.
* **Ollama silently discards everything past 2,048 tokens**, chopping off the tool definitions before the model even reads your message.
* **Fix:** Every Modelfile must explicitly define `PARAMETER num_ctx 32768`.

### 2. Schema Validation Strictness (JSON Typing)
OpenCode validates tool arguments with strict TypeScript schemas. If a model passes numeric parameters (like `timeout`) as quoted strings (`"timeout": "120000"`), the schema validator rejects the call.
* Add an explicit instruction to `~/.config/opencode/INSTRUCTIONS.md`:
  ```markdown
  # Tool Calling Schema Rule
  When invoking tools, all numeric parameters (such as timeout) MUST be passed as unquoted integers (e.g. timeout: 120000), NEVER as strings.
  ```

### 3. Upstream Parser Differences: Qwen 3.8 & Qwen 2.5 vs. Qwen-Coder
* **`qwen3.8:27b` and `qwen2.5:32b`:** Properly emit structured `<tool_call>` XML tags that Ollama recognizes, allowing OpenCode to execute `bash`, `read`, `write`, and `edit` commands in real time.
* **`qwen2.5-coder:32b`:** Dumps raw JSON without `<tool_call>` tags, causing Ollama's regex to fail and dump the text to the chat window instead of triggering the tool.

## Other Interfaces & Integrations

The local Ollama instance exposes standard APIs that integrate seamlessly with any LLM client or Web UI.

### 1. OpenAI-Compatible API Endpoint

* **Base URL:** `http://127.0.0.1:11434/v1`
* **API Key:** Any string (e.g. `ollama`)

Example `curl` call:
```bash
curl http://127.0.0.1:11434/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "qwen2.5:32b",
    "messages": [
      {"role": "system", "content": "You are a Linux systems engineering assistant."},
      {"role": "user", "content": "Provide a Bash one-liner to find open sockets by PID."}
    ],
    "temperature": 0.2
  }'
```

### 2. Python Client (`openai` SDK)

```python
from openai import OpenAI

client = OpenAI(
    base_url="http://127.0.0.1:11434/v1",
    api_key="ollama",
)

response = client.chat.completions.create(
    model="qwen2.5:32b",
    messages=[{"role": "user", "content": "How do I optimize ZFS ARC on Linux?"}],
)
print(response.choices[0].message.content)
```

### 3. Open WebUI (Rootless Podman Setup)

To deploy a ChatGPT-style web interface connected directly to the local Ollama instance (`169.254.1.2:11434` mapped via `passt` user-mode networking to `127.0.0.1:11434`):

```bash
(cd /tmp && sudo -u docker-data podman run -d \
  --name open-webui \
  --network pasta:--map-host-loopback,169.254.1.2 \
  -p [fd77:7777:5010:0:1337:f1ac:d00d:0e81]:8080:8080 \
  -v /data/open-webui:/app/backend/data \
  -e OLLAMA_BASE_URL=http://169.254.1.2:11434 \
  ghcr.io/open-webui/open-webui:main)
```
*(Configure reverse proxy with Authentik SSO in `/etc/nginx/sites-available/`).*

### 4. VS Code / JetBrains (Continue.dev Extension)

In `~/.continue/config.json`:
```json
{
  "models": [
    {
      "title": "Local Qwen 2.5 32B",
      "provider": "ollama",
      "model": "qwen2.5:32b",
      "apiBase": "http://127.0.0.1:11434"
    }
  ],
  "tabAutocompleteModel": {
    "title": "Local Qwen 2.5 Coder 7B",
    "provider": "ollama",
    "model": "qwen2.5-coder:7b",
    "apiBase": "http://127.0.0.1:11434"
  }
}
```

---

## Service Configuration & Storage

* **Service Unit:** `/usr/lib/systemd/system/ollama.service`
* **Override Config:** `/etc/systemd/system/ollama.service.d/override.conf`
  * `OLLAMA_MODELS=/data/ollama` (routes all model weights to the fast 160 TB pool)
  * `OLLAMA_SCHED_SPREAD=1`
  * `OLLAMA_MEMORY_VALIDATION=false`
* **GPU Permissions:** User `ollama` belongs to `video` and `render` groups (`/dev/kfd` and `/dev/dri/renderD128`).
* **Service Management:**
  ```bash
  sudo systemctl restart ollama
  sudo journalctl -u ollama -f
  ```




# Local Grill Monitoring Setup

<p class="callout info"><strong>System Overview:</strong> The <strong>Local Grill Monitoring Setup</strong> provides a 100% self-hosted, cloud-free telemetry and historical graphing pipeline for the <strong>FireBoard 2 (<code>FBX2-GJ8RC3JRD</code>)</strong> 6-probe grill thermometer. The FireBoard 2 itself has <strong>no IP address and no Wi-Fi connection</strong>. Instead, an internal <strong>Seeed Studio XIAO ESP32-C3</strong> bridge (hostname <code>fireboardctl.drheck.dev</code>, static DHCP IP <code>10.0.0.71</code>, Wi-Fi MAC <code>80:f1:b2:51:49:20</code>), switched on and off in hardware by an <strong>IRLB8721PBF</strong> N-Channel MOSFET driven directly from the FireBoard's <code>[Pin 35: TXD0]</code> pad, reads all probe channels over Bluetooth Low Energy GATT (<code>94:54:c5:f7:1c:fa</code>) and posts live updates over Wi-Fi to <a href="https://grill.drheck.dev">https://grill.drheck.dev</a> (<code>http://10.0.0.3:8088/api/ingest</code>).</p>

## 1. Hardware Wiring & Flashing Guide (`Seeed Studio XIAO ESP32-C3` + `IRLB8721PBF` MOSFET)

### 1.1 Parts Used
* **Microcontroller (`fireboardctl.drheck.dev`)**: **Seeed Studio XIAO ESP32-C3** ($21\text{ mm} \times 17.5\text{ mm}$, Wi-Fi STA MAC `80:f1:b2:51:49:20`, static DHCP IP `10.0.0.71`, built-in Li-Ion battery pads on underside + external `U.FL` antenna socket).
* **External Antenna**: Included **Seeed `U.FL` (IPEX) 2.4 GHz flexible dipole sticker antenna** (snap onto the XIAO's `ANT` socket before powering on; adhere inside the plastic FireBoard shell away from the metal Li-Ion pouch cell).
* **Hardware Power Switch MOSFET**: **`IRLB8721PBF`** TO-220 Logic-Level N-Channel MOSFET ($V_{\text{GS(th)}} = 1.35\text{ V–}2.35\text{ V}$, $R_{\text{DS(on)}} \approx 0.013\text{ }\Omega$ at $V_{\text{GS}} = 3.3\text{ V}$).

### 1.2 Step-by-Step Pin Connections (4 Wires Total)

<p class="callout warning"><strong>MOSFET Pin Orientation (<code>IRLB8721PBF</code>):</strong> Hold the MOSFET with the <strong>black printed label facing you</strong> and the <strong>metal heatsink tab facing away</strong> (legs pointing down). From left to right, the 3 legs are:<br>
• <strong>Pin 1 (Left Leg):</strong> <code>Gate (G)</code> — Trigger input ($3.3\text{ V}$ ON / $0.0\text{ V}$ OFF)<br>
• <strong>Pin 2 (Middle Leg):</strong> <code>Drain (D)</code> — Switched ground going to XIAO <code>BAT-</code><br>
• <strong>Pin 3 (Right Leg):</strong> <code>Source (S)</code> — True ground going to FireBoard Battery <code>-</code> (<code>GND</code>)</p>

1. **Wire 1 (`BAT+` Power)**: **FireBoard Battery `+` (Red)** $\longrightarrow$ **XIAO underside `BAT+` pad**.
2. **Wire 2 (`Source` Ground)**: **FireBoard Battery `-` (Black / `GND`)** $\longrightarrow$ **MOSFET Pin 3 (Right Leg, `Source`)**.
3. **Wire 3 (`Drain` Switched Ground)**: **MOSFET Pin 2 (Middle Leg, `Drain`)** $\longrightarrow$ **XIAO underside `BAT-` pad**.
4. **Wire 4 (`Gate` 3.3V Trigger)**: **FireBoard 6-pin header top-left pad (`[Pin 35: TXD0]`)** $\longrightarrow$ **MOSFET Pin 1 (Left Leg, `Gate`)**.
   * When the FireBoard 2 is turned **ON**, `[Pin 35: TXD0]` outputs **$3.30\text{ V}$**, turning the `IRLB8721PBF` MOSFET **ON** and connecting the XIAO's `BAT-` pad to ground so the XIAO boots immediately.
   * When the FireBoard 2 is turned **OFF**, `[Pin 35: TXD0]` drops to **$0.00\text{ V}$**, turning the `IRLB8721PBF` MOSFET **OFF** and completely disconnecting the XIAO from the battery ($0.000\text{ mA}$ standby drain).

### 1.3 Complete 4-Wire Schematic

```text
  FIREBOARD 2 MAIN PCB / BATTERY                   IRLB8721PBF MOSFET              SEEED XIAO ESP32-C3
  ==============================                   (Label Facing You)              ===================

  1S Li-Ion Battery (+) [3.7V-4.2V] ---------------------------------------------> Underside [BAT+] Pad

  6-Pin Header [35 TXD0] (3.3V ON / 0V OFF) ----> Pin 1 [Gate (G) - Left]

  1S Li-Ion Battery (-) / [GND] ----------------> Pin 3 [Source (S) - Right]

                                                  Pin 2 [Drain (D) - Middle] ----> Underside [BAT-] Pad
```

<p class="callout info"><strong>Why Power Connects to the Underside <code>BAT+</code> / <code>BAT-</code> Pads (Not the Top <code>5V</code> Pin):</strong> On the Seeed Studio XIAO ESP32-C3, the top <code>5V</code> (<code>VBUS</code>) pin passes through a protection Schottky diode ($\Delta V_{\text{diode}} \approx 0.34\text{ V}$) before feeding the <code>XC6206P332MR</code> $3.3\text{ V}$ LDO ($\Delta V_{\text{drop}} \approx 0.25\text{ V}$), requiring at least $3.85\text{ V}$ on <code>5V</code> before the $3.3\text{ V}$ rail sags. Soldering to the <strong>underside <code>BAT+</code> pad</strong> bypasses the Schottky diode and feeds the LDO directly ($V_{\text{min}} = 3.30\text{ V} + 0.25\text{ V} = 3.55\text{ V}$), running cleanly across the entire $3.5\text{ V–}4.2\text{ V}$ discharge curve of the 1S Li-Ion cell.</p>

### 1.4 Building, Flashing & Bench Testing (`PlatformIO`)
1. Snap the `U.FL` antenna onto the XIAO ESP32-C3 before powering on.
2. Copy `include/secrets.example.h` to `include/secrets.h` in `~/git/grill-sync` and set your 2.4 GHz Wi-Fi credentials:
   ```cpp
   #define WIFI_SSID "Your_2.4GHz_WiFi_SSID"
   #define WIFI_PASSWORD "Your_WiFi_Password"
   ```
3. Compile, flash over USB-C, and open the live serial monitor (`115200` baud):
   ```shell
   $ pio run -t upload -t monitor
   ```
4. Turn on the FireBoard 2 nearby with a probe plugged into Channel 1. Within 5 seconds, the serial console outputs:
   ```text
   === FireBoard 2 BLE -> Wi-Fi Sync (fireboardctl.drheck.dev) ===
   [WiFi] Connected, IP: 10.0.0.71, MAC: 80:F1:B2:51:49:20
   [BLE] Found FireBoard (94:54:c5:f7:1c:fa, RSSI -42 dBm)
   [BLE] Connected to FireBoard 2
   [BLE] Subscribed and sent 0x01 start command
   [BLE] Ch 1: 72.5 F
   [HTTP] POST http://10.0.0.3:8088/api/ingest -> 200
   ```

---

## 2. FireBoard 2 Hardware Forensics & PCB Pinout Reference

### 2.1 Internal ESP32-WROVER-E Photograph & Pin Labels

<p align="center">
  <a href="https://wiki.drheck.dev/uploads/images/gallery/2026-09/esp32.jpg" target="_blank">
    <img src="https://wiki.drheck.dev/uploads/images/gallery/2026-09/scaled-1680-/esp32.jpg" alt="FireBoard 2 ESP32-WROVER-E Pinout" width="75%" style="border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); display: block; margin: 0 auto;">
  </a>
  <em style="display: block; text-align: center; margin-top: 6px; font-size: 0.9em; color: #666;">Figure 1: FireBoard 2 internal ESP32-WROVER-E (MGN8R8) module and unpopulated 6-pin 1.27mm UART bootloader header with verified pin numbers.</em>
</p>

### 2.2 Unpopulated 6-Pin `1.27mm` (`0.050"`) Debug Header Pinout

Located immediately below the `ESP32-WROVER-E` module on the main PCB. All pads were verified by continuity against the castellated `ESP32-WROVER-E` pins:

```text
      TOP ROW (Closer to ESP32 bottom edge)
   +----------------+----------------+----------------+
   |  [Pin 35] *    |  [Pin 25]      |  [GND]         |
   |  UART0 TXD0    |  GPIO0 (BOOT)  |  Ground        |
   |  (Right #23)   |  (Right #14)   |  (Shield/1/15) |
   +----------------+----------------+----------------+
   |  [Pin 34]      |  [Pin 3]       |  [Pin 24]      |
   |  UART0 RXD0    |  EN / RST      |  GPIO2 (Strap) |
   |  (Right #22)   |  (Left #3)     |  (Right #13)   |
   +----------------+----------------+----------------+
      BOTTOM ROW (Closer to PCB edge)

   * [Pin 35: TXD0] (top-left pad) outputs 3.30V when FireBoard is ON
     and 0.00V when FireBoard is OFF — wire this pad to MOSFET Pin 1 (Gate).
```

| Header Pad | ESP32 Module Pin | Function | Voltage (FireBoard ON) | Voltage (FireBoard OFF) | Notes |
| :--- | :--- | :--- | :--- | :--- | :--- |
| **`[35 TXD0]`** | Pin 35 (`Right #23`) | `GPIO1` / `U0TXD` | **$3.30\text{ V}$** (Idle High) | **$0.00\text{ V}$** | **Used as the $3.3\text{ V}$ ON/OFF trigger for MOSFET Pin 1 (`Gate`)** |
| **`[34 RXD0]`** | Pin 34 (`Right #22`) | `GPIO3` / `U0RXD` | **$3.30\text{ V}$** | **$0.00\text{ V}$** | `UART0` receive input |
| **`[25 IO0]`** | Pin 25 (`Right #14`) | `GPIO0` (Boot Strap) | **$3.30\text{ V}$** | **$0.00\text{ V}$** | Hold at $0\text{ V}$ (`GND`) while pulsing `[3 RST]` to enter UART Download Mode |
| **`[3 RST]`** | Pin 3 (`Left #3`) | `CHIP_PU` (`EN` / `RST`) | **$3.30\text{ V}$** | **$0.00\text{ V}$** | Active-low hardware reset ($0\text{ V}$ resets ESP32) |
| **`[24 IO2]`** | Pin 24 (`Right #13`) | `GPIO2` (Boot Strap) | **$0.00\text{ V}$** | **$0.00\text{ V}$** | Must be low/floating to enter serial bootloader |
| **`[GND]`** | Pins 1, 15, 38, 39 | Common Ground | **$0.00\text{ V}$** | **$0.00\text{ V}$** | Connected to ESP32 RF shield can and battery negative (`-`) |

*Additional Module Reference Pad*: **`Left #2` (`Pin 2: 3V3`)** carries the regulated **$3.30\text{ V}$** rail when the FireBoard is powered on and drops to **$0.00\text{ V}$** when powered off.

### 2.3 Hardware & Network Identity Reference

| Device / Component | Property | Value | Notes |
| :--- | :--- | :--- | :--- |
| **XIAO ESP32-C3 Bridge** | **Hostname (`FQDN`)** | `fireboardctl.drheck.dev` (`fireboardctl`) | Only device on Wi-Fi; resolved in Unbound on `danknasty` |
| **XIAO ESP32-C3 Bridge** | **Static DHCP IPv4** | `10.0.0.71` | Reserved in `/etc/dhcpd.conf` (`~/git/router_config/dhcpd.conf`) on `danknasty` |
| **XIAO ESP32-C3 Bridge** | **Wi-Fi STA MAC** | `80:f1:b2:51:49:20` | Seeed Studio XIAO ESP32-C3 2.4 GHz Wi-Fi interface |
| **FireBoard 2 Unit** | **Device ID / Serial** | `FBX2-GJ8RC3JRD` | FireBoard 2 6-Channel Thermometer (`ESP32-WROVER-E` / `ESP32-D0WD-V3 rev v3.1`) |
| **FireBoard 2 Unit** | **Bluetooth LE MAC (`Base + 2`)** | `94:54:c5:f7:1c:fa` | Active whenever the FireBoard 2 is powered on |
| **FireBoard 2 Unit** | **Wi-Fi STA MAC (`Base`)** | `94:54:c5:f7:1c:f8` *(Unconfigured / No IP)* | FireBoard 2 is not connected to Wi-Fi and holds no IP address |
| **FireBoard 2 Unit** | **Battery Pack** | `1S Li-Ion Pouch Cell` ($3.7\text{ V}$ nominal, $4.20\text{ V}$ full) | Powers both the FireBoard 2 and the `IRLB8721PBF` MOSFET-switched XIAO ESP32-C3 |

### 2.4 Why the Stock FireBoard Firmware Is Bypassed via BLE
1. **Closed Local Network Surface**: Full 65,535-port TCP/UDP `nmap` scans and packet captures confirmed the stock FireBoard 2 firmware listens on zero local ports and requires remote cloud servers when on Wi-Fi. Keeping the FireBoard 2 off Wi-Fi and reading its local BLE GATT server eliminates all external network traffic.
2. **Locked Factory Silicon (`eFuses`)**: Interrogation over the 6-pin UART bootloader header proved all hardware security eFuses on the FireBoard's internal `ESP32-WROVER-E` are permanently burned:
   * **Flash Encryption (`FLASH_CRYPT_CNT = 127` / `0b1111111`)**: All 7 bits burned and write-protected; plaintext flashing renders the chip unbootable.
   * **UART Crypto Disabled (`DISABLE_DL_ENCRYPT = True`, `DISABLE_DL_DECRYPT = True`)**: The ROM bootloader is physically barred from encrypting new images or decrypting flash over UART.
   * **Secure Boot v1 (`ABS_DONE_0 = True`)**: Only images signed with FireBoard's private key (`BLOCK2`, read/write protected) are booted.
   * **Silent UART Console (`MTDO` Strapped Low + `ESP_LOG_NONE`)**: Zero application log bytes are emitted on `UART0` during normal boot.

<details>
  <summary><strong>Click to expand full ESP32-D0WD-V3 Security eFuse Register Dump (espefuse summary)</strong></summary>

```text
FLASH_CRYPT_CNT        (BLOCK0) = 127 R/- (0b1111111)  [Flash encryption permanently enabled; odd bit count = 7; Write-Protected]
FLASH_CRYPT_CONFIG     (BLOCK0) = 15  R/W (0xf)        [Full AES flash encryption tweak configuration]
ABS_DONE_0             (BLOCK0) = True R/W             [Secure Boot V1 permanently enforced on boot]
DISABLE_DL_ENCRYPT     (BLOCK0) = True R/W             [UART bootloader flash encryption disabled]
DISABLE_DL_DECRYPT     (BLOCK0) = True R/W             [UART bootloader flash decryption disabled]
DISABLE_DL_CACHE       (BLOCK0) = True R/W             [UART bootloader MMU flash cache disabled]
JTAG_DISABLE           (BLOCK0) = True R/W             [Hardware JTAG debugging permanently disabled]
CONSOLE_DEBUG_DISABLE  (BLOCK0) = True R/W             [ROM BASIC fallback console disabled]
BLOCK1 (Flash Key)              = ?? ?? ... -/-        [256-bit AES Flash Encryption Key: Read & Write Protected]
BLOCK2 (Secure Boot Key)        = ?? ?? ... -/-        [256-bit Secure Boot Key: Read & Write Protected]
```

</details>

---

## 3. FireBoard 2 BLE GATT Protocol Reference

<p class="callout success"><strong>Zero Cloud Dependency:</strong> The FireBoard 2 BLE GATT server remains active with zero Wi-Fi configuration, allowing the XIAO ESP32-C3 (<code>fireboardctl.drheck.dev</code>) to stream all 6 channels locally.</p>

| GATT Attribute | UUID / Value | Properties | Description |
| :--- | :--- | :--- | :--- |
| **`FIREBOARD_DATA_CHAR_UUID`** | `c2f780ec-45e1-452b-a879-327e3140d1f1` | `Notify` | Streams UTF-8 JSON temperature packets every `3–5s` per active probe channel |
| **`FIREBOARD_CONTROL_CHAR_UUID`** | `c2f780ec-45e1-452b-a879-327e3140d1e8` | `Write` | Writing single byte **`0x01`** starts the notification stream on `d1f1` |
| **`FIREBOARD_START_STREAM_BYTE`** | `0x01` (`b'\x01'`) | `1 Byte` | Must be written after enabling notifications (`CCCD`) on `d1f1` |

### BLE Handshake & Notification Payload Format
1. **Scan & Connect**: Connect to BLE MAC `94:54:c5:f7:1c:fa` (or any device matching prefix `94:54:c5:f7:1c:` / name `FBX`). No pairing PIN or bond is required.
2. **Subscribe**: Enable GATT notifications on `c2f780ec-45e1-452b-a879-327e3140d1f1`.
3. **Start Stream**: Write `0x01` (`1 byte`, with response) to `c2f780ec-45e1-452b-a879-327e3140d1e8`.
4. **Parse JSON Notifications**: The FireBoard immediately begins emitting UTF-8 JSON notifications for each connected thermistor probe (`channel` `1` through `6`):
   ```json
   {
     "channel": 1,
     "temp": 225.4,
     "degreetype": 2,
     "date": "2026-09-28T20:30:00Z"
   }
   ```
   * `channel`: Probe port number (`1..6`).
   * `temp`: Floating-point temperature (`null` or `<= 0` when unplugged).
   * `degreetype`: `2` = Fahrenheit (`°F`), `1` = Celsius (`°C`).

---

## 4. Firmware Repository (`~/git/grill-sync`) & Magic Constants (`include/config.h`)

All hardware, BLE protocol, and network constants are isolated in **`include/config.h`** (with Wi-Fi credentials in gitignored **`include/secrets.h`**).

### Repository Layout
* `platformio.ini` — PlatformIO configuration targeting `seeed_xiao_esp32c3` (`huge_app.csv` 3.1 MB partition table, `NimBLE-Arduino`, `ArduinoJson`).
* `include/config.h` — Central configuration file containing all network URLs, bridge identity (`fireboardctl.drheck.dev`, `10.0.0.71`, `80:f1:b2:51:49:20`), and BLE GATT UUIDs.
* `include/secrets.example.h` / `include/secrets.h` — Local Wi-Fi credentials (`WIFI_SSID`, `WIFI_PASSWORD`) and optional local overrides.
* `src/main.cpp` — Non-blocking BLE GATT client, JSON parser, and HTTP POST transmitter.

### Complete Reference of Constants in `include/config.h`

| Constant | Default Value | Purpose & When to Change |
| :--- | :--- | :--- |
| **`BRIDGE_DHCP_HOSTNAME`** | `"fireboardctl"` | DHCP client hostname announced by the XIAO ESP32-C3. |
| **`BRIDGE_FQDN`** | `"fireboardctl.drheck.dev"` | Local DNS name in Unbound (`10.0.0.71`). |
| **`BRIDGE_WIFI_MAC`** | `"80:f1:b2:51:49:20"` | Wi-Fi STA MAC of the Seeed Studio XIAO ESP32-C3 reserved as `10.0.0.71` in `/etc/dhcpd.conf` on `danknasty`. |
| **`BRIDGE_STATIC_IPV4`** | `"10.0.0.71"` | Static DHCP IPv4 address assigned to `fireboardctl.drheck.dev`. |
| **`INGEST_URL`** | `"http://10.0.0.3:8088/api/ingest"` | Target HTTP POST URL on host `ul` (`10.0.0.3` ports `8088` and `8000`). |
| **`HTTP_POST_INTERVAL_MS`** | `5000UL` (`5s`) | How frequently the XIAO posts temperature snapshots to `ul` when BLE is connected. |
| **`HTTP_HEARTBEAT_INTERVAL_MS`** | `15000UL` (`15s`) | How frequently the XIAO posts status heartbeats when scanning for BLE. |
| **`WIFI_CONNECT_TIMEOUT_MS`** | `10000UL` (`10s`) | Maximum time spent waiting for Wi-Fi association per attempt. |
| **`FIREBOARD_DEVICE_ID`** | `"FBX2-GJ8RC3JRD"` | Device ID reported in JSON payloads to `ul`. |
| **`FIREBOARD_BLE_MAC`** | `"94:54:c5:f7:1c:fa"` | Bluetooth LE MAC of the FireBoard 2 (`Base MAC + 2`). Change if pairing with a replacement FireBoard unit. |
| **`FIREBOARD_MAC_PREFIX`** | `"94:54:c5:f7:1c:"` | OUI/prefix matched during BLE scanning. |
| **`FIREBOARD_LOCK_TO_MAC`** | `0` | Set to `1` to only connect to `FIREBOARD_BLE_MAC`, or `0` to also connect to any nearby device advertising `"FBX"` / `"FireBoard"`. |
| **`FIREBOARD_DATA_CHAR_UUID`** | `"c2f780ec-45e1-452b-a879-327e3140d1f1"` | FireBoard 2 BLE GATT `Notify` characteristic that outputs JSON temperature readings. |
| **`FIREBOARD_CONTROL_CHAR_UUID`** | `"c2f780ec-45e1-452b-a879-327e3140d1e8"` | FireBoard 2 BLE GATT `Write` characteristic that starts the stream when `0x01` is written. |
| **`FIREBOARD_START_STREAM_BYTE`** | `0x01` | Magic wake byte written to `FIREBOARD_CONTROL_CHAR_UUID` upon BLE connect. |
| **`FIREBOARD_MAX_CHANNELS`** | `6` | Maximum probe channel index (`Channels 1..6`). |
| **`FIREBOARD_PROBE_STALE_MS`** | `30000UL` (`30s`) | Time without a BLE notification before a channel is considered unplugged. |

---

## 5. Dashboard Infrastructure Reference on Host `ul` ([https://grill.drheck.dev](https://grill.drheck.dev))

| Component | Path / Address | Details |
| :--- | :--- | :--- |
| **Public URL (SSO Protected)** | [https://grill.drheck.dev](https://grill.drheck.dev) ([https://fireboard.drheck.dev](https://fireboard.drheck.dev)) | Protected by **Authentik ForwardAuth** (`Grill Monitor (FireBoard 2)` application tile in user portal) |
| **BLE-to-Wi-Fi Bridge Host** | `fireboardctl.drheck.dev` (`10.0.0.71` • MAC `80:f1:b2:51:49:20`) | Seeed Studio XIAO ESP32-C3; static DHCP lease in `/etc/dhcpd.conf` and local DNS in `/var/unbound/etc/unbound.conf` on `danknasty` |
| **Unauthenticated Ingest API** | `http://10.0.0.3:8088/api/ingest` / `http://10.0.0.3:8000/api/ingest` / `https://grill.drheck.dev/api/ingest` | Accepts `POST` JSON (`{"device_id": "FBX2-GJ8RC3JRD", "ble_connected": true, "rssi": -42, "probes": {"1": 225.4, "2": 165.2}}`) |
| **Podman Quadlet & App Source** | `/home/docker-data/.config/containers/systemd/grill.container`, `/data/docker/grill/app.py`, `/data/docker/grill/static/index.html` | Rootless Podman container `grill` (`docker-data`, `UID 1100`) running FastAPI + SQLite + Chart.js live/historical multi-channel dashboard (100% local, zero cloud functionality) |
| **Persistent SQLite Database** | `/data/docker/grill/data/grill.db` | Stores historical temperature series (`15m`, `1h`, `3h`, `6h`, `12h`, `24h` views) and bridge ping RTT history |
| **Dedicated Podman ULA IPv6** | `fd77:7777:5010:0:1337:f1ac:d00d:fb02` (Port `8000`) | Bound on `eth77` (`10.0.0.3:8088` and `10.0.0.3:8000` mapped on IPv4 LAN for `fireboardctl.drheck.dev`) |
| **DNS Configuration** | `~/git/router_config/unbound.conf` & `update_dns.py` | Local Unbound (`10.0.0.100` / `fd77:7777:5010::1337:f1ac:d00d:aaaa`) on `danknasty` + public deSEC IPv6 GUA sync |
| **Nginx Site Config** | `/etc/nginx/sites-available/grill` | Proxies `/` with Authentik ForwardAuth and `/api/ingest` with `auth_request off` |
| **Backup & Restore Scripts** | `/home/luke/git/backups/ul/scripts/backup/grill`, `/home/luke/git/backups/ul/scripts/restore/grill` | `shellcheck`-verified backups with `par2 create -r10` parity archives in `/data/backups/services/current/grill/` |


# VPN

## Overview & Architecture

This document describes the modern, high-performance **WireGuard VPN** service running on the internal infrastructure server **`ul`** (`10.0.0.3`) with full **IPv4 and IPv6 dual-stack** pass-through from the edge router **`danknasty`** (OpenBSD `pf`).

### Network Topology

```
+-----------------------------------------------------------------------------------------+
|                                    Internet / WAN                                       |
+-----------------------------------------------------------------------------------------+
                               │                                       │
                      IPv4 UDP Port 51820                     IPv6 UDP Port 51820
                               │                                       │
                               ▼                                       ▼
+-----------------------------------------------------------------------------------------+
|                         danknasty (OpenBSD Router & Gateway)                            |
|                     WAN IPv4: 71.25.179.69                                              |
|                     WAN IPv6: 2603:3001:626:9d80::/64                                   |
|                     LAN: 10.0.0.1 / fd77:7777:5010::1                                   |
|                                                                                         |
| PF Inbound Rules:                                                                       |
|   pass in quick on $ext inet proto udp from any to any port 51820 rdr-to 10.0.0.3       |
|   pass in quick on $ext inet6 proto udp from any to $ul_gu port 51820                    |
|     rdr-to $ul_ula port 51820                                                           |
|                                                                                         |
| Static Route for VPN IPv6 Subnet:                                                       |
|   route add -inet6 fd77:7777:5010:7700::/64 fd77:7777:5010:0:1337:f1ac:d00d:337         |
+-----------------------------------------------------------------------------------------+
                               │                                       │
                               └───────────────────┬───────────────────┘
                                                   │
                                                   ▼
+-----------------------------------------------------------------------------------------+
|                               ul.drheck.dev (VPN Server)                                |
|                        IPv4: 10.0.0.3 | IPv6: fd77:7777:5010:0:1337:f1ac:d00d:337       |
|                               WireGuard Interface: wg0                                  |
|                 IPv4 Address: 10.77.0.1/24 | IPv6 Address: fd77:7777:5010:7700::1/64    |
|                                                                                         |
|  - Full Dual-Stack In-Kernel WireGuard Tunneling (ChaCha20-Poly1305, Curve25519)       |
|  - Dual-Stack IPv4 & IPv6 Forwarding & MASQUERADE NAT for outgoing client traffic       |
+-----------------------------------------------------------------------------------------+
                               │                                       │
               ┌───────────────┴───────────────┐       ┌───────────────┴───────────────┐
               ▼                               ▼       ▼                               ▼
      10.0.0.0/24 LAN Hosts          WAN IPv4 via danknasty  fd77:7777:5010::/64 ULA   WAN IPv6 via danknasty
```

---

## Server Configuration

### Parameters & Endpoints

| Setting | Value |
| :--- | :--- |
| **Server Host** | `ul.drheck.dev` (`10.0.0.3`) |
| **Edge Router** | `danknasty.drheck.dev` (`10.0.0.1`) |
| **Public IPv4 Endpoint** | `71.25.179.69:51820` |
| **Public IPv6 Endpoint** | `[2603:3001:626:9d80:1337:f1ac:d00d:337]:51820` |
| **FQDN Endpoint** | `vpn.drheck.dev:51820` (or `ul.drheck.dev:51820`) |
| **Protocol & Port** | UDP `51820` (IPv4 and IPv6) |
| **VPN IPv4 Subnet** | `10.77.0.0/24` (Server: `10.77.0.1`) |
| **VPN IPv6 Subnet** | `fd77:7777:5010:7700::/64` (Server: `fd77:7777:5010:7700::1`) |
| **DNS Resolvers** | `10.0.0.1`, `fd77:7777:5010::1` (Unbound on `danknasty`) |
| **Server Public Key** | `ubh+z69hcWcUKgHl3LVfm54sYkDZAaocIKNN5zlsZWA=` |

### Router (`danknasty`) PF & Route Configuration

Tracked in `/home/luke/git/router_config/pf.conf`:
```pf
# Ports
wireguard = "51820"

# IPv4 Inbound
pass in quick on $ext inet proto udp from any to any port $wireguard rdr-to $ul_ipv4

# IPv6 Inbound
pass in quick on $ext inet6 proto udp from any to $ul_gu port $wireguard rdr-to $ul_ula port $wireguard
```

IPv6 routing in `/home/luke/git/router_config/hostname.em1`:
```
!route add -inet6 fd77:7777:5010:7700::/64 fd77:7777:5010:0:1337:f1ac:d00d:337
```

DNS Access Control in `/home/luke/git/router_config/unbound.conf`:
```conf
access-control: 10.77.0.0/24 allow
access-control: fd77:7777:5010:7700::/64 allow
```

### Server (`ul`) Interface Configuration: `/etc/wireguard/wg0.conf`

```ini
[Interface]
Address = 10.77.0.1/24, fd77:7777:5010:7700::1/64
ListenPort = 51820
PrivateKey = <SERVER_PRIVATE_KEY>

# Dual-stack packet forwarding and NAT masquerade
PostUp = iptables -A FORWARD -i wg0 -j ACCEPT; iptables -A FORWARD -o wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -s 10.77.0.0/24 -o eth77 -j MASQUERADE; ip6tables -A FORWARD -i wg0 -j ACCEPT; ip6tables -A FORWARD -o wg0 -j ACCEPT; ip6tables -t nat -A POSTROUTING -s fd77:7777:5010:7700::/64 -o eth77 -j MASQUERADE
PostDown = iptables -D FORWARD -i wg0 -j ACCEPT; iptables -D FORWARD -o wg0 -j ACCEPT; iptables -t nat -D POSTROUTING -s 10.77.0.0/24 -o eth77 -j MASQUERADE; ip6tables -D FORWARD -i wg0 -j ACCEPT; ip6tables -D FORWARD -o wg0 -j ACCEPT; ip6tables -t nat -D POSTROUTING -s fd77:7777:5010:7700::/64 -o eth77 -j MASQUERADE

# Peer entry template
[Peer]
PublicKey = <CLIENT_PUBLIC_KEY>
PresharedKey = <PRESHARED_KEY>
AllowedIPs = 10.77.0.X/32, fd77:7777:5010:7700::X/128
```

---

## Client Management Utilities

Management scripts are installed in `/home/luke/bin/` on `ul`:

### 1. Adding Clients (`wg-addclient`)
```bash
# Syntax: wg-addclient <device-name> [optional_ip_octet]
# IP is automatically assigned to the next available address if omitted:
wg-addclient macbook
wg-addclient iphone

# Or manually specify an IP octet if preferred:
wg-addclient custom-device 10
```
This utility:
- Automatically allocates the next free IPv4 & IPv6 addresses.
- Generates private/public keypairs + Pre-shared Key (PSK).
- Creates the client configuration in `/etc/wireguard/clients/<name>.conf`.
- Injects the peer into `/etc/wireguard/wg0.conf` and live kernel interface dynamically.
- Renders an ASCII QR code in the terminal for instant mobile setup.

### 2. Listing Active Clients (`wg-listclients`)
```bash
wg-listclients
```
Prints all configured clients, their assigned IP addresses, and their WireGuard public keys.

### 3. Revoking / Removing Clients (`wg-delclient`)
```bash
# Syntax: wg-delclient <device-name>
wg-delclient macbook
```
This utility:
- Instantly drops the peer from the live in-kernel WireGuard routing table (`sudo wg set wg0 peer <pubkey> remove`), severing all active sessions.
- Removes the `[Peer]` block from `/etc/wireguard/wg0.conf`.
- Deletes the stored configuration file `/etc/wireguard/clients/<name>.conf`.

To display a QR code for mobile devices:
```bash
sudo qrencode -t ansiutf8 < /etc/wireguard/clients/<name>.conf
```

---

## Client Setup Instructions (All Platforms)

### 1. Apple macOS & iOS

#### Setup via Mobile / QR Code (iOS)
1. Install **WireGuard** from the App Store.
2. Open the app, tap **+** -> **Create from QR code**.
3. On `ul`, run `sudo qrencode -t ansiutf8 < /etc/wireguard/clients/<client-name>.conf` and scan the terminal screen.
4. Name the tunnel `drheck` and toggle to connect.

#### Setup via Desktop App (macOS)
1. Install **WireGuard** from the Mac App Store.
2. Copy the `.conf` file from `/etc/wireguard/clients/<name>.conf` to your Mac.
3. Open WireGuard, click **Import tunnel(s) from file**, and select the `.conf` file.
4. Click **Activate**.

---

### 2. Linux (systemd-networkd / NetworkManager / wg-quick)

#### Method A: wg-quick
1. Install `wireguard-tools`:
   - Arch Linux: `sudo pacman -S wireguard-tools`
   - Debian/Ubuntu: `sudo apt install wireguard-tools`
   - Fedora: `sudo dnf install wireguard-tools`
2. Save your configuration as `/etc/wireguard/drheck.conf`:
   ```ini
   [Interface]
   PrivateKey = <CLIENT_PRIVATE_KEY>
   Address = 10.77.0.X/24, fd77:7777:5010:7700::X/64
   DNS = 10.0.0.1, fd77:7777:5010::1

   [Peer]
   PublicKey = ubh+z69hcWcUKgHl3LVfm54sYkDZAaocIKNN5zlsZWA=
   PresharedKey = <PRESHARED_KEY>
   Endpoint = 71.25.179.69:51820
   AllowedIPs = 0.0.0.0/0, ::/0
   PersistentKeepalive = 25
   ```
3. Bring the tunnel up:
   ```bash
   sudo wg-quick up drheck
   # To enable on boot:
   sudo systemctl enable --now wg-quick@drheck
   ```

#### Method B: NetworkManager
```bash
sudo nmcli connection import type wireguard file /path/to/<name>.conf
sudo nmcli connection up <name>
```

---

### 3. Android

1. Install **WireGuard** from Google Play Store or F-Droid.
2. Tap the floating **+** button in the bottom-right corner.
3. Select **Scan from QR code** and scan the output of:
   ```bash
   sudo qrencode -t ansiutf8 < /etc/wireguard/clients/<name>.conf
   ```
4. Enter tunnel name `drheck` and tap to activate.

---

### 4. Microsoft Windows

1. Download and run the official installer from [wireguard.com/install](https://www.wireguard.com/install/).
2. Click **Add Tunnel** (or press `Ctrl+O`) and open `<name>.conf`.
3. Click **Activate**.

---

### 5. OpenBSD / FreeBSD

#### OpenBSD (`hostname.wg0`)
Create `/etc/hostname.wg0`:
```
inet 10.77.0.X 255.255.255.0 NONE
inet6 fd77:7777:5010:7700::X 64
wgkey <CLIENT_PRIVATE_KEY>
wgpeer ubh+z69hcWcUKgHl3LVfm54sYkDZAaocIKNN5zlsZWA= \
  wgpresharedkey <PRESHARED_KEY> \
  wgendpoint 71.25.179.69 51820 \
  wgaip 0.0.0.0/0 wgaip ::/0
up
```
Run `sh /etc/netstart wg0`.

---

## Split Tunneling vs Full Tunneling

By default, the client configuration routes all traffic (`AllowedIPs = 0.0.0.0/0, ::/0`) through the home network connection.

If you only want access to the internal `drheck.dev` LAN resources and DNS without tunneling general internet browsing, adjust `AllowedIPs` in the client's `[Peer]` section:

```ini
# Split Tunnel (Internal LAN & VPN Subnet Only)
AllowedIPs = 10.0.0.0/24, 10.77.0.0/24, fd77:7777:5010::/48
```

---

## Backup & Disaster Recovery

WireGuard configuration, keys, and client management scripts are fully integrated into the host backup architecture under `~/git/backups/ul/scripts/`:

### Automated Backup (`scripts/backup/wireguard`)
- **Archive Path:** `/data/backups/services/current/wireguard/<TIMESTAMP>/wireguard_config.tar.xz`
- **Included Paths:**
  - `/etc/wireguard/` (Server config `wg0.conf` and all client profiles in `clients/`)
  - `/etc/modules-load.d/wireguard.conf`
  - `/home/luke/bin/wg-addclient`, `/home/luke/bin/wg-delclient`, `/home/luke/bin/wg-listclients`
- **Parity Redundancy:** 10% PAR2 recovery volume generation (`par2create -r10`).
- **Cloud Replication:** Automatically bundled by `stage_cloud_archive` into GPG-encrypted cloud staging for off-site sync.

### Disaster Recovery (`scripts/restore/wireguard`)
To restore the complete WireGuard service from the latest backup:
```bash
sudo /home/luke/git/backups/ul/scripts/restore/wireguard
```
This verifies PAR2 parity, unpacks files with intact permissions (`0700`/`0600`), ensures the kernel module is loaded, and enables/starts `wg-quick@wg0`.


# Minecraft Worlds

## Quickstart: Bring a World Up

1. **Create via Web Control Plane (`https://mc.drheck.dev`) or CLI (`ipctl`):**
   - **Web UI (Recommended):** Open [https://mc.drheck.dev](https://mc.drheck.dev), click **+ New World**, style the world name/MOTD and 64×64 icon, select the **World Type (`level-type`)** (`Default Overworld`, `Amplified`, `Large Biomes`, `Superflat` with all 9 Mojang presets, or `Single Biome / Buffet`), configure **Structures / Hardcore / Spawn Protection**, preview/reroll the world seed with the live 2D biome map, and click **Create & Provision World**.
   - **CLI (`ipctl`):**
     ```bash
     sudo /data/minecraft/ipctl create <name> --dns
     ```
     *(Allocates backend IPv6 `fd77:7777:5010::1337:3c:d00d:<hex>/64` in `/etc/systemd/network/20-wired.network.d/minecraft.conf`, configures HAProxy SNI routing in `/etc/haproxy/conf.d/minecraft_backends.cfg` and `/etc/haproxy/minecraft_hosts.map`, adds `<name>.drheck.dev` to `/var/unbound/etc/unbound.minecraft.conf` on `danknasty` using a short-lived 5-minute `step-ca` SSH user certificate, syncs public A/AAAA records to deSEC.io, and automatically provisions the live 3D BlueMap web map via `/usr/local/bin/mc-bluemap-provision <name>` — creating `bluemap@<name>.service` on `[fd77:7777:5010::1337:3c:d00d:<hex>]:8100`, the `/etc/nginx/sites-available/<name>` vhost, and the Authentik ForwardAuth `ProxyProvider` + `Application` attached to `authentik Embedded Outpost` at `https://<name>.drheck.dev`).*

2. **Configure the backend server:**
   In your backend server's `server.properties`:
   ```properties
   server-ip=fd77:7777:5010::1337:3c:d00d:<hex>
   server-port=25565
   online-mode=true
   ```
   *(Backend servers run with `online-mode=true`, listening on standard port `25565` on their dedicated ULA IPv6 address).*

3. **Start the server:**
   Start via `https://mc.drheck.dev` (**Start** button or automatic **On-Demand** wake-on-join) or `sudo systemctl start minecraft@<slug>.service`.

4. **Connect:**
   Players connect using `<name>.drheck.dev` (standard port `25565`). Microsoft account authentication takes place directly between the client and the backend server.

---

### 5. Manage the 3D Web Map (`Provision`, `Decom`, `Enable`, `Disable`)
Every world can have its live 3D BlueMap web map (`https://<name>.drheck.dev`) independently provisioned, decommissioned, enabled, or disabled from either the Web UI (`https://mc.drheck.dev` header buttons) or the CLI (`/usr/local/bin/mc-bluemap-provision` / `/data/minecraft/ipctl bluemap`):

- **Provision (`Provision Map` / `sudo /data/minecraft/ipctl bluemap <name> provision`):**
  Creates `/etc/nginx/sites-available/<name>` + `/etc/nginx/sites-enabled/<name>`, provisions the Authentik ForwardAuth `ProxyProvider` + `Application` attached to `authentik Embedded Outpost`, initializes `/data/minecraft/<dir>/bluemap/`, and enables/starts `bluemap@<name>.service`.
- **Disable (`Disable Map` / `sudo /data/minecraft/ipctl bluemap <name> disable`):**
  Turns off `bluemap@<name>.service` while keeping the Nginx vhost and Authentik SSO active. Visitors to `https://<name>.drheck.dev` are served a styled **3D Web Map Currently Offline** status page (`proxy_intercept_errors on; error_page 502 503 504 @bluemap_offline;` routed to `mc-manager` `/bluemap-offline/<name>`) displaying the world icon, live game server status, an auto-reload poller, and a 1-click **Turn On 3D Map Now** button for admins.
- **Enable (`Enable Map` / `sudo /data/minecraft/ipctl bluemap <name> enable`):**
  Turns `bluemap@<name>.service` back on so `https://<name>.drheck.dev` immediately resumes serving the live 3D map.
- **Decommission (`Decom Map` / `sudo /data/minecraft/ipctl bluemap <name> decom`):**
  Stops `bluemap@<name>.service`, deletes `/etc/nginx/sites-enabled/<name>` and `/etc/nginx/sites-available/<name>`, reloads Nginx, and removes the Authentik `Application` and `ProxyProvider`.

---

## Quickstart: Take a World Down

1. **Stop the backend server:**
   Click **Stop** in `https://mc.drheck.dev`, send `stop` in the live web console, or run `sudo systemctl stop minecraft@<slug>.service`.

2. **What Players See When Offline or Standby (`On-Demand`):**
   HAProxy intercepts all Multiplayer Server List pings ($\text{state} = 1$) and offline/standby Login joins ($\text{state} = 2$) via `/etc/haproxy/minecraft.lua` and `mc-manager`:
   - **In the Multiplayer Server List (`state = 1`):** Returns the server's configured custom MOTD and 64×64 `server-icon.png` (`favicon`) immediately, with version status badge `Standby (Join to Wake)` (for `ondemand` servers) or `Offline` (for `manual` servers).
   - **When Clicking "Join Server" (`state = 2`):**
     - **`ondemand` Standby:** Automatically triggers `minecraft@<slug>.service` to boot and displays a gold countdown screen (`Waking up <Server>... Ready in ~20s!`).
     - **`manual` Offline:** Immediately disconnects with a clean red screen (`<Server> is currently offline. Start at mc.drheck.dev`).

3. **Disable or remove router mapping & DNS (Teardown):**
   - **Temporary takedown (disable in router only):**
     ```bash
     sudo /data/minecraft/ipctl route disable <name>
     ```
   - **Complete teardown (release IP, remove HAProxy route, purge DNS):**
     ```bash
     sudo /data/minecraft/ipctl delete <name> --dns
     ```

---

## Architecture & Design Details

### 1. Unified Endpoint Model
All public and LAN client connections resolve to the single **Minecraft Router endpoint pair**:
- **IPv4:** `10.0.0.113` *(NAT port-forwarded from WAN IPv4 `:25565` on `danknasty`)*
- **IPv6:** `fd77:7777:5010::1337:f1ac:d00d:3c` *(dynamically mapped to GUA `<gua_prefix>:1337:f1ac:d00d:3c` in public DNS)*

Whether connecting via `pokelovers.drheck.dev`, `desertworld.drheck.dev`, or any other server subdomain, players connect to this exact IP pair on standard port `25565`.

### 2. Transparent TCP SNI Handshake Routing & Live Ping Injection (HAProxy + `mc-manager`)
Rather than using an application-level proxy (which breaks vanilla Diffie-Hellman encryption and forces `online-mode=false`), **HAProxy** performs transparent pre-handshake packet inspection:
1. HAProxy listens dual-stack on `10.0.0.113:25565` and `[fd77:7777:5010::1337:f1ac:d00d:3c]:25565`.
2. When a player connects, HAProxy peeks at the initial unencrypted Minecraft handshake packet (`0x00`) via `/etc/haproxy/minecraft.lua` and extracts the requested hostname (`txn.mc_host`, e.g. `pokelovers.drheck.dev`) and handshake state (`txn.mc_state`: `1` = Status Ping, `2` = Login Join).
3. **For All Multiplayer Server List Pings ($\text{state} = 1$):**
   Each backend uses the rule:
   ```haproxy
   tcp-request content use-service lua.mc_offline if { var(txn.mc_state) -m int eq 1 } || !{ nbsrv(<slug>) gt 0 }
   ```
   `lua.mc_offline` queries `mc-manager` (`http://127.0.0.1:8095/internal/haproxy` with `STATUS <slug>`). If the backend JVM is running, `mc-manager` queries the backend's live TCP status (`0x00`) to obtain real-time online player counts and version metadata, then overlays the latest `motd` from `server.properties` and `favicon` from `/data/minecraft/<dir>/server-icon.png`. This ensures **MOTD styling and 64×64 icon changes saved in `mc.drheck.dev` appear immediately on client Refresh without restarting a running Minecraft server**.
4. **For Player Login Joins ($\text{state} = 2$):**
   - **If the backend server is UP:** HAProxy splices the raw TCP stream directly to `[fd77:7777:5010::1337:3c:d00d:<hex>]:25565`. The client and backend perform native Microsoft/Mojang `online-mode=true` authentication directly over the spliced TCP connection.
   - **If the backend server is DOWN:** `lua.mc_offline` sends `JOIN <slug>` to `mc-manager`, which wakes `ondemand` servers automatically and returns a formatted Minecraft Disconnect packet (`0x00`).

### 3. Backend IPv6 Address Scheme & Active Worlds
Each backend Minecraft server is isolated on a dedicated Unique Local Address (ULA) on interface `eth77` of `ul`:
- **Prefix:** `fd77:7777:5010::1337:3c:d00d:<hex>/64`
- **Port:** Each server binds to standard port `25565` on its assigned IPv6 address.
- **Active Managed Worlds:**

| Hex ID | Slug / Subdomain | Display Name | Backend ULA IPv6 Address | Lifecycle Mode |
| :--- | :--- | :--- | :--- | :--- |
| `1` | `pokelovers.drheck.dev` | `Poke Lovers` | `fd77:7777:5010::1337:3c:d00d:1/64` | `ondemand` (`20G` heap) |
| `2` | `desertworld.drheck.dev` | `⚔ 𝔇𝔢𝔰𝔢𝔯𝔱 𝔚𝔬𝔯𝔩𝔡 ⚔` | `fd77:7777:5010::1337:3c:d00d:2/64` | `ondemand` (`8G` heap) |

### 4. Web Map (BlueMap) & Authentik Gated HTTPS
Interactive 3D web mapping for Minecraft is powered by **BlueMap** running in standalone watch mode:
- **Service:** `bluemap-pokelovers.service` running from `/data/minecraft/poke lovers/bluemap`.
  - Automatically updates the rendered 3D map as chunks change in `/data/minecraft/poke lovers/world`.
  - Serves live map data & Server-Sent Events (SSE) updates on internal port `8100`.
- **Reverse Proxy & Authentication:** NGINX terminates SSL on port `443` at `/etc/nginx/sites-available/pokelovers` and gates access behind **Authentik ForwardAuth** (`/outpost.goauthentik.io/auth/nginx`).
  - Serves directly at **`https://pokelovers.drheck.dev`**.
  - Unauthenticated visitors are automatically redirected to `auth.drheck.dev` to log in before viewing the 3D map.

### 5. Network & DNS Automation (`ipctl`) — Externalized Configs & Short-Lived SSH CA

To prevent dynamic Minecraft world provisioning from mutating code-reviewed host and router configuration files (`/etc/systemd/network/20-wired.network`, `/etc/haproxy/haproxy.cfg`, and `/home/luke/git/router_config/unbound.conf`), `/data/minecraft/ipctl` (symlinked at `/home/luke/bin/ipctl`) operates **exclusively on dedicated external drop-in and include files**:

| Subsystem | Static Code-Reviewed Base Config (Never Modified by `ipctl`) | Dynamic External File Managed by `ipctl` | Mirror in `/data/minecraft/.network/` |
| :--- | :--- | :--- | :--- |
| **`systemd-networkd` (`ul`)** | `/etc/systemd/network/20-wired.network` | `/etc/systemd/network/20-wired.network.d/minecraft.conf` | `minecraft.network.conf` |
| **HAProxy Map (`ul`)** | `/etc/haproxy/haproxy.cfg` | `/etc/haproxy/minecraft_hosts.map` | `minecraft_hosts.map` |
| **HAProxy Backends (`ul`)** | `/etc/haproxy/haproxy.cfg` *(loads `-f /etc/haproxy/conf.d`)* | `/etc/haproxy/conf.d/minecraft_backends.cfg` | `minecraft_backends.cfg` |
| **Unbound DNS (`danknasty`)** | `/home/luke/git/router_config/unbound.conf` *(has `include: "/var/unbound/etc/unbound.minecraft.conf"`)* | `/var/unbound/etc/unbound.minecraft.conf` | `unbound.minecraft.conf` |

#### Programmatic Short-Lived SSH CA Authentication (`step-ca` $\longrightarrow$ `danknasty`)
When `/data/minecraft/ipctl` is invoked with `--dns` (either interactively or unattended by `mc-manager.service` after a reboot when no `ssh-agent` is active):
1. `ipctl` generates a fresh ephemeral Ed25519 keypair in a mode-`0700` temporary directory (`atexit`-cleaned).
2. `ipctl` requests a **5-minute (`--not-after=5m`) SSH user certificate** for principal `luke` from the local `step-ca` container using the `webshell-svc` JWK provisioner (the same short-lived CA used by `webshell.drheck.dev`).
3. `danknasty` validates the certificate against `TrustedUserCAKeys /etc/ssh/drheck_ca_user_key.pub` (configured in `/etc/sshd_config.d/10-drheck-certs.conf`), allowing `ipctl` to update `/var/unbound/etc/unbound.minecraft.conf`, run `doas unbound-checkconf` and `doas rcctl restart unbound`, and invoke `/home/luke/git/router_config/dns_danger_sync.py --force` to synchronize public `A` and `AAAA` records to deSEC.io.
4. Both `update_dns.py` and `dns_danger_sync.py` (as well as `verify_dns.sh`) on `danknasty` recursively follow `include:` directives in `unbound.conf`, ensuring all external Minecraft subdomains are automatically included during prefix rotations and deSEC.io synchronization.

---

## Backups & Disaster Recovery

Both the HAProxy router layer and the `mc-manager` control plane (including all external network, proxy, and DNS mirror files) are integrated into `/home/luke/git/backups/ul/scripts/`:

- **Control Plane & External Network Backup:** `/home/luke/git/backups/ul/scripts/backup/mc_manager`
  - Archives `/data/minecraft/.manager`, `/data/minecraft/.network`, `/data/minecraft/ipctl`, `/usr/local/bin/{mc-manager,mc-runner,mc-seed-render}`, `/etc/systemd/system/{mc-manager.service,minecraft@.service}`, `/etc/systemd/network/20-wired.network.d/minecraft.conf`, `/etc/nginx/sites-available/mc`, `/etc/haproxy/minecraft.lua`, `/etc/haproxy/minecraft_hosts.map`, and `/etc/haproxy/conf.d/minecraft_backends.cfg` with 10% `par2` parity into `/data/backups/services/current/mc_manager/`.
- **Control Plane Restore:** `/home/luke/git/backups/ul/scripts/restore/mc_manager [timestamp]`
- **Router Backup & Restore:** `/home/luke/git/backups/ul/scripts/backup/minecraft_router` and `/home/luke/git/backups/ul/scripts/restore/minecraft_router [timestamp]`

---

## 6. Minecraft Server Manager & On-Demand Architecture (Production Architecture)

<p class="callout info"><strong>Production Control Plane:</strong> The unified web control plane at <code>https://mc.drheck.dev</code> (bound to <code>[fd77:7777:5010::1337:f1ac:d00d:3c00]:8095</code> behind Nginx and Authentik ForwardAuth) manages all Minecraft worlds on <code>ul</code>, automates <code>ipctl</code> networking and <code>systemd</code> lifecycle management, wakes sleeping servers on-demand via HAProxy when players join, streams live PTY server consoles, and provides an integrated <strong>Seed Preview Studio</strong> and <strong>Text, MOTD, Color Gradient &amp; 8-Bit Pixel Icon Studio</strong>.</p>

### 6.1 Canonical Definition of a "Server"

Every managed Minecraft world on `ul` is a self-contained unit consisting of five standardized components:

$$\text{Server}(\text{slug}) = \left\lbrace\text{Directory}, \quad \text{Manifest } (\texttt{server.json}), \quad \text{Network } (\texttt{ipctl}), \quad \text{Units } (\texttt{systemd}), \quad \text{Lifecycle Policy}\right\rbrace$$

| Component | Path / Identifier | Description |
| :--- | :--- | :--- |
| **1. Dedicated Directory** | `/data/minecraft/<dir_name>/` | Holds `server.json`, server `.jar` / libraries, `mods/` or `plugins/`, `server.properties`, `server-icon.png`, `whitelist.json`, `ops.json`, `world/`, and optional `bluemap/`. Existing worlds in `/data/minecraft/*` can be adopted in-place without moving files. |
| **2. Declarative Manifest** | `/data/minecraft/<dir_name>/server.json` | Stores the human display name, DNS slug (`<slug>`), loader type (`paper`, `vanilla`, `fabric`, `neoforge`, `forge`, `custom`), Minecraft version, JVM path (`/usr/lib/jvm/default/bin/java`), heap size (`memory_min`/`memory_max`), seed, and `start_mode` (`manual`, `ondemand`, `always`). |
| **3. Network & DNS (`ipctl`)** | `fd77:7777:5010::1337:3c:d00d:<hex>/64` | Dedicated internal IPv6 address on `eth77` in `/etc/systemd/network/20-wired.network.d/minecraft.conf`, bound in `server.properties` (`server-ip=` and `server-port=25565`), mapped in `/etc/haproxy/minecraft_hosts.map` + `/etc/haproxy/conf.d/minecraft_backends.cfg`, and published as `<slug>.drheck.dev` in `/var/unbound/etc/unbound.minecraft.conf` on `danknasty` and deSEC.io. |
| **4. Dedicated `systemd` Service & PTY Wrapper** | `minecraft@<slug>.service` *(via `/usr/local/bin/mc-runner`)* | Runs the server JVM inside a managed pseudo-terminal (PTY) with a 1,000-line ring buffer and UNIX domain socket `/run/mc-manager/<slug>.sock`, enabling real-time WebSocket log streaming and console command injection (`SendRunnerCommand`) as well as graceful `save-all` + `stop` on shutdown. |
| **5. Lifecycle Policy** | `manual` \| `ondemand` \| `always` | Controls when the server consumes RAM on `ul` (see Section 6.2). |

<details>
  <summary><strong>Click to expand example <code>/data/minecraft/desertworld/server.json</code> manifest</strong></summary>

```json
{
  "slug": "desertworld",
  "display_name": "⚔ 𝔇𝔢𝔰𝔢𝔯𝔱 𝔚𝔬𝔯𝔩𝔡 ⚔",
  "dir_name": "desertworld",
  "domain": "desertworld.drheck.dev",
  "ipv6": "fd77:7777:5010::1337:3c:d00d:2",
  "hex_id": "2",
  "port": 25565,
  "start_mode": "ondemand",
  "idle_timeout_minutes": 30,
  "type": "paper",
  "mc_version": "1.21.4",
  "jar_file": "server_26.3.jar",
  "java_bin": "/usr/lib/jvm/default/bin/java",
  "memory_min": "8G",
  "memory_max": "8G",
  "seed": "4900633019313953717",
  "admins": [],
  "created_at": "2026-10-10T18:03:46-04:00"
}
```
</details>

---

### 6.2 Resource Management & Server Lifecycle Modes

Because each active Minecraft JVM allocates $8\text{--}20\text{ GiB}$ of pinned heap memory (`-Xms` = `-Xmx` with `+AlwaysPreTouch`), running all $25+$ worlds in `/data/minecraft` simultaneously is impossible. Each server is assigned one of three lifecycle modes:

1. **`manual` (Cold / Archived)**:
   * `minecraft@<slug>.service` is disabled and stopped ($0\text{ GiB}$ RAM).
   * Only starts when explicitly clicked in the Web UI.
   * When stopped, HAProxy returns `<name> is currently offline. Start at mc.drheck.dev`.
2. **`ondemand` (Wake-on-Join + Idle Auto-Stop)**:
   * Stopped by default ($0\text{ GiB}$ RAM).
   * Automatically started when a player clicks **Join Server** in Minecraft (intercepted by HAProxy Lua $\longrightarrow$ `mc-manager`).
   * Automatically stopped (`save-all` $\longrightarrow$ `stop`) once $0$ players have been online continuously for $T_{\text{idle}}$ minutes (default $30\text{ min}$).
3. **`always` (Pinned Production)**:
   * `minecraft@<slug>.service` is enabled and active across host reboots (`systemctl enable --now`).
   * Used for primary worlds that should remain online $24/7$ regardless of player count.

---

### 6.3 HAProxy On-Demand Wake-on-Join & Live Status Ping Architecture

The Minecraft protocol sends a `Handshake` packet (`0x00`) at the start of every TCP connection containing a `Next State` VarInt:
* $\text{Next State} = 1$ (**Status Ping**): Sent when a client refreshes their Multiplayer Server List.
* $\text{Next State} = 2$ (**Login Join**): Sent **only** when a player clicks **Join Server**.

`/etc/haproxy/minecraft.lua` extracts both `txn.mc_host` and `txn.mc_state` and delegates status pings and offline joins to `mc-manager` (`http://127.0.0.1:8095/internal/haproxy`):

$$\text{Client Packet } (\texttt{0x00}) \longrightarrow \begin{cases} \text{state} = 1 \text{ (Status Ping)} & \longrightarrow \text{Return live JSON MOTD + } \texttt{server-icon.png} \text{ from } \texttt{mc-manager} \text{ without waking} \\\\ \text{state} = 2 \text{ (Login Join)} & \longrightarrow \text{If } \texttt{ONLINE}\text{: TCP splice to backend; if } \texttt{ondemand}\text{: wake server + return countdown} \end{cases}$$

#### State Machine for Managed Worlds

| Server State | Multiplayer List Ping (`state = 1`) MOTD & Icon | Player Clicks "Join Server" (`state = 2`) |
| :--- | :--- | :--- |
| **`SLEEPING` (`ondemand`)** | Configured custom MOTD (or `§e<name> §7[Standby] — §aClick Join to wake server!`) + `server-icon.png` + version badge `Standby (Join to Wake)` | HAProxy Lua sends `JOIN <slug>` to `mc-manager`, starts `minecraft@<slug>.service`, and disconnects client with: **`"⚡ Waking up <name>... Server is starting on-demand. Please reconnect in ~20 seconds!"`** |
| **`STARTING`** | Configured custom MOTD + `server-icon.png` + version badge `Starting...` | Disconnects client with: **`"⏳ <name> is currently booting up! Please wait ~15 seconds and try connecting again."`** |
| **`ONLINE`** | Live player count & sample from backend JVM overlaid with latest custom MOTD (`server.properties`) and `server-icon.png` (`favicon`) | Native TCP splice to `[fd77:7777:5010::1337:3c:d00d:<hex>]:25565` with full Microsoft `online-mode=true` auth |
| **`OFFLINE` (`manual`)** | Configured custom MOTD + `server-icon.png` + version badge `Offline` | Disconnects client with: **`"<name> is currently offline. Start it from https://mc.drheck.dev"`** |

#### Idle Shutdown Timer
For any running server in `ondemand` mode, `mc-manager` tracks the time since the last player disconnected ($N_{\text{players}} = 0$). When:

$$t_{\text{empty}} \ge T_{\text{idle}} \quad (\text{default } 30\text{ min})$$

`mc-manager` issues `save-all` and `stop` via the `/run/mc-manager/<slug>.sock` PTY socket and stops `minecraft@<slug>.service`, freeing the JVM heap until the next join request.

---

### 6.4 Web Control Plane (`https://mc.drheck.dev`)

The web frontend is served over HTTPS behind **Nginx + Authentik ForwardAuth** and provides two primary workspaces designed with a clean, hardware-ops dark interface:

#### 1. Existing Servers Dashboard & Live PTY Console
* **Server Sidebar & Status Header**:
  * Displays every registered server with its 64×64 pixel icon, live state badge (`ONLINE`, `STANDBY`, `OFFLINE`), endpoint (`<slug>.drheck.dev`), IPv6 address, heap allocation, and lifecycle mode (`On-Demand`, `Always On`, `Manual`).
  * **One-Click Controls**: `Start Server`, `Restart`, `Stop`, and `+ Provision Network / DNS` (if not yet allocated).
* **Live Server Console (`mc-runner` WebSocket Stream)**:
  * Connects via WebSocket (`/api/servers/<slug>/console/ws`) to `/run/mc-manager/<slug>.sock`, streaming the 1,000-line PTY ring buffer and real-time server stdout/stderr lines.
  * Allows admins to send console commands (`say`, `op`, `whitelist`, `time set`, `gamemode`, `/give`, etc.) directly to the server's stdin (`leading /` is automatically stripped because the server console expects bare commands).
* **Configuration, Text/MOTD Studio, Icon Studio, Whitelist & Operators Panels**:
  * Edit `Display Name`, `MOTD`, `Start Mode`, `Idle Timeout`, `Min/Max Heap`, `Jar File`, `Gamemode`, `Difficulty`, `Max Players`, `View Distance`, `Level Seed`, `server-icon.png`, `whitelist.json`, and `ops.json`.

#### 2. Minecraft Text, MOTD, Color Gradient & 8-Bit Pixel Icon Studio
Built directly into both the **+ New World** screen and the **Configuration** panel of every existing server:
* **Unicode Font Converter (Copy/Paste Safe)**:
  * Converts text into Minecraft Java Edition font variants without resource packs: `Small Caps (ꜱᴍᴘ)`, `Gothic / Fraktur (𝔖𝔲𝔯𝔳𝔦𝔳𝔞𝔩)`, `Double-Struck (𝕊𝕦𝕣𝕧𝕚𝕧𝕒𝕝)`, `Full-Width (ＳＭＰ)`, and `Monospace`.
  * Includes one-click **Java-Safe Decorative Symbols & Borders**: `⚔`, `◆`, `★`, `⚡`, `👑`, `☠`, `✦`, `❖`, `»`, `«`, and `⚔ ... ⚔`.
* **§ Color Gradient & Formatting Code Builder**:
  * Interpolates any two RGB colors (`Gradient Start` $\longrightarrow$ `Gradient End`) across the input string and maps each character to the nearest native Minecraft color code (`§0`–`§f`), combined with optional `§l` (**Bold**), `§o` (*Italic*), `§n` (Underline), `§m` (Strikethrough), or `§k` (Obfuscated).
  * Can be applied with one click to either **MOTD (`server.properties`)** or **Display Name (`server.json`)**, with a real-time **Minecraft Multiplayer Server List Preview** rendering `§` codes and the 64×64 icon side-by-side.
* **8-Bit Pixel Icon & Badge Generator (`64×64 server-icon.png`)**:
  * Generates crisp 64×64 pixel-art server icons in the browser using 6 Minecraft material themes (`Stone`, `Dirt & Grass`, `Diamond`, `Glowstone`, `Nether`, `Enchanted Glint`), 7 pixel-art stamps (`Sword`, `Pickaxe`, `Creeper`, `Crown`, `Heart`, `Star`, `Pokéball`), and 1–3 character 8-bit bitmap text with 3D drop shadows, or accepts any custom uploaded PNG/JPG/WebP image (auto-cropped and resampled to `64×64`).

#### 3. Interactive Seed Preview Studio (`cubiomes` Native Renderer)
* Uses `/usr/local/bin/mc-seed-render` (compiled against `cubiomes`) on `ul` to render a 2D biome map ($\pm 2048$ blocks around $(0,0)$) and locate nearest structures (`Village`, `Ancient City`, `Mansion`, `Monument`, `Outpost`, `Stronghold`, `Ruined Portal`) in $< 50\text{ ms}$ per seed.
* Supports **Roll Random Seed** and interactive biome legend inspection prior to provisioning a new world.

---

### 6.5 Authentik Gate, RBAC & Automatic `/op` (`ops.json`) Synchronization

Access to `https://mc.drheck.dev` is gated at the Nginx perimeter via **Authentik ForwardAuth** (`X-Authentik-Username`), and `mc-manager` maps authenticated identities via `/data/minecraft/.manager/users.json`:

$$\text{Authentik SSO } (\texttt{auth.drheck.dev}) \longrightarrow \text{Nginx ForwardAuth } (\texttt{X-Authentik-Username}) \longrightarrow \texttt{mc-manager} \text{ RBAC + } \texttt{ops.json} \text{ Sync}$$

* **Global Admins (`global_admin: true`, e.g., `luke`)**: Full access to create servers, provision network/DNS, manage the **Users & Operators Registry** (with live Mojang API username $\longrightarrow$ UUID lookup), and administer all worlds.
* **Per-Server Admins (`server.json` `admins`)**: Full control to start/stop/restart, access the live console, and edit configuration/whitelist/icon on their assigned worlds.
* **Automatic `ops.json` Synchronization**: Adding a user as a Global Admin or Server Admin automatically syncs their Mojang UUID and username into `ops.json` (Level 4 operator with `bypassesPlayerLimit: true`).

---

### 6.6 Server-Side Quality-of-Life (QoL) Datapack Library & Non-Agentic Modrinth Updater

All server-side Quality-of-Life (QoL), building, and world-generation datapacks (`world/datapacks/*.zip`) run 100\% on the server so players connect with a standard Vanilla Minecraft client without installing any client-side mod loader.

#### Curated Server-Side Datapack Library (`/data/minecraft/datapacks/`)
Both the **+ Create New Server** studio and every server's **QoL Mods & Datapacks** tab in `https://mc.drheck.dev` include a 16-pack verified library plus live Modrinth v2 search:

| Category | Datapack / QoL Mod | Modrinth Slug / Source | Default Bundle | Description |
| :--- | :--- | :--- | :--- | :--- |
| **Survival QoL** | **Graves (Ketket)** | `ketket-graves` | **PokeLovers Preset** | Spawns a grave on death storing inventory and XP safely until retrieved. |
| **Survival QoL** | **Fast Leaf Decay** | `fast_leaf_decay` | **PokeLovers Preset** | Leaves decay rapidly after chopping down tree logs. |
| **Survival QoL** | **Craftable Invisible Item Frames** | `invisframes` (local) | **PokeLovers Preset** | Adds crafting recipes for invisible item frames and glow invisible item frames. |
| **Survival QoL** | **Player Drops Head (Jodek)** | `player-drops-head` | **PokeLovers Preset** | Players drop their textured head item when killed. |
| **Survival QoL** | **NetherPortalFix** | *(Curated local pack)* | **Yes** | Tracks each player's Overworld entry portal and returns them to the exact portal when exiting the Nether in multiplayer. |
| **Survival QoL** | **Shulkers Drop Two Shells (Jodek)** | `two-shulker-shells-drop` | **PokeLovers Preset** | Every Shulker guarantees 2 Shulker Shells on death. |
| **Survival QoL** | **All Mob Heads (Jodek)** | `mob-heads` | Optional | Mobs have a balanced chance to drop their decorative head (including variants). |
| **Survival QoL** | **Veinminer** | `veinminer` | Optional | Mine entire connected ore veins at once while sneaking with a pickaxe. |
| **Survival QoL** | **Timber Strike** | `timber-strike` | Optional | Fells connected tree logs in one chop using an axe. |
| **Survival QoL** | **Call Your Horse** | `call-your-horse` | Optional | Whistle to summon your tamed horse, donkey, or mule from nearby chunks. |
| **Survival QoL** | **Mineable Spawners** | `mineable-spawner` | Optional | Pickaxes enchanted with Silk Touch can mine and relocate Monster Spawners. |
| **Building & Utility** | **Armor Stand Arms** | `armor-stand-arms` | Optional | Gives Armor Stands arms by default and enables pose customization. |
| **Building & Utility** | **MasterCutter (Wood Stonecutter)** | `mastercutter` | Optional | Adds Stonecutter recipes for all wood planks, stairs, slabs, and copper blocks. |
| **Building & Utility** | **Day Counter** | `daycounter` | Optional | Displays and tracks in-game world days survived on the scoreboard/actionbar. |
| **HUD & Utility** | **AFK Announcer & Display** | `afk-announcer` | **Yes** | Automatically marks idle players as AFK in the tab list and provides `/trigger afk`. |
| **Hermitcraft & Building** | **Mini Blocks (550+ Blocks)** | `mini-blocks-datapack` | **Yes** | Adds 550+ decorative Mini Blocks in survival via the Stonecutter (100% server-side custom heads, zero client mods). |
| **Hermitcraft & Building** | **Mini Blocks: Items Edition** | `mini-blocks-items` | Optional | Adds 150+ item-themed decorative Mini Blocks in survival (100% server-side, zero client mods). |
| **Hermitcraft & Building** | **Silence Mobs ('silent' Name Tag)** | `silent-mobs` | **Yes** | Name any mob or minecart `silent` with a Name Tag to silence it (Hermitcraft style). |
| **Survival QoL** | **No Enderman Grief** | `no-enderman-grief` | **Yes** | Prevents Endermen from picking up blocks and damaging player builds. |
| **Hermitcraft & Building** | **Armor Statues (Hermitcraft Pose Book)** | `armor-statues-datapack` | Optional | Hermitcraft clickable book for posing armor stands, toggling arms/baseplates/gravity, and locking stands. |
| **Hermitcraft & Building** | **Universal Dyeing** | `universal-dyeing-datapack` | Optional | Re-dye any stained glass, terracotta, concrete, wool, or bed in the crafting grid. |
| **Worldgen & Structures** | **Dungeons and Taverns** | `dungeons-and-taverns` | Optional | Overhauls vanilla structures and adds Vanilla-friendly taverns, shrines, and dungeons. |
| **Worldgen & Structures** | **Terralith** | `terralith` | Optional | Adds 95+ vanilla-block biomes, canyons, and caves with zero client mods required. |

#### Non-Agentic Deterministic Update Pipeline (Web UI, CLI & Weekly `systemd` Timer)
Datapacks are updated deterministically without any AI agent involvement by querying the Modrinth v2 REST API (`/v2/version_file/<sha1>` and `/v2/project/<slug>/version?loaders=["datapack"]`), verifying the downloaded archive against Modrinth's published SHA-1 digest, replacing the old `.zip` atomically in `world/datapacks/`, and issuing `reload` over `/tmp/mc-runner-<slug>.sock` if the server is online:

$$\text{On-Disk }\texttt{.zip}\text{ SHA-1} \longrightarrow \texttt{api.modrinth.com/v2} \longrightarrow \text{SHA-1 Verified Download} \longrightarrow \text{Atomic Swap + }\texttt{/reload}$$

1. **Web UI (`https://mc.drheck.dev`)**: Open any server $\longrightarrow$ **QoL Mods & Datapacks** tab $\longrightarrow$ click **↻ Check Modrinth Updates** or **⬆ Update All Installed Datapacks**.
2. **Manual CLI Check / Update**:
   ```bash
   # Check all servers for available Modrinth datapack updates (read-only):
   /usr/local/bin/mc-manager -check-datapacks

   # Download, SHA-1 verify, and apply all available Modrinth datapack updates across all servers:
   /usr/local/bin/mc-manager -update-datapacks
   ```
3. **Automated Weekly `systemd` Timer (`mc-datapack-update.timer`)**:
   * `/etc/systemd/system/mc-datapack-update.timer` triggers `/etc/systemd/system/mc-datapack-update.service` every Sunday at `04:15 EDT`, keeping all server datapacks and `/data/minecraft/datapacks/` up to date automatically.

---

### 6.7 Server Binary Repository (`/data/minecraft/jars/`) & Non-Agentic Upstream Version Catalog

`mc-manager` maintains a centralized, metadata-indexed **Server Binary Repository** in `/data/minecraft/jars/` backed by two JSON manifests:
1. **`/data/minecraft/jars/index.json` (Local Binary Inventory & Introspected JAR Metadata)**:
   * Every `.jar` in `/data/minecraft/jars/` (and every managed server's active `jar_file`) is automatically inspected by reading the embedded `version.json` and `META-INF/MANIFEST.MF` inside the ZIP archive:
     * `flavor`: `paper`, `vanilla`, `folia`, `fabric`, or `forge`
     * `mc_version`: Exact Minecraft release version (e.g., `26.3`, `26.2`, `1.21.11`, `1.20.1`)
     * `build`: PaperMC / Folia build number (e.g., `170` for `paper-26.3-170.jar`)
     * `protocol_version`: Wire protocol version (e.g., `777` for `26.3`, `776` for `26.2`)
     * `data_pack_format`: Datapack major format (e.g., `121` for `26.3`)
     * `java_version`: Required Java major version (e.g., `25` for `26.2`/`26.3`, `21` for `1.21.x`, `17` for `1.18`–`1.20`)
     * `sha256`: Full SHA-256 digest of the `.jar` file
2. **`/data/minecraft/jars/upstream_catalog.json` (Upstream Supported Versions & Latest Builds)**:
   * Synchronized deterministically (without any AI agent) against the four official upstream REST APIs:
     * **PaperMC Fill v3 API** (`https://fill.papermc.io/v3/projects/paper` and `/versions/<ver>/builds` with SHA-256 verification)
     * **Folia Fill v3 API** (`https://fill.papermc.io/v3/projects/folia` and `/versions/<ver>/builds` with SHA-256 verification)
     * **Mojang Official Piston Meta v2** (`https://piston-meta.mojang.com/mc/game/version_manifest_v2.json` with SHA-1 verification)
     * **FabricMC Meta v2** (`https://meta.fabricmc.net/v2/versions` for game versions, loader `0.19.5`, and installer `1.1.2`)

#### Non-Agentic Binary & Version Sync Workflow (Web UI, CLI & Weekly `systemd` Timer)
1. **Web UI (`https://mc.drheck.dev`)**:
   * Click **Binary Repo & Versions** in the left sidebar to view all indexed binaries in `/data/minecraft/jars/`, sync upstream version lists (`26.3` first), download & verify any `PaperMC`, `Vanilla`, `Fabric`, or `Folia` release into `/data/minecraft/jars/`, or click **⬆ Update All Cached & Server Binaries**.
   * In any server's **Config, Seed & Text Studio** tab, select any binary from the **Repository Binary** dropdown or click **⬆ Upgrade JAR** to switch or upgrade the server in one click.
2. **Standalone CLI Commands**:
   ```bash
   # Sync upstream version catalog and check all repository & server binaries:
   /usr/local/bin/mc-manager -check-binaries

   # Fetch & SHA-256/SHA-1 verify a specific upstream server binary into /data/minecraft/jars/:
   /usr/local/bin/mc-manager -fetch-jar paper:26.3
   /usr/local/bin/mc-manager -fetch-jar vanilla:26.3

   # Automatically download newer Paper/Folia builds and update servers in-place:
   /usr/local/bin/mc-manager -update-binaries
   ```
3. **Automated Weekly `systemd` Timer (`mc-datapack-update.timer`)**:
   * Runs `/usr/local/bin/mc-manager -update-binaries -update-datapacks` every Sunday at `04:15 EDT`.


# amux - Agent modified tmux

## Overview

**amux** (*Agent-Modified tmux*) is a custom build of tmux designed to provide clean, robust bidirectional programmatic control for AI CLI agents and automated scripting.

Standard tmux requires emulating physical keystrokes (`send-keys`) and screen-scraping the visual terminal grid (`capture-pane`). This introduces significant failure modes:
1. **Quoting and Escaping Hell:** Nested quotes, shell variables, subshells, and escapes are easily mangled when passed across nested SSH sessions or bash wrappers.
2. **Terminal Echo & Input Race Conditions:** Rapid key injection can race with shell readline redraws, bracketed paste triggers, or auto-suggestions.
3. **ANSI & Layout Pollution:** Screen scraping mixes prompt redraws, color escape codes, truncated lines at terminal column boundaries, and terminal dimensions with actual command output.
4. **No Command Completion or Busy-State Visibility:** Standard tmux cannot distinguish a pane running a command from a pane sitting idle at a prompt without polling `capture-pane` or guessing via `monitor-silence`.

**amux** provides symmetric, full-duplex programmatic control:
- **Zero-Escape Writing (`write-pane -f`):** Direct PTY stream injection bypassing keystroke emulation and shell escaping, with immediate `PANE_CMDRUNNING` state pre-marking on execution (`-e` or trailing `\n`).
- **Direct Stream Reading & Synchronous Waiting (`read-pane`):** Direct PTY output buffer capture (`wp->stream_buf`) bypassing 2D screen grid layout truncation and ANSI pollution, with deterministic OSC 133 / `tcgetpgrp` command completion waiting (`-w`), state inspection (`-S`), and exit-code propagation (`-e` / `-E`).

---

## Architecture & Session Hierarchy

The tmux sessions on the host are organized into clean layers managed by `/home/luke/bin/tmux_control`:

```
+-------------------------------------------------------+
|  mtx / mx: Outer Master Session                       |
|  Socket: /tmp/tmux-1000/tmux_luke_mtx                 |
|  Config: ~/.tmux.conf_outer                           |
|                                                       |
|   +-----------------------------------------------+   |
|   |  stx / sx: User Interactive Session           |   |
|   |  Socket: /tmp/tmux-1000/tmux_luke_stx         |   |
|   |  Config: ~/.tmux.conf_inner                   |   |
|   +-----------------------------------------------+   |
|                                                       |
|   +-----------------------------------------------+   |
|   |  atx / ax: Agent Session (amux)               |   |
|   |  Socket: /tmp/tmux-1000/tmux_luke_atx         |   |
|   |  Config: ~/.tmux.conf_agent                   |   |
|   +-----------------------------------------------+   |
+-------------------------------------------------------+
```

- **`atx [session]`**: Attaches to or creates the agent session inside `mtx`. Styled with a high-visibility orange status bar (`#ff8800`), `[AGENT]` badge, live per-window `[RUN]` and `[ERR:<code>]` badges, and `C-b` prefix key (matching inner session convention without colliding with outer `mtx` `C-a`).
- **`ax <command>`**: Runs commands directly on the agent socket (e.g. `ax list-panes`, `ax write-pane ...`, `ax read-pane ...`).
- **Binary:** The custom binary is built from `/home/luke/git/amux` (`/home/luke/git/tmux`) and installed at `/usr/local/bin/tmux-custom`. The system tmux (`/usr/bin/tmux`) remains unmodified as a fallback.

---

## Persistent Agent Environment & OSC 133 Shell Integration

When the agent session spawns, it automatically sources `~/.agent_profile`:
- **Persistent Environment Variables:**
  - `AGENT_SESSION=1`
  - `ROUTER=danknasty`
  - `ROUTER_REPO=/home/luke/git/router_config`
  - `MC_DIR=/data/minecraft`
  - `MC_ROUTER_V4=10.0.0.113`
  - `MC_ROUTER_V6=fd77:7777:5010::1337:f1ac:d00d:3c`
  - `MC_BACKEND_PREFIX=fd77:7777:5010::1337:3c:d00d:`
- **Persistent Helper Functions:**
  - `rcfg`: Git wrapper for router config (`git -C $ROUTER_REPO`)
  - `rssh`: Direct SSH to danknasty
  - `rdo <cmd>`: Direct non-interactive execution on router
  - `mipctl`: Shortcut for `/data/minecraft/ipctl`
- **OSC 133 Semantic Prompt & Command Hooks:**
  - `PS0` emits `\033]133;C;k=1\007` immediately before command execution begins.
  - `PROMPT_COMMAND` captures the command's exit status `$?` (`__amux_osc133_pre`) before history syncing and emits `\033]133;D;<exit_code>\007\033]133;A\007` (`__amux_osc133_post`) right before rendering `PS1` (`\033]133;B\007`).

---

## Window Naming & Task Lifecycle Methodology

To maintain strict isolation between tasks, prevent command collisions, and keep workspaces clean, windows in `atx` follow a structured naming convention:

```
<project_or_domain>-<target>-<task_suffix>
```

### Naming Pattern Examples
- `mc-pokelovers-bluemap`: BlueMap setup or rendering for pokelovers
- `mc-pokelovers-datapacks`: Managing datapacks and gameplay tweaks
- `mc-pokelovers-console`: Direct interactive Minecraft server console
- `rtr-dns-sync`: Router DNS record and deSEC synchronization
- `rtr-pf-rules`: Firewall rule adjustments and testing
- `bld-amux-stream`: Compiling and patching tmux C source code
- `bkp-verify-daily`: Backup execution and PAR2 verification

### Window Lifecycle Protocol

1. **Window Creation (Task Start):**
   Before running multi-step commands for a task, verify if the dedicated window exists; if not, spawn it:
   ```bash
   ax list-windows -F '#{window_name}' | grep -q '^mc-pokelovers-bluemap$' || \
   ax new-window -n "mc-pokelovers-bluemap" "bash --rcfile ~/.agent_profile"
   ```

2. **Scoped Execution via File Injection:**
   Write the script or commands to a file on `/dev/shm`, flush the output buffer, and inject into the target window using `write-pane -f`:
   ```bash
   ax read-pane -C -t "mc-pokelovers-bluemap"
   ax write-pane -t "mc-pokelovers-bluemap" -e -f /dev/shm/task.sh
   rm -f /dev/shm/task.sh
   ```

3. **Synchronous Output Retrieval & Exit Verification:**
   Wait for the command or multi-line script to finish, isolate the clean OSC 133 output, drain the queue, and propagate the exit code:
   ```bash
   ax read-pane -w -O -c -E -t "mc-pokelovers-bluemap"
   ```

4. **Status Renaming:**
   If a task transitions to a persistent state (e.g. watching logs, running tests), update the title:
   ```bash
   ax rename-window -t "mc-pokelovers-bluemap" "mc-pokelovers-watch"
   ```

5. **Task Completion Confirmation & Cleanup Rule:**
   - When a task or subtask is complete, the outcome must be explicitly verified (service status, connectivity, file state).
   - Once verified, promptly close the dedicated task window:
     ```bash
     ax kill-window -t "mc-pokelovers-bluemap"
     ```
   - No completed task windows are left lingering in the agent session. Confirm to the user that the task is finished and that its task window has been torn down.

---

## Built-In Commands

### `write-pane` (alias: `writep`)

Injects raw binary data, files, or Base64 payloads directly into the target pane's PTY input buffer. It completely bypasses terminal keystroke decoding and shell quoting, records the current stream buffer mark (`wp->stream_mark`), and immediately pre-marks the pane as `running` (`PANE_CMDRUNNING`) when newline execution (`-e` or trailing `\n`) is triggered.

```bash
tmux write-pane [-en] [-f file] [-b base64-data] [-t target-pane] [data ...]
```

#### Flags:
- **`-f <file>`**: Reads the given file from disk and streams its raw bytes directly into the pane's input buffer. **(Recommended Standard)**
- **`-e`**: Automatically appends a newline (`\n` / Enter) to trigger immediate execution.
- **`-b <base64-data>`**: Decodes a base64 payload in C (`b64_pton`) and writes raw bytes to the PTY.
- **`-n`**: Do not separate multiple positional arguments with spaces.
- **`-t <target-pane>`**: Specifies the target session, window, or pane (e.g. `-t mc-pokelovers-bluemap`).

---

### `read-pane` (alias: `readp`)

Reads raw or filtered output directly from the target pane's 16 MB in-memory stream buffer (`wp->stream_buf`), queries command execution state/exit status, or synchronously waits for command completion without screen scraping or polling.

```bash
tmux read-pane [-cCeElOprsSw] [-b buffer-name] [-f file] [-o file] [-T timeout] [-t target-pane]
```

#### Flags:
- **Default (Clean Mode):** Automatically strips ANSI / VT100 CSI, OSC, APC/title, and charset escape sequences, removes bracketed paste triggers, and normalizes `\r\n` linebreaks to standard `\n`.
- **`-w` (wait for completion):** Suspends the client until all active/queued commands in the target pane finish (`OSC 133;D` / `133;A` with a 25 ms multi-line settle window, or PTY foreground process group completion) before reading.
- **`-T <seconds>` (timeout):** Maximum seconds to wait when `-w` is specified; exits with return code `124` on timeout.
- **`-O` (OSC 133 isolation):** Semantically extracts command output blocks between `OSC 133;C` (output start) and `OSC 133;D` (output end) produced since the last `write-pane` (or buffer drain), completely eliminating prompt redraws and command echo across both single-line and multi-line script injections.
- **`-c` (consume):** Drains read bytes from the pane buffer (FIFO behavior). Repeated calls return only *new* output produced since the last read.
- **`-C` (clear/flush):** Immediately flushes the pane output buffer without reading (ideal before running a new command).
- **`-S` (status summary):** Prints a single-line structured state summary without modifying the stream buffer:
  ```text
  state=<idle|running|dead|unknown> exit_code=<N|-> duration=<N> unread_bytes=<N> command=<cmd>
  ```
- **`-e` (exit code):** Prints the numeric exit status (`0..255`, or `-1` if running/unknown) of the last completed OSC 133 command.
- **`-E` (propagate exit code):** Propagates a non-zero command exit status from the target pane directly to the `read-pane` client exit status (`$?`).
- **`-l` (length):** Returns the current number of queued bytes in the pane buffer without draining.
- **`-r` (raw):** Bypasses ANSI stripping and outputs the raw, unmodified PTY byte stream.
- **`-s` (strip):** Explicitly strip ANSI escape codes (default behavior).
- **`-o <file>` / `-f <file>`**: Streams buffer content directly to a file on disk.
- **`-b <buffer-name>`**: Stores captured content into a tmux paste buffer.
- **`-t <target-pane>`**: Target pane specification.

---

## Format Variables & Status Bar Integration

**amux** exposes pane command state and buffer metrics as native tmux format variables usable in `list-panes -F`, `display-message -p`, and status lines:

| Format Variable | Description |
| :--- | :--- |
| `#{pane_command_state}` | `running`, `idle`, `dead`, or `unknown` (combines OSC 133 and PTY `tcgetpgrp` tracking) |
| `#{pane_last_exit_code}` | Exit code (`0..255`) of the last completed OSC 133 command (empty if none) |
| `#{pane_stream_bytes}` | Unread byte count currently queued in `wp->stream_buf` |
| `#{pane_command_running}` | `1` if an OSC 133 command is running, `0` otherwise |
| `#{pane_command_status}` | Exit status from most recent OSC 133 command |
| `#{pane_command_duration}` | Elapsed duration in seconds of the current or most recent OSC 133 command |

### Inspecting All Agent Panes at Once
```bash
ax list-panes -a -F '#{window_name} (#{pane_id}): state=#{pane_command_state} exit=#{pane_last_exit_code} duration=#{pane_command_duration}s unread=#{pane_stream_bytes}B'
```

In `~/.tmux.conf_agent`, window titles in the orange `[AGENT]` status bar automatically display `[RUN]` while a command is executing and `[ERR:<code>]` if the last command exited with a non-zero code.

---

## Standard Workflow: Synchronous Zero-Escape Bidirectional Automation

### Step 1: Write Script to `/dev/shm`
```bash
cat > /dev/shm/job.sh << 'SCRIPT'
python3 - << 'PY'
import json, os
print(json.dumps({
    "user": os.getenv("USER"),
    "quote_test": 'Double "quotes" and single \'quotes\' work without escapes!'
}, indent=2))
PY
SCRIPT
```

### Step 2: Flush, Inject, Wait, and Read Clean Output
```bash
ax read-pane -C -t mc-pokelovers-datapacks
ax write-pane -t mc-pokelovers-datapacks -e -f /dev/shm/job.sh
rm -f /dev/shm/job.sh

# Blocks until completion, extracts clean OSC 133 output, drains buffer, and propagates exit code
ax read-pane -w -O -c -E -t mc-pokelovers-datapacks
```

---

## Structured Audit Logging

Both `write-pane` and `read-pane` integrate with systemd journal for structured security and execution logging:
- Injected scripts are archived to `/data/amux/scripts/<timestamp>_<window>.sh`.
- Audit events are sent to the systemd journal under identifier `amux` (including `AMUX_PANE_ID`, `AMUX_WINDOW_NAME`, `AMUX_READ_SIZE`, `AMUX_CONSUMED`, `AMUX_CMD_STATE`, and `AMUX_EXIT_CODE`):
  ```bash
  journalctl -t amux -n 20 --no-pager
  ```

---

## Rebuilding & Updating amux

The source tree is maintained at `/home/luke/git/amux` (`/home/luke/git/tmux`):
```bash
cd /home/luke/git/amux
make -j"$(nproc)"
sudo install -m 755 tmux /usr/local/bin/tmux-custom
```
Existing sessions can continue running undisturbed; new sessions or restarts of `tmux_control` (`atx`, `stx`, `mtx`) automatically pick up the updated binary.


# subocr service

<p class="callout info"><strong>Service Overview:</strong> <strong>SubOCR</strong> (<a href="https://subocr.drheck.dev/">https://subocr.drheck.dev/</a>) is an end-to-end Japanese burned-in video subtitle (<em>telop</em> / テロップ) extraction, <ruby>漢字<rt>かんじ</rt></ruby> <ruby>振<rt>ふ</rt></ruby>り<ruby>仮名<rt>がな</rt></ruby> annotation, offline <strong>JMdict + JLPT (N5–N1)</strong> morphological dictionary lookup, LLM study-note refinement, and interactive video study platform running on host <code>ul</code>, accelerated by the <strong>AMD Radeon AI PRO R9700 (<code>gfx1201</code>, RDNA 4, 32 GB VRAM)</strong> GPU.</p>

## 1. Architecture & Network Topology

SubOCR is deployed as a containerized FastAPI + ONNX Runtime (ROCm 7.2 MIGraphX) service bound to a dedicated IPv6 ULA address on `eth77`, reverse-proxied through Nginx with Authentik ForwardAuth single sign-on, and paired with the local `ollama-rocm` (`qwen2.5:32b`) LLM engine on the same GPU.

$$\text{Video Frame } I(t) \longrightarrow \text{PP-OCRv5 (MIGraphX FP32)} \longrightarrow \text{Qwen2.5-32B (ROCm)} \longrightarrow \text{UniDic + JMdict/JLPT Study View}$$

* **Public URL**: [https://subocr.drheck.dev/](https://subocr.drheck.dev/)
* **Dedicated ULA IPv6 Address**: `fd77:7777:5010:0:1337:f1ac:d00d:0c70` (`eth77`, port `8000`)
* **Host Directory**: `/data/docker/subocr/`
* **Databases**:
  * **Application Database**: `/data/docker/subocr/data/subocr.db` (SQLite 3 in WAL mode; stores jobs, timed cues, readings, translations, and custom edits)
  * **Offline Dictionary & JLPT Database**: `/data/docker/subocr/data/jmdict_jlpt.db` (SQLite 3, 63 MB; 218,856 EDRDG JMdict entries, 499,325 indexed kanji/kana forms, and 7,972 JLPT N5–N1 vocabulary entries)
* **Export & Sidecar Storage**:
  * Per-job exports: `/data/docker/subocr/data/jobs/<job_id>/`
  * Automatic sidecar writes next to local videos: `/data/language/Japanese/Video/...` (mounted read-write at `/videos` inside the container)
* **Authentication**: Authentik ForwardAuth (`subocr` ProxyProvider, 30-day token validity) via `/etc/nginx/sites-available/subocr`
* **DNS Resolution**:
  * Local Unbound (`danknasty`, `@10.0.0.1`): `A 10.0.0.100`, `AAAA fd77:7777:5010::1337:f1ac:d00d:aaaa`
  * Public Authoritative DNS (deSEC): `AAAA fd77:7777:5010::1337:f1ac:d00d:aaaa`

---

## 2. Two-Stage GPU Processing & Morphological Dictionary Pipeline

When a job is submitted from [https://subocr.drheck.dev/new](https://subocr.drheck.dev/new) (either by selecting a local video under `/data/language/Japanese/Video`, uploading a video file, or providing a YouTube URL fetched via `yt-dlp`), SubOCR executes a two-stage GPU pipeline on the **AMD Radeon AI PRO R9700 (`gfx1201`)** followed by deterministic morphological and dictionary analysis:

### Stage 1: ROCm 7.2 MIGraphX Visual Subtitle OCR (`PP-OCRv5`)

1. **Frame Sampling & Spatial Crop**:
   * OpenCV samples the video at a configurable rate $f_s$ (default $2.0\text{ fps}$, or frame interval $\Delta t = 0.5\text{ s}$).
   * To isolate burned-in subtitles and ignore top-of-screen variety-show topic banners by default, frames are cropped to the lower subtitle region $y \in [0.58H, 0.98H]$.
2. **GPU Inference (`MIGraphXExecutionProvider` FP32)**:
   * Detection (`PP-OCRv5_server_det_infer.onnx`) and recognition (`japanese_PP-OCRv5_mobile_rec_infer.onnx`) run via `onnxruntime-rocm` with `MIGraphXExecutionProvider`.
   * Compiled MIGraphX kernel plans (`.mxr`) are cached in `/data/docker/subocr/cache/migraphx` so subsequent container restarts bypass ahead-of-time compilation.
   * **Precision Note**: `migraphx_fp16_enable` is explicitly set to `0` (`FP32`) because FP16 causes bounding-box coordinate underflow in `PP-OCRv5` detection on `gfx1201`.
3. **Bilingual Line Classification & Spatial Role Separation**:
   * Detected text boxes within each frame are sorted vertically and classified into Japanese (`ja_lines`) or English (`en_lines`) based on character script ratios.
   * Within the lower subtitle band, higher-positioned smaller Japanese commentary boxes ($y < 0.66H$) are separated from the primary bottom speaker dialogue ($y \ge 0.66H$) so simultaneous on-screen editor commentary and speaker subtitles are preserved cleanly.
4. **Temporal Deduplication**:
   * Consecutive frames whose normalized Japanese string similarity satisfies:
     $$\text{sim}(A, B) = \frac{2 \cdot |M|}{|A| + |B|} \ge 0.78$$
     are merged into a single timed subtitle cue $[t_0, t_1]$, selecting the highest-confidence OCR recognition frame as the canonical raw text (`ja_raw`) and storing the initial English line (`en_raw`).

<p class="callout warning"><strong>Hardware & ROCm Note:</strong> Always keep <code>MIGRAPHX_DISABLE_MIOPEN_FUSION=1</code> and <code>migraphx_fp16_enable=0</code> enabled in <code>/home/docker-data/.config/containers/systemd/subocr.container</code>, and never set <code>UserNS=keep-id</code> on the 23.3 GB ROCm image (default rootless user namespace starts in under 2 seconds). Enabling FP16 on <code>gfx1201</code> degrades bounding-box detection recall in <code>PP-OCRv5</code>.</p>

### Stage 2: Contextual LLM Refinement, Kanji-Only Furigana & Offline Dictionary Tagging

1. **Whole-Conversation Person Name Identification (`extract_conversation_person_names`)**:
   * Before batched refinement or vocabulary page generation, `qwen2.5:32b` scans the full numbered transcript across the entire video (together with the video title) to identify recurring speakers, cast members, and mentioned individuals.
   * Extracted names are stored in `jobs.person_names_json` (mapping each canonical surface form and OCR variant to its reading, English romanization, role description, and `固有名詞` tag), automatically repairing visual OCR name typos (e.g., `たくあん` / `たくみん` -> `たくみ`) and preventing person names from being misclassified as common homophones in vocabulary chips.
2. **Batched Contextual Refinement (`qwen2.5:32b` on `ollama-rocm`)**:
   * Extracted cues are processed in batches of 15 cues against `http://169.254.1.2:11434/api/chat` (`qwen2.5:32b`, routed via `pasta` `--map-host-loopback` to `127.0.0.1:11434`).
   * The LLM receives both the raw Japanese OCR (`ja_raw`) and any burned-in English subtitle (`en_raw`), repairing single-character visual OCR confusions (e.g., katakana `ニ`/`二`, `カ`/`力`, or visually degraded glyphs) while preserving the original spoken phrasing, slang, and dialect.
   * If `en_raw` is present and accurate, it is cleaned; if missing or garbled, a natural English translation (`en_text`) is synthesized alongside contextual idiom/slang notes (`vocab`).
3. **Punctuation & On-Screen Symbol Restoration (`restore_raw_punctuation`)**:
   * Post-processing compares `ja_raw` and `ja_text` to ensure the LLM never strips on-screen quotation marks (`"..."`, `「...」`, `『...』`), wave dashes (`~`, `〜`), exclamation/question marks (`!`, `?`), ellipses (`…`), or counter katakana (`何カ国`, `6カ国`).
   * Example preserved cue: `"スペシャルゲスト~!"が<ruby>無<rt>な</rt></ruby>くなり<ruby>皆<rt>みな</rt></ruby>メンバーに`
4. **Deterministic Kanji-Only Furigana (`fugashi` + `unidic-lite`)**:
   * Morphological tokenization (`fugashi` + `unidic-lite` + `jaconv`) in `generate_kanji_furigana()` strips leading, trailing, and internal okurigana and attaches `<ruby>` annotations **strictly above Kanji characters**, leaving hiragana and katakana unannotated:
   * **Example Render**: <ruby>世界<rt>せかい</rt></ruby>の<ruby>違<rt>ちが</rt></ruby>いに6カ<ruby>国<rt>こく</rt></ruby>が<ruby>大混乱<rt>だいこんらん</rt></ruby> — それ<ruby>考<rt>かんが</rt></ruby>えたらすごい<ruby>日本<rt>にほん</rt></ruby>っぽいっていうか
5. **Exhaustive Per-Word Tokenization, Grammar Categorization & Offline JMdict / JLPT Lookup (`analyze_cue_tokens`)**:
   * Every word in every subtitle cue is tokenized by `fugashi` (`MECAB_TAGGER`), lemmatized to its dictionary base form (`orthBase`), and matched against the local read-only `/data/docker/subocr/data/jmdict_jlpt.db` SQLite database and a curated `GRAMMAR_GLOSS_MAP` for Japanese particles and colloquial auxiliaries (`てる`/`てん`, `ちゃう`, `たら`, `じゃん`, Kansai `ねん`/`やん`/`へん`, etc.).
   * Each token is assigned:
     * **JLPT Level Badge (`jlpt`)**: `N5`, `N4`, `N3`, `N2`, `N1`, or `Other`
     * **Part-of-Speech Filter & Chip Tag (`pos_cat` / `pos_label`)**: Grouped under the 6 `品詞` filter buttons (`名詞`, `動詞`, `形容詞`, `副詞`, `表現`, `助詞`) and labeled on each vocabulary chip with its specific Japanese grammatical category (see the complete reference table below)
     * **English Gloss (`meaning`)**: Offline JMdict definition or grammatical function

### Reference Table: Japanese Part-of-Speech (`品詞`) Filter Groups & Vocabulary Chip Tags

| Chip Tag<br>(`pos_label`) | Reading<br>(<ruby>振<rt>ふ</rt></ruby>り<ruby>仮名<rt>がな</rt></ruby>) | Filter Button<br>(`品詞`) | English Translation | Grammatical Role & Subtitle Examples |
| :--- | :--- | :--- | :--- | :--- |
| **`名詞`** | <ruby>名詞<rt>めいし</rt></ruby> | **`名詞`**<br>(Noun) | Common Noun | General nouns and *suru*-noun stems (e.g., `世界`, `違い`, `大混乱`) |
| **`固有名詞`** | <ruby>固有名詞<rt>こゆうめいし</rt></ruby> | **`名詞`**<br>(Noun) | Proper Noun | Places, countries, people, and brand names (e.g., `日本`, `関西`, `フランス`) |
| **`代名詞`** | <ruby>代名詞<rt>だいめいし</rt></ruby> | **`名詞`**<br>(Noun) | Pronoun | Personal and demonstrative pronouns (e.g., `私`, `僕`, `これ`, `それ`, `どこ`) |
| **`助数詞`** | <ruby>助数詞<rt>じょすうし</rt></ruby> | **`名詞`**<br>(Noun) | Counter | Numerical counter suffixes (e.g., `カ国`, `人`, `回`, `つ`, `個`) |
| **`動詞`** | <ruby>動詞<rt>どうし</rt></ruby> | **`動詞`**<br>(Verb) | Verb | All main verbs (*Godan*, *Ichidan*, *Suru*, *Kuru*); displayed as `conjugated (dictionary form)` when inflected (e.g., `言った (言う)`, `考えたら (考える)`) |
| **`形容詞`** | <ruby>形容詞<rt>けいようし</rt></ruby> | **`形容詞`**<br>(Adjective) | *i*-Adjective | Inflecting adjectives ending in `-i` (e.g., `すごい`, `面白い`, `無い`) |
| **`形容動詞`** | <ruby>形容動詞<rt>けいようどうし</rt></ruby> | **`形容詞`**<br>(Adjective) | *na*-Adjective | Adjectival nouns that take `な` before nouns (e.g., `好き`, `大変`, `複雑`) |
| **`連体詞`** | <ruby>連体詞<rt>れんたいし</rt></ruby> | **`形容詞`**<br>(Adjective) | Pre-noun Adjectival | Non-inflecting words that directly modify nouns (e.g., `この`, `その`, `あんな`, `いろんな`) |
| **`副詞`** | <ruby>副詞<rt>ふくし</rt></ruby> | **`副詞`**<br>(Adverb) | Adverb | Words modifying verbs, adjectives, or clauses (e.g., `ちょっと`, `めっちゃ`, `全然`, `やっぱり`) |
| **`表現`** | <ruby>表現<rt>ひょうげん</rt></ruby> | **`表現`**<br>(Expression) | Expression / Idiom | Multi-word idioms, set phrases, and contextual slang compounds |
| **`感動詞`** | <ruby>感動詞<rt>かんどうし</rt></ruby> | **`表現`**<br>(Expression) | Interjection | Exclamations, greetings, and conversational fillers (e.g., `えっ`, `はい`, `うわ`, `なるほど`) |
| **`接続詞`** | <ruby>接続詞<rt>せつぞくし</rt></ruby> | **`表現`**<br>(Expression) | Conjunction | Sentence/clause connectors (e.g., `でも`, `だから`, `そして`, `ただ`) |
| **`助詞`** | <ruby>助詞<rt>じょし</rt></ruby> | **`助詞`**<br>(Particle) | Particle (General) | General or adverbial/quotative particles (e.g., `って`, `とか`, `だけ`, `くらい`) |
| **`格助詞`** | <ruby>格助詞<rt>かくじょし</rt></ruby> | **`助詞`**<br>(Particle) | Case Particle | Marks grammatical case relationships (e.g., `が`, `を`, `に`, `で`, `と`, `から`) |
| **`係助詞`** | <ruby>係助詞<rt>かかりじょし</rt></ruby> | **`助詞`**<br>(Particle) | Topic / Binding Particle | Marks topic, contrast, or inclusion (e.g., `は`, `も`, `こそ`) |
| **`終助詞`** | <ruby>終助詞<rt>しゅうじょし</rt></ruby> | **`助詞`**<br>(Particle) | Sentence-Ending Particle | Adds tone, question, or emphasis at clause end (e.g., `ね`, `よ`, `な`, `か`, `じゃん`) |
| **`接続助詞`** | <ruby>接続助詞<rt>せつぞくじょし</rt></ruby> | **`助詞`**<br>(Particle) | Conjunctive Particle | Connects clauses (e.g., `て`/`で`, `けど`, `から`, `ば`, `のに`, `し`) |
| **`助動詞`** | <ruby>助動詞<rt>じょどうし</rt></ruby> | **`助詞`**<br>(Particle) | Auxiliary Verb | Inflecting verbal/copular endings and colloquial contractions (e.g., `だ`/`です`, `た`, `ない`, `ます`, `てる`/`てん`, `ちゃう`, `ねん`, `やん`, `へん`) |
| **`接頭辞`** | <ruby>接頭辞<rt>せっとうじ</rt></ruby> | **`助詞`**<br>(Particle) | Prefix | Honorific or intensifying prefixes attached to stems (e.g., `お`, `ご`, `大`, `超`) |
| **`接尾辞`** | <ruby>接尾辞<rt>せつびじ</rt></ruby> | **`助詞`**<br>(Particle) | Suffix | Meaning-modifying suffixes attached to stems (e.g., `さん`, `たち`, `っぽい`, `的`, `目`) |

---

## 3. Web Application Layout & Study Controls

SubOCR uses a sharp-cornered (`border-radius: 0`) layout organized into three focused pages:

* **Main OCR Library ([https://subocr.drheck.dev/](https://subocr.drheck.dev/))**:
  * Displays all OCR jobs with status badges, cue counts, video durations, category folders, and a `Source Video ↗` link whenever an original video URL (`youtube_url`) is recorded.
  * Provides direct export and download links (`JA .SRT`, `Bilingual .SRT`, `WebVTT .VTT`, `Anki .TSV`, `Study .MD`, `JSON`, and `Save to /data`) on each OCR job card.
  * Supports pinning (`★`), category filtering, searching, sorting (`Pinned & Newest`, `Oldest First`, `Title (A–Z)`, `Most Cues`), inline editing (`Organize` drawer for Title, Category, and `Original Video URL`), one-click video recovery (`Re-download Video` via `POST /api/jobs/{job_id}/redownload-video` if a local video file is missing on disk), and job deletion (`Delete`).
* **New Subtitle OCR ([https://subocr.drheck.dev/new](https://subocr.drheck.dev/new))**:
  * Dedicated job submission form for local videos under `/data/language/Japanese/Video`, direct file uploads, or YouTube URLs, with optional start/end timestamps (`start_sec`, `end_sec`), frame sampling FPS, category assignment, and an `Original Video URL` field across all tabs (plus automatic YouTube ID extraction from `-<11char_id>.mp4/.mkv` filenames) so source URLs are preserved for backup recovery.
* **Dedicated OCR Study View (`https://subocr.drheck.dev/ocr/{job_id}`)**:
  * **Resizable Split-Screen Workspace**: Full-viewport two-column layout with a `52vw` default left video column and an interactive `8px` vertical drag handle (`#split-resizer`) between the left video panel and right subtitle cue panel. Dragging the divider resizes the video player and subtitle list in real time, persists the chosen split width in `localStorage` (`subocr_split_width`), and resets to `52vw` on double-click.
  * Compact top bar above the HTML5 video player with `← Library`, `Source Video ↗` link, category badge, and small muted video title. Native `<video controls>` and WebVTT overlays are disabled on the video canvas so burned-in subtitles at the bottom of the frame are never obscured; instead, a dedicated external player control bar (`.player-bar`) sits directly below the video with Play/Pause, an interactive timeline scrubber, `currentTime / duration` readout, volume/mute controls, and fullscreen toggle. If the local video file is ever missing on disk, a recovery banner appears above the player with a `Re-download from Source URL` button (`yt-dlp`).
  * Clicking any subtitle card seeks the video to that cue's start timestamp, starts playback, and highlights the active card in high-contrast amber/gold (`#fbbf24` border/pill and `#fef08a` text). Each card header displays the cue index, timestamp span, and `OCR <confidence>%`.
  * **Fixed-Width Display & Vocabulary Filter Controls** (under the player controls on the left, persisted in `localStorage` with fixed-width `ON`/`OFF` labels that never shift button size):
    * **Display Toggles**: `Japanese Subs`, `Furigana`, `English Sub`, `Vocab`, and `Original OCR`
    * **JLPT Level Filters**: `N5`, `N4`, `N3`, `N2`, `N1`, `Other`
    * **`品詞` (Part-of-Speech) Filters**: `名詞` (Noun), `動詞` (Verb), `形容詞` (Adjective), `副詞` (Adverb), `表現` (Expression), `助詞` (Particle / Auxiliary)
  * **Universal Pre-Generated Vocabulary & Grammar Pages (`https://subocr.drheck.dev/{品詞}/{単語}{読み}.html`)**:
    * Clicking any vocabulary box on a subtitle cue card navigates to a universal, pre-generated reference page for that word, phrase, or grammatical particle (e.g., [https://subocr.drheck.dev/代名詞/私わたし.html](https://subocr.drheck.dev/%E4%BB%A3%E5%90%8D%E8%A9%9E/%E7%A7%81%E3%82%8F%E3%81%9F%E3%81%97.html) or [https://subocr.drheck.dev/動詞/考えるかんがえる.html](https://subocr.drheck.dev/%E5%8B%95%E8%A9%9E/%E8%80%83%E3%81%88%E3%82%8B%E3%81%8B%E3%82%93%E3%81%8C%E3%81%88%E3%82%8B.html)).
    * Pages are pre-generated automatically in `/data/docker/subocr/data/vocab_pages/{pos_label}/{slug}.html` at the end of every video analysis job (and updated whenever a subtitle cue is edited).
    * Each vocabulary page includes:
      1. **Full Dictionary Senses & JLPT Level**: All matching definitions and readings from `jmdict_jlpt.db` (`218,856` JMdict entries + `7,972` JLPT entries + colloquial/dialect grammar map).
      2. **Interactive `300x300px` Kanji Stroke Order & `kanji.drheck.dev` Integration**: For any word containing Kanji, embeds an interactive `300x300px` KanjiVG + Vivus stroke-order viewer (`160x160px` per frame in `Step-by-Step` mode, with compact `5.5px` SVG stroke numbers, grid lines, and replay controls) alongside direct links to `https://kanji.drheck.dev/?text=...&mode=animated&duration=327&showNumbers=true&showGrid=true&numberSize=6&boxSize=300&nightMode=true`.
      3. **Complete Verb & *i*-Adjective Conjugation Tables**: For all verbs (`五段動詞`, `一段動詞`, `サ変動詞`, `カ変動詞`), *suru*-nouns, and `形容詞`, renders a full 19-row conjugation table (`辞書形`, `ます形`, `ない形`, `ません`, `た形`, `ました`, `なかった`, `て形`, `なくて・ないで`, `ている形`, `可能形`, `意向形`, `受身形`, `使役形`, `使役受身形`, `仮定形`, `たら形`, `命令形`, `禁止形`) with `<ruby>` furigana and plain kana readings.
      4. **Token-Highlighted Cross-Video Subtitle Occurrences & Corpus Examples**: Lists every subtitle cue across processed videos where the word appears (clicking any occurrence jumps directly to `/ocr/{job_id}?cue={cue_index}`), paired with offline EDRDG Tanaka Corpus Japanese-English example sentences (`92,940` indexed sentences). Morphological token and `<ruby>` span matching (`fugashi.Tagger`) lightly highlights the target word or phrase—including conjugated verb and adjective inflections—using `<mark class="ex-hl">` without matching inside `<rt>` furigana tags.
  * **Keyboard Shortcuts** (active whenever focus is outside text inputs):
    * `Space` or `K`: Play / Pause video without scrolling the page
    * `A` or `←`: Jump to previous subtitle cue (preserving current play/pause state)
    * `D` or `→`: Jump to next subtitle cue (preserving current play/pause state)
    * `R`: Replay current subtitle cue from its start timestamp
    * `L`: Toggle single-cue A/B loop mode (`Loop: ON/OFF`)
  * **Self-Supporting Per-Cue & Job-Wide Local Re-Analysis (`Re-analyze`, `Edit`, and `Auto-Repair Cues`)**:
    * **One-Click `Re-analyze` Button (`POST /api/cues/{cue_id}/reanalyze`)**: Every subtitle cue card header includes a **Re-analyze** button next to **Edit** that re-runs local Radeon AI PRO R9700 GPU LLM refinement (`qwen2.5:32b` via `ollama-docker-proxy.socket`) on that single cue with surrounding dialogue context (`cue - 1` and `cue + 1`), quote normalization (`"..."` -> `「...」`), and output sanity validation (`is_valid_llm_ja`), updating the card, furigana, vocabulary chips, and pre-generated vocabulary pages in place in ~1.5 seconds.
    * **Inline Cue Editor & Targeted Recovery Modes**: Clicking **Edit** on any cue card opens an inline editor for Japanese text, Furigana reading, English meaning, and vocabulary notes, with four built-in actions:
      1. **Reset to Raw OCR** (`mode="raw"`): Instantly restores `ja_raw` and re-runs local `JMdict + JLPT` morphological token and furigana analysis.
      2. **Re-analyze Edited JA (AI)** (`mode="custom_ja"`): Preserves your manually corrected Japanese text and uses the local GPU LLM + `JMdict` to regenerate accurate Furigana, English translation, and vocabulary notes.
      3. **Re-OCR Video + AI** (`mode="reocr"`): Re-samples the video frames across `[start_sec - 0.2, end_sec + 0.2]` at a dense `4.0 fps` on the GPU (`PP-OCRv5`) and runs local LLM refinement.
      4. **Save Cue**: Instantly (`< 20 ms`) saves manual field edits and automatically re-runs morphological tokenization (`analyze_cue_tokens`), Kanji-only `<ruby>` furigana generation, symbol-compound recognition (`◯✖`, `◯×`, `×印`), and background vocabulary HTML page regeneration whenever `ja_text` is modified.
    * **Job-Wide `Auto-Repair Cues` (`POST /api/jobs/{job_id}/repair-cues`)**: Located in the left control panel, scans all cues in the current video for any truncated or invalid LLM outputs and automatically re-analyzes every affected cue on the local GPU.

---

## 4. Operations, Maintenance & Backup / Restore

### Container Management

<details>
  <summary><strong>Click to expand Rootless Podman Quadlet & Service Management Commands</strong></summary>

```shell
# Check container status and logs
$ (cd /tmp && sudo -u docker-data HOME=/home/docker-data XDG_RUNTIME_DIR=/run/user/1100 podman ps --filter name=subocr)
$ sudo -u docker-data XDG_RUNTIME_DIR=/run/user/1100 journalctl --user -u subocr.service -n 100 -f

# Restart after modifying /data/docker/subocr/app.py or vocab_pages.py
$ sudo systemctl --user -M docker-data@ restart subocr.service

# Rebuild container image if Dockerfile dependencies change
$ (cd /data/docker/subocr && sudo -u docker-data HOME=/home/docker-data XDG_RUNTIME_DIR=/run/user/1100 TMPDIR=/dev/shm podman build -t localhost/subocr:latest /data/docker/subocr)
$ sudo systemctl --user -M docker-data@ restart subocr.service
```

</details>

### Automated System Backup & Restore

<p class="callout success"><strong>Backup Integration:</strong> SubOCR is integrated into the standard host <code>ul</code> backup and restore suite under <code>/home/luke/git/backups/ul/scripts/</code> and runs automatically during <code>sudo /home/luke/git/backups/ul/scripts/run_all_backups</code>.</p>

* **Backup Script (`/home/luke/git/backups/ul/scripts/backup/subocr`)**:
  1. Rotates any existing backup in `/data/backups/services/current/subocr/` to `/data/backups/services/archive/subocr/`.
  2. Refreshes `/data/docker/subocr/data/video_sources.json` (recording every job's ID, title, category, `youtube_url`, `source_path`, and `host_path`) and executes a non-blocking SQLite WAL checkpoint (`PRAGMA wal_checkpoint(TRUNCATE);`) on `/data/docker/subocr/data/subocr.db`.
  3. Creates a compressed archive (`subocr_data.tar.xz`, ~1.4 MB) of `/data/docker/subocr/` including `app.py`, `vocab_pages.py`, `Dockerfile`, `docker-compose.yml`, `static/`, `data/subocr.db`, `data/video_sources.json`, **all pre-generated `.html` vocabulary/grammar pages (`data/vocab_pages/*/*.html`)**, and all exported job subtitle/study files (`data/jobs/*/*.srt`, `.vtt`, `.anki.tsv`, `.study.md`), while excluding transient model/kernel caches (`cache/`), the static JMdict reference database (`data/jmdict_jlpt.db`), and large raw video files (`*.mp4`, `*.mkv`, `*.webm`, `*.mov`).
  4. Generates 10% Reed-Solomon parity protection archives (`subocr_data.tar.xz.par2`) via `par2 create -r10`.
* **Restore Script (`/home/luke/git/backups/ul/scripts/restore/subocr`)**:
  1. Verifies archive integrity against the `par2` parity data (`par2 verify`).
  2. Extracts `subocr_data.tar.xz` into `/data/docker/subocr/`, restoring the SQLite database, all pre-generated vocabulary HTML pages, and all subtitle exports.
  3. Restarts the `subocr` container and checks all jobs in `subocr.db`: if any job's video file is missing on disk and has a recorded `youtube_url`, automatically re-downloads the video via `yt-dlp`.

```shell
# Run a standalone SubOCR backup
$ sudo /home/luke/git/backups/ul/scripts/backup/subocr

# Restore from the latest backup in /data/backups/services/current/subocr/
$ sudo /home/luke/git/backups/ul/scripts/restore/subocr

# Restore from a specific timestamped backup
$ sudo /home/luke/git/backups/ul/scripts/restore/subocr 2026-10-03_1133.09
```


# ip cameras

<p class="callout info"><strong>Service Summary:</strong> The DrHeck Camera Hub (<code>https://cam.drheck.dev</code>) provides automatic LAN discovery, low-latency live video and MP3 audio streaming, two-way RTSP ONVIF backchannel voice talk, YOLOv8n + hardware AI event capture with a web-viewable timeline and text metadata log, optical 3D click-to-zoom and optional auto-zoom on moving objects, smart floodlight/IR/LED controls, privacy sleep mode, bandwidth adaptation, and a sideloadable Google TV interface for the Reolink PoE IP cameras on <code>10.0.0.0/24</code>. Access is restricted via Authentik ForwardAuth to members of the <code>camera_access</code> group (<code>luke</code>, <code>ikura</code>, and <code>haruka</code>).</p>

## Hardware Inventory & Detected Cameras

Two **Reolink RLC-811A** (`P430`) 4K 8MP PoE Spotlight IP cameras are connected to the `10.0.0.0/24` LAN on interface `eth77` of host `ul` with static DHCP leases on `danknasty`:

| Property | Camera 1 (`RLC-811A (122)`) | Camera 2 (`RLC-811A (123)`) |
| :--- | :--- | :--- |
| **IPv4 Address (Static Lease)** | `10.0.0.122` (`host cam1`) | `10.0.0.123` (`host cam2`) |
| **MAC Address** | `ec:71:db:f1:25:5a` | `ec:71:db:6d:84:ce` |
| **Vendor / OUI** | Reolink (`ec:71:db`) | Reolink (`ec:71:db`) |
| **Model / Item No.** | `RLC-811A` / `P430` | `RLC-811A` / `P430` |
| **Hardware Revision** | `IPC_560B158MP` | `IPC_560B158MP` |
| **Firmware Version** | `v3.1.0.4695_2504301440` | `v3.1.0.4695_2504301440` |
| **Serial Number** | `141484731968753` | `141484731989233` |
| **Ethernet Link** | `100M_FULL` (802.3af PoE) | `100M_FULL` (802.3af PoE) |
| **Optical Zoom Range** | `5X` Motorized (`ZoomPos 0..28`, `FocusPos 0..238`, 3D Box Zoom) | `5X` Motorized (`ZoomPos 0..28`, `FocusPos 0..238`, 3D Box Zoom) |
| **Illumination** | IR Night Vision (`auto`/`close`) + `450 lm` Warm Floodlight (`1..100%`) | IR Night Vision (`auto`/`close`) + `450 lm` Warm Floodlight (`1..100%`) |
| **Audio** | Live Mic (`16 kHz` AAC -> MP3) + Two-Way Backchannel (`8 kHz` G.711 PCMU) + Siren | Live Mic (`16 kHz` AAC -> MP3) + Two-Way Backchannel (`8 kHz` G.711 PCMU) + Siren |
| **Time Sync (NTP)** | `time.google.com:123` (`America/New_York` `EDT`/`EST`) | `time.google.com:123` (`America/New_York` `EDT`/`EST`) |

---

## Camera Services, Network Ports & Internal Operation

On firmware `v3.1.0.4695_2504301440`, Reolink cameras lock down legacy plaintext web/streaming ports out of the box and expose only the proprietary **Baichuan** management port (`9000/tcp`) until explicitly instructed to enable standard streaming protocols.

### 1. Port & Service Matrix (`cmd_id = 37` `GetNetPort` / `cmd_id = 36` `SetNetPort`)

| Service | Protocol / Port | Factory Default | Active State on `ul` | Purpose & Operation |
| :--- | :--- | :--- | :--- | :--- |
| **Baichuan (`server`)** | `9000/tcp` | **Enabled** | **Enabled** | Proprietary binary + XML control, telemetry, snapshot, PTZ/zoom, AI/motion alarm, and configuration bus. |
| **RTSP (`rtsp`)** | `554/tcp`, `554/udp` | Disabled | **Enabled** (Auto-enabled by `cam`) | Real-Time Streaming Protocol serving hardware-encoded `main` (H.265 4K) and `sub` (H.264 360p) video, `16 kHz` AAC microphone audio, and ONVIF Backchannel (`track3` `PCMU/8000`) speaker talkback. |
| **ONVIF (`onvif`)** | `8000/tcp` | Disabled | Disabled (Toggleable) | Open Network Video Interface Forum profile for third-party NVR integration. |
| **RTMP (`rtmp`)** | `1935/tcp` | Disabled | Disabled (Toggleable) | Flash/RTMP H.264 sub-stream (`rtmp://<ip>:1935/bcs/channel0_sub.bcs`). |
| **HTTP (`http`)** | `80/tcp` | Disabled | Disabled (Toggleable) | Embedded camera web UI and `/cgi-bin/api.cgi` JSON endpoint. |
| **HTTPS (`https`)** | `443/tcp` | Disabled | Disabled (Toggleable) | Self-signed TLS embedded camera web UI and `/cgi-bin/api.cgi` JSON endpoint. |

### 2. How the Baichuan Protocol (`9000/tcp`) Works

All discovery, hardware control, and instantaneous snapshots are performed over TCP port `9000` using Reolink's **Baichuan** protocol:

1. **Binary Framing Header**: Every Baichuan packet begins with a 20-byte or 24-byte little-endian binary header starting with magic bytes `0xf0debc0a`, followed by `cmd_id` (uint32), payload length (uint32), `channel_id` (uint8), status code (`200` OK, `400` Bad Request), and payload offset.
2. **Modern Nonce Authentication (`cmd_id = 1`)**:
   - The client sends an initial unauthenticated `cmd_id = 1` request.
   - The camera replies with an XML `<Encryption>` challenge containing a random `<nonce>`.
   - The client computes uppercase MD5 digests of `username + nonce` and `password + nonce` (truncated to 31 characters) and submits the authenticated login frame.
   - Once authenticated, payload bodies are encrypted using AES-128-CFB (or XOR stream cipher depending on message class) keyed from the session nonce.
3. **Complete Baichuan Command IDs Used by `cam.drheck.dev`**:
   - `cmd_id = 80` (`GetDevInfo`): Model (`RLC-811A`), item number (`P430`), firmware version, serial number.
   - `cmd_id = 37` / `36` (`GetNetPort` / `SetNetPort`): Inspects and enables `rtsp` (`554/tcp`), `onvif` (`8000/tcp`), `rtmp` (`1935/tcp`), `http` (`80/tcp`), and `https` (`443/tcp`).
   - `cmd_id = 76` / `75` (`GetLocalLink` / `SetLocalLink`): Reads or writes DHCP vs. static IPv4 settings and MAC address.
   - `cmd_id = 182` (`Snap`): Captures an immediate hardware-encoded JPEG frame (`main` 4K or `sub` `640x360`) directly over the control socket in `~110 ms`.
   - `cmd_id = 56` / `57` (`GetEnc` / `SetEnc`): Reads and updates H.265/H.264 resolution, frame rate (`frame`), target bitrate (`bitRate` in kbps), and audio track embedding (`<audio>1</audio>`) for `mainStream` and `subStream`.
   - `cmd_id = 26` / `25` (`GetImage` / `SetImage`): Reads and updates ISP sensor parameters (`bright`, `contrast`, `saturation`, `hue`, `sharpen`, `DayNight/mode` `auto|color|black&white`, `nr3d`, `mirror`, `flip`).
   - `cmd_id = 44` / `45` (`GetOsd` / `SetOsd`): Reads and updates On-Screen Display overlays, including the Reolink logo watermark (`<OsdChannelName><enWatermark>0|1</enWatermark></OsdChannelName>`), camera name overlay, and timestamp overlay.
   - `cmd_id = 288` (`SetWhiteLed` manual) & `cmd_id = 289` / `290` (`FloodlightTask`): Controls manual spotlight toggle (`0|1`), brightness (`1..100`), automatic lighting mode (`0` Off/Manual, `1` Smart Night AI Trigger, `3` Night Schedule), duration (`60..600s`), and AI trigger classes (`people,vehicle,dog_cat`).
   - `cmd_id = 208` / `209` (`LedState`): Controls Infrared Night Vision LEDs (`<state>auto|close</state>`) and the front power status LED (`<lightState>open|close</lightState>`).
   - `cmd_id = 263` (`AudioAlarmPlay`) & `cmd_id = 232` / `231` (`GetAudioAlarm` / `SetAudioAlarm`): Triggers manual 1x siren or configures automatic siren on AI/motion alarm (`<AudioTask><enable>0|1</enable></AudioTask>`, **disabled `0` by default**).
   - `cmd_id = 264` / `265` (`GetAudioCfg` / `SetAudioCfg`) & `cmd_id = 439` / `440` (`GetAudioNoise` / `SetAudioNoise`): Controls speaker volume (`0..100`) and microphone 3D AI noise reduction (`level 0..3`).
   - `cmd_id = 574` / `575` (`get_privacy_mode` / `set_privacy_mode`) & `cmd_id = 52` / `53` (`GetMask` / `SetMask`): Toggles hardware Privacy / Sleep mode (`<sleepState><sleep>0|1</sleep></sleepState>`) and hardware privacy masks (`<Shelter><enable>0|1</enable></Shelter>`).
   - `cmd_id = 295` (`StartZoomFocus`), `cmd_id = 445` (`Set3DPos`), & `cmd_id = 224` / `225` (`GetAutoFocus` / `SetAutoFocus`): Steps the 5X motorized varifocal lens (`ZoomPos 0..28`, `FocusPos 0..238`), executes 3D area click-to-zoom (`posX`, `posY`, `posWidth`, `posHeight` in `0..1000` normalized coordinates), and locks/unlocks autofocus (`disable 0|1`).
   - `cmd_id = 342` / `343` (`GetAiAlarm` / `SetAiAlarm`) & `cmd_id = 46` / `47` (`GetMdAlarm` / `SetMdAlarm`): Configures per-class on-camera AI sensitivity (`0..100`) and stay-time (`0..8s`) for `people`, `vehicle`, `dog_cat`, `face`, plus general motion sensitivity (`1..50`).
   - `cmd_id = 38` / `39` (`GetNtp` / `SetNtp`), `cmd_id = 104` / `105` (`SystemGeneral`), `cmd_id = 106` / `107` (`Dst`), & `cmd_id = 23` (`reboot`): Configures NTP synchronization (`time.google.com:123`), US Daylight Saving Time, EDT/EST clock sync, and device reboot.

### 3. RTSP Stream Endpoints & ONVIF Two-Way Audio Backchannel (`554/tcp`)

Once `rtspEnable = 1` is set via Baichuan `cmd_id = 36`, each camera serves two RTSP streams plus an ONVIF two-way audio backchannel track:

- **Sub Stream (Low-Latency H.264 + AAC Audio + PCMU Backchannel)**:
  ```text
  rtsp://admin:<password>@10.0.0.122:554/h264Preview_01_sub
  rtsp://admin:<password>@10.0.0.123:554/h264Preview_01_sub
  ```
  - `track1`: **H.264 High Profile** (`yuv420p`, `640x360`, default `10 fps` at `256 kbps`, GOP = `4`, `recvonly`).
  - `track2`: **MPEG4-GENERIC AAC-hbr** (`16 kHz` mono audio, `recvonly`).
  - `track3` (when negotiated with `Require: www.onvif.org/ver20/backchannel`): **G.711 PCMU (`PCMU/8000`, payload type `0`, `sendonly`)** for transmitting two-way voice audio directly to the camera's built-in speaker.
- **Main Stream (4K UHD H.265/HEVC)**:
  ```text
  rtsp://admin:<password>@10.0.0.122:554/h265Preview_01_main
  rtsp://admin:<password>@10.0.0.123:554/h265Preview_01_main
  ```
  Encoded as **HEVC / H.265** (`yuvj420p`, `3840x2160`, default `25 fps` at `6144 kbps`, GOP = `2`) with `16 kHz` mono AAC audio.

---

## Architecture of `cam.drheck.dev`

The web service on host `ul` (`/data/docker/cam`) bridges the isolated IPv4 camera LAN (`10.0.0.0/24`) to an authenticated, browser-compatible HTTPS service at `https://cam.drheck.dev`.

| Component | Configuration on Host `ul` |
| :--- | :--- |
| **Service Directory** | `/data/docker/cam` (`app.py`, `static/index.html`, `static/tv.html`, `tests/app_test.py`) |
| **Persistent State & Models** | `/data/docker/cam/data/cameras.json`, `/data/docker/cam/data/yolov8n.onnx` (`13 MB`), `/data/docker/cam/data/events/` (`events.json` + `<event_id>.jpg`) |
| **Container Name** | `cam` (`cam:latest`, Python 3.12 + FastAPI + `reolink-aio` + `onnxruntime` + `Pillow` + `numpy` + `espeak-ng` + FFmpeg 7.1) |
| **ULA IPv6 Binding** | `[fd77:7777:5010:0:1337:f1ac:d00d:ca30]:8000` on `eth77` |
| **Nginx Reverse Proxy** | `/etc/nginx/sites-available/cam` (`X-Accel-Buffering: no`, `proxy_buffering off`, `X-Authentik-Groups` forwarding) |
| **Authentik SSO & Group** | `ProxyProvider` (`cam-provider`, ForwardAuth) -> `Application` (`cam`) bound to `Group` (`camera_access`) |
| **Backup & Restore** | `/home/luke/git/backups/ul/scripts/backup/cam` & `/home/luke/git/backups/ul/scripts/restore/cam` (`.tar.xz` + `par2 -r10`) |

### Key Subsystems Implemented in `app.py`

1. **Zero-Config Discovery (`discover_cameras`)**:
   - Reads the host kernel ARP table (`/proc/net/arp` mounted read-only at `/host_proc_arp`), matches Reolink OUIs (`ec:71:db`, `9c:8e:cd`), queries all Baichuan settings (`cmd_id` `80`, `37`, `76`, `56`, `26`, `264`, `44`, `289`, `208`, `232`, `439`, `574`, `52`, `224`, `38`, `46`, `342`), and auto-enables RTSP (`554/tcp`) and audio encoding (`<audio>1</audio>`).
2. **Motion & YOLOv8n AI Event Capture + Web-Viewable Timeline (`event_monitor_loop`, `record_camera_event`)**:
   - A background monitor coroutine (`event_monitor_loop`) samples sub-stream snapshots from online, non-sleeping cameras every `3.5 seconds`, computing downscaled `160x90` grayscale pixel frame-differences (`compute_frame_motion`).
   - Whenever motion (`>= 2.2%` of frame pixels) or a manual capture (`POST /api/cameras/{ip}/capture_event`) occurs, `classify_snapshot_yolo` runs the 80-class COCO **YOLOv8n** model (`yolov8n.onnx` via `onnxruntime`, `~62 ms` CPU inference on letterboxed `640x640` input with IoU `0.45` Non-Maximum Suppression).
   - **Clean Unannotated Snapshots + Text Metadata**: The raw JPEG snapshot is saved **without drawn bounding boxes** to `/data/docker/cam/data/events/<event_id>.jpg`, while the detected class labels, confidence percentages (`confidence_pct`), normalized target coordinates (`box_norm`), trigger types (`people`, `vehicle`, `dog_cat`, `motion`, `manual`), frame motion percentage, and `EDT`/`EST` timestamp (`format_edt_timestamp`) are recorded in `/data/docker/cam/data/events/events.json` and displayed in the **Detection Event Timeline & Metadata Log** on `https://cam.drheck.dev/`.
3. **Optional Auto-Zoom on Moving Objects (`apply_auto_zoom_on_box`)**:
   - Each camera has an optional **Auto-Zoom on Moving Objects** toggle (`auto_zoom_motion`, **off by default**).
   - When enabled, detecting a moving person/object or motion region (`[x, y, w, h]` in `0..1000` normalized coordinates) automatically issues Baichuan `cmd_id = 445` (`Set3DPos` `set_ptz_3d_zoom`) to zoom the 5X motorized lens onto the moving target, marks the timeline event with an `Auto-Zoomed` badge, and restores the lens to `baseline_zoom` after `20 seconds` of inactivity.
4. **Two-Way Audio Talk (`POST /api/cameras/{ip}/talk`)**:
   - Supports both **Push-to-Talk (Browser Microphone)** (recording `audio/webm` via `MediaRecorder` in the browser) and **Speak Text (TTS)** (synthesizing speech via `espeak-ng --stdout`).
   - `transcode_to_mulaw` converts the audio stream via `ffmpeg -i pipe:0 -ar 8000 -ac 1 -f mulaw pipe:1` into `8 kHz` G.711 mu-law (`PCMU/8000`), and `stream_rtsp_backchannel_mulaw` negotiates an RTSP Digest session on `554/tcp` with `Require: www.onvif.org/ver20/backchannel`, sets up `track3` (`Transport: RTP/AVP/TCP;unicast;interleaved=0-1`), and transmits `160-byte` (`20 ms`) RTP packets directly to the camera's speaker.

---

## Static DHCP Reservations on `danknasty` & IPv6 Support

### 1. Static DHCP Reservations (`/etc/dhcpd.conf` & `~/git/router_config/dhcpd.conf`)

Both cameras have permanent static DHCP leases configured in `/etc/dhcpd.conf` (and synced to `~/git/router_config/dhcpd.conf`) on `danknasty`:

```text
  host cam1 {
    # RLC-811A (141484731968753)
    hardware ethernet ec:71:db:f1:25:5a;
    fixed-address 10.0.0.122;
  }
  host cam2 {
    # RLC-811A (141484731989233)
    hardware ethernet ec:71:db:6d:84:ce;
    fixed-address 10.0.0.123;
  }
```

### 2. IPv6 Support

- **Camera Hardware (`RLC-811A` Firmware `v3.1.0.4695_2504301440`)**: No native IPv6 stack on the camera SoC (`10.0.0.0/24` IPv4 only).
- **Service Frontend (`cam.drheck.dev`)**: Full dual-stack IPv4 and IPv6 support via Nginx -> `[fd77:7777:5010:0:1337:f1ac:d00d:ca30]:8000`.

---

## Complete Camera Controls & Settings Matrix

All settings are available interactively in `https://cam.drheck.dev/` or via `POST /api/cameras/{ip}/control`:

| Category | Setting | Supported Range / Values | Default on `ul` | Baichuan `cmd_id` | Web UI / API Action |
| :--- | :--- | :--- | :--- | :--- | :--- |
| **Events & AI** | Auto Event Capture to Timeline | `true` / `false` | `true` (Enabled) | Local + `182` | `Auto Event Capture` (`action: "auto_zoom_config"`) |
| **Events & AI** | Manual Event Capture & Classify | Captures JPEG + YOLOv8n text metadata | On-demand | `182` + ONNX | `Capture Event` (`POST /api/cameras/{ip}/capture_event`) |
| **Events & AI** | Person / Vehicle / Pet AI Sensitivity | `0 .. 100` (stayTime `0..8s`) | `60` | `342` / `343` (`SetAiAlarm`) | `Person / Vehicle / Pet AI Sensitivity` (`action: "ai_sensitivity"`) |
| **Events & AI** | Motion Detection Sensitivity | `1 .. 50` | `10` | `46` / `47` (`SetMdAlarm`) | `Motion Sensitivity` (`action: "ai_sensitivity"`) |
| **Optics & PTZ** | Optical Zoom (`ZoomPos`) | `0 .. 28` (`1X .. 5X` optical) | `0` | `295` (`StartZoomFocus`) | `Zoom` slider (`action: "zoom"`) |
| **Optics & PTZ** | Optical Focus (`FocusPos`) | `0 .. 238` | Auto | `295` (`StartZoomFocus`) | `Focus` slider (`action: "focus"`) |
| **Optics & PTZ** | 3D Click-to-Zoom (`Set3DPos`) | `posX, posY, posWidth, posHeight` (`0..1000`) | Interactive | `445` (`set_ptz_3d_zoom`) | `3D Click-Zoom` button (`action: "zoom_3d"`) |
| **Optics & PTZ** | Auto-Zoom on Moving Objects | `false` (Off) or `true` (On) + `baseline_zoom` | `false` (Off) | `445` + `295` | `Auto-Zoom on Moving Objects` (`action: "auto_zoom_config"`) |
| **Optics & PTZ** | Autofocus Lock | `false` (Auto) or `true` (Locked) | `false` (Auto) | `224` / `225` (`SetAutoFocus`) | `Autofocus Lock` (`action: "autofocus"`) |
| **Lighting** | Floodlight Manual Toggle & Brightness | `state: true|false`, `brightness: 1..100` | `Off`, `85%` | `288` (`SetWhiteLed`) | `Spotlight: ON/OFF` & `Light %` (`action: "spotlight"`) |
| **Lighting** | Floodlight Auto Behavior (`FloodlightTask`) | Mode `0` (Manual), `1` (Smart Night AI), `3` (Schedule); Duration `60..600s`; Triggers `people,vehicle,dog_cat` | Mode `1`, `180s` | `289` / `290` (`FloodlightTask`) | `Spotlight Automatic Mode / Triggers / Duration` (`action: "floodlight_config"`) |
| **Lighting** | IR Night Vision LEDs | `auto` (Auto Night IR) or `close` (Off) | `auto` | `208` / `209` (`LedState`) | `IR Night Vision LEDs` (`action: "leds"`) |
| **Lighting** | Front Status Power LED | `open` (On) or `close` (Off / Stealth) | `open` | `208` / `209` (`LedState`) | `Front Status Power LED` (`action: "leds"`) |
| **Lighting** | Day / Night Sensor Mode | `auto`, `color` (24/7 Color), `black&white` | `auto` | `26` / `25` (`SetImage`) | `Day / Night Sensor Mode` (`action: "image"`) |
| **Deterrents & Audio** | Auto-Siren on Detection | `false` (Off) or `true` (On) | **`false` (Off)** | `232` / `231` (`SetAudioAlarm`) | `Auto-Siren on Detection` (`action: "audio_alarm"`) |
| **Deterrents & Audio** | Manual Security Siren | Trigger 1x | Off | `263` (`AudioAlarmPlay`) | `Siren (1x)` (`action: "siren"`) |
| **Deterrents & Audio** | Live Microphone Audio Stream | `16 kHz` AAC -> `64 kbps` MP3 | Enabled | `57` + RTSP | `Audio: Muted / LIVE` (`GET /api/cameras/{ip}/audio.mp3`) |
| **Deterrents & Audio** | Two-Way Audio Talk (Mic & TTS) | Browser `MediaRecorder` or `espeak-ng` -> `8 kHz` G.711 PCMU | On-demand | RTSP `track3` (`554`) | `Push-to-Talk (Mic)` & `Speak Text` (`POST /api/cameras/{ip}/talk`) |
| **Deterrents & Audio** | Mic 3D Noise Reduction | Level `0` (Off), `1` (Low), `2` (Med), `3` (High) | `3` (High) | `439` / `440` (`SetAudioNoise`) | `Mic 3D Noise Reduction` (`action: "audio_noise"`) |
| **Deterrents & Audio** | Speaker Volume | `0 .. 100` (`%`) | `92` | `265` (`SetAudioCfg`) | `Speaker Volume` (`action: "volume"`) |
| **Privacy** | Privacy / Sleep Mode | `false` (Awake) or `true` (Sleeping) | `false` (Awake) | `574` / `575` (`set_privacy_mode`) | `Privacy Sleep` button (`action: "privacy_mode"`) |
| **Privacy** | Hardware Privacy Mask (`Shelter`) | `false` (Off) or `true` (On) | `false` (Off) | `52` / `53` (`SetMask`) | `Hardware Privacy Mask` (`action: "privacy_mask"`) |
| **Image & OSD** | Mounting / Orientation (`mirror`, `flip`) | `Normal`, `180° Upside-Down`, `Flip`, `Mirror` | `Normal` | `25` (`SetImage`) | `Flip 180°` button (`action: "image"`) |
| **Image & OSD** | Reolink Logo Watermark | `false` (Hidden) or `true` (Visible) | `false` (Hidden) | `45` (`SetOsd`) | `Reolink Logo Watermark` (`action: "osd"`) |
| **Time & System** | NTP Server & Clock Sync | `time.google.com:123` (`EDT`/`EST`) | `time.google.com` | `38` / `39` (`SetNtp`) & `105` | `Set time.google.com` & `Sync EDT Clock` (`action: "ntp"`, `action: "sync_time"`) |
| **Time & System** | Reboot Camera | Hardware reboot | On-demand | `23` (`reboot`) | `Reboot Camera` (`action: "reboot"`) |

---

## Bandwidth Adjustments for Unreliable or Slow Internet

When viewing two cameras simultaneously over a slow, high-latency, or lossy internet connection, total downstream bandwidth for $N$ cameras is governed by:

$$B_{\text{total}} = N \times f_{\text{fps}} \times S_{\text{frame}}(W, H, q) \times 8 \quad \text{bits/sec}$$

where $f_{\text{fps}}$ is the frame rate, $W \times H$ is the scaled resolution, and $q \in [2, 24]$ is the FFmpeg MJPEG quantization factor (`-q:v`).

| Preset Mode | Resolution | Frame Rate | JPEG `q` | Avg Frame Size | Per-Camera Bitrate | 2-Camera Total WAN Bitrate | Best For |
| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
| **HD Main Stream** | `1280x720` | `10 fps` | `5` | `~25 KB` | `~2.0 Mbps` | `~4.0 Mbps` | Fast fiber / LAN full-screen inspection |
| **Standard Live (Default)** | `640x360` | `10 fps` | `5` | `~6.2 KB` | `~500 kbps` | `~1.0 Mbps` | Normal broadband / Wi-Fi |
| **Low Bandwidth** | `480x270` | `5 fps` | `10` | `~4.0 KB` | `~160 kbps` | `~320 kbps` | Congested Wi-Fi / mobile hotspot |
| **Ultra-Low WAN** | `320x180` | `2 fps` | `15` | `~3.1 KB` | `~50 kbps` | `~100 kbps` | Throttled cellular / international roaming |
| **Unreliable WAN Snapshot (2s)** | `640x360` | `0.5 fps` (`1 / 2s`) | Native | `~9.0 KB` | `~36 kbps` | `~72 kbps` | High packet loss / TCP reset prone links |
| **Minimal WAN Snapshot (5s)** | `640x360` | `0.2 fps` (`1 / 5s`) | Native | `~9.0 KB` | `~14 kbps` | `~28 kbps` | Extreme low-speed satellite / 2G fallback |

---

## Authentik Access Control (`camera_access` Group) & Google TV App

- **Authorized Group (`camera_access`)**: `luke`, `ikura`, `haruka`. Enforced at both the Nginx ForwardAuth edge (`PolicyBinding` on `cam`) and inside `check_camera_access_headers()` in `app.py`.
- **Google TV Sideloadable App (`dev.drheck.cam`)**:
  - Built APK on `ul`: `/data/docker/cam/googletv_src/cam-googletv.apk`
  - 10-Foot TV Interface: `https://cam.drheck.dev/tv` (authenticated automatically via baked-in 256-bit `TV_AUTH_TOKEN`).


# podman

<p class="callout info"><strong>Overview:</strong> All 30 containerized services (41 containers total) on <code>ul</code> run exclusively under <strong>rootless Podman</strong> (<code>podman 6.1.4</code>) managed as native <code>systemd --user</code> services via <strong>Podman Quadlets</strong> under the dedicated unprivileged account <code>docker-data</code> (<code>UID 1100</code>, <code>GID 1100</code>). Rootful Docker (<code>docker.service</code>, <code>docker.socket</code>, and <code>containerd.service</code>) is completely disabled and decommissioned, eliminating the privileged root container daemon attack surface while preserving per-service ULA IPv6 reverse-proxy isolation on <code>eth77</code>.</p>

---

## 1. Architecture & Security Comparison: Rootless Podman vs. Rootful Docker

Under traditional Docker, `dockerd` and `containerd` execute permanently as `root` (`UID 0`) inside the host init user namespace (`user:[4026531837]`). Every container spawned by `dockerd` without user-namespace remapping shares kernel `UID 0` whenever the container process runs as `root`, mutates host `iptables`/`nftables` chains directly as `root`, and requires root socket access (`/var/run/docker.sock`) for orchestration or maintenance timers.

Under rootless Podman on `ul`, **there is no root container daemon anywhere on the host**:
* `systemd` starts an unprivileged user manager (`user@1100.service` via lingering) for `docker-data`.
* Each Quadlet `.container` unit directly spawns lightweight `conmon` and `crun` processes inside an unprivileged Linux user namespace (`user:[4026534031]`).
* Shared stateful databases (**PostgreSQL** on `127.0.0.1:5432` and **Valkey** on `127.0.0.1:6379`) run natively on the host locked strictly to loopback, with zero LAN or Docker bridge exposure.

### 1.1 User Namespace UID/GID Translation Model

Rootless Podman translates container UIDs and GIDs into unprivileged host IDs using `/etc/subuid` and `/etc/subgid` (`docker-data:624288:65536`). Under the default rootless mapping, any container UID $u \in [0, 65536]$ maps deterministically to a host kernel UID:

$$
\text{UID}_{\text{host}}(u) = \begin{cases} 1100 & \text{if } u = 0 \quad (\text{container } \texttt{root} \longrightarrow \text{host } \texttt{docker-data}) \cr 624287 + u & \text{if } 1 \le u \le 65536 \quad (\text{subordinate range } 624288\text{--}689823) \end{cases}
$$

Common live worker translations visible in `ps` / `podman-map-tree` on `ul`:
* Container `root` (`u = 0`) $\longrightarrow$ Host `UID 1100` (`docker-data`) with zero host capabilities.
* Container `www-data` (`u = 33`, e.g., `nextcloud`, `yourls`, `webrcade`) $\longrightarrow$ Host `UID 624320`.
* Container `nginx` (`u = 101`, e.g., `audiomass`, `bentopdf`, `excalidraw`, `penpot-frontend`, `tldraw`, `wick`) $\longrightarrow$ Host `UID 624388`.
* Container `postgres` (`u = 999`, e.g., `penpot-postgres`) $\longrightarrow$ Host `UID 625286`.
* Container `authentik` / `degoog` (`u = 1000`) $\longrightarrow$ Host `UID 625287`.
* Container `penpot` (`u = 1001`) $\longrightarrow$ Host `UID 625288`.

When a binary explicitly refuses to start if internal `getuid() == 0` (specifically `drawpile-srv`), setting `UserNS=keep-id` with `User=1100:1100` preserves `UID 1100` across the namespace boundary:

$$
\text{UID}_{\text{keep-id}}(1100) \stackrel{\texttt{userns}}{\longleftrightarrow} 1100 \quad (\texttt{docker-data})
$$

<p class="callout warning"><strong>Why Default Rootless UserNS Beats <code>UserNS=keep-id</code> for Large ROCm Images:</strong> Avoid setting <code>UserNS=keep-id</code> unless an application explicitly aborts when <code>getuid() == 0</code>. On multi-gigabyte images such as <code>immich-machine-learning:v3.3.1-rocm</code> (23.3 GB) or <code>subocr</code>, <code>UserNS=keep-id</code> triggers a full recursive ownership copy-up across thousands of Python/ROCm files that stalls startup for over 5 minutes. Under the default rootless user namespace, container <code>UID 0</code> already maps directly to host <code>docker-data</code> (<code>1100:1100</code>) with zero layer copy-up and sub-2-second startup.</p>

### 1.2 Side-by-Side Comparison

| Dimension | Legacy Rootful Docker (`dockerd`) | Current Rootless Podman (`docker-data` Quadlets) |
| :--- | :--- | :--- |
| **Daemon Model** | Persistent root daemons (`dockerd` + `containerd`) running as `UID 0` | Daemonless fork/exec (`user@1100` $\longrightarrow$ `conmon` $\longrightarrow$ `crun`) running as `UID 1100` |
| **Host Kernel UID of Container `root`** | Host `UID 0` (`root` in init `user:[4026531837]`) | Host `UID 1100` (`docker-data` in isolated `user:[4026534031]`) |
| **Host Kernel UID of Container Worker (`u > 0`)** | Same numeric host UID (`33` collides with host `http`, `999` collides with system accounts) | Isolated subordinate UID $624287 + u$ (`624288`--`689823`, zero host UID collisions) |
| **Service Definition & Lifecycle** | `docker-compose.yml` managed by `dockerd` | Declarative `<svc>.container` + `<svc>.network` Quadlets in `/data/docker/<svc>/` |
| **Offline Boot Resilience** | Cached locally unless pull policy overridden | Explicit `Pull=never` on all 41 `.container` units; 100% WAN-outage-safe boot |
| **Host Firewall Mutation** | Mutates host `iptables`/`nftables` chains as `root` | Zero host firewall mutation; unprivileged user sockets bind directly to `eth77` ULA IPs |
| **Privileged Ports (`389` / `636` LDAP/LDAPS)** | Direct root port bind in container + mounted `/etc/nginx/certs` | Rootless `authentik-ldap` on `:3389`/`:6636` fronted by host HAProxy TCP passthrough (`/etc/haproxy/conf.d/ldap.cfg`); zero TLS key mounts in containers |
| **Shared Host PostgreSQL & Valkey** | Exposed on Docker bridges (`172.16.0.0/12`) + LAN (`10.0.0.0/24`) | Locked strictly to `127.0.0.1` / `::1`; reached from containers via `pasta` `--map-host-loopback 169.254.1.2` |
| **ROCm GPU Access (`/dev/kfd`, `/dev/dri`)** | `devices:` + `group_add` as `root` | `AddDevice=` + `GroupAdd=keep-groups` inheriting `docker-data`'s `video` (`91`) and `render` (`991`) groups |

---

## 2. Host Configuration & Intra-Service Networking on `ul`

### 2.1 Unprivileged Account, Subordinate IDs & Systemd Lingering

1. **Service Account (`docker-data`)**:
   * Primary UID/GID: `1100:1100` (`docker-data`), home `/home/docker-data`.
   * Supplementary Groups: `video` (`91`) and `render` (`991`) for AMD Radeon AI PRO R9700 (`gfx1201`) ROCm compute access (`/dev/dri` and `/dev/kfd`).
2. **Subordinate UID/GID Ranges (`/etc/subuid` & `/etc/subgid`)**:
   ```text
   docker-data:624288:65536
   ```
3. **Systemd Lingering & Automatic Maintenance**:
   * Lingering is enabled at `/var/lib/systemd/linger/docker-data` (`sudo loginctl enable-linger docker-data`) so `user@1100.service` starts automatically at boot without an interactive login.
   * Weekly image pruning runs rootlessly via `/etc/systemd/system/podman-prune.{service,timer}` (`User=docker-data`, Sundays at `04:00` EDT/EST), replacing the retired `docker-prune.timer`.

### 2.2 Global Configuration Files & Storage Layout

* **`/etc/containers/containers.conf`**:
  Configures `pasta` (`passt`) as the default rootless network backend and maps the synthetic guest address `169.254.1.2` (`host.containers.internal`) cleanly to host `127.0.0.1` loopback:
  ```toml
  [network]
  default_rootless_network_cmd = "pasta"
  pasta_options = ["--map-host-loopback", "169.254.1.2"]
  ```
* **`/etc/containers/registries.conf`**:
  ```toml
  unqualified-search-registries = ["docker.io"]
  ```
* **Storage Paths**:
  * Persistent image/layer store (`GraphRoot`): `/home/docker-data/.local/share/containers/storage` (`overlay` driver owned by `docker-data`, backed by dedicated ZFS dataset `data/podman` mounted at `/home/docker-data/.local/share/containers` with `compression=lz4`, `xattr=sa`, `acltype=posix`, and `atime=off` so container images never consume space on the `/` NVMe).
  * Ephemeral runtime state & DNS tables (`RunRoot`): `/run/user/1100/containers` (including `/run/user/1100/containers/networks/aardvark-dns/`).
  * Quadlet unit directory: `/home/docker-data/.config/containers/systemd/` (symlinks to `/data/docker/<service>/*.{container,network}`).
  * Per-service persistent data & definitions: `/data/docker/<service>/` on the ZFS storage pool.

### 2.3 The Four Intra-Service Networking Patterns on `ul`

All container traffic on `ul` follows four clean, deterministic patterns without shared mega-bridges like the retired `infra_net`:

1. **Ingress (`Nginx` $\longrightarrow$ Container ULA IPv6 Socket)**:
   Host Nginx (`UID 33` `http`) terminates public HTTPS (`*.drheck.dev`) and proxies upstream HTTP/WebSocket traffic to each service's dedicated `/64` ULA IPv6 address on `eth77`:

   $$
   \text{Client HTTPS} \longrightarrow \text{Host Nginx } (\texttt{UID 33}) \longrightarrow [\texttt{fd77:7777:5010:0:1337:f1ac:d00d:hex}]:P_{\text{host}} \longrightarrow \text{Rootless Container } (\texttt{UID 1100})
   $$

2. **Container $\longrightarrow$ Native Host Daemons (`PostgreSQL`, `Valkey`, `Ollama`) via `169.254.1.2`**:
   Native `postgresql.service` (`127.0.0.1:5432`), `valkey.service` (`127.0.0.1:6379`, per-service ACL users), and `ollama.service` (`127.0.0.1:11434`) listen exclusively on host loopback. Inside any rootless container, `pasta` translates `169.254.1.2` directly to host `127.0.0.1`:

   $$
   \text{Container App} \longrightarrow \texttt{169.254.1.2}:(5432, 6379, 11434) \stackrel{\texttt{pasta}}{\longrightarrow} \text{Host } \texttt{127.0.0.1}:(5432, 6379, 11434)
   $$

3. **Container $\longrightarrow$ Host OIDC / WOPI / LDAPS Hairpin (`AddHost=auth.drheck.dev:169.254.1.2`)**:
   Inside a rootless `pasta` network namespace, connecting to an IP address owned by the host itself (`10.0.0.100`, `10.0.0.3`, or a host `fd77:...` ULA address) loops back inside the container namespace (`Connection refused`) rather than hairpinning out to host Nginx or HAProxy. Every OIDC, WOPI, or LDAPS client container therefore declares:
   ```ini
   AddHost=auth.drheck.dev:169.254.1.2
   ```
   (plus `AddHost=cloud.drheck.dev:169.254.1.2` and `AddHost=office.drheck.dev:169.254.1.2` on Nextcloud + Collabora). This preserves full Let's Encrypt TLS SNI and hostname verification while routing cleanly over loopback to host Nginx (`127.0.0.1:443`) and HAProxy (`127.0.0.1:636`).

4. **Multi-Container Private Stack Bridges (`<service>.network` + `aardvark-dns`) & Privileged Port Proxy (`HAProxy`)**:
   * Multi-container stacks (`authentik`, `immich`, `penpot`, `nextcloud`+`collabora`, `bookstack`, `romm`, `yourls`, `wipstudio`, `webshell`+`step-ca`) each have a dedicated isolated bridge (`10.89.X.0/24`) with container-name DNS served by `/usr/lib/podman/aardvark-dns`. Database sidecars (`bookstack_db`, `romm-db`, `yourls-db`, `penpot-postgres`, `penpot-redis`) publish **zero** host ports.
   * Because unprivileged `UID 1100` does not bind privileged ports `< 1024`, `authentik-ldap` listens on `[fd77:...:aaaa]:3389` (LDAP) and `:6636` (LDAPS), and host HAProxy (`/etc/haproxy/conf.d/ldap.cfg`) performs zero-copy Layer-4 TCP passthrough from `127.0.0.1`, `10.0.0.3`, `10.0.0.100`, and `[fd77:...:aaaa]` ports `389`/`636` to `[fd77:...:aaaa]:3389`/`6636`.

---

## 3. Complete Rootless Podman Service Inventory (30 Services / 40 Containers)

| Service | Systemd Unit(s) (`docker-data@`) | Network | Published Host Binding(s) | Backing Store / Notes |
| :--- | :--- | :--- | :--- | :--- |
| **`anchor`** | `anchor.service` | `anchor_net` | `[fd77:...:c4a1]:3000 -> 3000` | Host PG (`169.254.1.2:5432`), OIDC |
| **`audiomass`** | `audiomass.service` | `podman` | `[fd77:...:a0d1]:9004 -> 80` | Stateless WebAudio editor |
| **`authentik`** | `authentik-server.service`<br>`authentik-worker.service`<br>`authentik-ldap.service` | `authentik_net` | `[fd77:...:aaaa]:9000,9443`<br>*(internal worker)*<br>`[fd77:...:aaaa]:3389,6636` (via HAProxy `:389,:636`) | Host PG + Host Valkey (`db 0`), zero host TLS key mounts |
| **`bentopdf`** | `bentopdf.service` | `podman` | `[fd77:...:bdf]:8080 -> 8080` | Stateless PDF toolkit |
| **`bookstack`** | `bookstack.service`<br>`bookstack_db.service` | `bookstack_net` | `[fd77:...:b00c]:6875 -> 80`<br>*(internal MariaDB 3306)* | MariaDB sidecar + OIDC |
| **`cam`** | `cam.service` | `cam_net` | `[fd77:...:ca30]:8000 -> 8000`, `127.0.0.1:8089 -> 8000` | `/proc/net/arp:ro`, YOLOv8n + Reolink Baichuan/RTSP |
| **`collabora`** | `collabora.service` | `nextcloud_net` | `[fd77:...:7a31]:9980 -> 9980` | Strict `cloud\.drheck\.dev` WOPI allowlist |
| **`degoog`** | `degoog.service` | `degoog_net` | `[fd77:...:de90]:4444 -> 4444` | Host Valkey (`db 4`), updated via `/usr/local/bin/degoog-update` |
| **`demucs`** | `demucs.service` | `podman` | `[fd77:...:de3]:8000 -> 8000` | ROCm GPU (`/dev/dri`, `/dev/kfd`, `GroupAdd=keep-groups`) |
| **`drawpile`** | `drawpile.service` | `podman` | `[fd77:...:d7a0]:27750-27752` | `UserNS=keep-id`, `User=1100:1100`, SQLite sessions |
| **`excalidraw`** | `excalidraw.service` | `podman` | `[fd77:...:e8ca]:9003 -> 80` | Stateless whiteboard |
| **`funkwhale`** | `funkwhale.service` | `funkwhale_net` | `[fd77:...:f7a0]:8085 -> 80` | Embedded PG/Redis + LDAPS (`auth.drheck.dev:636` via `169.254.1.2`) |
| **`grill`** | `grill.service` | `grill_net` | `[fd77:...:fb02]:8000`, `10.0.0.3:8000,8088 -> 8000` | SQLite + ESP32-C3 HTTP ingest |
| **`immich`** | `immich_server.service`<br>`immich_machine_learning.service` | `immich_net` | `[fd77:...:2283]:2283 -> 2283`<br>*(internal ML worker)* | Host PG (`pgvecto.rs`) + Host Valkey (`db 1`) + ROCm GPU |
| **`navidrome`** | `navidrome.service` | `podman` | `[fd77:...:5a70]:4533 -> 4533` | `/data/music/flac:ro`, SQLite |
| **`nextcloud`** | `nextcloud.service` | `nextcloud_net` | `[fd77:...:7a31]:8081 -> 80` | Host PG (`nextcloud_db`) + Host Valkey (`db 2`) + OIDC + Collabora |
| **`penpot`** | `penpot-frontend.service`<br>`penpot-backend.service`<br>`penpot-exporter.service`<br>`penpot-postgres.service`<br>`penpot-redis.service` | `penpot_net` | `[fd77:...:be49]:9001 -> 8080`<br>*(4 internal services)* | ZFS `/data/docker/penpot/{postgres_data,assets}` + OIDC |
| **`romm`** | `romm.service`<br>`romm-db.service` | `romm_net` | `[fd77:...:7033]:8080 -> 8080`<br>*(internal MariaDB 3306)* | MariaDB 11 sidecar + `/data/games/roms` + OIDC |
| **`scontrol`** | `scontrol.service` | `scontrol_net` | `[fd77:...:5c01]:8000 -> 8000` | `id_scontrol` SSH orchestration to `studio-kvm-ssh` |
| **`sopro`** | `sopro.service` | `podman` | `[fd77:...:509]:7860 -> 7860` | Local TTS image (`localhost/sopro:latest`) |
| **`step-ca`** | `step-ca.service` | `webshell_net` | `[fd77:...:ca]:9000 -> 9000` | Smallstep SSH & X.509 CA (used by `webshell` & `ipctl`) |
| **`subocr`** | `subocr.service` | `subocr_net` | `[fd77:...:c70]:8000 -> 8000`, `127.0.0.1:8077 -> 8000` | ROCm MIGraphX GPU + Host Ollama (`169.254.1.2:11434`) |
| **`tldraw`** | `tldraw.service` | `podman` | `[fd77:...:71d7]:9002 -> 80` | Stateless canvas |
| **`vaultwarden`** | `vaultwarden.service` | `podman` | `[fd77:...:a117]:8222 -> 80` | SQLite + OIDC (`AddHost=auth.drheck.dev:169.254.1.2`) |
| **`vikunja`** | `vikunja.service` | `vikunja_net` | `[fd77:...:71c0]:3456 -> 3456` | Host PG + Host Valkey (`db 3`) + OIDC; JSON `HealthCmd` |
| **`webrcade`** | `webrcade.service` | `podman` | `[fd77:...:a1ca]:8080 -> 80` | `/data/docker/webrcade/content`, `/data/games:ro` |
| **`webshell`** | `webshell.service` | `webshell_net` | `[fd77:...:771d]:7681 -> 7681` | Dual-stack `ttyd` + ephemeral 5m `step-ca` SSH certs |
| **`wick`** | `wick.service` | `podman` | `[fd77:...:41c8]:9005 -> 80` | Static HTML5 studio on `nginx:alpine` |
| **`wipstudio`** | `wipstudio.service`<br>`wipstudio-minio.service` | `wipstudio_net` | `[fd77:...:7190]:3000 -> 3000`<br>`[fd77:...:7190]:9000 -> 9000` | Host PG + MinIO S3 sidecar + OIDC (`DISABLE_SIGN_UP=true`) |
| **`yourls`** | `yourls.service`<br>`yourls-db.service` | `yourls_net` | `[fd77:...:77]:8080 -> 8080`<br>*(internal MariaDB 3306)* | MariaDB 10.11 sidecar (`YOURLS_PRIVATE=true` + ForwardAuth on API) |

### Reference Quadlet Examples

<details>
  <summary><strong>Pattern A: OIDC + Host PostgreSQL/Valkey Service (e.g., <code>/data/docker/vikunja/vikunja.container</code>)</strong></summary>

```ini
[Unit]
Description=Vikunja Task Manager (Rootless Podman)
After=network-online.target
Wants=network-online.target

[Container]
Pull=never
ContainerName=vikunja
Image=docker.io/vikunja/vikunja:latest
Network=vikunja.network
AddHost=auth.drheck.dev:169.254.1.2
PublishPort=[fd77:7777:5010:0:1337:f1ac:d00d:71c0]:3456:3456
EnvironmentFile=/data/docker/vikunja/.env
Volume=/data/docker/vikunja/config.yml:/etc/vikunja/config.yml:ro
Volume=/data/docker/vikunja/files:/app/vikunja/files:rw
HealthCmd=["/app/vikunja/vikunja", "healthcheck"]
HealthInterval=30s
HealthRetries=3

[Service]
Restart=always
RestartSec=5
TimeoutStartSec=120

[Install]
WantedBy=default.target
```
</details>

<details>
  <summary><strong>Pattern B: Multi-Container ROCm GPU Stack (e.g., <code>/data/docker/immich/immich_machine_learning.container</code>)</strong></summary>

```ini
[Unit]
Description=Immich ROCm Machine Learning Worker (Rootless Podman)
After=network-online.target
Wants=network-online.target

[Container]
Pull=never
ContainerName=immich_machine_learning
Image=ghcr.io/immich-app/immich-machine-learning:v3.3.1-rocm
Network=immich.network
AddDevice=/dev/dri:/dev/dri
AddDevice=/dev/kfd:/dev/kfd
GroupAdd=keep-groups
EnvironmentFile=/data/docker/immich/.env
Volume=/data/docker/immich/model-cache:/cache:rw

[Service]
Restart=always
RestartSec=5
TimeoutStartSec=180

[Install]
WantedBy=default.target
```
</details>

---

## 4. Day-to-Day Operations: Status, Logs & Lifecycle

<p class="callout warning"><strong>Important Execution Context on Arch Linux:</strong> Rootless Podman containers run under <code>docker-data</code> (<code>UID 1100</code>), not <code>root</code> or <code>luke</code>. Two Arch Linux <code>systemd</code>/<code>podman</code> details matter in CLI commands:<br>
1. Always run <code>sudo -u docker-data</code> from a world-traversable directory such as <code>cd /tmp</code> (otherwise <code>podman</code> tries to <code>stat("/home/luke")</code> on mode <code>0700</code> and warns about <code>Permission denied</code>).<br>
2. On <code>systemd 258+</code>, <code>systemctl --user -M docker-data@ ...</code> works directly, whereas <code>journalctl --user -M docker-data@</code> returns <code>Connecting to a machine as non-root is not supported.</code> Always read user unit journals via <code>sudo -u docker-data XDG_RUNTIME_DIR=/run/user/1100 journalctl --user -u &lt;service&gt;.service</code>.</p>

### 4.1 Checking Service, Container & Namespace Status

```shell
# 1. Host-wide health summary across all nginx, native systemd, and rootless Podman services
$ status_all
$ services

# 2. Live container-to-host UID, PID, and UserNS translation audit tree
$ sudo /usr/local/sbin/podman-map-tree

# 3. Check systemd unit status for any Quadlet container
$ sudo systemctl --user -M docker-data@ status authentik-server.service

# 4. List all running rootless Podman containers, networks, ports, and health states
$ (cd /tmp && sudo -u docker-data HOME=/home/docker-data XDG_RUNTIME_DIR=/run/user/1100 podman ps -a)

# 5. Live CPU, memory, and I/O telemetry across all 41 containers
$ (cd /tmp && sudo -u docker-data HOME=/home/docker-data XDG_RUNTIME_DIR=/run/user/1100 podman stats --no-stream)
```

### 4.2 Viewing Service & Container Logs

```shell
# Follow live systemd user unit logs (includes systemd lifecycle + container stdout/stderr)
$ sudo -u docker-data XDG_RUNTIME_DIR=/run/user/1100 journalctl --user -u authentik-server.service -f

# View the last 100 journal lines without paging
$ sudo -u docker-data XDG_RUNTIME_DIR=/run/user/1100 journalctl --user -u immich_server.service -n 100 --no-pager

# Follow container stdout/stderr directly via podman logs
$ (cd /tmp && sudo -u docker-data HOME=/home/docker-data XDG_RUNTIME_DIR=/run/user/1100 podman logs --tail 100 -f bookstack)
```

### 4.3 Starting, Stopping, Restarting & Applying Quadlet Edits

```shell
# Restart a running Quadlet container
$ sudo systemctl --user -M docker-data@ restart vaultwarden.service

# Reload systemd generator after editing /data/docker/<service>/<service>.container
$ sudo systemctl --user -M docker-data@ daemon-reload
$ sudo systemctl --user -M docker-data@ restart <service>.service
```

### 4.4 Updating Images & Rebuilding Local Dockerfiles

Because every `.container` unit sets `Pull=never` for offline boot safety, image updates are always explicit:

```shell
# Pull updated registry image into docker-data's store and restart
$ (cd /tmp && sudo -u docker-data HOME=/home/docker-data XDG_RUNTIME_DIR=/run/user/1100 podman pull docker.io/vaultwarden/server:latest)
$ sudo systemctl --user -M docker-data@ restart vaultwarden.service

# Rebuild a locally built Dockerfile image (use TMPDIR=/dev/shm for large ROCm images)
$ (cd /data/docker/subocr && sudo -u docker-data HOME=/home/docker-data XDG_RUNTIME_DIR=/run/user/1100 TMPDIR=/dev/shm podman build -t localhost/subocr:latest /data/docker/subocr)
$ sudo systemctl --user -M docker-data@ restart subocr.service
```

---

## 5. Troubleshooting & Diagnostics Runbook

### 5.1 Symptom: OIDC Login or Container LDAPS Fails with `Connection refused` to `auth.drheck.dev`
* **Root Cause**: Inside a rootless `pasta` network namespace, `auth.drheck.dev` resolves via DNS to host `10.0.0.100` or `fd77:...:aaaa`, which loops back inside the container's own network namespace where nothing listens on `:443` or `:636`.
* **Fix**: Add `AddHost=auth.drheck.dev:169.254.1.2` under `[Container]` in `<service>.container`, reload `daemon-reload`, and restart the service so OIDC and LDAPS connections route cleanly through `--map-host-loopback` to `127.0.0.1:443` / `127.0.0.1:636`.

### 5.2 Symptom: Multi-Container Service Fails to Connect to Sibling Hostname (`penpot-redis` / `bookstack_db`) After Abrupt Kill
* **Root Cause**: If a container on a custom `<service>.network` bridge is killed abruptly without running `netavark teardown`, a stale container ID + dead IP line can remain ahead of the new container's IP in `/run/user/1100/containers/networks/aardvark-dns/<net_name>`. Clients that connect only to the first `A` record returned by DNS (such as Java's Lettuce client in `penpot-backend`) time out against the stale IP.
* **Diagnosis & Fix**:
  1. Inspect `/run/user/1100/containers/networks/aardvark-dns/<net_name>` against live container IDs from `podman ps --no-trunc`.
  2. Remove any line whose 64-character container ID is no longer running, then reload `aardvark-dns` without restarting healthy containers:
     ```shell
     $ sudo kill -HUP "$(cat /run/user/1100/containers/networks/aardvark-dns/aardvark.pid)"
     ```

### 5.3 Symptom: `Permission denied` Inside Non-Root Worker Container (`UID 624320` / `625288`) on `/data/docker/<svc>/...`
* **Root Cause**: Worker processes running as container UID $u > 0$ execute on the host as subordinate UID $624287 + u$ (outside group `1100`). If the parent directory `/data/docker/<svc>` has mode `2770` (`drwxrws---`, missing `other` execute `+x`), subordinate UIDs cannot traverse `/data/docker/<svc>` to reach child bind mounts, and tools run on the host (like `git pull`) can accidentally write `root`-owned files over `625287`-owned files.
* **Fix**:
  1. Keep parent directories traversable without allowing directory listing (`sudo chmod 2771 /data/docker/<svc>`, i.e., `drwxrws--x`).
  2. Execute inside-container file updates inside the container namespace (or via `podman unshare`), just as `/usr/local/bin/degoog-update` runs `git fetch` and `bun install` inside `degoog`.

### 5.4 Symptom: Distroless Container Marks `unhealthy` Despite Listening Normally (e.g., `vikunja`)
* **Root Cause**: Distroless images contain no `/bin/sh`, so string-form `HealthCmd=/app/vikunja/vikunja healthcheck` fails because Podman invokes `/bin/sh -c` unless `HealthCmd` is formatted as a JSON exec array.
* **Fix**: Write `HealthCmd=["/app/vikunja/vikunja", "healthcheck"]` as a JSON array in `<service>.container`.

### 5.5 Symptom: `502 Bad Gateway` on Dual-Stack Service When Passing IPv6-Only Flag (e.g., `ttyd -6 -i ::`)
* **Root Cause**: Rootless port forwarding may connect into the container over IPv4 even when accepting connections on an `eth77` ULA IPv6 socket. Passing IPv6-only bind flags inside the container rejects IPv4 loopback/bridge forwards.
* **Fix**: Let internal container daemons listen on default dual-stack (`0.0.0.0` / `::`) rather than forcing IPv6-only flags.

### 5.6 Validating Quadlet Syntax Before Restarting
```shell
$ (cd /tmp && sudo -u docker-data HOME=/home/docker-data XDG_RUNTIME_DIR=/run/user/1100 /usr/lib/systemd/user-generators/podman-system-generator --user --dry-run)
```