Compare commits

..
Author SHA1 Message Date
Frank ce370a5b28 fix(deploy): relay firewall DROP must be appended, not -I-inserted above its ACCEPTs
iptables -I prepends, so the install block's final -I of the blanket DROP
landed it at position 1, above all three ACCEPTs: every packet to
18093/tcp died there (live 2026-10-21: 159 packets on the DROP, 0 on each
ACCEPT). The host's own health probe timed out beside a LISTENing socket
and the whole dankclaw drain aborted at the alertmanager gate twice.

Install now rebuilds the tail: delete any blanket DROP wherever it sits,
keep the ACCEPTs at the head, append the DROP last - converges on hosts
poisoned by the old order. New bats case runs install_signal_relay for
real against an iptables sandbox seeded with the poisoned order (red on
the old code, green on this one).
2026-10-21 16:52:40 -07:00
Frank bfe4d185bd docs(vault): OpenWebUI picker opened to all three models for every account (2026-10-21)
Per Frank, frank-archon is public alongside dragoon/zealot: three-leg DB
trio (conn enable, model row ctx 200000, access_grant principal='*'),
verified as role=user. Token-mint traps recorded (cwd, PID-1 secret).
2026-10-21 16:48:32 -07:00
dank 94a9f3aa31 Merge pull request 'feat(security): digest-pin every registry image; drop batch-killing HostAddr' (#74) from security/quadlet-digest-pins into main 2026-10-21 16:32:44 -07:00
Frank d2810ab610 feat(security): digest-pin every registry image on all three hosts (fleet law)
Closing the command-center security queue #70/#71/#72/#73/#74: rolling tags
let a rebuild move under a running service with nobody approving it, and
make a CVE audit ungradeable. 36 quadlet units across dankclaw, dankserver
and frankbook-pro now carry tag@sha256; localhost/* builds stay exempt
(their build is the pin). The pihole and copyparty pins pull the fixed
builds; valkey 9.1.2 (already running) and redis 7.4.11 are pinned in
place, vendor fixes TBD.

New gate tests/quadlet_image_pins.bats: no unpinned registry image, no
generator-rejected key anywhere. Three seams that pinned the old exact-tag
law (frankbook readarr, open_webui, seerr) now require tag AND digest -
strictly stronger. Quadlet Lifecycle docs: batch-abort law, entrypoint
flags-only law, pin law.
2026-10-21 16:32:36 -07:00
Frank 7a8cc83385 fix(quadlet): drop HostAddr from prometheus.unit - podman 5.7.0 rejects the key and the quadlet generator then aborts the ENTIRE user batch, silently orphaning every other unit (prometheus.service vanished while the container ran; deploy died 'Unit prometheus.service not found', live 2026-10-21). host.containers.internal resolves natively; the key was never needed. 2026-10-21 16:32:36 -07:00
dank c4623f1629 Merge pull request 'fix(blackbox): flags-only Exec; bind loopback+LAN; exec-form HealthCmd' (#73) from fix/blackbox-entrypoint-bind into main 2026-10-21 16:25:32 -07:00
Frank 065a6911f9 fix(blackbox): Exec carries flags only (image has ENTRYPOINT) and the exporter binds loopback+LAN
Three stacked lessons, each proved live 2026-10-21:
- the image's ENTRYPOINT is /bin/blackbox_exporter, so Exec= repeating the
  binary made it a positional arg: 'unexpected /bin/blackbox_exporter'.
- the podman gateway 169.254.1.2 is NOT bindable from the host netns - it
  exists only inside each rootless netns: 'bind: cannot assign requested
  address'. The scrape path does not need it bound: a bridge container
  dialing the gateway lands on the host LOOPBACK (proved 200 from the
  prometheus container), so the exporter binds 127.0.0.1 + LAN.
- HealthCmd shell form ('/bin/sh -c "..."') is passed as one quoted token
  and quadlet folds the following --health-interval/--health-retries (and
  the image) into the command string; the plain exec form the sibling units
  use is correct.

Prometheus.md bind law corrected (gateway is a forward-to-loopback, not a
bind address); deploy-remote gate stays loopback, which is now honest.
2026-10-21 16:04:53 -07:00
dank 745d66d60d Merge pull request 'fix(blackbox): --web.listen-address (repeatable), not --listen-address' (#72) from fix/blackbox-web-listen-flag into main 2026-10-21 15:58:36 -07:00
Frank 4009f118c1 fix(blackbox): the flag is --web.listen-address, repeatable, not --listen-address
Third boot failure: the exporter exited 100 ms in with 'unknown long
flag --listen-address' (proved against the pinned image, plus --help).
The bind semantics were right; the flag name was invented. --web.listen-address
is repeatable - one per address - so the dual gateway+LAN bind stands.
Proven again pre-deploy with the real argv: the run starts and dies
only on the deliberate /dev/null placeholder config.
2026-10-21 15:55:21 -07:00
dank 7de343c171 Merge pull request 'fix(blackbox): complete the unit - Image, Network=host, honest bind topology' (#71) from fix/blackbox-unit-generation into main 2026-10-21 15:52:30 -07:00
48 changed files with 267 additions and 85 deletions

No files matched your search

@@ -20,7 +20,7 @@ Wants=authentik-server.service
[Container] [Container]
ContainerName=authentik-outpost ContainerName=authentik-outpost
Image=ghcr.io/goauthentik/server:2026.8.3 Image=ghcr.io/goauthentik/server:2026.8.3@sha256:ab9b4e8cc4ab3f8d1198d2db6aeea66bafea1963b3f2843589e0d163f97d9849
Exec=worker Exec=worker
Network=dankclaw_default Network=dankclaw_default
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
@@ -14,7 +14,7 @@ After=network.target
[Container] [Container]
ContainerName=authentik-postgres ContainerName=authentik-postgres
Image=docker.io/library/postgres:16-alpine Image=docker.io/library/postgres:16-alpine@sha256:721873c34ceb9f8d8fc265984940dc982404c105f19ad51be9fdc5970a6080ea
Network=dankclaw_default Network=dankclaw_default
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
Environment=POSTGRES_DB=authentik Environment=POSTGRES_DB=authentik
@@ -19,7 +19,7 @@ Wants=authentik-postgres.service
[Container] [Container]
ContainerName=authentik-server ContainerName=authentik-server
Image=ghcr.io/goauthentik/server:2026.8.3 Image=ghcr.io/goauthentik/server:2026.8.3@sha256:ab9b4e8cc4ab3f8d1198d2db6aeea66bafea1963b3f2843589e0d163f97d9849
Exec=server Exec=server
Network=dankclaw_default Network=dankclaw_default
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
@@ -16,7 +16,7 @@ Wants=authentik-server.service
[Container] [Container]
ContainerName=authentik-worker ContainerName=authentik-worker
Image=ghcr.io/goauthentik/server:2026.8.3 Image=ghcr.io/goauthentik/server:2026.8.3@sha256:ab9b4e8cc4ab3f8d1198d2db6aeea66bafea1963b3f2843589e0d163f97d9849
Exec=worker Exec=worker
Network=dankclaw_default Network=dankclaw_default
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
@@ -15,7 +15,7 @@ After=network.target
[Container] [Container]
ContainerName=gitea-postgres ContainerName=gitea-postgres
Network=gitea.network Network=gitea.network
Image=docker.io/library/postgres:16-alpine Image=docker.io/library/postgres:16-alpine@sha256:721873c34ceb9f8d8fc265984940dc982404c105f19ad51be9fdc5970a6080ea
EnvironmentFile=/home/dank/Containers/gitea/env EnvironmentFile=/home/dank/Containers/gitea/env
Environment=POSTGRES_USER=gitea Environment=POSTGRES_USER=gitea
Environment=POSTGRES_PASSWORD=${GITEA_DB_PASSWORD} Environment=POSTGRES_PASSWORD=${GITEA_DB_PASSWORD}
@@ -19,7 +19,7 @@ After=gitea-postgres.service network.target
[Container] [Container]
ContainerName=gitea-server ContainerName=gitea-server
Network=gitea.network Network=gitea.network
Image=docker.io/gitea/gitea:latest Image=docker.io/gitea/gitea:latest@sha256:30777c91280d973fa02be48b1ade735bd6859e8f6f7af3b4444d519437852504
EnvironmentFile=/home/dank/Containers/gitea/env EnvironmentFile=/home/dank/Containers/gitea/env
Environment=DATABASE_TYPE=postgres Environment=DATABASE_TYPE=postgres
Environment=DATABASE_HOST=gitea-postgres Environment=DATABASE_HOST=gitea-postgres
@@ -23,7 +23,7 @@ After=gluetun.service network.target
[Container] [Container]
ContainerName=marreta_browser ContainerName=marreta_browser
Network=gluetun.container Network=gluetun.container
Image=docker.io/lightpanda/browser:nightly Image=docker.io/lightpanda/browser:nightly@sha256:979c36ed869f1016b8dcf22939a0633c262f2c30e892a03a9f720eba2ff1931e
[Service] [Service]
TimeoutStartSec=300 TimeoutStartSec=300
+1 -1
View File
@@ -32,7 +32,7 @@ After=gluetun.service marreta-browser.service network.target
[Container] [Container]
ContainerName=marreta ContainerName=marreta
Network=gluetun.container Network=gluetun.container
Image=ghcr.io/manualdousuario/marreta:latest Image=ghcr.io/manualdousuario/marreta:latest@sha256:30f2a34567b79b66da794b4b9423751bbbcd07ceff600d9f6a2d219e7e439362
# Relocate off SearXNG's 8080 inside the shared netns. # Relocate off SearXNG's 8080 inside the shared netns.
Environment=NGINX_HTTP_PORT=8085 Environment=NGINX_HTTP_PORT=8085
Environment=APP_NAME=Marreta Environment=APP_NAME=Marreta
@@ -33,7 +33,7 @@ Wants=network-online.target
[Container] [Container]
ContainerName=alertmanager ContainerName=alertmanager
Image=docker.io/prom/alertmanager:latest Image=docker.io/prom/alertmanager:latest@sha256:e9733bafb1bdef9b00e25a21f8f99dc26a22224bf16641ad754d1649f4c3357a
Network=dankclaw_default Network=dankclaw_default
User=0:0 User=0:0
Exec=--config.file=/etc/alertmanager/alertmanager.yml --storage.path=/alertmanager Exec=--config.file=/etc/alertmanager/alertmanager.yml --storage.path=/alertmanager
@@ -11,16 +11,19 @@
# 0.0.0.0:8188 publish answers on loopback). A bridge-networked prober could # 0.0.0.0:8188 publish answers on loopback). A bridge-networked prober could
# reach neither class reliably. # reach neither class reliably.
# #
# WHY the listener binds TWO addresses. The netavark hairpin gap (measured # WHY the listener binds 127.0.0.1 + LAN. A bridge container CANNOT dial
# 2026-10-21, cadvisor job comment): a BRIDGE CONTAINER CANNOT DIAL ITS OWN # its own host's LAN IP (netavark hairpin gap, measured 2026-10-21), but a
# HOST'S LAN IP at all — so a LAN-only bind is unscrapable by Prometheus, # bridge container dialing the podman gateway 169.254.1.2 lands on the host
# whatever the signal-relay law suggests for services containers dial as # LOOPBACK — that is the scrape path, and it needs this exporter to be
# clients. The podman gateway (169.254.1.2 = host.containers.internal) is the # LISTENING ON LOOPBACK (proved: 200 from the prometheus container to
# address bridge containers reach same-host targets at, so the exporter binds # http://169.254.1.2:9115/-/healthy with a loopback bind). The gateway
# it explicitly for the scrape rather than relying on loopback-forwarding # address itself is NOT bindable from the host netns — it exists only
# semantics, and keeps the LAN bind for its own healthcheck and diagnostics. # inside each rootless netns; binding it dies "bind: cannot assign
# requested address" (live 2026-10-21). The LAN bind stays for diagnostics.
# No public DNS name, no guard chain: internal net only, per the exposure # No public DNS name, no guard chain: internal net only, per the exposure
# policy. # policy. The listen flag is --web.listen-address and it is REPEATABLE, one
# flag per bind; plain --listen-address does not exist and the exporter
# exits in 100 ms with "unknown long flag" (live 2026-10-21).
[Unit] [Unit]
Description=Blackbox exporter (HTTP probe prober) Description=Blackbox exporter (HTTP probe prober)
@@ -30,9 +33,18 @@ After=network-online.target
Image=docker.io/prom/blackbox-exporter@sha256:ea1c7aa3ef3f307cd03e85f34abb604478ccb90b567fc690da0cd9776da76b13 Image=docker.io/prom/blackbox-exporter@sha256:ea1c7aa3ef3f307cd03e85f34abb604478ccb90b567fc690da0cd9776da76b13
ContainerName=blackbox-exporter ContainerName=blackbox-exporter
Network=host Network=host
Exec=/bin/blackbox_exporter --config.file=/etc/blackbox_exporter/config.yml --listen-address=192.168.0.143:9115,169.254.1.2:9115 # The image's ENTRYPOINT is /bin/blackbox_exporter itself (proved live
# 2026-10-21: `podman run IMAGE sh` dies "unexpected sh"), so Exec= carries
# FLAGS ONLY — repeating the binary makes it a positional arg and kingpin
# exits "unexpected /bin/blackbox_exporter".
Exec=--config.file=/etc/blackbox_exporter/config.yml --web.listen-address=127.0.0.1:9115 --web.listen-address=192.168.0.143:9115
Volume=/opt/blackbox/blackbox.yml:/etc/blackbox_exporter/config.yml:ro Volume=/opt/blackbox/blackbox.yml:/etc/blackbox_exporter/config.yml:ro
HealthCmd=/bin/sh -c "wget -q -O /dev/null http://169.254.1.2:9115/-/healthy || exit 1" # Plain exec form like every sibling unit. Shell form (`/bin/sh -c "…"`)
# is passed as one quoted token and quadlet then folds the following
# --health-interval/--health-retries (and the image name) into the command
# string — unit died twice that way, live 2026-10-21. wget only; this
# busybox variant has no curl, and host-network wget answers on loopback.
HealthCmd=wget -q -O /dev/null http://127.0.0.1:9115/-/healthy
HealthInterval=30s HealthInterval=30s
HealthRetries=3 HealthRetries=3
@@ -35,7 +35,7 @@ Wants=network-online.target
[Container] [Container]
ContainerName=cadvisor ContainerName=cadvisor
Image=gcr.io/cadvisor/cadvisor:v0.49.1 Image=gcr.io/cadvisor/cadvisor:v0.49.1@sha256:3cde6faf0791ebf7b41d6f8ae7145466fed712ea6f252c935294d2608b1af388
PodmanArgs=--pid=host --cgroupns=host PodmanArgs=--pid=host --cgroupns=host
PublishPort=127.0.0.1:8080:8080 PublishPort=127.0.0.1:8080:8080
PublishPort=192.168.0.143:8080:8080 PublishPort=192.168.0.143:8080:8080
@@ -34,7 +34,7 @@ Wants=network-online.target prometheus.service
[Container] [Container]
ContainerName=grafana ContainerName=grafana
Image=docker.io/grafana/grafana:latest Image=docker.io/grafana/grafana:latest@sha256:dbbc38ea21103cefb50396340cd88afec779c7e070b0a7bf5a75b4ce46651d3f
Network=dankclaw_default Network=dankclaw_default
User=0:0 User=0:0
PublishPort=127.0.0.1:3002:3000 PublishPort=127.0.0.1:3002:3000
@@ -33,7 +33,7 @@ Wants=network-online.target
[Container] [Container]
ContainerName=node-exporter ContainerName=node-exporter
Image=docker.io/prom/node-exporter:latest Image=docker.io/prom/node-exporter:latest@sha256:1b4e4438faca4dd7e001dd445d161a4a2091b0fededa84093b3a8dfeae1f1be0
Network=host Network=host
PodmanArgs=--pid=host PodmanArgs=--pid=host
Exec=--path.procfs=/host/proc --path.sysfs=/host/sys --path.rootfs=/rootfs --web.listen-address=127.0.0.1:9100 --web.listen-address=192.168.0.143:9100 Exec=--path.procfs=/host/proc --path.sysfs=/host/sys --path.rootfs=/rootfs --web.listen-address=127.0.0.1:9100 --web.listen-address=192.168.0.143:9100
@@ -43,14 +43,16 @@ Wants=network-online.target
[Container] [Container]
ContainerName=prometheus ContainerName=prometheus
Image=docker.io/prom/prometheus:latest Image=docker.io/prom/prometheus:latest@sha256:fa3302279710be2f21a8301ec17a8b4f2301fd829bd113839b3b6b02b6f191a2
Network=dankclaw_default Network=dankclaw_default
# Name the podman gateway so scrape jobs can reach same-host targets (the # Same-host scrape targets are dialed at host.containers.internal (the podman
# netavark hairpin gap bars this container from dialing its own host's LAN # gateway): a bridge container cannot dial its own host's LAN IP (netavark
# IP; the cadvisor/gpu jobs and the blackbox scrape all ride this name). # hairpin gap, measured 2026-10-21), and traffic to the gateway lands on host
# Same mechanism the hermes Caddy upstream learned the hard way (Container # loopback / published ports (cadvisor/gpu jobs and the blackbox scrape all
# Host Reachability in the vault). # ride it). The name resolves natively — NO HostAddr key: quadlet 5.7.0 calls
HostAddr=host.containers.internal=169.254.1.2 # it unsupported and the WHOLE user quadlet batch then fails to generate,
# silently orphaning every other unit (live 2026-10-21: prometheus.service
# vanished while its container kept running as an orphan).
User=0:0 User=0:0
Exec=--config.file=/etc/prometheus/prometheus.yml --storage.tsdb.path=/prometheus --storage.tsdb.retention.time=30d --web.enable-lifecycle Exec=--config.file=/etc/prometheus/prometheus.yml --storage.tsdb.path=/prometheus --storage.tsdb.retention.time=30d --web.enable-lifecycle
PublishPort=127.0.0.1:9090:9090 PublishPort=127.0.0.1:9090:9090
@@ -27,7 +27,7 @@ After=network.target
[Container] [Container]
ContainerName=open-webui ContainerName=open-webui
Network=open-webui.network Network=open-webui.network
Image=ghcr.io/open-webui/open-webui:main Image=ghcr.io/open-webui/open-webui:main@sha256:4860ba005ae8f19f08d1e12b0868855b7b339ea0dad9927f196820fc0de04575
PublishPort=3005:8080 PublishPort=3005:8080
Environment=OPENAI_API_BASE_URL=http://192.168.0.143:7770 Environment=OPENAI_API_BASE_URL=http://192.168.0.143:7770
Environment=ANONYMIZED_TELEMETRY=False Environment=ANONYMIZED_TELEMETRY=False
+1 -1
View File
@@ -24,7 +24,7 @@ Wants=network-online.target
[Container] [Container]
ContainerName=pihole ContainerName=pihole
Image=docker.io/pihole/pihole:latest Image=docker.io/pihole/pihole:latest@sha256:d34134716fc32038b052a8674c7c6879e0ff21f0e6d8af345166fffa2c27c113
Network=slirp4netns Network=slirp4netns
PublishPort=0.0.0.0:53:53/udp PublishPort=0.0.0.0:53:53/udp
PublishPort=0.0.0.0:53:53/tcp PublishPort=0.0.0.0:53:53/tcp
+1 -1
View File
@@ -16,7 +16,7 @@ After=searxng-network.service network.target
[Container] [Container]
ContainerName=gluetun ContainerName=gluetun
Network=searxng.network Network=searxng.network
Image=docker.io/qmcgaw/gluetun:latest Image=docker.io/qmcgaw/gluetun:latest@sha256:c2ee4191762fca6b6a3c3359a731198a95d9c9af4db32d466e223bf3a84e779b
AddCapability=NET_ADMIN AddCapability=NET_ADMIN
AddDevice=/dev/net/tun:/dev/net/tun AddDevice=/dev/net/tun:/dev/net/tun
Environment=VPN_SERVICE_PROVIDER=${VPN_SERVICE_PROVIDER} Environment=VPN_SERVICE_PROVIDER=${VPN_SERVICE_PROVIDER}
@@ -16,7 +16,7 @@ After=searxng-network.service network.target
ContainerName=searxng-valkey ContainerName=searxng-valkey
Network=searxng.network Network=searxng.network
NetworkAlias=valkey NetworkAlias=valkey
Image=docker.io/valkey/valkey:9-alpine Image=docker.io/valkey/valkey:9-alpine@sha256:48332870af354a799964c0012ae1194a0bf2bf894eb508f945810596dc2d8d11
Volume=/opt/searxng/valkey:/data Volume=/opt/searxng/valkey:/data
[Service] [Service]
+1 -1
View File
@@ -16,7 +16,7 @@ After=gluetun.service searxng-valkey.service network.target
[Container] [Container]
ContainerName=searxng ContainerName=searxng
Network=gluetun.container Network=gluetun.container
Image=ghcr.io/searxng/searxng:latest Image=ghcr.io/searxng/searxng:latest@sha256:5ed0323b5f5f8701264ef65e239a2bd83cb57098f2b971fa30bf79c06eeae271
Environment=SEARXNG_SECRET=${SEARXNG_SECRET} Environment=SEARXNG_SECRET=${SEARXNG_SECRET}
Environment=SEARXNG_BASE_URL=${SEARXNG_BASE_URL} Environment=SEARXNG_BASE_URL=${SEARXNG_BASE_URL}
Volume=/opt/searxng/config:/etc/searxng Volume=/opt/searxng/config:/etc/searxng
+1 -1
View File
@@ -29,7 +29,7 @@ Wants=network-online.target
[Container] [Container]
ContainerName=seerr ContainerName=seerr
Image=ghcr.io/seerr-team/seerr:v3.4.1 Image=ghcr.io/seerr-team/seerr:v3.4.1@sha256:f4768de5f616248d723e05891f3345a1402123775d03bf0890dbfedc0831bda1
Network=dankclaw_default Network=dankclaw_default
PublishPort=127.0.0.1:5055:5055 PublishPort=127.0.0.1:5055:5055
Volume=/home/dank/Containers/seerr/data:/app/config Volume=/home/dank/Containers/seerr/data:/app/config
@@ -43,7 +43,7 @@ After=network.target
[Container] [Container]
ContainerName=calibre-server ContainerName=calibre-server
Image=lscr.io/linuxserver/calibre:9.15.0 Image=lscr.io/linuxserver/calibre:9.15.0@sha256:59e4b0e036e62f228f8bcfae1ade42f47047719885fe454ddbad38e4597b987b
Network=pasta Network=pasta
EnvironmentFile=/home/dank/Containers/calibre-server/env EnvironmentFile=/home/dank/Containers/calibre-server/env
Environment=PUID=1000 Environment=PUID=1000
@@ -16,7 +16,7 @@ After=network.target
[Container] [Container]
ContainerName=calibre-web ContainerName=calibre-web
Image=lscr.io/linuxserver/calibre-web:0.6.27-ls402 Image=lscr.io/linuxserver/calibre-web:0.6.27-ls402@sha256:802729966faed5e32d671cd4f92ba6196e9e754a6c0f740ce1ee9acd86b10e55
Network=pasta Network=pasta
Environment=DOCKER_MODS=linuxserver/mods:universal-calibre Environment=DOCKER_MODS=linuxserver/mods:universal-calibre
Environment=LANG=en_US.UTF-8 Environment=LANG=en_US.UTF-8
@@ -21,7 +21,13 @@ After=network.target
[Container] [Container]
ContainerName=copyparty ContainerName=copyparty
Image=ghcr.io/9001/copyparty-ac:latest # Digest-pinned: upstream stopped publishing version tags after 1.19.15, so
# the pin rides :latest@digest (the tag in the reference is cosmetic; the
# digest is the pull). Pinned 2026-10-21 at the build whose label is
# 1.20.26 - closes GHSA-3c88-p299-7hvr (1.20.25) and GHSA-hg99-jqfp-rf76
# (1.20.26); the previously running build was the rolling :latest at
# 1.20.24 (issue #73 on command-center).
Image=ghcr.io/9001/copyparty-ac:latest@sha256:7f77459160a4bf34fdca79f2965dff4f0c1d7069a3d9cdfb3632acb041dd2f6a
Network=dankserver_appnet Network=dankserver_appnet
EnvironmentFile=/home/dank/Containers/copyparty/env EnvironmentFile=/home/dank/Containers/copyparty/env
Exec=-a ${COPAY_ACCOUNT}:${COPAY_PASSWORD} -v /w::r:ra,dank Exec=-a ${COPAY_ACCOUNT}:${COPAY_PASSWORD} -v /w::r:ra,dank
@@ -16,7 +16,7 @@ After=network.target
[Container] [Container]
ContainerName=jellyfin ContainerName=jellyfin
Image=ghcr.io/jellyfin/jellyfin:latest Image=ghcr.io/jellyfin/jellyfin:latest@sha256:c3efa69a22f2691fc88d6bb035e87a8560e395a6047f062fb381c61c3185e8f5
Network=dankserver_appnet Network=dankserver_appnet
Environment=DEBIAN_FRONTEND=noninteractive Environment=DEBIAN_FRONTEND=noninteractive
Environment=DOTNET_SYSTEM_IO_DISABLEFILELOCKING=true Environment=DOTNET_SYSTEM_IO_DISABLEFILELOCKING=true
@@ -18,7 +18,7 @@ Wants=network-online.target
[Container] [Container]
ContainerName=cadvisor ContainerName=cadvisor
Image=gcr.io/cadvisor/cadvisor:v0.49.1 Image=gcr.io/cadvisor/cadvisor:v0.49.1@sha256:3cde6faf0791ebf7b41d6f8ae7145466fed712ea6f252c935294d2608b1af388
PodmanArgs=--pid=host --cgroupns=host PodmanArgs=--pid=host --cgroupns=host
PublishPort=127.0.0.1:8080:8080 PublishPort=127.0.0.1:8080:8080
PublishPort=192.168.0.235:8080:8080 PublishPort=192.168.0.235:8080:8080
@@ -37,7 +37,7 @@ Wants=network-online.target
[Container] [Container]
ContainerName=node-exporter ContainerName=node-exporter
Image=docker.io/prom/node-exporter:latest Image=docker.io/prom/node-exporter:latest@sha256:1b4e4438faca4dd7e001dd445d161a4a2091b0fededa84093b3a8dfeae1f1be0
Network=host Network=host
PodmanArgs=--pid=host PodmanArgs=--pid=host
Exec=--path.procfs=/host/proc --path.sysfs=/host/sys --path.rootfs=/rootfs --web.listen-address=127.0.0.1:9100 --web.listen-address=192.168.0.235:9100 Exec=--path.procfs=/host/proc --path.sysfs=/host/sys --path.rootfs=/rootfs --web.listen-address=127.0.0.1:9100 --web.listen-address=192.168.0.235:9100
+1 -1
View File
@@ -4,7 +4,7 @@ After=network-online.target
Wants=network-online.target Wants=network-online.target
[Container] [Container]
Image=lscr.io/linuxserver/plex:1.43.5.11029-6bf8af3f0-ls329 Image=lscr.io/linuxserver/plex:1.43.5.11029-6bf8af3f0-ls329@sha256:8f655bad7dcbc5398a78f70a5af12600b4ed3424f901566ed1eaa6710e9e2334
ContainerName=plex ContainerName=plex
Network=dankserver_appnet:alias=plex Network=dankserver_appnet:alias=plex
Environment=PUID=0 Environment=PUID=0
@@ -14,7 +14,7 @@ After=network.target
[Container] [Container]
ContainerName=romm-mariadb ContainerName=romm-mariadb
Image=docker.io/library/mariadb:11 Image=docker.io/library/mariadb:11@sha256:f16d7a344413e519dff518f27bdb221388f84b0d274c97c4fb5f2cb0dc1b34fa
Network=dankserver_appnet:alias=mariadb Network=dankserver_appnet:alias=mariadb
EnvironmentFile=/home/dank/Containers/romm/env EnvironmentFile=/home/dank/Containers/romm/env
Environment=MARIADB_DATABASE=romm Environment=MARIADB_DATABASE=romm
+1 -1
View File
@@ -12,7 +12,7 @@ After=network.target
[Container] [Container]
ContainerName=romm-redis ContainerName=romm-redis
Image=docker.io/library/redis:7-alpine Image=docker.io/library/redis:7-alpine@sha256:858f009f9709ce576febc734aa78b8f6d624b82571f9ddb6bda4377c833b3499
Network=dankserver_appnet:alias=redis Network=dankserver_appnet:alias=redis
Volume=/home/dank/Containers/romm/redis:/data Volume=/home/dank/Containers/romm/redis:/data
HealthCmd=redis-cli ping HealthCmd=redis-cli ping
+1 -1
View File
@@ -18,7 +18,7 @@ After=romm-mariadb.service romm-redis.service network.target
[Container] [Container]
ContainerName=romm ContainerName=romm
Image=ghcr.io/rommapp/romm:latest Image=ghcr.io/rommapp/romm:latest@sha256:03295f95a6290e752641f9553ac4198b0e5b71b879d57f7b2371b98f608feb54
Network=dankserver_appnet Network=dankserver_appnet
EnvironmentFile=/home/dank/Containers/romm/env EnvironmentFile=/home/dank/Containers/romm/env
Environment=DB_HOST=mariadb Environment=DB_HOST=mariadb
@@ -4,7 +4,7 @@ After=network-online.target
Wants=network-online.target Wants=network-online.target
[Container] [Container]
Image=lscr.io/linuxserver/tautulli:v2.18.1-ls245 Image=lscr.io/linuxserver/tautulli:v2.18.1-ls245@sha256:c66d3afa8f7a701a48dd1b1f57b22eca0483d895d670fe11ecff783f6a9bbd73
ContainerName=tautulli ContainerName=tautulli
Network=dankserver_appnet:alias=tautulli Network=dankserver_appnet:alias=tautulli
Environment=PUID=0 Environment=PUID=0
@@ -19,7 +19,7 @@ Description=Gluetun VPN tunnel (FrankBook-Pro podman machine)
[Container] [Container]
ContainerName=gluetun ContainerName=gluetun
Image=docker.io/qmcgaw/gluetun:latest Image=docker.io/qmcgaw/gluetun:latest@sha256:c2ee4191762fca6b6a3c3359a731198a95d9c9af4db32d466e223bf3a84e779b
AddCapability=NET_ADMIN AddCapability=NET_ADMIN
AddDevice=/dev/net/tun:/dev/net/tun AddDevice=/dev/net/tun:/dev/net/tun
Environment=VPN_SERVICE_PROVIDER=${VPN_SERVICE_PROVIDER} Environment=VPN_SERVICE_PROVIDER=${VPN_SERVICE_PROVIDER}
@@ -15,7 +15,7 @@ After=gluetun.service
[Container] [Container]
ContainerName=lidarr ContainerName=lidarr
Image=lscr.io/linuxserver/lidarr:latest Image=lscr.io/linuxserver/lidarr:latest@sha256:d8549ff9ea29b4015c32257360652c1000c31ea471a2b3f0835ae77292faf35b
Environment=PUID=0 Environment=PUID=0
Environment=PGID=0 Environment=PGID=0
Environment=TZ=America/Los_Angeles Environment=TZ=America/Los_Angeles
@@ -24,7 +24,7 @@ After=gluetun.service
[Container] [Container]
ContainerName=prowlarr ContainerName=prowlarr
Image=lscr.io/linuxserver/prowlarr:latest Image=lscr.io/linuxserver/prowlarr:latest@sha256:b3b00047ed20f6ce2b16780145cb6a0acb51c9e28ea6aa6f5dcd33c7034f1797
Environment=PUID=0 Environment=PUID=0
Environment=PGID=0 Environment=PGID=0
Environment=TZ=America/Los_Angeles Environment=TZ=America/Los_Angeles
@@ -29,7 +29,7 @@ Network=gluetun.container
# Pinned to the last 4.x line: Readarr's retired qB client requires the # Pinned to the last 4.x line: Readarr's retired qB client requires the
# qB 4.x login response (HTTP 200, body "Ok."); qB 5.x answers 204 with an # qB 4.x login response (HTTP 200, body "Ok."); qB 5.x answers 204 with an
# empty body, which fails Readarr's auth check. All other clients accept 4.x. # empty body, which fails Readarr's auth check. All other clients accept 4.x.
Image=lscr.io/linuxserver/qbittorrent:4.6.7 Image=lscr.io/linuxserver/qbittorrent:4.6.7@sha256:55f15d44396315551f87294a176efae733b16e283f38980308e46073950257c6
Environment=PUID=0 Environment=PUID=0
Environment=PGID=0 Environment=PGID=0
Environment=TZ=America/Los_Angeles Environment=TZ=America/Los_Angeles
@@ -15,7 +15,7 @@ After=gluetun.service
[Container] [Container]
ContainerName=radarr ContainerName=radarr
Image=lscr.io/linuxserver/radarr:latest Image=lscr.io/linuxserver/radarr:latest@sha256:fc0ae3c50f8128b97e84381903ec8d6845c3a569641cb7117c124376cccb57c1
Environment=PUID=0 Environment=PUID=0
Environment=PGID=0 Environment=PGID=0
Environment=TZ=America/Los_Angeles Environment=TZ=America/Los_Angeles
@@ -19,7 +19,7 @@ After=gluetun.service
[Container] [Container]
ContainerName=readarr ContainerName=readarr
Image=lscr.io/linuxserver/readarr:amd64-1.0.980 Image=lscr.io/linuxserver/readarr:amd64-1.0.980@sha256:a95e82dbb0eb2bb43de4c150f1dab9dd821306c67ea0b418983a957e731f2b61
Environment=PUID=0 Environment=PUID=0
Environment=PGID=0 Environment=PGID=0
Environment=TZ=America/Los_Angeles Environment=TZ=America/Los_Angeles
@@ -16,7 +16,7 @@ After=gluetun.service
[Container] [Container]
ContainerName=sonarr ContainerName=sonarr
Image=lscr.io/linuxserver/sonarr:latest Image=lscr.io/linuxserver/sonarr:latest@sha256:889dcc58c6aea859033985894b519b2e6b1e55d92527ed123f9f52e42ae7c97b
Environment=PUID=0 Environment=PUID=0
Environment=PGID=0 Environment=PGID=0
Environment=TZ=America/Los_Angeles Environment=TZ=America/Los_Angeles
+14 -4
View File
@@ -252,16 +252,24 @@ install_signal_relay() {
# The relay binds the host's LAN IP (the one address a bridge container # The relay binds the host's LAN IP (the one address a bridge container
# can reach as host.containers.internal), so keep the unauthenticated # can reach as host.containers.internal), so keep the unauthenticated
# endpoint off the rest of the LAN: admit loopback, the host itself # endpoint off the rest of the LAN: admit loopback, the host itself
# (its own health probe) and the podman bridge subnet only. Idempotent: # (its own health probe) and the podman bridge subnet only.
# -C before -I. # ORDER IS THE GATE: `iptables -I` inserts at position 1, so the old
# shape (-C/-I x3 ACCEPT, then -C/-I the DROP) landed the blanket DROP
# ABOVE the ACCEPTs and every probe timed out beside a LISTENing socket
# (live 2026-10-21: 159 packets on the DROP, 0 on each ACCEPT; the whole
# dankclaw drain aborted at the alertmanager gate twice before the cause
# was read off `nft list ruleset`). Rebuild the tail instead of inserting
# it: delete any blanket DROP wherever it sits, keep the three ACCEPTs
# at the head (-C before -I, unchanged), then APPEND the DROP so it is
# last and the ACCEPTs decide first. Converges on poisoned hosts.
run_privileged iptables -D INPUT -p tcp --dport 18093 -j DROP 2>/dev/null || true
run_privileged iptables -C INPUT -p tcp --dport 18093 -s 127.0.0.1 -j ACCEPT 2>/dev/null \ run_privileged iptables -C INPUT -p tcp --dport 18093 -s 127.0.0.1 -j ACCEPT 2>/dev/null \
|| run_privileged iptables -I INPUT -p tcp --dport 18093 -s 127.0.0.1 -j ACCEPT || run_privileged iptables -I INPUT -p tcp --dport 18093 -s 127.0.0.1 -j ACCEPT
run_privileged iptables -C INPUT -p tcp --dport 18093 -s 192.168.0.143 -j ACCEPT 2>/dev/null \ run_privileged iptables -C INPUT -p tcp --dport 18093 -s 192.168.0.143 -j ACCEPT 2>/dev/null \
|| run_privileged iptables -I INPUT -p tcp --dport 18093 -s 192.168.0.143 -j ACCEPT || run_privileged iptables -I INPUT -p tcp --dport 18093 -s 192.168.0.143 -j ACCEPT
run_privileged iptables -C INPUT -p tcp --dport 18093 -s 10.89.0.0/16 -j ACCEPT 2>/dev/null \ run_privileged iptables -C INPUT -p tcp --dport 18093 -s 10.89.0.0/16 -j ACCEPT 2>/dev/null \
|| run_privileged iptables -I INPUT -p tcp --dport 18093 -s 10.89.0.0/16 -j ACCEPT || run_privileged iptables -I INPUT -p tcp --dport 18093 -s 10.89.0.0/16 -j ACCEPT
run_privileged iptables -C INPUT -p tcp --dport 18093 -j DROP 2>/dev/null \ run_privileged iptables -A INPUT -p tcp --dport 18093 -j DROP
|| run_privileged iptables -I INPUT -p tcp --dport 18093 -j DROP
# The interpreter needs a moment to bind after restart; a one-shot curl # The interpreter needs a moment to bind after restart; a one-shot curl
# raced the socket on 2026-09-17 and failed a healthy deploy. # raced the socket on 2026-09-17 and failed a healthy deploy.
local i local i
@@ -617,6 +625,8 @@ deploy_observability() {
loki) wait_for_health loki http://127.0.0.1:3100/ready 60 ;; loki) wait_for_health loki http://127.0.0.1:3100/ready 60 ;;
node-exporter) wait_for_health node-exporter http://127.0.0.1:9100/metrics 60 ;; node-exporter) wait_for_health node-exporter http://127.0.0.1:9100/metrics 60 ;;
cadvisor) wait_for_health cadvisor http://127.0.0.1:8080/healthz 90 ;; cadvisor) wait_for_health cadvisor http://127.0.0.1:8080/healthz 90 ;;
# Loopback: the exporter binds 127.0.0.1 + LAN (the gateway address
# is not bindable from the host netns — see the unit header).
blackbox-exporter) wait_for_health blackbox-exporter http://127.0.0.1:9115/-/healthy 60 ;; blackbox-exporter) wait_for_health blackbox-exporter http://127.0.0.1:9115/-/healthy 60 ;;
esac esac
} }
@@ -130,10 +130,19 @@ Quickest unblock: `podman compose up -d --force-recreate <svc>`. Durable fix
## 4. Podman 5.x quadlet v1 generator quirks (verified live 2026-08-28) ## 4. Podman 5.x quadlet v1 generator quirks (verified live 2026-08-28)
- **Hard-fail keys** — the whole unit silently fails to generate - **Hard-fail keys abort the WHOLE BATCH, not one unit** (podman 5.7.0,
(`systemctl --user cat <unit>` shows "could not be found"): any live 2026-10-21): one unsupported key — e.g. `HostAddr=` — makes the
dash-prefixed `[Container]` option (`--shm-size=…` → `unsupported key … generator log `unsupported key … in group 'Container'` plus
in group 'Container'`); `Command=` is unsupported, use `Exec=`. `processing encountered some errors` and generate NOTHING for that
manager. The running containers keep going as orphans, so the failure
first appears on the next deploy as `Unit <x>.service not found` for a
service that is visibly up. Other offenders: any dash-prefixed
`[Container]` option (`--shm-size=…`), `Command=` (use `Exec=`).
The seam `tests/quadlet_image_pins.bats` fails the build on any
generator-rejected key in any host's units.
Also: an image whose ENTRYPOINT is the binary takes FLAGS ONLY in
`Exec=` — repeating the binary makes it a positional arg
(`unexpected /bin/blackbox_exporter`, live 2026-10-21).
- **HealthCmd space-splitting:** the v1 generator splits `HealthCmd` on - **HealthCmd space-splitting:** the v1 generator splits `HealthCmd` on
spaces into the healthcheck argv; a quoted probe spaces into the healthcheck argv; a quoted probe
`/bin/sh -c "pg_isready …"` lands as argv token `"pg_isready` → `/bin/sh -c "pg_isready …"` lands as argv token `"pg_isready` →
@@ -148,6 +157,16 @@ Quickest unblock: `podman compose up -d --force-recreate <svc>`. Durable fix
"unsupported key"`, `systemctl --user cat probe.service` (shows the "unsupported key"`, `systemctl --user cat probe.service` (shows the
generated ExecStart verbatim), then remove + reload. generated ExecStart verbatim), then remove + reload.
## 4a. Image pins (fleet law, 2026-10-21)
Every registry image in a quadlet is digest-pinned (`tag@sha256:…`);
locally built images (`localhost/*`, `franksdank/*`) are exempt — their
build is the pin. A rolling tag lets a rebuild move under a running
service with nobody approving it, and it makes a CVE audit ungradeable.
Gate: `tests/quadlet_image_pins.bats`. Where upstream publishes no version
tags (copyparty-ac stops at 1.19.15), pin `:latest@sha256:` and name the
version the config label carries in the comment beside it.
## 5. Rootless Quadlet log retrieval ## 5. Rootless Quadlet log retrieval
Step 0 — establish ownership: `systemctl --user status <name>.service` Step 0 — establish ownership: `systemctl --user status <name>.service`
+26 -10
View File
@@ -34,15 +34,16 @@ https://github.com/open-webui/open-webui
The picker is built from the `model` table in `webui.db` (one row per model) The picker is built from the `model` table in `webui.db` (one row per model)
plus the `model_ids` of each **enabled** OpenAI connection, NOT from live plus the `model_ids` of each **enabled** OpenAI connection, NOT from live
connection discovery — so the picker shows exactly what is seeded there. connection discovery — so the picker shows exactly what is seeded there.
As of 2026-10-07 the picker exposes **exactly one model: `frank-zealot`** *(unverified Since 2026-10-21 the picker exposes **all three fleet models to every
2026-10-18: `webui.db` is the authority; the fleet itself serves all three ids account**: `frank-dragoon`, `frank-archon`, `frank-zealot` (per Frank: open
`frank-dragoon`/`frank-zealot`/`frank-archon`, per the cards in `frank-archon` to everyone; the other two were already public). Proven with
`shared/homepage/services.json`)*. a `role=user` token: `/api/models` lists all three ids and a chat turn to
`frank-archon` answers end-to-end.
| Connection (`openai.api_configs` idx) | URL as recorded 2026-10-09 | Enabled | `model_ids` | Endpoint page | | Connection (`openai.api_configs` idx) | `openai.api_base_urls` (live 2026-10-21) | Enabled | `model_ids` | Endpoint page |
|---|---|---|---|---| |---|---|---|---|---|
| 0 | `http://host.containers.internal:7770/v1` | no (2026-10-07) | `frank-dragoon` | [[frank-dragoon]] | | 0 | `http://host.containers.internal:7770/v1` | yes | `frank-dragoon` | [[frank-dragoon]] |
| 1 | `http://192.168.0.235:7770/v1` | no (2026-10-07) | `frank-archon` | [[frank-archon]] | | 1 | `http://192.168.0.235:7770/v1` | yes | `frank-archon` | [[frank-archon]] |
| 2 | `http://host.containers.internal:7771/v1` | yes | `frank-zealot` | [[frank-zealot]] | | 2 | `http://host.containers.internal:7771/v1` | yes | `frank-zealot` | [[frank-zealot]] |
The URLs in the first column are the DB's recorded values and predate the 2026-08-22 The URLs in the first column are the DB's recorded values and predate the 2026-08-22
@@ -50,9 +51,10 @@ bridge move; on this network they are wrong — see the reachability gotcha belo
is the corrected law. The picker row for `frank-zealot` was re-keyed on 2026-10-18 after is the corrected law. The picker row for `frank-zealot` was re-keyed on 2026-10-18 after
that day's payload swap (see [[frank-zealot]] for what was deployed and when). that day's payload swap (see [[frank-zealot]] for what was deployed and when).
The `model` table carries exactly two decoration rows, `frank-zealot` and The `model` table carries three decoration rows — `frank-zealot`,
`frank-dragoon`. Both the context length and the description live **inside `frank-dragoon`, `frank-archon` (200,000 context, added 2026-10-21).
JSON columns** — `params` holds `{"context": {"length": …}}` and `meta` holds Both the context length and the description live **inside JSON columns** —
`params` holds `{"context": {"length": …}}` and `meta` holds
`{"description": …}`; there is no `context_length` or `description` column, so `{"description": …}`; there is no `context_length` or `description` column, so
`sqlite3` one-liners against those names fail. `params.context.length` is a copy `sqlite3` one-liners against those names fail. `params.context.length` is a copy
of the endpoint's per-slot window, so the authoritative number lives on the of the endpoint's per-slot window, so the authoritative number lives on the
@@ -380,6 +382,20 @@ neither.
name: /selected model/i })`) and the welcome text "How can I help you name: /selected model/i })`) and the welcome text "How can I help you
today?"; that snapshot is also the cheapest proof the picker is populated. today?"; that snapshot is also the cheapest proof the picker is populated.
- **2026-10-21 — picker opened to all three models, for everyone.** Per
Frank: `frank-archon` accessible to everyone. Three-leg repair on
`webui.db` (snapshot `webui.db.bak-20261021` first): connection idx 1
`enable: true`, a `model` row `frank-archon` (params
`{"context":{"length":200000}}`, meta description from the endpoint card),
and an `access_grant` row `(resource_type='model', resource_id='frank-archon',
principal_id='*', 'read')` — `principal_id='*'` is the public grant, not a
role row. Verified as a plain `role=user` token: `/api/models` lists all
three ids, chat to `frank-archon` answers. Token-mint gotchas: the backend
package imports only with cwd `/app/backend`, and `WEBUI_SECRET_KEY` must
come from **PID 1's environ** (`podman exec` sessions do not inherit the
app process env; the `.webui_secret_key` file happens to equal it — use it
explicitly, `podman exec -e`).
## See Also ## See Also
- [[Dankclaw Stack]] - [[Dankclaw Stack]]
@@ -74,11 +74,19 @@ published ports — node `:9100`, cadvisor `:8080`, gpu `:9401` all answer there
— while the host's own LAN IP is refused (`192.168.0.143:8080`, `:9401`), so — while the host's own LAN IP is refused (`192.168.0.143:8080`, `:9401`), so
every same-host Prometheus target uses the gateway and only cross-host targets every same-host Prometheus target uses the gateway and only cross-host targets
(dankserver) use plain LAN. Whether LAN works at all is network-specific: the (dankserver) use plain LAN. Whether LAN works at all is network-specific: the
relay at `192.168.0.143:18093` IS reached from alertmanager's network, with relay at `192.168.0.143:18093` is reached from alertmanager's network only
iptables INPUT admitting loopback, the host itself and the podman bridge subnet while `install_signal_relay`'s INPUT rules are in force: loopback, the host
`10.89.0.0/16` before the DROP (`scripts/deploy-remote.sh:255-262`). Never itself and the podman bridge subnet `10.89.0.0/16` accepted **above** a
point a scrape target at a host LAN IP from its own host on the strength of trailing blanket DROP. The law the poisoned host taught (2026-10-21):
one working example; dial the gateway and probe. `iptables -I` prepends, so inserting the DROP last lands it first — the old
install order dropped every packet (159 on the DROP, 0 on the ACCEPTs), the
deployer's health gate failed beside a LISTENing socket, and the drain
aborted twice before `nft list ruleset` named the cause. The install now
rebuilds the tail (delete the DROP wherever it sits, ACCEPTs at the head,
DROP appended); verify any change to it with the sandboxed bats case, not
with `-C` greps — `-C` answers existence, never order. Never point a scrape
target at a host LAN IP from its own host on the strength of one working
example; dial the gateway and probe.
## Deployment ## Deployment
@@ -54,9 +54,10 @@ Scraping a web root is not a health check: an HTML page fails the scrape
protocol (`unsupported Content-Type`), which is what kept those targets red protocol (`unsupported Content-Type`), which is what kept those targets red
beside healthy services. The classification rule and the honesty law live in beside healthy services. The classification rule and the honesty law live in
[[Stale Signal Audit]]. The prober is `blackbox-exporter.container` (host [[Stale Signal Audit]]. The prober is `blackbox-exporter.container` (host
netns, config `shared/blackbox/blackbox.yml`); it binds 9115 on both the LAN netns, config `shared/blackbox/blackbox.yml`); it binds 9115 on loopback and
and the podman gateway, and Prometheus scrapes the gateway address — the the LAN, and Prometheus scrapes it at `host.containers.internal` — traffic to
netavark hairpin gap bars this container from its own host's LAN IP. that gateway lands on the host's loopback listener. The gateway address is
not itself bindable from the host netns.
Plain targets stay `host:port`, never URLs — the probe job's URL targets are Plain targets stay `host:port`, never URLs — the probe job's URL targets are
the documented exception, relabeled onto the prober. the documented exception, relabeled onto the prober.
+2 -1
View File
@@ -77,6 +77,7 @@ FRANKBOOK_UNITS=(
} }
@test "readarr keeps its pinned image" { @test "readarr keeps its pinned image" {
grep -q '^Image=lscr.io/linuxserver/readarr:amd64-1.0.980$' \ # 2026-10-21 fleet law: version tag AND digest (tests/quadlet_image_pins.bats)
grep -qE '^Image=lscr\.io/linuxserver/readarr:amd64-1\.0\.980@sha256:[0-9a-f]{64}$' \
"$REPO_ROOT/frankbook-pro/readarr/quadlet/readarr.container" || return 1 "$REPO_ROOT/frankbook-pro/readarr/quadlet/readarr.container" || return 1
} }
+58
View File
@@ -559,6 +559,64 @@ assert any('ALERTS' in e for e in exprs), exprs
grep -q -- '--dport 18093 -j DROP' "$handler" grep -q -- '--dport 18093 -j DROP' "$handler"
} }
@test "the deployer installs the relay firewall with the ACCEPTs deciding before the DROP" {
# Behavioral, run for real against a sandbox that plays the kernel: the
# rule set is a LIST and iptables -I prepends, so the shape this function
# had until 2026-10-21 (-C/-I ACCEPT x3, then -C/-I the blanket DROP)
# landed the DROP at position 1 and every packet died there — the drain
# failed its health gate beside a LISTENing socket twice. Seeding the
# sandbox with that poisoned order also pins the convergent repair: a
# re-deploy must rewrite the tail, not skip because -C finds every rule.
local sandbox="$BATS_TEST_TMPDIR/fw"
mkdir -p "$sandbox/bin"
cat > "$sandbox/bin/iptables" <<'EOF'
#!/usr/bin/env bash
# ordered rule list in $FW_STATE: -C lookup, -I at head, -A at tail, -D delete.
state="${FW_STATE:?}"
touch "$state"
op="$1"; shift
case "$op" in
-C) grep -Fxq -- "$*" "$state" ;;
-I) grep -Fxq -- "$*" "$state" || { { echo "$*"; cat "$state"; } > "$state.new"; mv "$state.new" "$state"; } ;;
-A) grep -Fxq -- "$*" "$state" || echo "$*" >> "$state" ;;
-D) if grep -Fxq -- "$*" "$state"; then
grep -Fxv -- "$*" "$state" > "$state.new"; mv "$state.new" "$state"
else exit 1; fi ;;
*) exit 0 ;;
esac
EOF
chmod +x "$sandbox/bin/iptables"
# Poisoned state exactly as the pre-fix deployer left Dankclaw.
printf '%s\n' \
'INPUT -p tcp --dport 18093 -j DROP' \
'INPUT -p tcp --dport 18093 -s 10.89.0.0/16 -j ACCEPT' \
'INPUT -p tcp --dport 18093 -s 192.168.0.143 -j ACCEPT' \
'INPUT -p tcp --dport 18093 -s 127.0.0.1 -j ACCEPT' \
> "$sandbox/rules"
(
cd "$REPO_ROOT"
export PATH="$sandbox/bin:$PATH" FW_STATE="$sandbox/rules" HOME="$sandbox/home"
mkdir -p "$HOME"
eval "$(sed -n '/^install_signal_relay()/,/^}/p' scripts/deploy-remote.sh)"
run_privileged() { "$@"; }
systemctl() { :; }
curl() { return 0; }
sleep() { :; }
install_signal_relay
)
# Exactly one blanket DROP, and it sits after every ACCEPT.
[ "$(grep -cF -- 'INPUT -p tcp --dport 18093 -j DROP' "$sandbox/rules")" -eq 1 ]
local drop_at total
drop_at="$(grep -nF -- 'INPUT -p tcp --dport 18093 -j DROP' "$sandbox/rules" | cut -d: -f1)"
total="$(wc -l < "$sandbox/rules")"
[ "$drop_at" -eq "$total" ]
local s seen
for s in 127.0.0.1 192.168.0.143 10.89.0.0/16; do
seen="$(grep -nF -- "-s $s -j ACCEPT" "$sandbox/rules" | cut -d: -f1)"
[ -n "$seen" ] && [ "$seen" -lt "$drop_at" ]
done
}
@test "the planner selects alertmanager for its config and relay files" { @test "the planner selects alertmanager for its config and relay files" {
run python3 "$REPO_ROOT/scripts/deploy-plan.py" --target dankclaw \ run python3 "$REPO_ROOT/scripts/deploy-plan.py" --target dankclaw \
--paths shared/prometheus/alertmanager.yml --paths shared/prometheus/alertmanager.yml
+3 -2
View File
@@ -15,8 +15,9 @@ QDIR="$REPO_ROOT/dankclaw/open-webui/quadlet"
} }
@test "open-webui uses the CPU image, not :cuda (dankclaw is CPU-only)" { @test "open-webui uses the CPU image, not :cuda (dankclaw is CPU-only)" {
grep -qE '^Image=ghcr\.io/open-webui/open-webui:main$' "$QDIR/open-webui.container" \ # 2026-10-21 fleet law: tag AND digest (tests/quadlet_image_pins.bats)
|| { echo "open-webui.container must use the :main (CPU) image"; return 1; } grep -qE '^Image=ghcr\.io/open-webui/open-webui:main@sha256:[0-9a-f]{64}$' "$QDIR/open-webui.container" \
|| { echo "open-webui.container must use the :main (CPU) image, digest-pinned"; return 1; }
if grep -qE '^Image=.*:cuda' "$QDIR/open-webui.container"; then if grep -qE '^Image=.*:cuda' "$QDIR/open-webui.container"; then
echo "open-webui.container must not use the :cuda image on a CPU-only host" echo "open-webui.container must not use the :cuda image on a CPU-only host"
return 1 return 1
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bats
# Seam: fleet-wide quadlet laws learned the hard way.
# 1. Every registry image is digest-pinned. A rolling tag (":latest",
# ":9-alpine", ":nightly") lets a rebuild move under a running
# service at the next restart/reboot with nobody approving it, and it
# makes a CVE audit ungradeable (you cannot score a tag). Locally
# built images (localhost/*, franksdank/*) are exempt: their build
# IS the pin, and they never pull.
# 2. No unit carries a key the podman 5.7.0 quadlet generator rejects.
# The generator aborts the WHOLE batch on an unsupported key, so one
# bad file silently un-generates every other unit in that manager -
# live 2026-10-21: HostAddr= in prometheus.container orphaned
# prometheus.service while its container kept running, and the next
# deploy died on "Unit prometheus.service not found".
load helpers
@test "every registry Image= is digest-pinned across all three hosts" {
bad=""
while IFS= read -r unit; do
while IFS= read -r img; do
case "$img" in
localhost/*|franksdank/*) continue ;;
esac
case "$img" in
*@sha256:*) ;;
*) bad="$bad$unit: $img"$'\n' ;;
esac
done < <(sed -n 's/^Image=//p' "$unit")
done < <(find "$REPO_ROOT"/dankserver "$REPO_ROOT"/dankclaw "$REPO_ROOT"/frankbook-pro \
-path '*/quadlet/*.container' -not -path '*.AGENTTRASH*')
[ -z "$bad" ] || { printf 'unpinned images:\n%s' "$bad" >&2; return 1; }
}
@test "no unit uses a key the 5.7.0 quadlet generator rejects (batch killer)" {
# HostAddr rejected by podman 5.7.0 (live 2026-10-21); Device= and
# Command= are the older v1 findings, guarded host-locally too.
# Keep this list current with every generator finding.
bad=""
while IFS= read -r unit; do
for key in HostAddr Device Command; do
grep -q "^${key}=" "$unit" && bad="$bad$unit: ${key}="$'\n'
done
done < <(find "$REPO_ROOT"/dankserver "$REPO_ROOT"/dankclaw "$REPO_ROOT"/frankbook-pro \
-path '*/quadlet/*.container' -not -path '*.AGENTTRASH*')
[ -z "$bad" ] || { printf 'generator-rejected keys:\n%s' "$bad" >&2; return 1; }
}
+3 -2
View File
@@ -20,8 +20,9 @@ SEERR_UNIT="$REPO_ROOT/dankclaw/seerr/quadlet/seerr.container"
grep -q '^Restart=always$' "$SEERR_UNIT" grep -q '^Restart=always$' "$SEERR_UNIT"
} }
@test "seerr image is pinned to an exact version tag" { @test "seerr image is pinned to an exact version tag and digest" {
grep -Eq '^Image=ghcr\.io/seerr-team/seerr:v[0-9]+\.[0-9]+\.[0-9]+$' "$SEERR_UNIT" # 2026-10-21 fleet law: tag AND digest (tests/quadlet_image_pins.bats)
grep -Eq '^Image=ghcr\.io/seerr-team/seerr:v[0-9]+\.[0-9]+\.[0-9]+@sha256:[0-9a-f]{64}$' "$SEERR_UNIT"
} }
@test "seerr publishes 5055 on loopback only (guard chain is the user path)" { @test "seerr publishes 5055 on loopback only (guard chain is the user path)" {