Compare commits

..
Author SHA1 Message Date
Frank 3b696107ad On main: peer frank-small WIP held for frank-medium deploy 2026-09-17 01:47:22 -07:00
Frank 98912aec89 index on main: d95f55b frank-medium: 36:29 split, 327680 context, vision enabled 2026-09-17 01:47:22 -07:00
Frank d95f55b822 frank-medium: 36:29 split, 327680 context, vision enabled
The 320k pool (163840 per conversation over two slots) costs ~4.3 GiB of KV,
and the BF16 projector (888 MiB) lands on ROCm0. Fit at the deployed point,
computed, not estimated: ROCm0 29.19 / ROCm1 26.04 GiB of 31.86 -- 2.67 / 5.82
GiB free, both above the 1.5 GiB floor.

36:29 is one layer over the 35:30 balance on purpose: at this context the
balance point is 34:31 (0.11 GiB gap), and the operator moved a layer to ROCm0
to buy headroom on the drafter card. The gap is 3.15 GiB by construction, so
the seam's slack is that gap rounded up (3225 MiB) with the trade written down,
and the ctx cap moves 262144 -> 327680 with the rope caveat (the GGUF carries
no scaling; positions past the trained 262144 extrapolate on the base rope).

Vision: NO_MMPROJ=0, the serve script now refuses to start without the
projector when vision is on (a missing file is a deploy error, not a silent
text-only server), the download script fetches and byte-verifies it, and the
deploy handler's preflight checks for it. The projector is already on the host,
byte- and sha256-verified against the Hub.
2026-09-17 01:37:55 -07:00
Frank 24cdbb0e45 docs: the VRAM figures settle 0.2 GiB below the post-load reading
6.44 / 6.90 is the state straight after load; after the service has served
traffic it sits at 6.23 / 6.64, with and without requests in flight. Both are
~5 GiB above the floor, but the runbook should not imply a load-dependent
difference that the two readings do not actually show.
2026-09-17 00:29:21 -07:00
Frank 6472d706be docs: the ubatch optimum, the rebalance, and the live atomics chain
New page [[frank-medium-batch-ubatch]]: the sweep's method (one invocation per
cell, a VRAM sampler per cell, 64k prefill measured AT 64k depth), the full
batch x ubatch matrix, the depth-0 control, the deployed-shape VRAM table, and
the limitations — including the one that cost me two runs, that amdgpu does not
return ~26 GiB at once, so a reload after a kill fails with "unable to allocate
ROCm0 buffer" and reads exactly like a configuration that does not fit.

AGENTS.md: the batch lesson now carries the measured curve (the peak at 512, the
10 % loss at 2048, the irrelevant logical batch, the invariant decode) instead
of the "~3.3 vs ~1.7 GiB" figure, which was measured at the bench's shape; at
the deployed shape the asymmetry is 2.41 against 1.30. Fleet row and the
frank-medium section now say 35:30 and batch 6144x512.

Runbook: deployed flags, the split history with the ubatch-512 table, the fault
note, and the verified VRAM (6.44 / 6.90 idle, 6.23 / 6.64 under two concurrent
requests).

Dual-R9700-Cooperation: the live capability table (card 0 ReqEn+ against card 1
ReqEn-, x1 effective link on card 1) and an honest label on the M.2_2 riser
premise — it is ASUS's spec sheet, not a measurement, and CPU-attached does not
imply Routing+ on this board (00:08.3 is CPU-side and Routing-).

log.md: the round trip recorded, newest first.
2026-09-17 00:27:59 -07:00
Frank a11fae07d8 frank-medium: ubatch 512, and rebalance the split to 35:30
The batch/ubatch sweep (scripts/bench-batch-ubatch.sh, 23 cells of a 28-cell
grid, 3 reps each) says the ubatch knob has an OPTIMUM, not a plateau:

  ubatch   256    384    512    768   1024   1280   1536   1792   2048
  tok/s   1046   1068   1193   1135   1152   1151   1165   1148   1078

The curve is remarkably reproducible -- 512 came out 1192.88 / 1193.23 /
1193.28 / 1193.88 across batch 2048, 4096, 6144 and 8192, and 2048 came out
1077.38 / 1077.54 / 1077.59 -- so this is a deterministic property of the
shape, not noise. The logical batch is throughput-neutral (2048..8192 all
within 2 % at a fixed ubatch) and VRAM-neutral (the compute buffers are sized
by ubatch alone). Decode is 15.88 tok/s at depth 65536 on every single cell.

So the deployed 6144x1536 was both 2.4 % slower than the optimum and 3.71 GiB
more expensive. Measured at the deployed shape (262144, 2 slots, DFlash2):

  36,29 ubatch 1536 -> 4.71 / 4.91 GiB free   (what was deployed)
  36,29 ubatch 512  -> 6.01 / 7.32 GiB free   (ubatch alone)
  35,30 ubatch 512  -> 6.44 / 6.90 GiB free   <- deployed

The ubatch move freed 1.30 GiB on ROCm0 but 2.41 GiB on ROCm1, because ROCm1
holds the output layers and the drafter's graphs -- so it shifted the balance
toward ROCm1 by 1.31 GiB and the split had to follow it one layer. 0.46 GiB is
one DeltaNet layer's worth of asymmetry, i.e. the granularity floor: no split
can be tuned finer than a layer, which is why 34,31 is not better.

Re-fitted the seam with both ubatch coefficients measured at this shape and
re-anchored it on the new deployed point (it reproduces 6.44 / 6.90 to
0.01 GiB). Every neighbouring split still fails it: 36,29 -> 1.85, 34,31 ->
0.94, 37,28 -> 3.24, 1,1 -> 3.03, 39,26 -> 6.03 GiB.

Also replaced the unit test's literal `TENSOR_SPLIT=36,29` assertion with the
shape it actually cares about -- two shares that add up to the model's 65
blocks, with ROCm1 taking the fewer -- so re-measuring the balance no longer
breaks a test about parallelism. The numeric contract lives in the seam test.
2026-09-17 00:21:48 -07:00
Frank 2f012fba39 the x4 link, measured: 6.72 GB/s — which decides the streaming question
pcie-link-test.cu (CUDA, pinned buffers, cudaEvent timing, both services stopped):
pinned H2D 6.72 GB/s and D2H 6.60 GB/s, i.e. 85 % of the Gen4 x4 ceiling. Async
is no better than sync and pageable sits within 1 % of pinned, so these copies are
link-limited rather than staging-limited — the usual "pageable costs 2x" does not
apply on this host. A 3 MiB slab costs 474 us, essentially 3 MiB / 6.72 GB/s plus
~27 us of launch.

That settles the expert-streaming question quantitatively: 1.70 GiB of active
experts per token (A3B @ Q4_K_M, 75 GiB expert mass) is 272 ms of link time ⇒
3.7 tok/s, 4.6 with a fifth of the experts resident. The same bytes computed in
host RAM at 256 GB/s cost 7.1 ms — the link is 31x slower than the memory behind
it, which is why llama.cpp's -ot exps=CPU path measured 20.46 tok/s while an
engine that streams loses by 3-5x. The port under the card is x8-capable and the
card is x16-capable, so the x4 is the adapter; doubling it still leaves streaming
well behind.

Also records FreeToken's own constraint: it keeps experts in host RAM with an LRU
GPU cache (exactly this streaming shape) and loads only modelopt block-FP8
safetensors for qwen3_5_moe, so Q4_K_M GGUF is unloadable there regardless.
2026-09-16 23:25:06 -07:00
Frank 262ac53c14 docs: how to bench this endpoint, and the llama-bench properties that bite
The harness is in the repo now, so the runbook says how to run it and what the
tool will not tell you:

- / splits devices within one configuration and , splits sweep values, so a
  legitimate --tensor-split 36,29 would have benched one card at a time.
- there is no -c: n_ctx = n_prompt + n_gen + n_depth, so no bench can decide
  whether a configuration's buffers fit beside the deployed 262144 KV.
- a test that cannot allocate exit(1)s the process, hence one invocation per
  cell.
- a shell's memlock limit (8 MiB here) is not the unit's (infinity), which
  fails the load for the wrong reason.
- no --spec-* and no --parallel: decode figures are drafterless and slots are
  never exercised; -d is what puts KV in front of the measurement at all.
2026-09-16 22:11:14 -07:00
Frank a40a1aad92 model cache cleared, re-fetched, and the update turned out to be real
Operator instruction: purge the cache so no run can serve a stale payload. The two
live payloads were cleared and then re-fetched rather than left absent — deleting
them without a re-fetch means neither endpoint starts.

halogen's re-fetch proved the point: its sidecars had been stale *in place*
(overlay.hgn 2,477,677,120 -> 2,572,466,560 bytes, overlay-speed.hgn
2,383,306,048 -> 2,478,095,488) while the 115.55 GiB checkpoint returned
byte-identical. frank-small's 27B and mmproj re-fetched to Hub-matching hashes,
unchanged.

Trap recorded: podman refuses a bind mount whose source directory is missing
(statfs ... no such file or directory), so frank-large needs /models to exist
*empty* for its first-start fetch to run.

Q4_K_S against the recorded Q4_K_M numbers: +2.1/+1.6/+2.6 % (iGPU-only pp512 /
pp16384 / tg64), +2.7 % tg32@64k, +4.1 %/+2.0 % for the two layer-split configs,
~0 on both pp512@64k, host RAM bottoming at 10.0 GiB against ~5 GiB. A memory
lever with a small speed dividend. IQ4_XS (97,473,155,360 bytes, sha256
b4787265...d0574f9d) is pulled and verified for the next comparison.

Also repairs an AGENTS.md edit that landed in the wrong section: the peer
session's commits shifted that file between my read and my write, so the
frank-large text briefly split the Model Config section.
2026-09-16 22:07:04 -07:00
Frank 77a07ffc30 scripts: fix the unbound local that killed the sweep at its first cell
`local b=$1 ub=$2 dir="$OUT/b${b}_ub${ub}"` expands every argument of the builtin
before the builtin assigns any of them, so $b was unbound and set -u aborted the run
four seconds in - after the service was already stopped. Two statements instead.

Found by running it under systemd-run: it exited non-zero with no cells run, which is
exactly what the per-cell TSV is for.
2026-09-16 21:43:09 -07:00
Frank 87845fca22 scripts: a batch x ubatch sweep harness for frank-medium's model
Question it answers: within the headroom the deployment has, how large may the logical
batch and the micro-batch be before the activation buffers stop fitting — and what does
each size buy in prefill?

- One llama-bench invocation per cell. llama-bench exit(1)s the moment a test cannot run,
  so a single infeasible ubatch would otherwise take every later cell with it, and
  infeasibility is one of the things being looked for.
- A VRAM sampler per cell, covering the load. Batch/ubatch sizes the activation buffers,
  so a cell that looks fast because its allocation is smaller than intended has to be
  distinguishable from one that is genuinely fast.
- The service is stopped for the sweep and started again from a trap, because a bench
  beside a live service starves the same GPU.
- Run it from a transient unit: the deploy's unit sets LimitMEMLOCK=infinity for the ROCm
  staging buffers and a shell on these hosts only gets 8 MiB.
- llama-bench separates -ts/-dev with SLASHES and has no -c, --parallel or --spec-*: the
  context is n_prompt + n_gen + n_depth, and the drafter cannot take part.
2026-09-16 21:41:51 -07:00
Frank a04a2c27a9 AGENTS.md: a token on a curl command line is world-readable
Checking whether a download was still running with pgrep -a curl printed the
whole argv, Authorization header included, into the session transcript. The
token is not in the repo and nothing was committed, but the practice is wrong:
secrets belong in a -K config file or the environment, not in argv, and no
pgrep -a should ever target a command carrying an auth header.
2026-09-16 21:10:45 -07:00
Frank f917a2ef55 docs: the runbook records the DSpark excursion as an excursion
The note described DSpark as the deployed drafter and 40:25 as the split; both
were true for a few hours. It now leads with DFlash2 at 36:29 and keeps the
excursion as the record it is:

- the drafter comparison table with DSpark's REAL size (1,455,376,576, not the
  1,359,284,737 that went into the fetch script), and why it lost: no sliding
  window, so its draft KV spanned the whole context.
- the split's two moves as one story: the batch pushed it to 40:25, the drafter
  swap pulled it back to 36:29, with both measurement tables.
- the layer-size law (0.43 GiB DeltaNet, 1.43 GiB attention) as the reason the
  balance is measured and not interpolated.
- the verified-behaviour section now carries the LIVE numbers: 4.71 / 4.91 GiB
  free (4.43 / 4.61 under load, 0.18 GiB gap), the DFlash2 init lines, and
  draft acceptance from both slots (0.63 / 0.48 on slot 1, 0.39 on slot 0).
- the fault note scopes its own evidence: 39:26 was loaded, not exercised.
2026-09-16 21:01:45 -07:00
Frank 91546dbb95 frank-medium: back to the DFlash2 drafter, and rebalance to 36:29
DSpark came off after a day. It was the better-looking file (1.27 GiB against
1.92) but it declares no sliding window, and the draft context is built with
n_ctx = llama_n_ctx(ctx_tgt) -- so its KV spanned the whole 262144-token window
(5.0 GiB) and cost ROCm1 four layers of split to carry. DFlash2's window bounds
its draft KV at 2048 positions (~40 MiB), which frees ~4.3 GiB on the
draft-carrying card.

The balance point moved with it, so it was re-measured at the deployed batch
(6144 x 1536, 2 slots, 262144) rather than interpolated:

  39,26  2.42 / 7.20     37,28  4.28 / 5.34     35,30  5.14 / 4.48
  38,27  3.85 / 5.77     36,29  4.71 / 4.91     34,31  6.57 / 3.05

36:29 is the minimum (0.20 GiB apart) and leaves 3.2 GiB above the operator's
1.5 GiB floor on each card, so the full window stays. One -ts step is one layer,
and qwen35's layers are not the same size -- 0.43 GiB on a DeltaNet boundary,
1.43 GiB on a full-attention one -- which is why the table exists.

Also in here:
- the fetch script pointed at the wrong file (DSpark) AND declared the wrong
  size for it: 1359284737 against the Hub's 1455376576, 96 MB short, which made
  --verify-only fail on a payload that was in fact correct. Repointed at z-lab
  DFlash2 Q8_0, 2056414816, and both files' sha256 were checked against the
  Hub's LFS oids (host == Hub for both).
- the seam's fit was recalibrated for a window-bounded draft KV and for the new
  point; it now passes 36:29 and fails every other split measured, including
  1:1. Its ubatch term is an extrapolation and it cannot model contiguity, which
  the comment says out loud.
- the serve script's dead dspark-only tunables (n-min, p-min, p-split, poll)
  are gone; the build's defaults apply.
2026-09-16 20:59:20 -07:00
Frank 718a283afc docs: the frank-medium runbook catches up with DSpark, 2 slots and the rebalance
The note still described DFlash2, one slot at 262144 and a 1:1 split -- all of
which the day's work replaced. It now carries:

- the DSpark swap with the table that matters (1.27 vs 1.92 GiB, block_size 7 vs
  8 so n_max 6 vs 7, a Markov head instead of the candidate selector, and NO
  sliding window) and the rule behind the sizing: the draft context is built
  with n_ctx = llama_n_ctx(ctx_tgt), so a drafter without a window caches the
  whole target context -- 5.0 GiB here, against DFlash2's ~40 MiB.
- the failure that rule produces when ignored: load succeeds, /health answers,
  and the first draft decode dies with an OOM at 1:1.
- why the split moved a second time: tripling the batch costs ROCm1 about twice
  what it costs ROCm0, with the 38,27 -> 41,24 -> 40,25 measurement table.
- the deployed flags, the 2-slot shape (n_slots = 2, n_ctx_slot = 131072), the
  DSpark init lines, and balanced 29.98 / 29.98 GiB cards.
- honest scoping on the older measurements: the throughput, drafter-worth and
  ngram tables were taken on DFlash2, and the agent-loop numbers on one slot.
- the 39:26 page-fault signature as a thing to watch, marked as NOT an OOM.
2026-09-16 20:21:31 -07:00
Frank b312042706 frank-medium: 3x batch over 2 slots, and rebalance the split to pay for it
The operator's revised spec: batch x3 (6144/1536), parallel 2, a 1.5 GiB per-card
floor, and the context maximised inside it.

Measured at 262144 with the old 38:27 split, 3x batch was brutally asymmetric:
ROCm1 (output layers + the drafter's graphs) paid ~3.3 GiB where ROCm0 paid
~1.7, leaving the draft-carrying card at 0.08 GiB free against ROCm0's 3.78.
One -ts step is worth ~1.5 GiB at this batch, so the balance is a measurement:
41:24 overshot (1.47 / 2.38), 40:25 landed at 1.92 / 1.96 GiB free.

That balance is also what maximises the context -- an unbalanced split would have
to buy its headroom out of CTX_SIZE, and CTX_SIZE is already at the model's
trained window (262144), now split over two slots of 131072. Verified under two
concurrent 1832-token prompts: 200/200, 1.87 / 1.86 GiB free, n_slots = 2,
n_ctx_slot = 131072, tool calls intact, and DSpark drafting on BOTH slots
(acceptance 0.38/0.22 and 0.52/0.43).

8192/2048 does not fit at this context at all: its 2020 MiB pp buffer fails to
allocate on ROCm1, and 4096/1024 fits but leaves 1.11 GiB -- both recorded in
the env's sizing table.

The unit seam now models both cards (share + draft + ubatch-scaled buffers,
calibrated to the rocm-smi figures to within 0.002%), asserts the 1.5 GiB floor
on each, and asserts the two cards stay within 0.5 GiB of each other. Both
assertions were confirmed to FAIL on a deliberately bad config (ubatch 8192, and
a 1:1 split) rather than pass by construction.
2026-09-16 20:17:27 -07:00
Frank 88f0d79021 docs: how the Uncensored payload is really fetched (it is shipped in parts)
Correcting a claim I made from a bad query: Q4_K_S does exist for this finetune.
`mradermacher/Qwen3.8-Flash-Next-Uncensored-i1-GGUF` ships the large quants as
`.partNofM` fragments — Q4_K_M 37.00+37.00+36.97 = 110.97 GiB (which is how the
deployed file was assembled) and Q4_K_S 35.00+35.00+34.09 = 104.09 GiB — while
the smaller rungs are single files. A tree listing filtered on `.gguf` makes
Q4_K_S invisible, which is exactly how I concluded it did not exist.

Also records the fetch trap that hid it for a moment: `curl -o FILE URL` exits 0
on 401/403, so a gated repo produced three 153-byte "shards" with identical
sha256 values and a cheerful DOWNLOAD_DONE in 20 s for 104 GiB. -f plus the
Hub's declared size plus the LFS sha256 is the standard the fleet's own
download-frank-medium-gguf.sh already meets.
2026-09-16 20:13:44 -07:00
Frank ad18d2ddd8 frank-medium: step 3 -- the skew is the balance, and equality is off-limits
The 38:27 skew folded into the DSpark swap already does what step 3 asks: the
draft-carrying card sits 2.08 GiB above the other (5.49 / 3.41 GiB free) instead
of the ~9 GiB a 1:1 split would leave.

Chasing an exact balance is a mistake worth recording: 39:26 measured tighter
(3.86 / 4.69 GiB free) but that server died on its eighth request with SDMA0
UTCL2 page faults in the amdgpu driver -- "ROCm error: an illegal memory access
was encountered" -> ggml_abort, in the cross-card boundary copy that is the
layer split's hot path. 38:27 has since served the whole e2e gate and 12 varied
requests with no fault, so the residual 2.08 GiB gap is the safe side of this
trade. Comment-only: no restart needed.
2026-09-16 20:00:17 -07:00
Frank 169eba0734 frank-medium: checkpoints back to the build's default (0)
Step 2 of the operator's six. --ctx-checkpoints 8 held per-slot KV snapshots;
0 is what llama.cpp ships, and the fleet's clients open conversations fresh.
2026-09-16 19:51:06 -07:00
Frank b6d4914fcc frank-medium: pay for DSpark's draft KV with a 38:27 split
DSpark declares no sliding window, so its draft context -- built with
n_ctx = llama_n_ctx(ctx_tgt) -- caches the whole target window: 5 layers x 2 x 8
kv heads x 128 dim x 2 B = 20 KiB/position, i.e. 5.0 GiB at 262144, where
DFlash2's sliding_window 2048 cost ~40 MiB. At a 1:1 split that put ROCm1 at
33.9 GB of 34.2 GB and the first draft graph capture died inside
common_speculative_impl_draft_dflash::process with "ROCm error: out of memory"
-- after the model had loaded and /health had answered, which is why the deploy
looked like a crash-loop rather than a sizing failure.

Handing three target layers to ROCm0 restores headroom on both cards at the full
window. Measured at 262144: 28.52 GB / 30.71 GB, i.e. 5.30 GiB and 3.26 GiB free
(was 2.7 GB free on the draft-carrying card with 1:1 + DFlash2). The unit seam
now models the busier card as a skewed share plus the drafter's full-context KV,
calibrated to the rocm-smi figure.

AGENTS.md gains the durable lesson: read sliding_window out of the drafter's
GGUF header before sizing a spec-decode deployment.
2026-09-16 19:48:29 -07:00
Frank 46b56ab5e8 x4 note: the pause process died mid-run (18:58:30), not after the runs
The log mtimes plus each run's own duration pin it: the first 'invalid internal
status' in the user journal is 18:58:30, while the F bench (launched 18:54:31)
was still loading, and the preceding llama-cli placement probe had just spilled
3.2 GB of '> ' prompts. The note had said 'after the experiment'; that was wrong
and is now recorded with the timestamp it can be checked against.
2026-09-16 19:42:23 -07:00
Frank 4d6511d8b3 docs: the pause-process trigger is unproven — say so, everywhere
I had written 'memory pressure from the 116 GiB iGPU runs' as the cause of the
pause-process death. journalctl -k records no OOM kill for that window and the
kernel says nothing else about it, so the claim was a guess wearing a finding's
clothes — in AGENTS.md, the x4 note, and the log. All three now state the
trigger is not established, and the note's 'co-tenants' patience' bullet no
longer asserts that causality either. AGENTS.md also gains the real lesson:
journalctl -k is the check, and an unknown cause does not need a story.
2026-09-16 19:34:40 -07:00
Frank 7e11d247a8 frank-medium: swap the drafter to magnitudedev/Qwen3.8-27B-DSpark
DSpark (Q8_0, 1.27 GiB) replaces z-lab DFlash2 (1.92 GiB): same 5-layer
block-diffusion shape, Markov head instead of the candidate selector, and
block_size 7, so the draft depth drops 7 -> 6 (llama.cpp asserts
n_max <= block_size - 1).

The serve script's dspark branch never passed --spec-draft-model (only the
dflash branch did), so dspark was not deployable as written; the model file
gate now covers both types. Download script, env and the unit seam track the
new file and its exact byte count.
2026-09-16 19:34:00 -07:00
Frank 96062b627e x4 note: post-reboot headroom (12.3 GiB, 240 blocks vs 475) is part of the record
The ledger number in the note was pre-reboot. Same model, same pool, no clamp —
but the host was measurably more fragmented when it came back, which is the sort
of thing that decides whether the next co-tenant fits.
2026-09-16 19:33:36 -07:00
Frank 803398051a x4 pair round two: pure Vulkan changes nothing, and the first verdict was wrong
Asked whether Vulkan on both devices unlocks the split. It does not: the
per-tensor design aborts byte-identically, because the gate is not the driver
mix but `--override-tensor` — `has_tensor_overrides()` is a required term of
the scheduler's `pipeline_parallel` flag (llama-context.cpp:433, the only place
in the tree that reads it), and that flag sets `n_copies` in
`ggml_backend_sched_new`. With `n_copies == 1` a pre-allocated weight on a
non-primary device has no backend and the graph aborts.

Chasing it exposed a harness artifact that invalidated the earlier conclusion:
llama-bench splits a device LIST with `/` and SWEEP values with `,`, so
`-dev CUDA0,Vulkan1` was two single-device runs all along (and `-ts 0.85,0.15`
two sweep values, i.e. device 0 takes everything — which is where the bogus OOM
came from). With the correct syntax two-device splits work in both flavours:

  pure Vulkan  -dev Vulkan1/Vulkan0 -ts 0.85/0.15 -> 27.55 tok/s @64k (12.8 GiB on the 3090)
  cross-vendor -dev CUDA0/Vulkan1   -ts 0.15/0.85 -> 27.23 tok/s @64k, pp512 268.5 (15.0 GiB)
  iGPU alone                                       25.77 tok/s @64k, pp512 231.8

+7 % decode, +16 % prefill — real but not a regime change, and it costs the
3090 ~15 GiB, which this fleet spends on its only vision endpoint. The design's
premise still fails: whole layers carry their experts (1.62 GiB each), the 12
attention layers are spread every 4th, and the 32.8 GiB PLE must stay on the
main device, so KV cannot be consolidated on the 3090.

Also records the evening's incident (a dead rootless podman pause process took
all container management down; `podman system migrate` panics in 5.7.0; reboot
repaired it) and the netavark host-address lesson from the hermes 502.
2026-09-16 19:32:53 -07:00
Frank d14e0a94eb hermes vhost: dial netavark's host address, not the LAN IP (fixes the 502)
hermes-webui is Network=host, so 8787 is a bare host-netns bind, and netavark
DNATs only *published* ports. From Caddy's bridge netns 192.168.0.143:8787 and
10.89.1.1:8787 are both refused, while host.containers.internal:8787
(169.254.1.2) answers 200 — measured live 2026-09-16 from inside the Caddy
container. The dashboard's published 9119 answers on that same LAN IP, which is
what made the old upstream look sound.

The vhost comment asserted "192.168.0.143:8787 is open" from that bridge; it is
not, so the claim is replaced with the measurement, and the hermes-webui quadlet
comment carries the same correction. The seam guarding this invariant accepted
any numeric upstream, so the LAN IP passed it; it now pins the measured-working
form and fails on the LAN IP (teeth verified by substitution).
2026-09-16 19:28:34 -07:00
Frank 65fbef63dc docs: what the x4 pair can and cannot do — measured, not argued
The design we sketched (trunk + KV on the 3090, MoE experts on the iGPU) is not
expressible in mainline llama.cpp. Per-tensor cross-backend placement aborts in
the scheduler -- "pre-allocated tensor (blk.0.ffn_down_exps.weight) in a buffer
(Vulkan1) that cannot run the operation (NONE)" -- and both per-layer
cross-backend forms fail at load. CUDA + Vulkan cannot serve one model; the CPU
is the only fallback side that works, which is exactly why -ot exps=CPU runs.

What the same build does instead is the surprise: iGPU-only (Vulkan/RADV, PLE in
page cache) gives 32.64 tok/s shallow, 25.77 tok/s at 64k depth and 456.7 tok/s
prefill, with the 3090 idle at 0 %. That matches halogen's deployed ~25 tok/s --
but commits 116 of the host's 121 GiB against halogen's 95.4 held with 14.7
spare, so it is not deployable beside the co-tenants. Throughput parity, memory
bankruptcy: the fleet's two-endpoint split stands, now on evidence rather than
preference.

New page LLM/x4-pair-experiment.md carries the tensor inventory, the results
table, the two scheduler limits, reproduction commands, and the traps found on
the way. Both index pages also stop calling halogen retired -- the ik swap's
stale claims were still sitting in them.
2026-09-16 17:40:37 -07:00
Frank e2dc2b67ce AGENTS.md: what can be chained, and what cannot
The fleet now runs draft-dflash,ngram-mod, so the rule needs to be written down
where the next agent will look: draftless types (ngram family) are added to the
spec configuration unconditionally, while draft-mtp/dflash/dspark/eagle3 all
require a draft CONTEXT -- therefore one context-backed drafter plus any number
of ngram types is legal, and two context-backed ones never are ('draft-mtp,draft-dflash'
dies at load with 'model doesn't contain MTP layers'). Plus the operational
trap: ngram-mod prints no startup banner, so a working chain looks unengaged in
the journal.
2026-09-16 16:52:05 -07:00
Frank 9902267f85 deploy: stop the frank-medium unit before waiting for port 7770
The port gate was written for the cutover, when the vLLM proxy held 7770. Once
frank-medium itself owns the port, a re-deploy waits 90s for a socket that only
its own predecessor is holding and fails: 'FAIL: port 7770 is still held after
90s', with the old llama-server still serving scrapes. Stop the unit first, then
sweep a stray manual llama-server (the shape a benchmark harness leaves behind);
pkill -x, never -f, so the deploying shell cannot match itself.
2026-09-16 16:47:24 -07:00
Frank a4f4429a99 frank-medium: chain DFlash2 with ngram-mod -- measured free where it cannot match
ngram-mod is draftless (added to the spec config vector unconditionally, while
every model-backed drafter needs ctx_dft), so unlike MTP it can share the
process with DFlash2. Chained at the fleet's 48/64 window, over three shapes:

  prose 1k/512      dflash 43.77 | ngram 17.72 (0 drafts) | chain 43.65
  echo 1.5k/400     dflash 76.82 | ngram 102.50          | chain 200.58
  structured/400    dflash 91.08 | ngram 17.80 (0 drafts) | chain 91.22
  identity          dflash 70.37 | ngram 17.77           | chain 70.40

On prose the chain is byte-identical to the drafter alone (same 119 drafts/49
accepts) -- that is what makes it a safe default -- and on prompt-echo traffic
it is 2.6x. VRAM is unchanged: the matcher is a 64 MiB host-RAM hash table.

The window sweep says keep the long one: 48/64 beats 16/32 (161.0) and 8/16
(113.8) on echo, and shortening it costs prose (43.9 -> 36.0) because the
matcher then proposes on ordinary fragments and the rejections still have to be
verified. That is the 'ngram hurts' result this fleet hit twice before.

Two documentation traps recorded with it: ngram-mod prints NO startup banner in
this build (its drafts are only visible behaviourally), and the harness's
'structured' shape is a negative control because its lines differ.
2026-09-16 16:44:45 -07:00
Frank 95936bbc81 bench-spec-ab: rewrite (write_env was lost), restore the unit in a finally
Two real defects from patching a rewritten file by line number: the CUT that
removed a duplicate parse_configs also took write_env with it (NameError after
the first configuration, with the unit already stopped), and the restore step
lived after the loop, so a crash left frank-medium DOWN. The endpoint is the
reason the harness exists; it now restores itself in a finally and shouts if the
service does not come back healthy.
2026-09-16 16:38:12 -07:00
Frank 605e32dfc3 bench-spec-ab: pep8 spacing 2026-09-16 16:31:55 -07:00
Frank 9c59513cf5 bench-spec-ab: sweep an impl's own window, not just the spec type
ngram-mod's match window (n_min/n_max) decides which traffic it can see at all
-- the fleet's 48/64 preset is tuned for long verbatim echoes and finds nothing
in a repetitive-but-short output. Config entries now take |KEY=VALUE overrides,
so the window can be swept in the same run as the configurations it applies to.
2026-09-16 16:31:48 -07:00
Frank e767810c9a bench-spec-ab: think-free echo/structured shapes, and prove the echo is an echo
The first run asked for a verbatim repeat with thinking ON, so the model copied
the passage inside reasoning_content, content came back empty for all three
configurations, and the cross-configuration identity check compared '' to '' --
a pass that proves nothing. echo and structured now disable thinking via
chat_template_kwargs, the echo row carries a copy_ratio (share of answer lines
that really are passage lines), and an empty answer in any shape is a loud
warning rather than a silent pass.

'structured' is new and answers the case the fleet actually runs on: repetitive
OUTPUT with no prompt overlap (record lists, tables, config rewrites), which is
where an n-gram matcher should pay and an echo-shaped test cannot tell.
2026-09-16 16:27:57 -07:00
Frank 6e6e7187ca bench-spec-ab: absolute launcher paths
A relative serve-script path plus a cd into its own directory double-counts the
prefix, so every configuration failed to start ('scripts/serve-frank-medium.sh:
No such file or directory'). The safety net did its job -- the unit was restored
-- but the run measured nothing.
2026-09-16 16:23:14 -07:00
Frank 21daf07713 frank-medium: ngram-mod is a legal chain member -- wire it and measure it
Unlike MTP, ngram-mod needs no draft context: common/speculative.cpp adds the
ngram types unconditionally while the model-backed drafters are gated on
ctx_dft != nullptr, so 'draft-dflash,ngram-mod' is structurally allowed (and the
config vector's fixed order -- ngram first, then the drafters -- makes CLI order
irrelevant).

The harness grows the two things this question needs: --configs, and an ECHO
shape. ngram-mod can only ever match text that is present in the prompt, so
measuring it on prose calls it useless and measuring DFlash on echo calls it
wasteful; both shapes now run, and the greedy text of each is compared across
configurations because speculation may not change answers.
2026-09-16 16:19:15 -07:00
Frank 34253a9903 frank-medium: the drafter beats MTP and MTP beats nothing -- measured, not assumed
Four configurations of the same binary, identical prompts at temperature 0, the
unit stopped between runs, on the deployed payload:

  none            17.7 tok/s                     card1 26.07 GB
  draft-mtp  n=3  32.4 tok/s  58 % accepted       card1 29.35 GB
  draft-mtp  n=7  26.7 tok/s  33 % accepted       card1 29.65 GB
  draft-dflash 7  43.5 tok/s  41 % accepted       card1 29.42 GB

DFlash2 is worth 2.46x over autoregressive and 34 % over the best MTP, and all
four produced IDENTICAL greedy text, so the 'lossless verification' claim in the
runbook is now a check rather than an assumption. MTP is not the cheaper option
either: its head loads ~3.3 GB against the drafter's 2.06 GB file, and deeper
drafting loses (n=7 accepts a third of its proposals, n=3 more than half).

The two CANNOT be chained, and the failure is structural rather than tunable:
'spec-type draft-mtp,draft-dflash' dies at load with 'context type MTP requested
but model doesn't contain MTP layers', because a chain has ONE draft context and
MTP builds against whatever model that context holds -- here the DFlash2 file.
The serve script therefore passes --spec-draft-model in the dflash branch ONLY,
and the seam now asserts exactly that (the wiring whose violation is this
error), plus that the deployment stays on the measured winner.

scripts/bench-spec-ab.py makes the comparison reproducible: the benchmarking
policy wants the runtime's own harness for this, and the numbers above were
otherwise unreproducible one-offs.
2026-09-16 16:15:59 -07:00
Frank 9a83dae73a docs: blank line before the throughput heading 2026-09-16 15:52:45 -07:00
Frank ff52543d8a docs: frank-medium splits weights and KV across both cards, it does not replicate them
The loader prints no per-device ledger at this verbosity, so the claim is
measured rather than read: +131072 tokens of context added 4.83 GB to one card
and 4.84 GB to the other (both carry their own attention layers' KV), and
neither card's total usage could hold the 31.46 GB file. Worth writing down
because the retired shape WAS two full copies — that is what a replica is, and
it is why the 29.8 GB FP8 checkpoint could never run there.
2026-09-16 15:52:18 -07:00
Frank c97e359bc2 docs: the LLM index and the cooperation page catch up with layer split
The LLM index still opened with vllm-radiance as frank-medium's runtime and
drew the replica pair in its topology diagram; the cooperation page's thesis
(collectives are impossible, so the answer is hardware) now has to name the
path the fleet actually took: llama.cpp's layer split, which launches no RCCL
kernel on either card. That reframes the M.2 riser as a means to TENSOR
parallelism rather than a means to using both cards at all.
2026-09-16 15:39:47 -07:00
Frank 87492c7b10 frank-medium: the agent-loop shape on one slot, and a harness header that matches the runtime
Two families of ~30k-token turns ran against the deployed endpoint: 24.8 s and
51.4 s for the first turn (the second waits for the slot), 30.7 s and 8.0 s for
the second. Against the prefill table that is ~25 s of cold prefill plus the
wait, so the honest reading is that ONE slot is now the limiting resource for
concurrent agent loops — the trade this deployment makes for giving a single
conversation the whole 262144-token window. --parallel splits the window; it
does not add throughput.

The harness header still described the retired two-replica proxy ('the deployed
routing pins such a conversation to one replica', vLLM's prefix-cache key). It
now says what is true: one endpoint, one slot, concurrent families queue, and
the old routing numbers are history in the runbook.
2026-09-16 15:38:20 -07:00
Frank dc76dc5dc4 frank-medium: serve the whole 262144-token window, and repoint every signal to llama.cpp
Context: 262144 was measured before it was deployed -- 24.72 GB on GPU 0 and
29.14 GB on GPU 1 of 34.21 GB each, ~5 GB of headroom on the busier card, and a
140,069-token prompt prefilled through the single slot in 134 s to prove the
window is real. The 114688 wall was the vLLM predecessor's arithmetic, not this
runtime's.

Throughput, measured through the service: 1.0-1.4k tok/s prefill from 4k to
140k, 43.8 tok/s decode on a 512-token generation, 41-71 % DFlash2 acceptance,
and the prefix cache re-prefilling 4 of 998 tokens on a repeated prompt.

The signals move with the runtime, so the fleet's rules and dashboard are
re-cut rather than renamed: vllm: series do not exist any more, and two of the
old rules watched things llama.cpp does not export at all (KV-pool fill, p95
latency). Those are retired with the reason written down, and LlmSpecDecodeOff
takes one of the slots -- a lost drafter is the failure no client can see. The
observability seam now fails if any rule names a series the endpoint does not
export, which is what would have caught the stale vllm: expressions.

omp models.yml (outside the repo) is updated in the same pass: contextWindow
262144, and the full reasoning-effort map, because Qwen3.8's template answers
500 on 'high' -- verified against the live endpoint, low/medium/xhigh answer
200. Subagents run at high.
2026-09-16 15:29:40 -07:00
Frank ab51d61198 docs: frank-medium is llama.cpp with DFlash2 on both cards
The runbook for the new deployment, plus the sweep of everything that still
described the vllm-radiance replica pair: the vault index and its ACTIVE
marker, the superseded banner on the radiance page, the pointer from the
ROCmFPX page, the homepage card, the Grafana dashboard description and the
observability page's scrape table.

The new page carries the two facts a future agent needs and the flags do not
say: llama.cpp's layer split is what lets BOTH cards serve ONE model without
any collective (vLLM's PP handoff is an RCCL send/recv, which is the primitive
this host cannot dispatch), and the rocBLAS Tensile-path trap that makes a
healthy-looking build fail on the first request.
2026-09-16 15:15:02 -07:00
Frank c9c42e7489 build: bundle the rocBLAS device libraries at the depth the runtime looks for
'cp -r SRC DST/' with a missing DST creates DST as a copy of SRC and drops a
directory level, so rocblas/library/* landed as lib/rocblas/* and hipblaslt's
arch subdirectories landed one level high. Nothing complains at load time: the
engine reads 29 GiB of weights, answers /health, and dies on the first request
with

  rocBLAS error: Cannot read .../lib/rocblas/library/TensileLibrary.dat:
  No such file or directory for GPU arch : gfx1201

Declare the destination and copy contents, both trees.
2026-09-16 15:15:02 -07:00
Frank 05c0d3a00a frank-medium: llama.cpp layer split across both R9700s, DFlash2 drafting
The vllm-radiance replica pair could not use both cards for one model: TP=2
needs a cross-card collective and the second R9700 cannot dispatch one. The
same wall blocks vLLM's pipeline parallelism, whose stage handoff is an RCCL
send/recv of the hidden state (distributed/parallel_state.py) -- a fact worth
writing down, because the obvious reading of the ask ('use PP to dodge the
atomics problem') is exactly backwards.

llama.cpp's layer split needs no collective at all: layers are distributed and
the boundary activation is a per-backend copy, once per forward pass. One
process, both cards, one model. The runtime is mainline llama.cpp at fb27a525
(the ref is pinned by DFlash2 AND qwen35 MTP; the ROCmFPX fork this host had
before has neither), built for gfx1201 against the private ROCm 7.14 toolchain
the earlier build already used, and bundled next to the binary.

Server: ajgazin Swift-Qwen3.8-27B-Uncensored UD-Q8_K_XL with the model's MTP
head in the file, plus the z-lab DFlash2 Q8_0 block-diffusion drafter at depth
7 (its block is 8, so 7 is the ceiling llama.cpp allows). Text only: vision
stays on frank-small.

Retired into the trash rather than deleted: the three vLLM quadlets, the
radiance entrypoint/healthcheck, the routing proxy and its tests, and the two
bench scripts that drove 'vllm bench serve' inside the retired image. The
deploy handler now stops them, waits for 7770 to be released, installs the
ROOT unit and refuses to start anything when the runtime or payload is
missing. The planner maps the scripts/ trio to frank-medium only, and the
scoping seam asserts the retired service dir is gone rather than merely
unmapped.
2026-09-16 14:59:23 -07:00
Frank 49e8dfff66 docs: the vault and AGENTS.md describe two Dankclaw endpoints
halogen-flash-server is ACTIVE again with its 2026-09-16 measured ledger (budget
33.5 GiB against a need of 27.2 — no clamp — and 14.7 GiB left for everything
else), ik_llama.cpp is retired with its measurements kept as the record of why,
and the new frank-small runbook covers the -mtp file, the 64 KiB/token KV
arithmetic, the MTP contract, and the reasoning_effort trap that cost me every
subagent on the endpoint.

AGENTS.md: topology, Active Services, runbook index, and three new llama.cpp
lessons (MTP is a property of the file; --jinja is what `tools` needs; a model's
template can reject the harness's thinking level). Three pre-existing defects
found while editing it: a truncated sentence mid-file, a harness path that does
not exist on either server, and the benchmarking stop/mask recipe still calling
frank-large a root unit.

Truthfulness notes worth keeping: the co-tenant stall NOTE prints again, so the
claim that it "no longer prints" is gone and the three observed block counts are
recorded instead of a guessed threshold; and the Observability page says out
loud why frank-large has no scrape job rather than leaving a silent absence.
2026-09-16 14:52:51 -07:00
Frank 40850bbc8f frank-medium: add the RDNA4 llama.cpp build and the GGUF fetch
The runtime for the next frank-medium shape is mainline llama.cpp at
fb27a525, built for gfx1201 against the ROCm 7.14 nightly the ROCmFPX build
already used on this host, with the runtime bundled next to the binary.

Two things that pin the ref: DFlash2 (ggml-org/llama.cpp#27342) and qwen35
MTP. Neither is in the ROCmFPX fork's tip (c49ebdb, 2026-08-22), and the
DFlash2 drafter cannot run without the first.

The fetch script pulls the target at UD-Q8_K_XL plus the DFlash2 draft at
Q8_0, resumable and byte-verified -- a truncated 31 GB GGUF is otherwise a
load-time crash.
2026-09-16 14:52:11 -07:00
Frank f367a5a856 deploy: gate the port wait after stopping this service's own quadlet
A redeploy of a running frank-large holds 7770 itself, so waiting on the port
before stopping the unit would have made every subsequent deploy fail. Stop the
user unit first (a no-op on a first cutover), then wait, then install and start.
2026-09-16 14:06:51 -07:00
Frank 7403bd3d50 deploy: never start the replacement while the predecessor holds the port
The 2026-09-16 halogen restore failed exactly here. The ik root unit stopped,
systemd logged "Deactivated successfully", pasta tried to bind 7770 about a
second later, reported "Address already in use", and the quadlet exited 126.
Only Restart=on-failure recovered it -- minutes later, which is not a deploy.

The handler now waits for every socket on the port to clear (listener, accepted
and TIME_WAIT) before installing and starting the quadlet, and fails loudly with
the socket table attached if it never does. The retirement also reports when the
unit file was already gone, because this host's `disable` deletes it.
2026-09-16 14:06:03 -07:00
Frank 79cca19919 observability: the LLM surfaces follow the two-endpoint split
frank-large has no /metrics to scrape any more, and scraping it would be red by
construction: the halogen engine publishes /health, /cache and /v1/* only. That
job is removed with the reason written down (the remedy is the blackbox
exporter this fleet already wants for the comfyui/services probes), and
frank-small's llama.cpp endpoint takes the slot at 192.168.0.143:7771.

Homepage: frank-large's card still described the ik runtime, and the new
endpoint had no card at all. Both fixed, with a loopback probe -- the checker
runs on Dankclaw, which is also what makes the card green-able.

Dashboard: the endpoint picker was sourced from a vLLM-only metric, so a
llama.cpp job could never be selected, and the prose named frank-large as the
llama.cpp endpoint. The picker now matches both runtimes' series and the
llama.cpp targets carry the job filter every other panel already used.

The seams assert the new contract including the ABSENCE of a 7770 target, so a
well-meaning edit cannot re-add a permanently-red probe.
2026-09-16 13:59:49 -07:00
Frank 43ec77fee6 inference: frank-large returns to halogen, frank-small takes the 3090
Dankclaw's endpoints are re-split. frank-large is the halogen-flash-server
quadlet again -- the 2026-09-13 unit file was never touched by the two-day
ik_llama.cpp interlude, so the runtime was still in the repo and its image is
still in local storage -- retuned to the shape asked for: HALOGEN_CTX 200192
with a 400,384-position pool and two slots, exactly two 200k conversations.

frank-small is new: mainline llama.cpp on the RTX 3090 serving
ISTA-DASLab/Qwen3.8-27B-GSQ-RCO-GGUF at IQ3_S, using the -mtp build (it carries
blk.64.nextn.*, which is what lets --spec-type draft-mtp run on the main
model's own weights instead of a second 12 GiB copy) plus the BF16 vision
projector, as one 131,072-token slot with f16 KV on port 7771.

The host is shared now, and that is what the halogen retune pays for:
HALOGEN_MAX_TOK 24576 (arena 20.6 -> 16.4 GiB, ~4-5% prefill) and the vision
tower dropped (1.97 GiB) hand ~6 GiB back to the host, and the reserve rises to
20 to keep the fit check honest. Vision moved wholesale to frank-small.

Retired to scripts/.AGENTTRASH/: the ik trio (env, service, serve script), the
Qwen-Sharp template only that runtime read, and four benchmark harnesses bound
to retired runtimes. Because retiring a file makes any test that greps it pass
by absence, the seams assert the archive exists AND the live path does not.
2026-09-16 13:58:00 -07:00
Frank cc29875842 research: how to make the two R9700s work together (and one correction to my own claim)
New page: LLM/Dual-R9700-Cooperation.md — registered in the vault index, with
the measured evidence rather than the theory.

**The answer.** Card #2 sits behind the B650 chipset, whose bridges advertise
`AtomicOpsCap: Routing-` (read-only), so ROCr refuses to fill the hostcall
buffer RCCL's kernels declare and no RCCL op can dispatch on it. Two paths fix
it: an M.2→PCIe x4 riser into the free, CPU-attached M.2_2 slot (~$30; per
ASUS's spec page M.2_1 and M.2_2 are CPU-attached, PCIEX16_2 is chipset in x1
mode, and there is no x8/x8 bifurcation on this board), or a board whose CPU x16
bifurcates to x8/x8. Bandwidth is not the constraint: ≈10 MB per token-step ≈
1.4 ms at Gen4 x4.

**New measured results.** A bare 2-rank torch.distributed run: rank 0 (CPU-lane
card) passes broadcast/all_reduce/barrier; rank 1 (chipset card) fails all
three, and `send`/`recv` fails too — which kills pipeline parallelism as an
escape, since vLLM's PP handoff is exactly that primitive.

**Correction.** I previously said no software path exists. That overshot. One is
open and unverified: recompiling RCCL so its kernels stop declaring a hostcall
buffer (`-mprintf-kind=buffered` is the claim). Counter-evidence recorded: RCCL's
device source contains no live printf/assert/__assertfail, so the trigger is
unexplained, and the claim traces to search summaries, not a primary source. The
page carries the two-command test that would settle it. Also corrected: 0.9.3 is
radiance's version, not vLLM's — the base is vLLM 0.27.1 / torch 2.11.0 / ROCm
7.14, and its shipped RCCL exposes no `ncclDevComm*`, so torch's symmetric-memory
path cannot run.

**Rule added to the benchmarking policy**: it covers ANY GPU experiment, not just
benchmarks — including a bare 2-rank probe. Running one against the live service
on 2026-09-16 preceded the host going hard down (no ARP, no ICMP, power cycle
required); a hung AQL dispatch wedging the driver is the plausible mechanism.
The policy also now says how to tell off from wedged: `ip neigh` from a peer.
2026-09-16 08:59:12 -07:00
Frank f134cc8876 docs+units: fix every claim my own changes made false
Retiring dankclaw/compose.yaml and adding alert rules invalidated claims across
the repo, including in the rule file agents read first. Clean cutover includes
the prose.

**Tests that had gone hollow.** Four assertions grepped dankclaw/compose.yaml
for a service name. With the file gone they passed by *absence* — a gate that
can no longer fail. Each now asserts the real invariant ("no dankclaw compose
file competes with the quadlet units") and keeps a live check for a re-added
entry, so it still fails when one appears: verified by recreating a compose file
with a comfyui service and watching three tests go red, then removing it.

**AGENTS.md, which was wrong about ComfyUI.** It said ComfyUI runs CPU-only
("no ROCm wheels for gfx1151, CUDA images wrong"). That was true in the
compose era and is false now: the restored unit runs with /dev/kfd + /dev/dri and
must NOT pass --cpu. An agent trusting the rule file would have re-broken it.
Also corrected: the observability paragraph ("RUNNING BUT UNCONFIGURED, no
datasource, no rules" — all three have been configured since 2026-09-16), the
Active runbook row that still named halogen for frank-large, halogen's absence
from the Retired table, the benchmark policy naming halogen's harness, and the
seam list.

**Unit and page claims.** prometheus.container said "no alerting rules anywhere
in this stack"; node-exporter.container said Dankserver runs from its compose
stack; Prometheus.md documented three jobs as working (they scrape HTML: they
can never report up) and said no dashboard existed; index.md and the three
authentik service pages pointed at the retired file; Authentik.md's upgrade
instruction told you to bump an image tag in it.

One bad edit of my own on the way (a stale line number clobbered a See Also
entry in Prometheus.md) — caught by reading back, fixed, and the vault link
checker now proves the cross-references hold.
2026-09-16 07:46:23 -07:00
Frank 2e33ec36c1 docs: three dangling links, and a gate so they cannot accumulate
The vault is the project's source of truth, so a link to a page that does not
exist is a promise the repo cannot keep. Three were dangling:

- Dankclaw/Gitea.md pointed twice at dankclaw/compose.yaml — **which this
  session retired**. The clean cutover obligation from my own change: the page
  now describes the unit that publishes 3000 and the canonical Caddy unit, and
  records why the compose file is gone.
- Dankclaw/Hermes-Dashboard.md linked [[LAN DNS]]; the page is [[LAN-DNS]].
- LLM/frank-medium-radiance-mxfp4.md linked [[Observability on Dankclaw]]; the
  page is [[Observability]].

scripts/check-vault-links.py + a seam keep it that way. The checker models
Obsidian's rules, which took three corrections to get right — and each was a
false positive that would have sent me editing healthy docs:

  1. Wikilinks are case-insensitive. A case-sensitive matcher reported 45
     breaks; 39 were `[[qBittorrent]]` vs qbittorrent.md.
  2. `[[folder/Note]]` resolves by path suffix, not by stem.
  3. `%20` in a markdown link is URL-encoding, not a filename.

Validating the linter against the resolver it models is the prerequisite for
trusting any of its output — the same discipline that caught a bad assertion of
my own earlier tonight.
2026-09-16 07:43:33 -07:00
Frank 80a070d71b homepage: three cards could never be green, and one of them was lying
The board showed mariadb, valkey and Postgres as "down". None of them is down:
the checker runs on Dankclaw and can reach that host's loopback, its container
names, and published LAN addresses — while all three cards probe
container-internal database ports (192.168.0.235:3306, 127.0.0.1:6379/:5432).
They were red by construction, which is how a status board stops being read.
Publishing a database port on the LAN to make a badge green is not a trade this
fleet makes, so they are wip with the reason in their description, and the apps
that front them carry the status. Gluetun (WireGuard, UDP) was the pre-existing
example of the same call.

The rule is now written down in the Homepage doc: a card must be probeable from
the monitor's host or be wip. That is the same lesson as the Prometheus jobs
that scrape HTML pages and can never report up — an always-red signal teaches
the operator to ignore the surface it lives on.
2026-09-16 07:36:11 -07:00
Frank 25f387dd76 tests: gate the hermes vhost, and stop specs from silently not running
Two gaps behind the same near-miss.

1. **No spec covered hermes.franksdank.cloud** — the one guarded vhost whose
   upstream is a host address (`reverse_proxy 192.168.0.143:8787`) rather than a
   container name, because its service is host-networked. That is how a
   loopback-only bind could take the vhost down with a 502 and leave every gate
   green. tests/e2e/specs/17-hermes-vhost.spec.ts asserts what a dead upstream
   violates: no 5xx through the guard chain, no Caddy error page, and no app
   bytes to an unauthenticated raw client. It does not assert SSO-silence, since
   this app has its own password login.

2. **Specs are matched by number prefix, not globbed.** A spec file whose prefix
   is in no project silently never runs — which is exactly what happened to the
   new one until it was registered. The prefix is registered now, the comment in
   playwright.config.ts says why, and tests/e2e_scaffold.bats fails if any
   spec file matches no project.

The hermes-webui seam assertion is corrected too: it asserted the loopback bind
I wrongly imposed, and now asserts the real contract — the unit's bind must serve
the address the Caddyfile fronts it on.
2026-09-16 07:33:38 -07:00
Frank 9c8a62f9fe hermes: restore the LAN bind — reverting my own regression
I tightened HERMES_WEBUI_HOST to 127.0.0.1 on the reasoning that the compose
service had published loopback and the unit had widened it. That reasoning
missed the consumer: this unit is host-networked (it shares the CDP daemon's
loopback), so Caddy cannot reach it by container name and fronts the guarded
vhost hermes.franksdank.cloud with `reverse_proxy 192.168.0.143:8787` — recorded
in dankclaw/Caddyfile with the same explanation. With the app on loopback that
upstream is dead, so the vhost broke.

The wildcard bind is what serves BOTH consumers: Caddy on the LAN address and
the phone's SSH tunnel on the host's localhost. Controls are the auth chain in
front of the vhost plus the webui password. The unit comment now states this
instead of the incorrect 'as before' claim, and the near-miss is worth the
paragraph: the e2e gate stayed green because no spec covers that vhost.
2026-09-16 07:24:54 -07:00
Frank aaa1f82313 hermes: the webui was LAN-facing, not loopback — and Dankclaw's compose is gone
Two things, found while checking whether the hermes trio still needed migrating.

1. **The phone-facing webui was exposed to the LAN.** Its unit runs with
   Network=host, so there is no Podman port forwarder and the app's own bind is
   the exposure. The compose service it replaced published
   `127.0.0.1:8787:8787` (loopback); the unit bound `HERMES_WEBUI_HOST=0.0.0.0`,
   and the LAN address answered 200 — password-gated, but on every interface,
   which is not the documented "SSH tunnel is the sole ingress" posture. It now
   binds 127.0.0.1, which serves the tunnel just as well (sshd dials
   localhost:8787 from the host). The unit comment claiming "LAN-facing as
   before" was wrong about "before", and is corrected.

2. **Dankclaw's compose.yaml is retired.** Auditing ownership showed every
   running container on that host reports a PODMAN_SYSTEMD_UNIT label — the
   hermes trio's units have owned their containers since 2026-09-12 — so the
   three service blocks left in compose.yaml were dead weight that could only
   create a duplicate container fighting a unit for a name. deploy_hermes was
   already fully unit-based. The file is staged in .AGENTTRASH/; the planner
   plans nothing for it, an unknown service on that target now errors instead of
   silently no-oping through `podman-compose`, and DANKCLAW_COMPOSE_SERVICES is
   empty.

The hermes-webui seam now reads the unit — it read compose, which is exactly how
the bind drifted while the test kept passing against a file nobody ran.
2026-09-16 07:22:20 -07:00
Frank 06d3502a2b docs: ComfyUI is a unit now, and three scrape jobs can never be green
ComfyUI's page described a compose service and a systemd unit that shelled out
to `podman compose up`. It now documents the Quadlet, the keep-groups + udev
mechanism (verified inside the running container: torch sees Radeon 8060S), the
9,146-restart incident that forced the move, and the trap that caused it — a
stale container outlives the compose edit that should have replaced it.

The observability page gains the audit result: four jobs read "down" and three
of them always will, because they scrape HTML pages or a /metrics that does not
exist. The page names each error, says plainly that none is an outage, and
refuses the tempting fix (deleting the job makes an absent scrape look like a
healthy one) in favour of the remedy it already recommended: a blackbox exporter
in probe mode, and Gitea's metrics enabled or the job repointed.
2026-09-16 07:17:59 -07:00
Frank 03d44d3308 comfyui: replace the stale compose container with a Quadlet unit
ComfyUI had failed 9,146 times and had been down ~7 hours when this was found:
its container was created 2026-09-15 14:12, BEFORE the GPU declarations were
added to the compose service, and `restart: unless-stopped` kept restarting
that stale container — no /dev/kfd, no /dev/dri, no keep-groups — so it died
every time on `RuntimeError: No HIP GPUs are available`. Its "unit" was a
hand-written ~/.config/systemd/user/comfyui.service that shelled out to
`podman compose up -d`, which is not repo-tracked state and collides by name
with the Quadlet the generator produces.

  - dankclaw/comfyui/quadlet/comfyui.container: image comfyui-rocm:gfx1151
    (built from Containerfile.comfyui), /dev/kfd + /dev/dri, keep-groups,
    the four comfyui-data volumes, loopback-only publish (an unguarded service
    is internal-only here; the homepage card probes 127.0.0.1:8188), Restart=
    always, WantedBy=default.target.
  - the compose service is deleted — one owner per container name.
  - deploy_comfyui builds the image, installs the unit, retires the compose-era
    unit into ~/.AGENTTRASH, removes the stale container so the unit's own can
    take the name, and waits on :8188.
  - the healthcheck is a file, because the podman 5.x v1 generator splits
    HealthCmd on spaces and an inline `python3 -c "..."` becomes a quoting
    puzzle that fails silently.

The comfyui seam now reads the unit instead of compose for devices, command and
groups (and resolves the Containerfile from the deployer's declaration, not
from a directory listing).
2026-09-16 07:13:27 -07:00
Frank d8835e69c6 docs: six observability units across two hosts, and what the reboot cost
The page said "all five are rootless Quadlet units on Dankclaw", which stopped
being true when Dankserver's node-exporter was restored to a unit. It now names
both hosts and both deployments, records why the second one happened (the same
compose-only reboot argument, a day and a half of missing host metrics, and a
wildcard bind), and states the exposure rule for a two-listener fleet with the
`ss` evidence that the wildcard is gone.
2026-09-16 07:09:55 -07:00
Frank da9cff046e observability: give Dankserver its node-exporter back as a Quadlet
Dankserver's node-exporter has been dead since a reboot: `podman ps -a` showed
it `Exited (143)` and Prometheus' `node-dankserver` job has been scraping
nothing. It ran only from `dankserver/compose.yaml`, which is the failure mode
this repository's boot-persistence rule exists for (rootless Podman does not
restart bare containers after the stop that shutdown performs), and the compose
form bound the wildcard address — the unauthenticated-metrics-API-on-every-
interface posture already removed on Dankclaw.

  - dankserver/observability/quadlet/node-exporter.container — mirrors the
    Dankclaw unit, listening on exactly 127.0.0.1:9100 (host-local probes) and
    192.168.0.235:9100 (Prometheus on Dankclaw), with Restart=always and
    WantedBy=default.target.
  - the compose service is deleted: one owner per container name.
  - deploy_observability now installs from "$TARGET/observability/quadlet"; it
    hardcoded the Dankclaw path, so a Dankserver unit could not be installed by
    the shared dispatcher at all.
  - planner: "node-exporter" joins the Dankserver service set, with a path rule
    for dankserver/observability/**; the dispatcher's allowlist gains
    dankserver:node-exporter.

Seams cover the silent halves: both listen addresses present and the wildcard
absent, the compose service gone, the allowlist entry present, and the planner
mapping the path to the service.
2026-09-16 07:07:18 -07:00
Frank d104eef4e0 bench: commit the harnesses the frank-medium numbers came from
Every measurement behind the frank-medium runbook was taken with scripts that
lived in /tmp on two hosts — reproducible only until the next reboot, and
invisible to anyone reading the repository. This moves the useful four into
scripts/, where this repo already keeps its bench harnesses:

  bench-vllm-corpus.py    code-shaped corpus from the repo's own sources, because
                          synthetic random prompts misreport draft acceptance
                          badly (2.9-3.6 tok/update against ~5.8 on code)
  bench-vllm-serve.sh     concurrency sweep; encodes the two traps that produced
                          silent garbage — the tokenizer argument, without which
                          the client dies resolving "frank-medium" on HF and
                          reports no numbers at all, and the --ignore-eos
                          semantics that must not be compared across
  bench-turn-latency.py   per-turn TTFT for an agent-loop shape; the instrument
                          that decided the routing policy, and the one that shows
                          cold-vs-warm prefill directly
  bench-llm-soak.py       sustained multi-family load; what found the capacity
                          wall (three 30k-token families, 75% cold re-prefills,
                          zero failures)

Each header carries its traps and the interpretation; the runbook's Benchmarks
section now points at them instead of describing methodology with no tooling.

Also adds the missing half of the script seam: scripts/*.py are compiled, not
just scripts/*.sh syntax-checked — a harness with a syntax error could otherwise
ship unnoticed until someone needed it during an incident.

Both python harnesses were smoke-tested against the live fleet before commit:
the corpus builder wrote 15 prompts, and the turn harness measured 12.5 s cold
against 1.4 s warm on the same prompt — the cache behaviour the runbook claims.
2026-09-16 06:59:44 -07:00
Frank ef7755f2b3 chore: retire the unreferenced ComfyUI Containerfile
dankclaw/comfyui/Containerfile is referenced by nothing — compose builds from
dankclaw/Containerfile.comfyui, deploy-remote.sh copies that one, and the
ComfyUI doc names it — and its content is an older iteration (torch
2.9.1+rocm7.12.0rc1 against the deployed gfx1151 base). It also caused real
damage: the comfyui test's directory-listing selector picked non-comfyui files
and reported a red suite for sessions.

Staged in .AGENTTRASH/ rather than deleted, so it stays recoverable and visible
until Frank decides it can go for good. This is also why the earlier test fix
had to resolve the Containerfile from compose rather than from the filesystem:
the directory is not the contract.
2026-09-16 06:57:25 -07:00
Frank 3d5b1f44a6 tests: fix the comfyui Containerfile test instead of living with it red
This test failed on a clean tree for many sessions, and the working belief
became "test 73 is a known failure, ignore it" — which is exactly what a
permanently red suite teaches. The cause was a selector bug: `find
dankclaw -name 'Containerfile*' | head -1` returned whichever Containerfile the
directory listing happened to put first (dankclaw/alloy/Containerfile), so the
assertion read a file that never claimed to be ComfyUI's. It would equally have
passed on a wrong file.

The test now resolves the Containerfile the way the build does — build.context
joined with build.dockerfile, both read from dankclaw/compose.yaml — so it
checks the file the deploy actually builds (dankclaw/Containerfile.comfyui, the
one compose, deploy-remote.sh and the ComfyUI doc all name) and follows a future
rename instead of guessing.

Suite: ALL TESTS PASSED (310), no exceptions to explain away.
2026-09-16 06:57:22 -07:00
Frank 56edfabb19 homepage: the Dankserver LLM card was labelled with a retired name
The card probing 192.168.0.235:7770 — frank-medium's endpoint since the
radiance cutover — was still called "frank-small", a service retired on
2026-09-15. The probe target was right; only the identity was wrong, which is
the worse half: a green card named after a service that no longer exists teaches
the wrong thing about the fleet. It is now frank-medium, described as what it
actually is (Qwen3.8-27B Uncensored MXFP4, vllm-radiance, two R9700s behind one
endpoint), and frank-large's description stops crediting the retired halogen
runtime for work ik_llama.cpp does.

The seam added to homepage_config.bats pins the cross-file contract that let
this drift: a card's probe target and its name must agree with the fleet, and a
retired service must not keep a card.
2026-09-16 06:53:20 -07:00
Frank 823814920b docs: record the alerting rules and what they still cannot do
Both pages claimed there was no alerting; there are six rules now, so they say
which rules exist, that promtool and /api/v1/rules both confirm them, and — in
the same breath — that no rule reaches a person, because this stack has no
Alertmanager and no contact point. The dashboard's ALERTS panel is named as the
only place an alert becomes visible without opening Prometheus.
2026-09-16 06:51:23 -07:00
Frank af05c890fd observability: LLM alerting rules, evaluated but not yet delivered
The scraped metrics have no rules over them, so every graph was read after the
fact by a human. This adds shared/prometheus/rules/llm-fleet.yml, loaded via
rule_files and installed by the same copier that ships prometheus.yml (a rule
file nothing loads evaluates nothing, and Prometheus reports no error for it):

  LlmKvPoolSaturated      kv pool > 90% for 10m — the 122,636-token wall
  LlmRequestsQueueing     waiting > 0 for 10m — MAXSEQS=4 reached
  LlmPrefixCacheCollapse  reuse < 20% for 15m with traffic — the thrash signature
  LlmEndpointDown         scrape target down 2m — both replicas gone
  LlmLatencyHigh          p95 end-to-end > 60s for 10m — what an operator feels
  job:llm_prefix_cache_reuse:rate5m — recording rule shared by the alert and the
                          dashboard, so the two cannot drift

Every threshold is tied to something measured on this fleet, not to a guess,
and the file says plainly at the top that **nothing is notified**: there is no
Alertmanager here, so a firing alert reaches Prometheus' /alerts page, the
ALERTS metric and the dashboard panel added alongside it — and nowhere else.
Choosing how Frank actually gets paged is his call, not a missing config line.

Seam covers the silent failure mode: rules exist, the config references the
directory, the deployer installs it, and the dashboard graphs ALERTS.
2026-09-16 06:45:03 -07:00
Frank c272407e6e docs: observability state after the Grafana provisioning
The page said Grafana has zero datasources and zero dashboards; that is now
false, so it says what exists (Prometheus datasource, LLM fleet dashboard), how
it was verified (the API — a provisioned dashboard does not show up in the
legacy dashboard table on this Grafana version, which is a trap worth recording)
and what is still missing (nothing alerted on, Loki unread, correlation manual).
The section heading drops "UNCONFIGURED" because it no longer is, entirely.
2026-09-16 06:42:24 -07:00
Frank 6a191430ca observability: provision Grafana from git — datasource plus the LLM dashboard
Grafana has been running with zero datasources and zero dashboards, so the
metrics scraped last commit were only reachable through the Prometheus API.
This wires the plumbing and one dashboard, both declared in
shared/grafana/provisioning/ and installed to /opt/grafana/provisioning by the
deployer (the unit already mounted that directory read-only, so nothing about
the container changes):

- datasources/prometheus.yml — Prometheus at http://prometheus:9090, the
  container name, not the loopback publish, with a fixed uid so dashboards
  reference it without a lookup.
- dashboards/llm-fleet.json — KV pool fill, queue depth, prefix-cache reuse
  rate, e2e latency p95, and decode/prefill throughput, with an endpoint
  variable covering both frank-medium and frank-large. Every series name was
  checked against the live /metrics endpoint: this fleet already shipped one
  comment naming a metric that does not exist, and a dashboard full of those
  would be worse.

Dashboards stay editable in the UI (allowUiUpdates) because the panels are a
starting point, but the file is the source of truth and the deployer
overwrites Grafana's copy on every deploy.

Planner and seams follow the existing pattern: shared/grafana/** selects the
grafana service, and the tests pin the container-name address plus the
provisioning contract so a rename cannot silently empty the dashboard.
2026-09-16 06:39:15 -07:00
Frank 3037240f4f docs: point the frank-medium runbook and observability page at the new scrape
The capacity work in this runbook was done with ad-hoc probes; the same signals
now have history in Prometheus, so the page says which series to watch and
admits the honest gap — this stack has no alerting, so a saturated pool shows up
on a graph after the fact rather than paging anyone.
2026-09-16 06:37:39 -07:00
Frank 0f291115bd observability: name the prefix-cache series that actually exist
vllm:prefix_cache_hit_rate does not exist in this build; the exported pair is
vllm:prefix_cache_hits_total / vllm:prefix_cache_queries_total, whose rates give
the hit rate. A comment naming a series nobody can query is worse than no
comment.
2026-09-16 06:34:53 -07:00
Frank 42b47063ec observability: scrape the LLM fleet into Prometheus
Neither LLM endpoint was scraped, and frank-medium exports 418 vllm: series —
including the ones this fleet argues about under load: kv_cache_usage_perc (how
full the per-card pool is), prefix_cache_hit_rate (whether pinned conversations
are being reused), num_requests_waiting (queue depth) and the request-latency
histograms. Everything diagnosed tonight by hand — 122,636 KV tokens per card,
thrash when three large contexts overlap, cold-vs-warm prefix reuse — is a
graph now, on the LAN addresses rather than the guarded vhosts.

frank-large comes along in the same change: llama.cpp exposes throughput
counters under llamacpp:, and watching one LLM endpoint without the other was
arbitrary.

The seam added to observability_quadlet.bats pins the cross-file contract that
breaks silently: if a scrape target drifts from the :7770 the units publish,
the graphs vanish with no error anywhere.
2026-09-16 06:32:33 -07:00
Frank 4f2f49d2d0 frank-medium: measure admission control and cache health, keep neither change
Two more hypotheses, both measured on the same three-family workload (one
~80k-token family plus two ~50k-token families, each turn re-sending its
document):

1. Long-prefix caching is fine. One card, idle engine, a ~50k-token prompt
   sent three times: 22.3 s cold, then 1.4 s, 1.4 s — with a varying tail too
   (22.4 / 1.41 / 1.41). So the engine's lifetime "Prefix cache hit rate: 29%"
   is an average across every prompt that replica ever saw, not a verdict on
   any one request, and the pinning policy is safe.

2. Serialising large requests does not fix the overload. Admission control
   (at most one >16k-token request per card, small requests untouched) gave no
   clear win on small samples — heavy 25.4 s -> 48.2 s median, mediums
   34.7/45.2 s -> 22.7/22.6 s, cache-hit rates 14-33 % both ways — so it was
   reverted like the placement change before it.

The arithmetic now closes: with three families on two cards, one card holds two
families and must carry 2 x 50k of cached prefix plus the 50k of the sequence
generating — 150k against 122,636 tokens. Those co-tenants evict each other
regardless of concurrency limits. The wall is capacity, and the runbook says so
with the numbers.
2026-09-16 06:31:01 -07:00
Frank 36007666cf frank-medium: measure load-balanced placement, find it does not help, revert
The obvious next lever for cache thrash was placement: send a new conversation
to the card carrying less context instead of alternating. Implemented it
(decayed prompt-token estimate per replica, continuations still pinned) and
A/B'd it against the counter on the workload it targets — one ~80k-token
family plus two ~50k-token families, each turn re-sending the same document:

  counter          heavy 13 turns median 25.4 s (23% cached)
                   medium 34.7 s / 45.2 s (14% / 15% cached)
  balanced by load heavy  5 turns median 46.7 s (20% cached)
                   medium 23.4 s / 34.3 s (0% cached, small sample)

No improvement, so the change is reverted and routing.py keeps the counter.
The reason is arithmetic: a running request's live sequence and its cached
prefix draw on the same pool, so a card running two 50k-token generations has
already spent its 122,636 tokens before any cache entry is considered.
Placement chooses which card is over capacity, not whether one is.

Third hypothesis measured and rejected tonight (with SPEC depth kept and CHUNK
disproved); the runbook records all three so the next operator starts from
evidence rather than from these ideas looking obvious.
2026-09-16 06:20:19 -07:00
Frank 99ed1aca9a docs: reboot-tested the frank-medium trio
Rebooted dankserver unattended and verified without any manual step: all three
units active 59 s after boot, both replicas healthy ~3.5 min after boot, real
completion through :7770 — the failure mode this repo has been bitten by before
(a service that only ever ran from compose and stayed dark after a reboot).
Records why it works: [Install] WantedBy=default.target wired into the
generator's default.target wants, plus Linger=yes, plus a disk-backed compile
cache so a cold boot needs no warm-up beyond the model load.
2026-09-16 06:00:15 -07:00
Frank 24687f561f docs: frank-medium capacity budget and the CHUNK sweet spot
A 25-minute soak with three concurrent large-context agent loops (each turn
re-sending a ~30k-token document, growing history) came back clean — 155
requests, 155 answers, no errors, no failover, both replicas steady — but only
25% of turns hit the prefix cache, the rest paying a 13.9 s median re-prefill.
That is the capacity budget showing: 122,636 KV tokens per card is about two
30k-token conversations with their cached prefixes, and a third family on the
same card evicts.

Chasing the obvious lever disproved it. Available KV per card is 5.44 GiB at
CHUNK=8192, 4.22 GiB at 16384 and 4.16 GiB at 4096 — where the engine refuses
to start. The budget is not monotonic in the chunk size, so 8192 is a sweet
spot rather than a compromise and freeing KV needs another card, not a knob.
2026-09-16 05:49:54 -07:00
Frank d8189b65f4 docs: record the proxy's failover behaviour and how to verify it 2026-09-16 05:29:45 -07:00
Frank 3946ce9a3a frank-medium: fail over to the other replica instead of returning 502
The proxy trusted its health table, which the watcher refreshes every 10 s. A
replica that restarts — and a deploy restarts both — therefore produced a
window in which requests routed to the dead card got a 502 back, even though
the other card was serving the whole time.

A failed connection now demotes that replica immediately and retries the
request on another live one, before anything is written to the client.
vLLM's OpenAI endpoints are stateless, so re-sending a request that never
reached an engine is safe; a failure after streaming has begun is still not
retried. The failover is logged, because a silent one hides a card that is
down.

Measured with card 1 stopped mid-flight: six requests, six 200s, one failover
line in the log, no client-visible error. Before this commit that burst
returned 502s.
2026-09-16 05:29:08 -07:00
Frank 4d37f8ca65 frank-medium: measure speculative depth on the repo's own code corpus
Upstream ships dflash depth 7 as a content-dependent default measured for TP=2
on their corpus. Re-measured on one card here, two runs per setting, 16
repo-derived prompts at 512 output tokens:

  SPEC=7  conc1 46.2 tok/s, TPOT 20.8 ms, 3.86 tok/update
          conc4 118.7 / 127.0 tok/s
  SPEC=5  conc1 41.5 tok/s, TPOT 23.1 ms, 3.41 tok/update
          conc4 136.1 / 129.4 tok/s

Step time is nearly identical at both depths (~79 ms vs ~80 ms single stream):
7 wins by landing more tokens per step, 5 wins at conc 4 by ~8% because more
narrow verify steps pack better. Depth 7 stays — this fleet is interactive
agent loops where per-turn latency is what is felt — and the runbook now
records the numbers and the one-line switch for a batch-first workload.
2026-09-16 05:23:09 -07:00
Frank 8b79766d81 docs: record why CHUNK stays at 8192 on the single-GPU replicas
Raising the prefill chunk to 16384 grows the arena enough that the KV cache
falls to 4.22 GiB against the 5.21 GiB a 114688-token context needs, so the
engine refuses to start. Measured single-card prefill at 8192 is ~2.5k tok/s
(~15k-token prompt, 5992 ms TTFT); the trade would mean cutting context for
unmeasured prefill gain, so the default stands.
2026-09-16 04:58:47 -07:00
Frank 83625e84a7 frank-medium: route by workload — spread batches, pin agent loops
The two replicas behind :7770 were on plain round-robin, which throws away
prefix-cache hits: an agent loop re-sends its system prompt and file context
every turn, and alternating replicas means every other turn pays a cold
prefill. Measured with 90k-token contexts, warm-turn TTFT was 2676 ms.

Pinning everything to one replica is not the answer either — a cold batch
(the shape a benchmark or a burst of parallel subagents produces) then loses a
card's worth of prefill: 16 requests took 30.1 s pinned against 18.6 s spread.

So the policy routes by request shape. A continuation (more than one message —
the agent-loop signature) is pinned to the replica that served its prefix;
anything else spreads across the cards by counter. Measured: 19.97 s for the
burst and 1095 ms warm-turn TTFT — the best of both, without a regression on
either workload.

The policy lives in proxy/routing.py, separate from the HTTP plumbing in
proxy/affinity-proxy.py, so it can be tested without aiohttp, a network or a
GPU. tests/frank_medium_proxy.bats covers it, and it earned its keep: the
first version hashed fresh prefixes to pick a card, which sends every member of
a same-prefix burst to one card (29.8 s, measured), and a stale index in the
pin path returned 500 on every continuation. Both are now assertions.

ROUTING=roundrobin and ROUTING=affinity stay available to re-measure the
choice on this host; the default is adaptive.
2026-09-16 04:45:21 -07:00
Frank 7ce6e96bc5 docs: record the KV_MEM=0 passthrough trap in the frank-medium runbook 2026-09-16 03:58:48 -07:00
Frank df260dd419 frank-medium: pass KV_MEM=0 through as --kv-cache-memory 0
The single-GPU replicas died with torch.OutOfMemoryError during KV
allocation (2.16 GiB requested, 116 MiB free, 29.34 GiB already held by
PyTorch) because the entrypoint blanked KV_MEM=0 into an omitted flag. The
image's patched KV sizing reads an explicit 0 as 'profile this shape'; with
the flag absent vLLM sizes the cache from --gpu-memory-utilization alone and
overshoots. Passing 0 literally is the shape the bench numbers were measured
on, and MAXSEQS gets its default back under set -u.
2026-09-16 03:54:18 -07:00
Frank 4fca739bf8 frank-medium: deploy two single-GPU replicas — TP=2 is blocked by PCIe atomics
The radiance MXFP4 stack cannot run TP=2 on this host. RCCL's collective
kernels carry a hidden hostcall-buffer parameter, and ROCr refuses to
dispatch them on a device without PCIe atomics: the AQL dispatch fails with
hipErrorIllegalState ("the operation cannot be performed in the present
state"), which is what every two-rank collective hit. The second R9700 sits
behind the chipset bridges 05:00.0/06:09.0, which advertise
`AtomicOpsCap: Routing-` — a read-only capability, so no software can enable
atomic routing on that path and the driver's
pci_enable_atomic_ops_to_root() fails for that card. The first card, on CPU
root port 00:01.1, has atomics enabled.

Ruled out: kernel version (7.0.0-28/-30/-31 identical, after a full upgrade),
iommu=pt, P2P hardware (45.3 GB/s between the cards), the shipped librccl (a
from-source rebuild reproduces the failure at the same call path), vLLM and
pynccl (a bare two-rank torch.distributed allreduce fails the same way), all
NCCL transport knobs, and single-rank tests (NCCL short-circuits at
world_size=1, so one-card success proves nothing).

Deployment: three rootless Quadlets — frank-medium-a (:7771, card 1),
frank-medium-b (:7772, card 2), both TP=1 at 114688 context (262144 and
131072 do not fit one card's KV) with vLLM-profiled KV — and
frank-medium-proxy (:7770), a round-robin that preserves the fleet contract
of one endpoint and one served model name. frank-medium.container stays as
the TP=2 target for a host whose second card is atomic-capable; the deploy
handler does not install it and trashes any stale copy.

Measured through the proxy (vllm bench serve, random dataset, --ignore-eos):
conc 1 42.8 tok/s, conc 4 147.6 tok/s, conc 8 230.8 tok/s, 32k prefill at
~2.8k tok/s per card, TPOT p50 23.6-28.8 ms.

Also: healthcheck reads PORT so one probe serves all three units, the hermes
example and the harness models.yml drop to the real 114688 context, and the
runbook records the full evidence chain.
2026-09-16 03:47:11 -07:00
Frank ad7a9c42f9 log: record the 2026-09-15/16 ik cutover and decode campaign 2026-09-16 02:44:21 -07:00
Frank 795b0b3a5e frank-large: document the ik_llama.cpp deployment and the decode performance campaign
- Runbook with the measured envelope (prefill ~192 t/s at the VNNI ceiling;
  decode ~3.8-4.3 t/s gated by 100 backend splits/token, GPU 95% idle), the
  root-cause evidence chain, tuning levers, and co-tenancy traps
- AGENTS.md: fleet topology row + Active Services section for the ik
  deployment; halogen demoted to rollback; bench policy unit type fixed
- LLM + root indexes relinked (frank-large-ik-uncensored ACTIVE,
  halogen-flash-server retired)
2026-09-16 02:42:41 -07:00
Frank 0c1e10a5ef frank-large: retire the halogen quadlet test seam (ik_llama.cpp cutover) 2026-09-16 00:38:34 -07:00
Frank f2277863ae docs: rewrite AGENTS.md — dedupe fleet history into lessons + runbook index
- 734 -> 412 lines; active topology table replaces the swap-history log
- retired-runtime detail collapsed into a runbook pointer table
  (serverAppConfigs-docs/LLM/ holds the full runbooks)
- halogen section condensed to invariants that survive config changes
  (pool/reserve matched pair, free-lies rule, client contract)
- new Key Lessons Learned section: ROCm/layer-split, HIP env collisions,
  mmap/PLE constraints, crash diagnosis, swap ordering, podman/quadlet
  gotchas, ops guards
- fixed the merged/broken row in the Summary table
2026-09-16 00:20:58 -07:00
Frank f7cda17518 frank-large: restore serve-script defaults eaten by the guard edit 2026-09-16 00:06:46 -07:00
Frank 0022ba96ad frank-large: mmap the CPU-overridden weights (no CUDA_Host pinning) 2026-09-15 23:58:34 -07:00
Frank fe3fc62c41 frank-large: swap halogen Quadlet for ik_llama.cpp (3090 + Strix Halo CPU path) 2026-09-15 23:20:19 -07:00
Frank 4183e31fd1 frank-medium: default RADIANCE_NORMQUANT_FUSION under set -u 2026-09-15 21:02:50 -07:00
Frank 6b647c1558 frank-medium: swap ROCmFPX llama.cpp runtime for vllm-radiance MXFP4
Rootless Quadlet on dankserver serving
just1moremodel/Qwen3.8-27B-Uncensored-MXFP4-awq (the radiance stack's
designated alternate target; ships its fp8 MTP head, no rewrite step) with
the DFlash2-FP8 drafter at SPEC=7 on the 2x R9700s (TP=2, host network,
:7770, alias frank-medium unchanged).

- dankserver/frank-medium/: quadlet unit, container entrypoint (fork of
  serve-mxfp4.sh's patch block at pinned clone commit 97353293), healthcheck
- dispatcher retires the root llama.cpp unit, re-tags the registry image to
  the localhost alias, installs the quadlet, waits on /health
- planner maps dankserver/frank-medium/* to frank-medium; scripts-era
  mappings removed
- ROCmFPX-era env/serve/unit/download scripts staged in scripts/.AGENTTRASH
  with .rocmfpx suffix; seams updated (frank_medium_quadlet.bats new)
2026-09-15 20:59:55 -07:00
Frank db29c4d227 hermes: sync sanitized config example to frank-medium cutover 2026-09-15 19:31:41 -07:00
Frank d4024a9180 docs: frank-medium runbook, fleet topology and client cutover
New runbook page serverAppConfigs-docs/LLM/frank-medium-rocmfpx.md records
the hardware swap, the self-contained gfx1201 build, the measured layer-vs-
tensor decision (llama-bench multi-device syntax gotcha included), the
tensor-mode draft abort, served-path numbers, and the post-cleanup host ROCm
posture. AGENTS.md, README, docs indexes, Dankserver System Specs,
deployment/Hermes/OpenWebUI/Plex/Stack pages follow the rename;
freetoken-frank-small is marked RETIRED and kingjones-q8agent SUPERSEDED.
Work log entry added.
2026-09-15 19:27:04 -07:00
Frank d53962fec5 frank-medium: serve the new R9700s, retire frank-small
The RTX 3090 left Dankserver; two AMD Radeon AI PRO R9700 cards (Navi 48,
gfx1201, 32,624 MiB each) replaced it. FreeToken/CUDA cannot run there at all,
so the `frank-small` service is retired and `frank-medium` takes the endpoint.

New service: charlie12345/ROCmFPX llama.cpp (built for gfx1201 against the
fork's self-contained ROCm 7.14 toolchain, so the host's stale /opt/rocm-6.3.3
never participates) serving kingjones777/Qwen3.8-27B-ROCmFPX-Q8_0-AGENT-GGUF --
a Q8_0_ROCMFPX_AGENT (ftype 115) checkpoint that stock llama.cpp cannot load.
Placeholder config: --split-mode tensor across both cards, 131072 context,
1 slot, f16 KV, MTP draft head on ROCm0.

deploy-remote.sh grows retire_root_llm_small(): the CUDA-era unit is stopped,
disabled and moved to ~/.AGENTTRASH/20260915-frank-small-retire/host-units
before frank-medium starts, because `systemctl disable` has been observed to
delete the unit file itself on this host (2026-08-26 incident).

The retired frank-small repo files (env, unit, serve script) move to
scripts/.AGENTTRASH/, and the planner, dispatcher, service allow-list and the
deployment test seams now name frank-medium.

Follow-up (same session, measured): llama-bench on the host with the correct
multi-device syntax (-dev ROCm0/ROCm1) overturned the tensor choice:

  single R9700   pp4096 1160.9 / tg128 19.42  (d32768: 512 / 18.4)
  layer  2x      pp4096 1991.2 / tg128 18.76  (d32768: 859 / 17.6)
  tensor 2x       pp4096  271.8 / tg128 26.26 (d32768: 229 / 25.1)

This build has no RCCL, so tensor mode falls back to a "meta-backend
butterfly" allreduce and prefill collapses 7x. Served path with MTP:
layer 35.7-46.2 tok/s vs tensor 28.7. SPLIT_MODE=layer, draft pinned to
ROCm1 (the card holding the last target layers). Served prefix cache with
the draft head verified live: turn 2 reprocessed 22 tokens instead of 949.
2026-09-15 19:03:30 -07:00
Frank 6085a99dd8 frank-medium: serve the new R9700s, retire frank-small
The RTX 3090 left Dankserver; two AMD Radeon AI PRO R9700 cards (Navi 48,
gfx1201, 32,624 MiB each) replaced it. FreeToken/CUDA cannot run there at all,
so the `frank-small` service is retired and `frank-medium` takes the endpoint.

New service: charlie12345/ROCmFPX llama.cpp (built for gfx1201 against the
fork's self-contained ROCm 7.14 toolchain, so the host's stale /opt/rocm-6.3.3
never participates) serving kingjones777/Qwen3.8-27B-ROCmFPX-Q8_0-AGENT-GGUF --
a Q8_0_ROCMFPX_AGENT (ftype 115) checkpoint that stock llama.cpp cannot load.
Placeholder config: --split-mode tensor across both cards, 131072 context,
1 slot, f16 KV, MTP draft head on ROCm0.

deploy-remote.sh grows retire_root_llm_small(): the CUDA-era unit is stopped,
disabled and moved to ~/.AGENTTRASH/20260915-frank-small-retire/host-units
before frank-medium starts, because `systemctl disable` has been observed to
delete the unit file itself on this host (2026-08-26 incident).

The retired frank-small repo files (env, unit, serve script) move to
scripts/.AGENTTRASH/, and the planner, dispatcher, service allow-list and the
deployment test seams now name frank-medium.
2026-09-15 18:53:20 -07:00
Frank 27e63359d6 frank-large: correct docs to measured 401408 ledger figures
Predicted ~10.8 GiB KV / ~33.4 need / ~10.5 host-left; the boot ledger
printed ~11.0 / ~31.5 / 100.0 GiB held with 9.1 GiB + 1396 contiguous
2 MiB blocks left. Stall NOTE no longer prints at this footprint. Also
documents the ~45-60 s first-request PING silence after a restart (the
healthcheck's HealthStartPeriod covers it; do not restart the unit).
2026-09-13 23:02:16 -07:00
Frank 91406af2a1 frank-large: trim KV pool 524288 -> 401408 for co-tenant headroom
The 09-12 doubled pool fit only at reserve 15, leaving 7.1 GiB / 310
contiguous 2 MiB blocks for the ~28 co-tenant containers and making the
engine print its minutes-long-stall warning on every boot. 401,408
positions (~10.8 GiB KV) hands ~3.4 GiB back while keeping MAX_TOK=32768
and reserve 15: modelled need ~33.4 GiB vs budget 38.5, so the fit check
should not clamp. Capacity becomes one full 262k conversation resident
beside a ~139k one, or three concurrent 131k chats (three slots).

Docs: AGENTS.md fleet table + halogen section, runbook flags table /
co-tenancy tense / admission-arithmetic paragraph updated for the trim.
2026-09-13 22:57:25 -07:00
Frank 025216fc84 docs(frank-small): document that FreeToken samples by default from the checkpoint
Answering "does the FreeToken runtime use sampling arguments?" -- yes, and
nothing in our config revealed it. serve-frank-small.sh passes no sampling
flags; the behaviour is the runtime default:

    freetoken/server/args.py:31  sampling_defaults: str = "model"

which fills temperature/top_k/top_p from the checkpoint's
generation_config.json -- this one ships do_sample:true, temp 1.0, top_k 20,
top_p 0.95. Proved live: three identical requests with no sampling params
produced three different reasoning traces (487/540/577 chars).

The asymmetry with frank-large is the part that will bite someone: a silent
client samples on frank-small and decodes greedy on frank-large, so the same
request is reproducible on one endpoint and not the other. Documented with the
determinism options (explicit temperature:0, or --sampling-defaults none).

Also recorded two request-level traps found during the probe: thinking eats the
whole max_tokens budget and returns empty content with finish_reason length,
and the API accepts unknown fields with a 200, so a 200 proves nothing about
whether a key was understood.
2026-09-12 23:40:34 -07:00
Frank 4e3d79388b docs(frank-large): resync slots=3 across AGENTS and runbook
Ledger confirms the change landed: "3 slot(s) over one 524288-position pool",
"slots: 1 -> 3 POOLED, 111.0 MiB each", held 103.5 -> 103.4 GiB.

Recorded the oversubscription nuance that the old row missed: the slots were
never the binding constraint on context. 4 x 262144 exceeds the 524288 pool,
so admission was pool-bound all along -- two full-length conversations, or
four at 131k. Lowering the cap trims simultaneous requests, not capacity.
2026-09-12 23:26:03 -07:00
Frank 6169278911 frank-large: lower KV slots 4 -> 3
Concurrency cap trimmed to the real fleet size. Each slot is 111.0 MiB of
O(1) state, so this hands ~111 MiB back on a host sitting at 7.1 GiB left
for everything else.

Note the slots already oversubscribed the pool (4 x 262144 > 524288), so
admission was pool-bound regardless: at most two full-length conversations,
or four at 131k. This does not change how much context fits -- it caps
simultaneous requests at three, and a fourth now queues.
2026-09-12 23:20:16 -07:00
Frank 5dfcde6c8b docs(dankclaw): record the 64 GiB host swapfile and its safe resize ordering
Raised /swap.img from 8 GiB to 64 GiB on Dankclaw. Host-level state with no
repo file, so it was invisible to the repo until now: fstab line 12 drives the
generated swap.img.swap unit, ext4, fallocated hole-free, swappiness 60,
zswap off. 748 GiB still free on /.

The reusable part is the ordering. The old file held 4 GiB of live pages and
MemAvailable read 73 GiB while only ~5 GiB was genuinely free -- the pinned
model weights are counted as reclaimable page cache, so the headline lies by
the size of the model. Swapping off before the replacement is online would have
pushed those pages into RAM that does not exist. Creating and enabling the new
swap first let them migrate instead: swapoff took 65 s and the host never
wobbled.

New page serverAppConfigs-docs/Dankclaw/Swap Configuration.md, linked from
the index, plus a pointer in the AGENTS.md memory-model section so nobody
reasons about headroom without checking swapon.

Old 8 GiB file staged at dankclaw:~/.AGENTTRASH/20260912-swapfile-8g/
pending approval to purge.
2026-09-12 22:03:24 -07:00
Frank a45edbcc6d docs(halogen): record the reserve ceiling and that the reserve is not an allocation
Seven restarts measured. New section "The reserve cannot buy air": reserve 16
was deployed and held exactly what 15 holds — 103.5 GiB, 7.1 GiB left to the
host. The flag is a term in the fit-check inequality, not a reservation, so it
has only two regimes: below the pool's requirement it changes nothing
observable, above it the pool silently disappears.

Sizing rule now defers to the engine's printed requirement (budget >= 36.5 for
524288 at max_tok 32768, from "need ~35.0 GiB plus 1.5 of margin") over my
modelled 37.0, and the ledger table carries the reserve-16 row.
2026-09-12 21:32:08 -07:00
Frank 5c4e8a9600 frank-large: revert reserve to 15 — the knob cannot buy host headroom
Tested 16 live. Pool stayed 524288 (no clamp), server held 103.5 GiB and
reported "7.1 GiB left for everything else" at BOTH 15 and 16 — identical
footprint. HALOGEN_HOST_RESERVE_GIB feeds the fit-check budget only; it is not
an allocation, so raising it cannot give RAM back to the co-tenants. Its sole
observable effect above 16 is to take the KV pool away.

16 therefore cost half the fit margin (1.0 vs 2.0 against the engine's 36.5 GiB
requirement) and bought nothing. 15 is strictly better at the same cost.

Recorded the ceiling (16) and the real air levers in the unit comment: pool
262144 frees 7.2 GiB, max_tok 16384 frees 8.4.
2026-09-12 21:30:15 -07:00
Frank b87f18569a frank-large: reserve 15 -> 16 for co-tenant headroom
Operator asked for 18. 18 is not available: the engine's own fit-check line,
read from a clamped boot rather than modelled, requires budget >= 36.5 GiB to
keep 524288 at max_tok 32768 ("524288 positions need ~35.0 GiB (plus 1.5 of
margin)"). budget(18) = 35.5, so 18 silently un-doubles the pool we just paid
the reserve for. 17 fits by 0.02, inside the noise of the 67.7 resident-weights
term. 16 is the largest reserve that survives the check with real margin:
37.5 budget vs 36.5 required.

Recorded the ceiling and both remedies in the unit comment. The reserve is now
spent, so a future stall is answered by the pool or the arena, not here.
2026-09-12 21:27:58 -07:00
Frank 2969e08616 frank-large: document reserve 15 result — both features in force, stall risk logged
Ledger confirms the doubled pool is actually reserved (524288, 13312.0 MiB)
at max_tok 32768 with no clamp and no WARNING: 38.5 budget vs 37.0 needed.

Recorded the cost honestly. The server now holds 103.5 of 121.2 GiB and the
engine reports only 310 contiguous 2 MiB blocks / 7.1 GiB left for everything
else (24-reserve baseline: 2081 blocks / 12.1 GiB). All 28 co-tenants stayed
up, but the engine volunteers a new note: when memory runs out, frank-large
AND its neighbours "can stall for minutes at a time at full CPU with no disk
activity and no output, which is not a crash and needs no restart". That is
the least diagnosable of the three failure modes seen today, and `free` hides
it — MemAvailable still reads ~74 GiB because the pinned weights count as
reclaimable page cache.

Sizing rule now expresses the budget as a function of reserve, shows both
endpoints (24 -> 29.5, 15 -> 38.5), and the max_tok ceiling is labelled as
reserve-conditional (<= 18,130 at 24; <= 35,700 at 15).
2026-09-12 21:22:36 -07:00
Frank 8a79f9a709 frank-large: reserve 24 -> 15, buying the doubled pool AND full prefill speed
Neither lever alone could give both features. At reserve 24 the device budget
is 29.5 GiB and max_tok 32768's 37.0 GiB need always lost the doubling to the
fit-check clamp; at max_tok 16384 the doubling survived but prefill paid 8-9%.

Dropping the reserve to 15 moves the budget to 38.5:
  121.2 host - 67.7 resident weights - 15 reserve = 38.5
  need = 14.4 pool + 20.6 arena + 1.97 vision    = 37.0   -> 1.5 GiB spare

So the payment is page cache for the n-gram lookup table rather than either
feature. 15 GiB, against a table of 47.7 GiB read through the file cache.
Two reasons this is a survivable risk rather than a cliff:

  - The engine's own ledger reported only 12-17 GiB left at the previous 24
    setting, so the declared reserve was never held literally; 15 may cost
    less real cache than it reads as.
  - The failure mode is legible: long prompts fault heavily against a busy
    disk, and raising the reserve is the documented remedy. Not the silent
    clamp, and not the 65536 no-boot mode.

1.5 GiB of headroom is thin and deliberate. Documented in the unit as a
matched pair with the pool: raise the reserve back to 24 and the doubling
quietly goes away again.
2026-09-12 21:17:16 -07:00
Frank 65d1be06ac frank-large: record the 32k outcome — the doubling is clamped away, docs resynced
Deployed HALOGEN_MAX_TOK=32768 by operator call (upstream default, full prefill
speed) and confirmed the prediction from the ledger:

  kv pool: 524288 positions need ~35.0 GiB ... LOWERING THE POOL TO 262144 (~27.8 GiB)
  memory: 68.0 weights + 7.2 KV pool + 21.1 working = 96.3 GiB in all

so the effective pool is 262144 again and the doubling this task set out to do
is not in force. Healthy, completion verified. Left the pool requesting 524288
deliberately: with KV_POOL_FIT on that is a grow-if-RAM-frees-up declaration,
not a mistake.

Two corrections to what I wrote earlier today:

1. The engine's sizing block, and its "WARNING even 262144 ... host RAM cannot
   spare it" line, print ONLY when the fit check had to clamp something. The
   09-11 baseline boot prints no kv pool: sizing lines at all. So the absence
   of a warning in old logs was never evidence of headroom.

2. The budget needs a vision term. Actual need is pool + arena + 1.97 GiB of
   vision tower, not pool + a 1.5 GiB nominal margin. That makes the ceiling
   for a doubled pool max_tok <= 18,130 rather than 19,115, and it means the
   32768/262144 corner this box has run in since 09-11 is ~0.3 GiB OVER its own
   stated budget -- and has been the whole time. It boots and serves with
   12-14 GiB genuinely free because the 24 GiB reserve is generous. A soft
   floor, not a wall.

The arena model also reproduces upstream's three published "on the device"
figures exactly (35 / 42 / 41 GiB), confirming they are pool+arena totals at
the default max_tok and not pool-only -- which is the misreading that made the
doubling look affordable in my first estimate.
2026-09-12 20:12:03 -07:00
Frank 75ef47c342 frank-large: EXPERIMENT - HALOGEN_MAX_TOK back to upstream default 32768
Operator call. Prediction from the measured model: 524288 positions cost
35.0 GiB against a 29.5 budget, so HALOGEN_KV_POOL_FIT clamps the pool to
262144 (27.8). Rollback is a single revert; 16384 was the config that made
the doubling real.
2026-09-12 20:06:25 -07:00
Frank 41ac1f83f6 docs(halogen): record the measured arena model; the 65536 experiment was worth doing
Raising HALOGEN_MAX_TOK to 65536 did not shrink the pool, it OOMed the boot:
the arena alone (37.3 GiB) exceeds the 29.5 GiB device budget, so even the
minimum pool faulted and dmalloc failed at 39.97 GiB. Rolled back to 16384,
which restored the 524288 pool and a clean completion.

The three measured arenas are exactly linear in the flag — 3.80 GiB base plus
8.40 GiB per 16384 tokens, predicting all three to 0.1 GiB. That gives a real
sizing rule instead of guesswork, and it says the deployed 16384 is the
CEILING, not a conservative choice: 29.5 - 1.5 margin - 14.4 pool leaves 13.6
for the arena, i.e. max_tok <= 19,115.

The corner check falls out of the same model: set max_tok back to 32768 and
only 7.4 GiB remains for the pool, ~269k positions, which is precisely the
262144 config this host ran before. The two corners of the feasible box are
the only two points it has ever served.

No functional change — the quadlet edit is comments only; the running service
is already at 16384 from the rollback deploy (258cccb).
2026-09-12 19:38:13 -07:00
Frank 258cccbdcf Revert "frank-large: EXPERIMENT - raise HALOGEN_MAX_TOK to 65536"
This reverts commit 6851940b4d.
2026-09-12 19:34:56 -07:00
Frank 6851940b4d frank-large: EXPERIMENT - raise HALOGEN_MAX_TOK to 65536
Operator call, measuring what the prefill arena actually costs against
upstream's do-not-raise warning. Rollback is one revert: the settled
config was 16384, which made the 524288 pool fit at 26.6 GiB.
2026-09-12 19:33:29 -07:00
Frank d1544f3191 docs(halogen): correct the memory model to the measured ledger
The first draft of these notes estimated the doubled pool at 103.5 GiB
held / ~5 GiB host remainder. Both numbers were wrong — they costed the
KV alone and ignored that the arena shrinks when MAX_TOK drops. Measured:

  pool 262144 / max_tok 32768 -> 96.3 GiB held, 12.1 GiB host left
  pool 524288 / max_tok 16384 -> 94.6 GiB held, 13.8 GiB host left

The doubled pool is 1.7 GiB cheaper overall. Also records the failed
first attempt verbatim, since the silent-revert failure mode is the thing
a future operator most needs to recognise, and adds the HALOGEN_MAX_TOK
row to the deployed-flags table (the "baked 32768" line it overrode was
stale).
2026-09-12 18:04:02 -07:00
Frank c489b204ea frank-large: halve the prefill arena so the doubled pool actually fits
The previous commit's doubling was a silent no-op. HALOGEN_KV_POOL_FIT
measured the device budget at startup and lowered the pool straight back
to 262144, while the deploy still reported healthy in 12s.

The engine's arithmetic: 121 GiB host - 67.7 resident weights - 24.0
reserved for the n-gram page cache and the OS = 29.5 GiB of device
budget, and that single budget carries the KV pool AND the prefill arena.
At HALOGEN_MAX_TOK=32768 the arena is ~20.6 GiB, so 524288 positions
need 35.0 GiB against 29.5 available.

HALOGEN_MAX_TOK=16384 halves the arena to ~10.5 GiB, bringing the
524288 need to ~24.9 GiB, which fits with ~3 GiB of margin. Cost is
about 8-9% of prefill throughput, per upstream. This is the engine's own
prescribed fix, quoted verbatim in its refusal message.

Lowering HALOGEN_CTX was the other option the engine offered; rejected
because both clients declare 262144 as the context window, so it would
break the client contract rather than pay for the pool.
2026-09-12 17:58:08 -07:00
Frank 5ace29e7a0 frank-large: double the KV pool to 524288 positions
HALOGEN_KV_POOL_POSITIONS 262144 -> 524288, which is upstream's own
default (2 x HALOGEN_CTX). The old pool was the binding constraint on
concurrency, not context: a request reserves prompt + max_tokens against
the pool, so with pool == CTX four concurrent 65k chats were contending
for one full-length conversation's worth of positions. Two full-length
conversations, or four at 131k, now stay resident.

Cost is ~7.2 GiB more device RAM (measured 28 KiB/position), taken from
the page cache the 47.7 GiB n-gram lookup table reads through. Live
startup ledger for the prior config: 68.0 weights + 7.2 pool + 21.1
working = 96.3 GiB held, 12.1 GiB left on the host. The doubled pool
puts the remainder near 5 GiB against a ~4.3 GiB co-tenant working set,
and cache at ~69 GiB -- still above the lookup table, so the table can
stay hot. Compensating lever if long prompts start crawling:
HALOGEN_MAX_TOK=16384, which frees 8.3 GiB of prefill arena for ~8%
of prefill throughput.

Docs updated to match, including the admission-trap paragraph, which was
only true while pool == CTX.
2026-09-12 17:51:24 -07:00
Frank dd44223f3f docs: record marreta login-wall fix (proxy provider needs linked Application)
Documents the root-cause chain for the marreta.franksdank.cloud login wall:
null invalidation_flow (outpost serde crash) -> missing flows -> and the real
blocker, ProxyProvider.application=None so the embedded outpost's
filter(application__isnull=False) never loaded the provider. Notes the
providers are live-only (no repo blueprint) as a provenance gap.
2026-09-12 13:34:02 -07:00
Frank 57dbcc5459 e2e: add Marreta homepage-link regression test
16-marreta.spec.ts (standard project) asserts the user homepage's Marreta
open link actually reaches the Marreta app through the full guard chain
(Anubis PoW -> Authentik SSO -> app), and explicitly NOT the authentik
login flow. Guards the 2026-09-12 incident where marreta-proxy was created
without a linked Application, so the embedded outpost never loaded it and
the homepage link parked on a login wall.

Wires spec 16 into the standard project testMatch. Verified passing live.
2026-09-12 13:31:00 -07:00
Frank 6e6bdf7451 homepage: add Marreta as a user-category service
Adds the Marreta content-reader tile (http://marreta.franksdank.cloud,
probe 127.0.0.1:8082 via gluetun's publish) to the user grid so regular
authenticated users get a link to it. Registry-driven e2e picks it up
automatically.
2026-09-12 12:06:55 -07:00
Frank 09c54113b1 deploy: make Playwright e2e a mandatory post-deploy gate
The 2026-09-12 homepage outage slipped through because the live-fleet
Playwright suite was a manual 'MUST invoke' that deploys skipped. Wire it
into deploy.sh as the [3/3] gate: after a behavior-affecting deploy,
run tests/e2e/run.sh against the live fleet; a failure exits DEPLOY
INCOMPLETE. Add --skip-e2e as an emergency escape hatch. Update the
AGENTS.md deployment-completeness contract and flow diagram to match.

Verified: bash -n clean, --skip-e2e parsed, unknown flags rejected,
seam suite green (only the pre-existing comfyui test 73 fails).
2026-09-12 11:50:40 -07:00
Frank 476b21b85d docs(marreta): record live deploy + the base64: APP_KEY gotcha
Deployed live at 91e9410 (gluetun+marreta+caddy), authentik provider pk 13,
guard chain verified end-to-end. Documented that Laravel APP_KEY must carry the
base64: prefix — a bare base64 key 500s every app route with 'Unsupported
cipher or incorrect key length' while /up still passes.
2026-09-12 10:31:08 -07:00
Frank 91e9410c5e deploy.sh: allowlist dankclaw:marreta in the top-level service gate
The scoped runner (deploy-remote.sh) already knew marreta; the top-level
deploy.sh valid_service() gate did not, so --service marreta was rejected
before reaching the planner.
2026-09-12 10:10:01 -07:00
Frank 73e46a1d85 marreta: content-cleaning proxy on Dankclaw, gluetun netns, guard-chained
Deploy manualdousuario/marreta (Laravel / serversideup php:8.4-fpm-nginx)
as a rootless Quadlet pair (marreta + marreta-browser/Lightpanda), both
joining gluetun's netns so all fetch egress exits the same ProtonVPN
tunnel as SearXNG.

- marreta relocated to NGINX_HTTP_PORT=8085 (netns already has searxng on
  8080); gluetun publishes 8082:8085; Caddy dials 192.168.0.143:8082.
- Guard chain: marreta.franksdank.cloud -> Anubis :8923 -> Authentik ->
  post-auth :8017 @marreta -> 8082. Access = any authenticated user.
- Secrets seeded to /home/dank/Containers/marreta/env (0600) from
  MARRETA_APP_KEY / MARRETA_ADMIN_EMAIL / MARRETA_ADMIN_PASSWORD.
- deploy-plan.py: marreta joins the gluetun netns family.
- New seam tests/marreta_quadlet.bats (17 contracts); scoping test updated
  for the widened netns family. Doc: Dankclaw/Marreta.md + index + log.
2026-09-12 10:09:12 -07:00
Frank 9530bffad6 frank-large: ship HALOGEN_VERBOSE=1 for permanent startup evidence
Verbose engine logging is the vendor's first-line evidence for load,
allocation and admission questions; shipping it on means an incident
report already carries the full startup account instead of needing a
restart-into-debug. HALOGEN_DMALLOC_LOG stays off (every alloc >64MB is
steady-state noise on a shared host). Doc updated with the rootless
podman-logs-vs-journalctl retrieval note.
2026-09-12 10:08:57 -07:00
Frank 701ab14f60 docs(observability): record the quadlet cutover, the exposure posture, and the unconfigured state
Rewrites the [[Observability]] umbrella page and the five component pages for
what is actually deployed: rootless Quadlet units instead of compose services,
the loopback-only publish posture (node-exporter's LAN binding explained), the
loki healthcheck living in its image because the image has no shell, and the
Home=/root trap that killed the first grafana start.

Adds the note that matters most to a reader: the stack is installed, healthy
and UNCONFIGURED. Grafana reports zero datasources and zero dashboards with an
empty provisioning directory; Prometheus has no rule files and no Alertmanager;
Loki is configured but has no reader; node-exporter runs its default
collectors. Alloy is the only configured piece. Metrics and logs are collected
and retained, and nothing is visualized or alerted on.

AGENTS.md's boot-persistence gap note is replaced by the resolution.
2026-09-11 16:10:27 -07:00
Frank 25c93984ad fix(build): build the loki/alloy/comfyui images in docker format
The healthcheck added to the loki image was discarded by the build:
'HEALTHCHECK is not supported for OCI image format and will be ignored'.
podman builds OCI by default, and OCI has no HEALTHCHECK field, so the
container came back with no health at all — worse than the failing check it
replaced, because nothing reports the loss.

The build now passes --format docker, and the seam pins it so a future edit
cannot quietly reintroduce an image with no health field.
2026-09-11 16:02:47 -07:00
Frank b96359e628 fix(observability): give loki an exec-form healthcheck in its image
The loki unit came up 'unhealthy' after the quadlet cutover. Cause: a unit's
HealthCmd reaches podman as CMD-SHELL, i.e. /bin/sh -c, and the loki image is
distroless — no shell exists in it. The busybox command itself works (probed
by hand: /ready answers 'ready'); it was the shell wrapper that could not run.
podman does not accept an exec-form command through --health-cmd either: it
stores a JSON array as one literal argument.

The command therefore moves into dankclaw/loki/Containerfile as a
HEALTHCHECK ... CMD [...] exec form, which is what that image exists for — it
already ships the static busybox solely so a healthcheck has something to run.
podman reads the image's healthcheck when the unit declares none.
2026-09-11 16:01:21 -07:00
Frank fe4eeaa03a fix(observability): seed grafana's OAuth secret into a per-service env file
The first deploy of the grafana unit failed: 'stat /root/.config: permission
denied', exit 1, restart loop. Cause — the repo-root .env that the compose
service used to pick up is a gluetun env file, and it carries HOME=/root (and
the WireGuard keys). Pointing the unit's [Service] EnvironmentFile at it sets
HOME for the service, so podman looks for its configuration under /root.

grafana now reads /home/dank/Containers/grafana/env, the per-service file the
deployer seeds from dankclaw/.env with a single-variable filter — the same
shape caddy, pihole, copyparty, romm and seerr use. The seed is validated, so
a missing secret fails the deploy loudly instead of starting a container whose
OAuth login cannot complete. The value was copied to dankclaw/.env, which is
where dankclaw/.env.example already documented it.
2026-09-11 15:59:15 -07:00
Frank dab81b91a3 feat(observability): make the five Dankclaw observability containers quadlets
prometheus, grafana, loki, alloy and node-exporter ran only from
dankclaw/compose.yaml. Rootless podman does not restart bare containers, so
they did not survive the 2026-09-11 reboot for the amd_iommu=off change and
had to be restored by hand — the same gap that had already cost the fleet
Caddy and Pi-hole, which is why the boot-persistence rule exists.

Each is now a rootless Quadlet unit under dankclaw/observability/quadlet/,
installed and dispatched by deploy_observability in deploy-remote.sh, with
the compose definitions retired so only one owner exists per container name.
Images, mounts, arguments and container names are unchanged, so Caddy's
proxies and Prometheus' own scrape config keep resolving.

Two posture changes, both about the unauthenticated surface:

* The three HTTP UIs published on 0.0.0.0 with no authentication of any kind
  (Prometheus even with the lifecycle endpoint enabled). They now publish on
  127.0.0.1: the browser path is the guarded franksdank.cloud vhost, Caddy
  reaches them by name over dankclaw_default, and the host-side homepage
  status timer probes loopback. Nothing legitimately used the LAN port.
* node-exporter bound :9100 on every interface; it now binds exactly its two
  consumers, loopback (homepage timer) and the LAN address (Prometheus, which
  runs in a container and cannot reach the host's loopback).

alloy gains a reachable health endpoint: its UI listens on the container's
own loopback and alloy 1.x refuses a config block for it, so the compose
service had nothing to probe — the homepage status card for alloy read 'down'
continuously. The unit moves the UI with a command-line flag and publishes
127.0.0.1:12345.

grafana keeps its Authentik OAuth client secret without receiving the whole
.env: systemd expands the value from a [Service] EnvironmentFile, which never
reaches the container.
2026-09-11 15:56:39 -07:00
Frank 549bc57df2 fix(hermes): give title generation a working route on frank-small
Hermes hardcodes response_format=json_schema on its title call. No local
back-end implements constrained decoding — frank-small and frank-large both
answer 400 'response_format json_object/json_schema is not supported' — so
every session logged a title failure and fell back to a message-derived
title. The NPU endpoint that used to absorb this call was retired on
2026-09-11.

The call now goes out over the Responses transport (the only one that does
not forward response_format) with thinking off. A 64-token cap cannot hold a
thinking pass plus a title: with the default reasoning effort the model spent
all 64 tokens thinking and returned empty content, which is why disabling
reasoning is part of the fix and not decoration.

Verified against the live endpoint with the real prompt and the real
extraction path: raw {"title": "Friendly greeting"} -> title
'Friendly greeting' in 3.4s, where the previous configuration returned 400.
2026-09-11 15:56:38 -07:00
Frank 351fdf3a05 docs(agents): record the compose-only observability gap found by the reboot
Rebooting Dankclaw for the amd_iommu=off change exposed five services that
exist only as compose.yaml services with no Quadlet unit (prometheus, grafana,
loki, alloy, node-exporter): they did not come back and had to be restored by
hand. The boot-persistence rule already requires units for them; this records
the concrete gap so the next reboot is not a surprise.
2026-09-11 15:17:42 -07:00
Frank 301914b1c0 feat(llm): swap frank-large to halogen-flash-server, retire frank-npu
frank-large moves from the kingjones30/ROCmFPX llama.cpp build to
peonist-ai/halogen-flash-server 0.5.8 serving Qwen3.8-Flash-Next (w4b
.hgn 115.55 GiB + quality overlay). Upstream measures this engine at
roughly 4x the prefill throughput of the llama.cpp family on the same
gfx1151 silicon, and prefill dominates any prompt with real context.

Shape: a rootless Quadlet container (dankclaw/frank-large/quadlet/),
engine on container loopback :8730 (never published), OpenAI front-end
:8731 published as host 7770, HALOGEN_MODEL_ID=frank-large so no client
renamed anything. Context 262144, KV pool 262144 positions (~7.2 GiB,
conservative for a shared 121 GiB host), 4 slots, host reserve 24,
vision tower on, weights pinned, all other flags at vendor defaults.
Weights are fetched by HALOGEN_DOWNLOAD on first start into a writable
/home/dank/models/halogen.

The llama.cpp era (unit, env, serve script) is staged in .AGENTTRASH/
rather than deleted; the STRIX payload and the ROCmFPX build stay on
disk as the rollback target. deploy_llm_large becomes a user-quadlet
handler: it retires the old ROOT unit (moving its checkout symlink into
the home trash, never rm) and gates the first start on the download
rather than reporting a failure for a fetch that is working exactly as
designed.

frank-npu is retired in the same change. The kernel cmdline moves from
iommu=pt to amd_iommu=off for the 13-16% of prefill upstream measured,
and that takes the XDNA2 NPU with it: without PASID/SVA the driver
cannot open /dev/accel/accel0 (err=-19). Its five dependent routes are
repointed (Hermes vision -> frank-large, Hermes title generation ->
frank-small, harness vision -> frank-large, tiny -> frank-small, task ->
frank-large:medium) and its provider blocks are removed from both the
harness config and Hermes's live config. Audio input leaves the fleet
with no replacement; image input survives on halogen's vision tower.

Docs: new halogen-flash-server runbook, new IOMMU decision record, and
frank-npu rewritten as a retirement record. AGENTS.md topology, runtime
sections, benchmarking policy (frank-large is a user unit now) and the
harness model-config section are corrected.
2026-09-11 14:48:28 -07:00
Frank 184df9aa0d fix(hermes): pin hermes-webui HERMES_HOME to the shared /data home
The unit comment always claimed the webui drives the same agent state as
the gateway, but the image bakes HERMES_HOME=/opt/data and no unit
overrode it. Podman auto-creates a fresh anonymous volume at every
image-VOLUME point on container recreation, so each restart reset the
webui's config home to the image-default template (provider: auto ->
openrouter.ai with no API key), while the gateway read the corrected
/data/config.yaml. Pin HERMES_HOME=/data so both processes share one
config/state root and config edits propagate without a recreate.
2026-09-05 15:41:45 -07:00
Frank f75d78a98c docs(hermes): sync config backup with live post alias-rename propagation
Live config was still routing on pre-rename aliases (frank-default-large,
frank-default-small) after the 2026-09-03 frank-large/frank-small/frank-npu
rename cutover; hermes-webui resolved those ids to nonexistent models and
requests failed. Live config fixed in place (backup kept on-host as
config.yaml.bak-20260905); this backup now mirrors the live shape: named
providers for aux routes, title_generation/triage/kanban routed to their
actual models, moa + mcp sections, signal disabled.
2026-09-05 14:24:54 -07:00
Frank b6f5b2bcf5 test(e2e): pin unauthenticated wget-style denial for copyparty and homepage
Wget-style (no-JS) clients must never receive application bytes from guarded
services: copyparty serves authenticated traffic with anonymous read perms, so
the guard chain is the only boundary between an unauthenticated client and the
file bytes. Assertions target leakage markers in app code (Copyparty UI title,
homepage panel grid, services.json JSON shape), not gate internals, so any
guard-route regression fails the suite while any gate implementation passes.

Also route 15-*.spec.ts into the guest project (empty storage state).
2026-09-04 19:34:59 -07:00
Frank 32b4016e55 feat(exposure): sonarr to admin side, internal-only by construction
Flip the homepage Sonarr card from the user category to admin and repoint
its link at the LAN-direct port (192.168.0.112:8989). Retire the unguarded
public vhost sonarr.franksdank.cloud (direct reverse_proxy, no auth) and its
post-auth @sonarr handler from the Caddyfile; drop the Pi-hole addn-hosts A
record with it. No public Porkbun record ever existed (NXDOMAIN at the zone),
so after this there is no WAN path to Sonarr: users request TV via Seerr
(guarded), admins reach Sonarr's own UI from the LAN only.

Fixes the seerr/sonarr mix-up from 19b2589/e0bade6.
2026-09-04 18:34:02 -07:00
Frank df4339a82a docs(arr): fleet consolidated on FrankBook-Pro, VPN egress proven; E2E counts derived from registry 2026-09-04 13:17:39 -07:00
Frank 4c7465d00d arr fleet: FrankBook-Pro is the sole deployment, all egress VPN-gated
FrankBook-Pro becomes the only *arr/qBittorrent deployment. Every fleet
container (qB + five arrs) now shares gluetun's netns, so all egress —
torrent, indexer APIs, metadata lookups — exits the WireGuard tunnel by
construction (AllowedIPs=0.0.0.0/0); the arrs' own ports publish on the
gluetun unit, none on the arr units themselves.

- dankserver/compose.yaml: remove the six arr services (the Aug 20 compose
  copy — never healthy, superseded; config dirs trashed on the host)
- dankclaw/Caddyfile + shared/homepage/services.json: arr vhosts and
  probes repoint 192.168.0.235 -> 192.168.0.112 (FrankBook-Pro)
- frankbook-pro units: five arrs join gluetun netns (Wants/After/Network);
  gluetun publishes 9696/8989/7878/8686/8787 alongside 8080/6881
- tests/frankbook_quadlet.bats: new VPN egress seam — every arr+qB unit
  must share gluetun's netns and publish no port; gluetun must publish the
  whole fleet's ports
- deploy-plan.py: gluetun changes carry the whole netns family (a gluetun
  recreation kills all six joiners); dankserver compose set loses the arrs
- AGENTS.md: deployment is complete only when unit tests AND the Playwright
  E2E suite both pass
2026-09-04 12:54:28 -07:00
Frank 8a6362401a docs(arr): dankserver compose stack reactivated 2026-09-04; rootless identity model 2026-09-04 12:30:16 -07:00
Frank e4541598ff revert(dankserver): drop userns_mode from arr services
Rootless podman on dankserver already maps container root (PUID=0) to
the dank user (uid 1000) by default — /proc/<pid>/uid_map reads
0->1000, 1..->100000+. Verified across plex, tautulli, jellyfin,
calibre-web, copyparty, romm. userns_mode: host is unnecessary, and
podman-compose 1.5.0 cannot apply it anyway (compose services run in
pods; --userns and --pod cannot be set together). The real fix for the
never-booted arr stack was chowning the root-owned /opt/<svc>/config
dirs to 1000:1000.
2026-09-04 12:26:16 -07:00
Frank df10a33ead fix(dankserver): arr stack runs with userns_mode host — stack never booted rootless
The arr services were committed (b9de886) with PUID=0 but no userns_mode.
Under rootless podman's default subuid mapping, container-root cannot write
host-root-owned bind mounts, so every app died in logger init
(UnauthorizedAccessException on /config/Sentry) before binding its port —
the stack 502'd since it was first brought up (2026-09-04 00:33).

Per the fleet convention (AGENTS.md, plex/tautulli migration): PUID=0 +
UserNS=host maps container-root to dank (1000), which owns /opt data roots
and /mnt/entropy. Config dirs chowned 1000:1000 at bring-up.

Also drops a duplicate 'networks: [appnet]' key on prowlarr.
2026-09-04 12:14:09 -07:00
Frank 19b2589362 Add arr stack to homepage admin section — sonarr on user side
Six arr services added:
- Sonarr → user section (after Seerr)
- Prowlarr, Radarr, Readarr, Lidarr, qBittorrent → admin section

All with hostname URLs (via Caddy vhosts) and probe ports on dankserver.
2026-09-04 01:03:36 -07:00
Frank e0bade6789 Add arr stack vhosts to Caddyfile — public blocks + :8017 named handlers
The arr stack (prowlarr, radarr, sonarr, lidarr, readarr, qbittorrent)
had A records in addn-hosts but no Caddyfile blocks. This adds:

- Six public vhost blocks in the dankserver section, each proxying to
  192.168.0.235:<port> (exempt by construction: own auth, no Anubis)
- Six @arr named handlers inside :8017 for authenticated traffic routing

All 233 bats tests pass.
2026-09-04 00:31:37 -07:00
Frank 19675849aa Update service descriptions in shared/homepage/services.json 2026-09-03 22:54:09 -07:00
Frank 5797c0ee3f Format shared/homepage/services.json with proper indentation 2026-09-03 21:39:17 -07:00
Frank df0c669815 feat(npu): boot nuextract3:4b as frank-npu's resident model
Boot tag flips from qwen3.8-distilled-heretic:9b to nuextract3:4b (verified
live: per-request routing already serves every registered tag; the boot tag
only decides which model is resident at start, so the first request after a
swap pays the load cost). FLM_CTX_LEN=-1 drops the global --ctx-len override
so each pack loads with its own trained context (nuextract3 32768, gemma4
131072).
2026-09-03 17:37:41 -07:00
Frank b563053966 test(e2e): accept IdP redirect as a valid gate in the direct-nav guard spec
Live run against the fleet showed the outpost redirects direct navigation to
auth.franksdank.cloud with the guarded host preserved as redirect_uri, rather
than rendering the flow UI in place. The security property under test is that
an unauthenticated visitor never sees app content; assert that shape (uidField
visible + no leak markers + gate is either in-place or a redirect carrying the
return path) instead of hostname preservation.
2026-09-03 17:00:34 -07:00
Frank 8212d4451a docs: rename frank-default-large/small/sightsound to frank-large/small/npu across vault
Mechanical identity rename across the knowledge base, AGENTS.md, README, the
hermes config example, and homepage services.json. No behavior change; served
aliases on the LLM endpoints change with the deploy of commit 69f87c8.
2026-09-03 16:17:14 -07:00
Frank 5a083eee6c test(e2e): prove guard chain blocks direct navigation and admins keep admin reach
14-guard.spec.ts: every authentik-guarded vhost (www, search, romm, files,
seerr, grafana, prometheus, loki) must challenge an unauthenticated direct
navigation; plex is asserted exempt (PMS answers directly, no IdP form).
11-admin.spec.ts: admin direct-nav to grafana/prometheus/loki passes through
with host preserved and no uidField challenge.
2026-09-03 16:16:11 -07:00
Frank 69f87c8483 feat(llm): rename inference services dropping 'default' from names
frank-default-large -> frank-large (Dankclaw :7770), frank-default-small ->
frank-small (Dankserver :7770), frank-default-sightsound -> frank-npu
(Dankclaw :7771). Units, env files, serve scripts, deploy-plan mappings,
deploy-remote dispatch (stop/disable old units + relink + enable new), and the
test seams move together. Retired frank-default-npu.* artifacts moved to
scripts/.AGENTTRASH/ (tracked-in-history; ignored on disk).

Served aliases become frank-large / frank-small via --alias; the NPU endpoint
serves the whole FLM registry, so its rename changes no served tags.
2026-09-03 16:15:43 -07:00
Frank 1e491fa69f docs(hermes,dns): document the new internal-only exposure surface
Post-change doc reconciliation for the hermes vhosts + DNS deletion batch:

- Hermes-Dashboard.md: access is now the hermesadmin.franksdank.cloud vhost
  (direct Caddy proxy to 192.168.0.143:9119, no guard chain — exempt because the
  dashboard enforces its own basic-auth login), plus the raw LAN port. The old
  text claimed an Authentik-guarded public vhost, which was decommissioned.
- Hermes-WebUI.md: description updated — the webui is now also reachable via the
  unguarded hermes.franksdank.cloud vhost (its own password auth is the gate).
- LAN-DNS.md: "add a name in two places" corrected to a two-or-three-step
  decision; internal-only names (prometheus/loki/grafana/seerr) deliberately get
  no public A record and resolve on the LAN only, and DNS-01 issuance is
  unaffected because the challenge is a TXT record in the same zone.
- Seerr.md: DNS row corrected — addn-hosts only since 2026-09-02; NXDOMAIN on WAN.
- hermes-dashboard.container: unit comment no longer claims Authentik termination.

No behavior change in this commit; tests 233/233 green (docs-completeness seam included).
2026-09-02 18:57:38 -07:00
Frank 1e4daaa24a feat(caddy): expose Hermes webui and admin dashboard on internal-only vhosts
Both Hermes services get direct (unguarded) vhosts: each authenticates itself
(webui password; dashboard basic_auth in /data/config.yaml), so no Anubis
challenge and no Authentik proxy provider is required. Upstreams are the host
LAN IP because Caddy itself sits on dankclaw_default, where the gateway
address 10.89.1.1 has nothing serving 8787. Internal-only by DNS absence:
hermes.franksdank.cloud already resolves via Pi-hole addn-hosts; hermesadmin
is added in this same commit so the caddyfile/addn-hosts cross-check (test 178)
stays green.
2026-09-02 17:54:59 -07:00
Frank 2e179b024d docs(hermes): document namespace addressability and fix stale claims
New page Architecture/Container Network Namespaces.md: which local services are
addressable from which Podman network namespace on Dankclaw (rootless podman +
netavark). Verified live 2026-09-02: gateway/webui/CDP run in the host netns and
dial loopback; hermes-dashboard is the only Hermes container on a bridge network
(dankclaw_default, 10.89.1.0/24 gw 10.89.1.1) and can dial published ports only —
unpublished host-netns listeners (signal-cli :8080, webui :8787) are refused from
that namespace even though both bind 0.0.0.0.

Corrected while verifying the above:
- Hermes.md: data root is /home/dank/Containers/hermes/data (not the retired
  /home/dank/hermes-data); gateway dials the Signal daemon on loopback; the
  daemon itself is a host user unit (signal-cli-daemon.service) bound to
  0.0.0.0:8080, so the Signal HTTP API is LAN-reachable.
- Hermes-Dashboard.md: it is a Quadlet unit (Network=dankclaw_default), not a
  compose service; data root + /opt/data anonymous volume corrected.
- Hermes-WebUI.md: binds 0.0.0.0:8787 in the host netns (verified: unauthenticated
  GET /health from another LAN machine returned 200) — the SSH tunnel is an access
  path, not a containment boundary; HealthCmd and data-path corrections.
- Network Architecture.md: no hermes vhost exists in either the tracked or the live
  Caddyfile (hermes.franksdank.cloud resolves but routes nowhere); SearXNG exits the
  tunnel via the shared named network searxng.network, not service:gluetun.

Docs only — deploy is a no-op for this change.
2026-09-02 16:10:57 -07:00
Frank 834ad54112 homepage: probe Plex over TCP, not HTTP
PMS 1.43 serves TLS-only on 32400: a plain-HTTP GET gets a TLS alert and
curl reports "empty reply from server" (exit 52, code 000), so the card read
down while Plex answered 200 at /web/index.html over HTTPS. TCP connect is the
honest liveness signal for this port; the public URL stays HTTP because Caddy
terminates TLS and re-keys the path.
2026-09-02 13:51:25 -07:00
Frank 089255d45d homepage: repoint the Hermes probe at the live webui health port
The Hermes card probed 127.0.0.1:8090, a port retired when the hermes
stack moved from compose (8090 web dashboard) to quadlets: the dashboard
now publishes 9119 and the webui listens on 0.0.0.0:8787 in the host netns.
Nothing has listened on 8090 since, so the card reported "down" while the
gateway, dashboard, and webui were all running.

Probe 127.0.0.1:8787 instead — the webui runs HERMES_HOME=/data, the same
agent state as the gateway, and /health answers only when that server is
alive (uptime, sessions, active runs). The prober runs on Dankclaw, so the
loopback probe hits the host netns where the webui binds.
2026-09-02 13:40:44 -07:00
Frank 8895a02512 dankserver: entropy read-only fleet-wide; jellyfin gets whole /mnt/entropy at /data 2026-09-01 23:03:45 -07:00
Frank 6302508dfe plex: restore Network=dankserver_appnet:alias=plex (line churn ate it)
The C5 probe failed for the wrong reason: plex.container lost its Network=
key in the same edit churn that ate tautulli's ContainerName. Plex ran on
the default podman network — published 32400 still worked (clients fine)
but tautulli could not resolve the plex alias, breaking the seeded
PMS_Web_Host=plex. Restore appnet membership; structure now parallel to
tautulli.container. Lesson recorded: after multi-hunk line edits on these
unit files, diff the whole file — key-order churn has eaten three keys
already.
2026-09-01 23:02:31 -07:00
Frank 39c8135a36 tautulli: restore ContainerName=tautulli (line churn ate it)
Without ContainerName= the v1 generator names the container
systemd-tautulli; the fleet references containers by their plain names
(podman inspect/exec, deployer health checks). Container was healthy but
renamed — caught by post-cutover verification, not by the seam; the seam
only asserts file keys, so pin the name here and keep plex/tautulli
structures parallel.
2026-09-01 22:58:44 -07:00
Frank c694053950 tautulli healthcheck via curl; restore PublishPort; plex gate to 600s
- tautulli.container: HealthCmd switches to the repo's proven curl pattern
  (jellyfin unit) — quadlet's systemd-style value parsing mangles embedded
  double quotes; PublishPort=8181:8181 restored (a mis-aimed line edit
  replaced it; the PublishPort seam test caught the regression)
- deploy_plex readiness gate: 36x5s -> 120x5s; the 2026-09 cutover showed
  the first container boot of a years-old library runs ~10 min while PMS
  pre-binds 32400 and empty-replies; 180s failed the gate twice with a
  healthy, still-booting service
2026-09-01 22:56:19 -07:00
Frank 58aa084d9c quadlet: drop the userns identity model — rootless cannot map uid 997
Evidence from the failed first cutover: podman top shows container root
maps to dank (1000) under UserNS=host rootless (subuid range 100000:65536
cannot express 997), so PUID=997 fell back to root and PMS crash-looped on
write access to its 997:984-owned data root. Fleet model instead: PUID=0/
PGID=0 (container root = dank, same as jellyfin/copyparty data roots) and
deploy_plex chowns /var/lib/plexmediaserver to 1000:1000 inside the
first-cutover block. Zero bytes moved; rollback regains one chown -R
997:984 step, documented in Plex.md. Seam test pins PUID=0/PGID=0 and the
absence of any userns key.
2026-09-01 22:46:00 -07:00
Frank 1939135e4c quadlet: UsernsMode= is compose syntax — use the v1 key UserNS=host
podman 5.7 podman-user-generator on dankserver hard-fails the whole unit on
the unknown key ('unsupported key UsernsMode in group Container'), so the
first cutover attempt masked the native unit and then found no plex.service
at all. Swap both units to UserNS=host (same --userns=host flag), update the
ownership-contract seam test to pin the exact key, and correct both docs.
Pin the seam: the v1 generator rejects unknown Container keys silently at
deploy distance — the test now pins UserNS=, not the misspelled alias.
2026-09-01 22:36:43 -07:00
Frank 592a0baa35 docs: plex and tautulli runtime pages reflect the quadlet migration
- runtime rows: podman quadlet under the systemd user generator, pinned
  image digests, in-place bind mounts, identity model, appnet aliases
- cutover/rollback section on Plex.md (mask-native-unit lever, decommission
  gate); drop the false 'updates are apt-only' and 'snap refresh' gotchas
- update path is now deploy.sh dankserver --service plex|tautulli
2026-09-01 22:31:38 -07:00
Frank eebf2f6c7d dankserver: add plex and tautulli quadlet units
- plex: lscr.io/linuxserver/plex:1.43.3.10896-cb3ebc72d-ls322 (verified ==
  ghcr latest digest; plan's ls9 pin superseded by the re-verify clause),
  /var/lib/plexmediaserver bind-mounted in place as /config, /mnt/entropy
  mounted ro at the same path, PUID=997/PGID=984 + UsernsMode=host so the
  container's file identity is byte-compatible with the native unit
- tautulli: lscr.io/linuxserver/tautulli:v2.18.1-ls242 (verified == ghcr
  latest digest), fresh /home/dank/Containers/tautulli config root,
  PUID=1000/PGID=1000, python3 /home healthcheck
- both on dankserver_appnet with aliases; 32400/8181 published as before
2026-09-01 22:31:26 -07:00
Frank 93f16f68be deployer: route plex and tautulli as dankserver quadlet services
- deploy_plex(): install unit, stop+mask native plexmediaserver.service on
  first cutover (mask, never disable), gate on /identity for 180s
- deploy_tautulli(): install unit, stop+disable snap on first cutover,
  seed config.ini with PMS_Web_Host=plex only if absent
- allow-lists (deploy.sh valid_service, deploy-remote.sh service_is_allowed),
  planner routing (SERVICE_ORDER, DANKSERVER_SERVICES, quadlet_services),
  test-changed cutover case, quadlet seam enumeration + new
  userns/PUID=997/PGID=984 ownership-contract test
2026-09-01 22:31:14 -07:00
Frank 28f8dc2c0d homepage: point Plex link to /web/index.html 2026-09-01 20:45:49 -07:00
Frank 54239294b7 authentik: require flows + application on proxy providers; harden SSO e2e
The loki/grafana/seerr proxy providers were script-provisioned with no
flows and no Application: null flows made the embedded outpost's Rust
deserializer reject the whole config (all guarded vhosts down after any
outpost reload), and the missing apps bounced post-login to authentik's
user UI instead of the app. Backfilled both live; documented the two
provisioning rules and updated the provider inventory. SSO e2e now
asserts the app landing (pathname leaves /if/, no IdP markers in body)
instead of only the hostname.
2026-09-01 18:55:44 -07:00
Frank ffae9b5e21 qBittorrent pinned to 4.6.7; all five download clients live-verified
- qB 5.x's 204 login breaks Readarr's retired client at runtime; 4.6.7 answers
  legacy 200+Ok. Pin recorded in the unit and documented.
- Prowlarr download client created via schema template; all five clients test
  200 through the podman gateway.
- PBKDF2 gotcha: qB 5.x conf rewrite left a hash 4.6.7 rejects; fresh rewrite
  of the same line restored login.
- E2E: torrent through the Panama tunnel at ~17 MB/s, completed into Finished
  Torrents, deleted with data.
2026-09-01 18:10:32 -07:00
Frank a6da2221d7 frankbook-pro: pin qbittorrent to 4.6.7 (Readarr's retired client needs qB 4.x auth) 2026-09-01 18:00:59 -07:00
Frank ef1b077a2c homepage: move ComfyUI tile from user grid to admin panel
ComfyUI is internal-only since the 2026-08-31 vhost decommission; the
status tile belongs with the other admin-category services, not the
user grid.
2026-09-01 17:48:52 -07:00
Frank ea0bba3023 docs(authentik): outpost claim-cache gotcha (proxysession) + Containers data paths 2026-09-01 17:43:15 -07:00
Frank b945dd08cc docs: E2E Suite page + seams-table rows (quadlet, browser e2e) 2026-09-01 17:15:47 -07:00
Frank 00b7eb75d4 docs: Anubis v1.27.0 — domain-wide cookie, persistent key, new gotchas
- Facts table: image pin, COOKIE_DOMAIN, persistent ED25519 key via host
  env file; restarts no longer invalidate solved challenges.
- Gotcha 8: X-Real-Ip mandatory at v1.27+ (500 without; chain sets it).
- Gotcha 9: DENY renders error-styled page at HTTP 200 — probe bodies,
  not statuses.
- Verify live: probe www (apex 308s), version-marker grep, body-based
  deny check. All commands executed live before commit.
2026-09-01 17:13:51 -07:00
Frank 4e1c8a873f e2e: harden akLogin against Lit stage re-render; settle per hop in crawl/sso 2026-09-01 17:09:40 -07:00
Frank 912c0edeff e2e: budget user/admin title asserts to 60s for cold Anubis PoW 2026-09-01 17:09:17 -07:00
Frank 9c593476af anubis: pin v1.27.0, fleet-wide cookie domain, persistent signing key
- Image bump v1.13.0 -> v1.27.0 (deploy_anubis pull+tag updated; JSON
  policy schema unchanged at this release).
- COOKIE_DOMAIN=franksdank.cloud: the challenge cookie now covers all
  guarded *.franksdank.cloud vhosts — solve the PoW once, not per-host.
- ED25519_PRIVATE_KEY_HEX via host env file (0600) with gitea-style
  [Service] EnvironmentFile expansion: restarts no longer invalidate
  everyone's solved challenges.
- Scratch-verified v1.27.0 before pinning: policy loads, challenge/DENY/
  ALLOW all behave, X-Real-Ip now mandatory (chain always sets it).
2026-09-01 17:09:09 -07:00
Frank 7457a9c607 deploy.sh: accept dankclaw:homepage as a valid explicit service (drift vs deploy-plan.py) 2026-09-01 16:43:06 -07:00
Frank dfd9c8d1c0 e2e: Playwright suite for homepage + guard chain; RBAC gate on /admin; fix stale service links 2026-09-01 16:42:33 -07:00
Frank e513007aa4 deploy-remote: seed qB password under WebUI\\Password_PBKDF2 (qB 4.2+ ignores the legacy key) 2026-09-01 16:42:30 -07:00
Frank 94d9d249a1 docs: FrankBook-Pro active-tunnel reality (PA-12, 6881 retirement, qB wiring + save-path convention) 2026-09-01 16:32:16 -07:00
Frank d2ef56442d deploy-remote: strip systemd bullet from frankbook unit resolution 2026-09-01 16:18:23 -07:00
Frank f1171b99cc docs: log entry reflects full port scan of Proton endpoint 2026-09-01 15:59:29 -07:00
Frank 3094ffa746 docs: identify WG endpoint as ProtonVPN Seattle; Mac gate is a second dashboard WG config 2026-09-01 15:58:22 -07:00
Frank 2d06be876f docs: correct Jellyfin admin-password claim (archive OCR corruption); prepare Mac WG peer 2026-09-01 15:53:13 -07:00
Frank 6a67a9067f docs: Jellyfin LDAP-Auth integration (outpost + plugin) and authentik LDAP rules 2026-09-01 15:43:04 -07:00
Frank f4ef2b81ed authentik-outpost-ldap: pin image by amd64 digest
podman 5.7.0 refuses ghcr.io/goauthentik/ldap:2026.8.0's nested manifest
list ('Unexpectedly received a manifest list instead of a manifest for a
single image'); pin the linux/amd64 single-platform digest instead.
2026-09-01 15:27:31 -07:00
Frank 691da478ac deploy-remote: allow authentik-outpost-ldap in service_is_allowed 2026-09-01 15:26:26 -07:00
Frank 4ad85fae70 authentik: add LDAP outpost service for Jellyfin LDAP-Auth
- New authentik-outpost-ldap quadlet: ghcr.io/goauthentik/ldap:2026.8.0,
  publishes host 389/636 -> container 3389/6636 for cross-host binds from
  dankserver's Jellyfin LDAP-Auth plugin; outpost token in host .env.
- deploy-plan.py: register authentik-outpost-ldap in SERVICE_ORDER and
  DANKCLAW_SERVICES; map dankclaw/authentik/quadlet/ unit path.
- deploy-remote.sh: deploy_authentik_outpost_ldap handler + dispatch case.
- deploy.sh: valid-service entry.
2026-09-01 15:25:56 -07:00
Frank 792eb19feb docs: Seerr service page, index entry, and 2026-09-01 work log 2026-09-01 14:47:54 -07:00
Frank f4c9b4f381 seerr: quadlet on dankclaw + guard-chain vhost + deployer wiring
Unit: ghcr.io/seerr-team/seerr:v3.4.1, loopback-only 5055 publish
(homepage probe; users go through Anubis->Authentik->caddy:8017->seerr),
Containers data root, no env secrets. Guard chain: seerr.franksdank.cloud
vhost behind anubis:8923 with post-auth handler on 8017; addn-hosts record;
homepage widget; deploy-remote/deploy-plan/deploy.sh routing; seam test.
2026-09-01 14:40:10 -07:00
Frank b6be6d3da6 docs: FrankBook-Pro page + 2026-09-01 log
Document the Mac *arr/qBittorrent stack (VM placement, gluetun credential
gate, socat LaunchAgents, PBKDF2 qB seed) and log the day: dankserver
recovery + full quadlet cutover of the six media services, the frankbook
skip-propagation/PBKDF2 handler fix, the 7-service frankbook deploy, and
the jellyfin GPU-parity finding.
2026-09-01 14:32:57 -07:00
Frank 1a257c06fe frankbook: propagate skip-state before restart, PBKDF2 qB seed
The frankbook handler ran port verification even for services the
credential gate skipped, so a parked qbittorrent failed the deploy and
aborted the five arrs. Gate now lives Mac-side (test -s gluetun/env
inside the VM) before restart+verify.

The qB first-boot seed wrote a plaintext WebUI\Password, which qB >= 4.2
ignores (PBKDF2 @ByteArray only), and omitted HostHeaderValidation=false,
which would 401 the LAN forwarder path. Seed now generates the PBKDF2
hash in the VM (python3 hashlib) and sets ServerDomains=* +
HostHeaderValidation=false. Arr categories (lidarr/radarr/tv-sonarr)
pre-created with save_path=/Volumes/COMPLETE/Finished Torrents so any
grab lands in the review dir regardless of wiring details.
2026-09-01 14:22:30 -07:00
Frank fe34f04454 docs: record FreeToken compressed-tensors FP8 loader failures; Huihui restored
Ornith per-channel aborts in expert-source resolution (nonstandard
tensor naming); HauhauCS block-fp8 128x128 aborts in _dequant_fp8_weight
(shape mismatch). modelopt block-fp8 is the only FP8 path. Log entry +
runbook quant matrix updated; dead downloads staged to host trash.
2026-09-01 14:11:39 -07:00
Frank 949f68e2b5 frankbook-pro: *arr stack + qbittorrent quadlets inside podman machine
Seven units installed INSIDE the Mac VM (/home/core/.config/containers/
systemd) by a new deploy_frankbook_stack handler: gluetun (ProtonVPN
WireGuard, env-file credentials, publishes qbit's 8080/6881), qbittorrent
(Network=gluetun.container, verbatim /Volumes data roots, first-boot
config seed with fresh machine-local WebUI password and the Finished
Torrents category), and prowlarr/sonarr/radarr/lidarr/readarr (grab-only,
config migrated from dankserver at provisioning, readarr pinned to
amd64-1.0.980). deploy.sh routes the new target through the ssh-config
alias with REMOTE_SHELL=/usr/local/bin/bash (macOS bash 3.2 lacks
associative arrays); the planner learns FRANKBOOK_SERVICES and the
gluetun->qbittorrent netns pairing; new seam test pins the contract.
2026-09-01 14:10:23 -07:00
Frank 4d5bea42e0 frank-default-small: revert to Huihui FP8 — FreeToken v0.1.2 cannot load compressed-tensors FP8
Two live-verified loader failures on Dankserver (2026-09-01), each a
clean exit at load (no swap-thrash, 37 GB fits RAM):

1. ornith-ai/Ornith-1.5-35B-A3B-FP8 (compressed-tensors PER-CHANNEL fp8):
   ValueError: Missing MoE expert source layers: {'gate_up': [0, 1, ...]}
   — nonstandard expert tensor naming, provider finds no expert sources.

2. lamianlbe/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-fp8
   (compressed-tensors BLOCK-fp8 128x128, standard per-expert naming):
   RuntimeError: The size of tensor a (2048) must match the size of
   tensor b (...) in _dequant_fp8_weight — the dequant path multiplies
   weight * weight_scale directly and cannot expand compressed-tensors
   block scales.

Empirical matrix for FreeToken v0.1.2 on qwen3_5_moe (RTX 3090):
  modelopt block-fp8 (quant_method 'fp8' + weight_scale_inv)  -> LOADS
  compressed-tensors per-channel FP8                          -> fails
  compressed-tensors block-fp8 128x128                        -> fails

batsclamp/Huihui-...-abliterated-FP8 is the proven uncensored checkpoint;
restoring it until a modelopt/NVFP4 requant of a wanted model exists or
FreeToken learns compressed-tensors.
2026-09-01 14:02:27 -07:00
Frank 272ef6f0b0 docs: record quadlet standardization of dankserver media + dankclaw data roots
Phase 1/2 of the podman standardization plan: service pages updated to the
quadlet reality (units, Containers data roots, env-file secret convention,
the v1-generator findings each caught live), Storage Policy documents the
two rootless ownership regimes and the merge-move migration, Deployment
Flow's service-manager table no longer claims dankserver media is bare
inventory-only. Bundle change log entry added.
2026-09-01 13:57:33 -07:00
Frank 34ab6b4d0c frank-default-small: retarget to lamianlbe/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-fp8
Ornith-1.5-35B-A3B-FP8 failed to load on FreeToken v0.1.2: its
compressed-tensors checkpoint uses a nonstandard expert tensor layout
(FreeToken: 'Missing MoE expert source layers: gate_up').  Retarget to
the HauhauCS Aggressive FP8 finetune of the SAME Qwen3.6-35B-A3B arch
Huihui used, which carries compressed-tensors BLOCK-fp8 (128x128) with
the standard per-expert naming (mlp.experts.N.gate_proj/up_proj/down_proj
+ .weight_scale) that FreeToken's MoE loader reads.

- MODEL_DIR -> /home/dank/models/hauhaucs-qwen36-35b-a3b-aggressive-fp8
- use the checkpoint's own chat_template.jinja (drop the Sharp override;
  forcing a template the finetune wasn't trained on risks format drift)
- pool plan unchanged (same ~35 GB FP8 mass / qwen3_5_moe arch)
2026-09-01 13:56:22 -07:00
Frank a14a70a404 dankserver: copyparty PublishPort=3923 + seam pinning host-served ports
The cutover unit joined dankserver_appnet but dropped the compose-era
0.0.0.0:3923 mapping (live inspect: bridge, 3923 published), so
copyparty listened inside the netns while the host refused. New seam
asserts every host-facing port matches the pre-cutover PortBindings:
romm 8085->8080, jellyfin 8096, calibre-web 8083, copyparty 3923.
2026-09-01 13:53:10 -07:00
Frank 06de2b4cbf dankserver: jellyfin GPU device via AddDevice=, seam guard for Device=
The podman 5.x v1 quadlet generator rejected the whole jellyfin unit on
the unsupported Device= key (generator log: unsupported key 'Device' in
group 'Container'), so no jellyfin.service was generated and the cutover
restart failed with 'Unit not found' after the data root had already
migrated cleanly. gluetun proves AddDevice= is the house key. New seam
test denies Device= across all six dankserver units and pins the
AddDevice=/dev/dri replacement.
2026-09-01 13:49:45 -07:00
Frank 26b0d4c357 deploy-remote: privileged rename + deep merge in migrate_data_root
rename() of a directory requires write on the directory itself; mariadb/
redis data roots are owned by the containers' mapped subuids (100998),
so the deploy user cannot move them. Use run_privileged mv (numeric
ownership preserved = rootless mapping contract) and skip the chown pass
for romm via the new keep mode — chowning mapped subuid data to the
deploy uid breaks the in-container mysql/redis users. Collisions with
rescued anonymous-volume stubs now merge one level deep instead of
stranding real data under /opt. Reverse the podman rm order so the
dependent romm container is removed before its backends.
2026-09-01 13:42:31 -07:00
Frank a18bb62d30 deploy-remote: ensure quadlet unit dir exists for all handlers
dankserver's ~/.config/containers/systemd did not exist (no quadlet units
had ever been installed there); the dankserver media handlers cp'd into it
without mkdir -p, failing before touching any container.
2026-09-01 13:40:17 -07:00
Frank af6cd72d02 frank-default-small: swap to ornith-ai/Ornith-1.5-35B-A3B-FP8 + Qwen-Sharp chat template
- MODEL_DIR -> /home/dank/models/ornith-1.5-35b-a3b-fp8 (37 GB,
  compressed-tensors per-channel FP8, same qwen3_5_moe arch/mass as
  Huihui so the pool plan carries unchanged)
- CHAT_TEMPLATE_FILE -> scripts/chat-template.jinja (Qwen-Sharp, shared
  with frank-default-large); the checkpoint's stock template
  mis-serializes reasoning per its own model card
- env/serve/unit headers updated; loader-risk + BF16-does-not-fit note
2026-09-01 13:40:12 -07:00
Frank cdfaf48191 deploy-remote: migrate_data_root merges into pre-seeded destinations
mv of a data root onto a non-empty directory fails (Directory not empty).
The romm/copyparty cutover creates Containers/<svc>/ before migrating:
the seed env file lands there first, and the romm anonymous-volume rescue
extracts into it before the /opt move. Merge the old root's entries in
without overwriting existing state (mv -n), warn and leave residue if a
collision kept the new side, and keep the plain rename when the
destination is absent or empty.
2026-09-01 13:38:31 -07:00
Frank 9e904d23a3 deploy-remote: daemon-reload after copying hermes units, before restarts
The first deploy after a unit-file edit restarted with the stale generated
unit (generator had not seen the new Volume= paths), crash-looping hermes on
statfs ENOENT for the just-renamed data root. Reload between cp and restart.
2026-09-01 05:16:27 -07:00
Frank e570d92bb9 dankclaw: standardize gitea/authentik/hermes data roots under /home/dank/Containers
Repoint Volume=/EnvironmentFile= in the 8 affected quadlet units to the
caddy-established Containers/<svc> layout: gitea (data, postgres, env),
authentik (data, postgres), hermes (data, webui checkout). Data-root mv on the
target happens at deploy time before each group's unit restart; same-fs rename,
instantly reversible. Running containers keep their inode-bound binds until the
unit restart flips them to the new path.
2026-09-01 04:57:41 -07:00
Frank b206f477ad dankserver: quadlet units + deploy handlers for romm trio, jellyfin, calibre-web, copyparty
Cutover of the six surviving dankserver services from compose-era bare
containers to boot-persistent Quadlet units (WantedBy=default.target; podman
5.x v1 generator honors it at user-manager start).

Units are faithful to live inspect ground truth (image pins = running tags,
not compose inventory):
- romm trio shares one data root: /home/dank/Containers/romm mounted whole at
  /romm; mariadb datadir and redis data dir live inside it. Aliases mariadb/
  redis preserved on dankserver_appnet via Network=...:alias=. Secrets stay in
  the host-local env file (EnvironmentFile + ${VAR} expansion in the generated
  ExecStart, gitea precedent). copyparty passes its admin account via
  Exec=-a ${COPAY_ACCOUNT}:${COPAY_PASSWORD} (Exec= not Command=: the v1
  generator hard-fails on Command=); entropy stays rw only there.
- jellyfin keeps every creation-time env as Environment= lines (zero drift);
  Device=/dev/dri + GroupAdd=keep-groups preserve hardware transcoding.
- calibre-web pins the exact running image tag (id 986277563e76 =
  0.6.27-ls397), pasta network preserved.

Deployer: first-cutover handlers retire the old container, rescue unshadowed
anonymous-volume content (podman volume export + tar --skip-old-files), mv
data roots on the same fs, then start units in dependency order with health
gates. Re-deploys restart only the selected unit. deploy-plan.py maps compose
keys mariadb/redis to the new unit names; allowlists updated. New seam test
tests/dankserver_quadlet.bats pins boot-persistence + v1-generator contract;
test-changed.sh maps the new dirs to it. Full suite green (209 tests).
2026-09-01 04:14:08 -07:00
Frank 11638442e7 Revert frank-default-small to Huihui FP8: Thomson-1.0-Small is BF16 ~66GB, exceeds Dankserver 62GB RAM (thrash/OOM); needs a quantized checkpoint 2026-09-01 02:53:20 -07:00
Frank 415f64964a frank-default-small: swap Huihui FP8 for thomsonreuters/Thomson-1.0-Small 2026-09-01 02:11:35 -07:00
Frank bb5508b2ec docs: frank-default-sightsound runbook — model swap + tag switching 2026-09-01 01:53:59 -07:00
Frank 1401bd42d4 NPU: swap gemma4-it:e4b for qwen3.8-distilled-heretic:9b 2026-09-01 01:51:44 -07:00
Frank 5ba32ccecd Add FLM gemma4-it:e4b model to Hermes config
- Add dankclaw-npu provider with gemma4-it:e4b model at port 7771
- Wire auxiliary vision routing to FLM (sight+sound NPU model)
2026-08-31 17:10:24 -07:00
Frank 8e1b00646f copyparty: read-only volume (no uploads); correct runbook anon-perms claim
-v /w::A granted anonymous full rwmda on the LAN (verified: anon PUT -> 201,
PROPFIND -> 207). New perms ::r:ra,dank: browse/download for anonymous
(required by the guarded files.franksdank.cloud chain), admin for dank,
no w/m/d for anyone. Runbook corrected with the --help-accounts semantics.
2026-08-31 16:37:38 -07:00
Frank 207235420c hermes: CDP browser daemon + MCP client wiring for the agent
Adds a digest-pinned headless-shell Chrome DevTools Protocol daemon as a
boot-persistent quadlet unit (chrome-devtools-protocol.container). The
entrypoint override drops the image's socat front so the CDP endpoint binds
loopback-only on 127.0.0.1:9222 in the host network namespace; only the two
host-network consumers (Signal gateway, webui server) can drive it.

hermes-webui flips Network=dankclaw_default -> Network=host so its agent
process shares the daemon's namespace (0.0.0.0:8787 bind is LAN-facing as
before; no exposure change). deploy_hermes installs the new unit and starts
it if inactive without restarting in-flight browser sessions.
2026-08-31 16:25:28 -07:00
Frank f1dd586fbb refactor(caddy): retire decommissioned vhosts, rename chat->ai and jellyfin->jelly
DNS at Porkbun was renamed (chat->ai, jellyfin->jelly) and the qbittorrent,
*arr-stack, comfyui, hermes, dankclaw, and dankserver public records were
deleted. This brings the Caddy config in lockstep: ai/jelly vhosts now match
the new names; comfyui (guarded), hermes (guarded), and the *arr/qb vhosts
are removed as their DNS is gone. The legacy dankclaw/dankserver redirect
vhosts stay: Pi-hole still answers those names on the LAN.
2026-08-31 14:42:10 -07:00
Frank 44438979b3 feat(hermes-webui): publish webui on LAN interface instead of loopback
LAN clients now connect directly to 192.168.0.143:8787 (password-gated via
HERMES_WEBUI_PASSWORD); the SSH tunnel via port 22 remains the sole
off-network ingress. Recreation also re-reads the rotated password from
dankclaw/.env via the [Container] EnvironmentFile.
2026-08-31 13:13:07 -07:00
Frank ba0345ae7a docs(searxng): record verified agent-API contract under the Anubis guard
Verified live 2026-08-31: guarded path serves the Anubis JS challenge at
HTTP 200 to non-browser API clients (status-only consumers silently get
HTML); direct published port 8081 from pass_ip sources returns real JSON.
Decision: no second agent-only instance — same engines and same
rate-sensitive VPN exit IP would see double request volume for zero gain.
2026-08-31 06:49:35 -07:00
Frank c527f35bd4 docs(hermes): persistent SSH-tunnel ingress design for Hermex (tunnel user + restricted key options)
Server-side implementation landed on Dankclaw: dedicated 'tunnel' account
(nologin shell, password-locked, key-only auth), sshd drop-in
99-tunnel.conf scoping AllowTcpForwarding to that user with PermitTTY off,
and permitopen="localhost:8787" key-option guidance so a leaked tunnel key
can only forward to the webui port. WebUI stays loopback-only published.
2026-08-31 06:23:13 -07:00
Frank 9d678122d7 docs: grafana OAuth2-RP contract + authentik fork quirks (live-verified 2026-08-31)
Token exchange chain verified end-to-end in-process on the live provider
(pk 9): authorize -> ORM-minted code -> form-encoded token exchange with
Basic client auth -> userinfo. Key findings documented:
- Application row + grant_types must exist before authorize can complete
- token endpoint consumes form-encoded bodies only (JSON body => no
  grant_type parsed -> unsupported_grant_type); client auth is Basic-only
- PropertyMappingEvaluator.evaluate(*args,**kwargs) forwards kwargs to
  BaseEvaluator.evaluate(expression_source), so context kwargs become the
  expression source and every evaluation raises a swallowed exception;
  userinfo therefore answers sub-only claims for every provider in this
  fork. Scope granting filter (provider= M2M descriptor) is always empty.
- Grafana identity = sub hash until the evaluator bug is fixed upstream.
2026-08-31 06:06:57 -07:00
Frank 3d64f2ca99 grafana: switch auth_proxy -> OAuth2 relying-party against authentik
The embedded outpost carries no provider token (token=None), so the
auth_proxy identity header was never set and no user could log in. RP
mode is topology-independent: challenge gate stays in front, login runs
inside Grafana via the OAuth2 code flow against auth.franksdank.cloud.

Provider 'grafana' (pk 9, confidential) created via ak shell; secret
written straight into the live .env (never in git). Auth URL uses the
global /application/o/authorize/ path — this fork resolves the provider
by client_id query param; per-slug authorize does not exist.
Caddyfile comment corrected: no identity header is forwarded to Grafana.
2026-08-31 03:32:53 -07:00
Frank ec03929d95 grafana: auth_proxy behind the Authentik guard chain
Grafana leaves the exempt-by-construction set: grafana.franksdank.cloud
now terminates at the Anubis guard (anubis:8923) and authenticated
traffic returns at the internal :8017 listener, where an @grafana Host
handler proxies through to grafana:3000 with the outpost-proxy trust
token (GRAFANA_PROXY_TOKEN = client_id_client_secret of the new
'grafana-proxy' ProxyProvider on the embedded outpost; created via
ak shell before this route flip — provider-first ordering per the
External Exposure Policy).

The Grafana admin account is intentionally kept: auth.proxy in
auto-upsert mode only creates non-admin users, so the local admin
remains the only way to administer Grafana. The old edge-basicauth
wrapper is retired (shared family password, no per-user audit trail).

- Caddyfile: grafana vhost flipped to the guarded chain; @grafana
  handler added at the :8017 return listener.
- compose.yaml: GF_AUTH_PROXY_ENABLED/HEADERS/TOKEN on the grafana
  service (auth_proxy trusted-proxy CIDRs left at the image default —
  the proxying Caddy container is not statically addressable on
  10.89.0.0/24, so Grafana trusts any in-network source and relies on
  the token as the capability).
- .env.example: GRAFANA_PROXY_TOKEN placeholder so test env_consistency
  keeps guarding the compose<->env contract.
2026-08-31 01:48:30 -07:00
Frank c6d404ffde AGENTS.md: External Exposure Policy (MANDATORY); comfyui_rocm seam retargeted
Every Caddy-proxied service is now either behind the auth chain or
explicitly internal-only, with the provider-first flip procedure codified.
The comfyui GPU-access seam test now asserts the deployed contract
(group_add keep-groups + udev MODE 0666 on kfd/renderD*) instead of the
pre-fix 'video' group assumption.
2026-08-30 23:26:18 -07:00
Frank a382a3b883 caddy: put loki.franksdank.cloud behind the Anubis+Authentik guard chain
Loki's public vhost had zero auth — any host that can resolve DNS could
query all logs via LogQL. The vhost now terminates at anubis:8923 and
authenticated traffic returns at the internal :8017 listener, where a new
@loki host handler routes to loki:3100. The outpost proxy provider
'loki-proxy' (external_host https://loki.franksdank.cloud/, internal_host
http://caddy:8017) was created before this deploy — provider-first
ordering so authenticated traffic never hits the outpost error page.
Ingestion is unaffected: Alloy pushes to loki:3100 on the compose network.
2026-08-30 23:26:16 -07:00
Frank 246ba7737c caddy: strip stray colon prefix from comfyui guard comment
The flip hunk's first comment line was written with a literal ':' prefix
(edit-tool auto-prefix artifact). Caddyfile parses ':# ...' as a matcher token,
which made the following site block an unrecognized directive and failed
config validation — that is why the earlier deploy validated, failed, and
skipped the reload, leaving the live config pre-flip (comfyui still direct).
2026-08-30 19:06:36 -07:00
Frank 055fafc233 udev: match kfd by name only — misc-class devices have no udev subsystem
The first revision guarded the kfd line with SUBSYSTEM=="misc", which never
matches (misc-class devices carry no subsystem for udev matching), so the rule
was read but never fired and /dev/kfd stayed 0660 root:render. Name-only match
applies 0666 on creation and on trigger.

Also fixes test-changed.sh to exit 0 when changed paths map to zero tests
(udev rules are mapped-but-untestable) instead of invoking bats with an empty
selection.
2026-08-30 18:37:33 -07:00
Frank 28eb7531e5 udev: world-writable compute nodes for spawn-independent GPU access
The comfyui container is spawned by a systemd user unit whose spawn context
carries no supplementary groups, so render-group access to /dev/kfd and
renderD128 was structurally unreachable (keep-groups preserved an empty set;
named group_add 'render' fails name lookup inside the userns). World-mode on
the compute nodes is the only spawn-context-independent grant; display cards
keep their modes. Apply: install to /etc/udev/rules.d, udevadm control
--reload-rules, then trigger by subsystem to re-apply modes to live nodes.

Also maps dankclaw/udev/* in the focused-test selector (not bats-testable).
2026-08-30 18:30:34 -07:00
Frank fc4c521fb4 comfyui: use keep-groups instead of a named render group
Rootless podman cannot resolve the literal group name 'render' inside the
container user namespace and fails the start with 'unable to find group
render'. keep-groups preserves the launching user's real supplementary
groups (dank is in render on this host), which is the canonical rootless
ROCm device-access recipe.
2026-08-30 18:13:53 -07:00
Frank 14d99a8a17 comfyui: grant the render group so ROCm can open /dev/kfd
/dev/kfd and /dev/dri/renderD128 are root:render 0660 on this host. The
container passed the device nodes but only joined group video(44), so torch's
HIP init failed with 'No HIP GPUs are available' and main.py never bound
:8188 — every proxy hop to the app returned 502. Group membership is a
create-time property, so this deploy recreates the container.
2026-08-30 18:12:00 -07:00
Frank 5f4377276e caddy: move ComfyUI behind the Anubis + Authentik guard chain
comfyui.franksdank.cloud was exempt (direct reverse_proxy to comfyui:8188).
The comfyui-proxy proxy provider was created on the embedded outpost first
(internal_host http://caddy:8017, flows copied from hermes-proxy), then the
vhost flips onto the guard chain and authenticated traffic routes back via a
@comfy host match. ComfyUI has no auth of its own; this closes the last
unguarded admin surface on the internal hosts.
2026-08-30 17:35:49 -07:00
Frank 75e7217f4d authentik: fix mangled pg_isready health probe
The quadlet generator space-splits HealthCmd into the healthcheck argv, so the
quoted /bin/sh -c form arrived mangled and failed with exit 1 forever (FailingStreak
569) while the DB itself accepted connections. Bare argv survives both exec forms.
2026-08-30 17:35:47 -07:00
Frank 9ef034e5e6 sightsound: move FLM NPU endpoint port 52626 -> 7771
Env file is the source of truth (unit ExecStart runs the serve script which
sources it); serve-script default and the deployer health-gate URL flipped in
lockstep so the post-restart health gate polls the new port. Docs, fleet table,
and both index bullets updated to match.
2026-08-30 14:06:54 -07:00
Frank 9b9f0f9051 docs(sightsound): correct unknown-model-tag contract to live-probed behavior
Prior docs claimed unknown tags silently serve llama3.2:1b. Live probes on the
live endpoint disprove it: /v1/chat/completions answers HTTP 200 with a flat
{"error":"Failed to load <tag> model!"} body and no choices; /v1/completions
answers a nested OpenAI-shaped server_error (code 500) under HTTP 200. Valid
tags route per-request to the named model (response echoes the requested tag);
no restart needed to switch between registry tags. Corrected in the runbook,
the AGENTS.md runtime section, the serve-script header comment, and the
historical FastFlowLM incident note.
2026-08-30 11:41:15 -07:00
Frank 150412bbca docs(sightsound): NPU sight+sound runbook, fleet table, IOMMU/SVA root cause
frank-default-sightsound (FLM v1.0.1, XDNA2 NPU, port 52626) is live on
Dankclaw. Documents the first-deploy crash-loop root cause: iommu=off on the
kernel cmdline breaks the XDNA2 SVA bind (amdxdna_drm_open ret -19), so every
/dev/accel/accel0 open fails with ENODEV; fixed with iommu=pt + reboot.
Whole-registry single-port serving with per-request routing by exact FLM tag;
unknown tags fall back to llama3.2:1b (no 404). Health = GET /v1/version.
Vision + audio towers proven live (image_url color probe; audio control-pair
token-count probe). Updates AGENTS.md fleet table + runtime section, both
index pages, and the change log.
2026-08-30 09:05:22 -07:00
Frank 443adb04a7 hermes: drop the stale NetworkMode key from the gateway unit
The v1 quadlet generator skips the whole unit when [Container] holds an
unsupported key; the stale key was a leftover of a draft edit. Network=host is
the only host-networking key this generator version accepts.
2026-08-30 09:00:14 -07:00
Frank 39f0712746 hermes: Network= is the quadlet v1 key; NetworkMode= is not
The v1 generator on this host skips a unit whose [Container] section holds an
unknown key, so the deploy-time regeneration dropped hermes.service entirely
while the container kept running unmanaged. Network=host generates
--network host verbatim (v1 passes extension-less values through).
2026-08-30 08:39:53 -07:00
Frank e062fdfeff hermes: gateway dials the signal-cli daemon over loopback on host networking
The gateway unit ran on dankclaw_default and could not reach the host
daemon: from that network only DNAT'd published ports accept connections to
the LAN address. The compose era ran the gateway with network_mode: host, so
the daemon was reachable; the unit silently changed topology at takeover and
broke the adapter. Host networking + a loopback dial removes the dependency
entirely — the gateway is an outbound-only client and publishes nothing.
2026-08-30 07:42:07 -07:00
Frank 915d49d663 hermes: boot-persistent quadlet units for the gateway, dashboard, and webui
The trio ran as bare compose containers and died silently at the Aug 26
reboot — nothing restarts compose containers at boot. Each now has a
boot-persistent unit (WantedBy=default.target; the v1 quadlet generator on
this host cannot enable generated units). The deploy handler retires the
compose-era containers only while the units are not yet active, then
(re)starts each unit.

Units run the image under a localhost/hermes-agent:latest alias tag (the
quadlet create path on this host cannot resolve registry-prefixed refs from
local storage); the deploy handler tags first. The gateway's :8090 publish
is dropped — nothing consumes it; the public vhost now routes to the
dashboard's login-gated UI on 9119.
2026-08-30 07:22:20 -07:00
Frank 56763e014e fix(hermes): point the signal adapter at the verified alias path and route hermes.franksdank.cloud to the dashboard
The gateway's SIGNAL_HTTP_URL pointed at the host LAN IP. From container
space on netavark networks only DNAT'd published ports accept connections
to that address; :8080 has no forward, so every connection was refused
and the Signal adapter stayed disconnected since the Aug 26 reboot.
host.containers.internal is the verified working path (probed live from a
probe container on dankclaw_default).

The public hostname also routed to hermes:8090, which serves nothing —
the gateway has no HTTP listener. It now routes to the dashboard's login-
gated admin UI on :9119, behind the existing Anubis+Authentik guard.
2026-08-30 04:43:15 -07:00
Frank cf0c541cc9 deploy-remote: allow frank-default-sightsound on the dankclaw target
The first deploy attempt pulled c3ed3a6 on the target but the remote runner
rejected the explicit --service selection because service_is_allowed() keeps
its own per-target allowlist; add the new LLM service to it.
2026-08-30 04:40:57 -07:00
Frank c3ed3a6e12 sightsound: new FLM NPU sight+sound service on XDNA2 (gemma4-it:e4b)
New Dankclaw service frank-default-sightsound: FLM 1.0.1 serving the
Gemma4-E4B-IT-NPU2 pack (vision+audio+reasoning, 131072 ctx) on the XDNA2
NPU at port 52626. Model choice rationale: gemma4-it is the only NPU2 model
family with BOTH image and audio towers; the uncensored candidate qwythos:9b
is text-only, so sight+sound wins over uncensored here.

Deployer wiring: planner maps env/unit/serve files to the new service name;
deploy-remote gains deploy_llm_sightsound (relink + daemon-reload + enable +
restart + health gate on /v1/version — FLM has no /health route). Unlike the
retired frank-default-npu relink-only path, this unit is boot-enabled.
2026-08-30 04:37:42 -07:00
Frank 0937cfb047 docs: record fleet double swap (large -> kingjones STRIX on pinned fork, small -> Huihui FP8 on FreeToken)
- kingjones-strix-rocmfp4 reactivated as the ACTIVE large runbook:
  runtime build recipe (WEBUI=OFF gotcha), deployed serving profile,
  fa-on-works-unpatched evidence, MTP heads documented unloadable,
  swap procedure + 2026-08-30 validation record
- agentionai-fast-imatrix marked RETIRED (rollback record kept)
- freetoken-frank-default-small: model/lineage/template sections now
  Huihui abliterated FP8; new operational quirk: chat API 422 without
  explicit model field
- LLM index + AGENTS fleet table/runtime sections current; both
  services' history chains fixed (small was still Tiel in AGENTS.md)
- worklog entry added; models.json contextWindow for large corrected
  to the real per-slot 131072 (llama clamps n_ctx_train/parallel)
2026-08-30 02:35:38 -07:00
Frank aa9c1d065a frank-default-small: swap to batsclamp Huihui-Qwen3.6-35B-A3B-abliterated-FP8 on FreeToken
- same qwen3_5_moe arch and native block-fp8 format (e4m3, 128x128
  weight_block_size, un-fused per-expert layout) as the official
  Qwen FP8 checkpoint: identical FreeToken loader path, pool numbers
  unchanged (2048 MoE slots / 453005 KV tokens / 4 requests)
- checkpoint downloaded to /home/dank/models/huihui-qwen36-35b-a3b-abliterated-fp8
  (26 shards, ~35G); official checkpoint kept for rollback
- ships froggeric fixed Qwen3.6 chat template, read from checkpoint
- also refresh both .service Descriptions (large still said STRIX_LEAN)
2026-08-30 02:26:49 -07:00
Frank 3066d810ee frank-default-large: swap to kingjones STRIX model on card-pinned kingjones30/ROCmFPX runtime
- runtime: charlie build-qwen38 -> kingjones30/ROCmFPX (card recipe
  build, LLAMA_BUILD_WEBUI=OFF since npm/HF-bucket provisioning fails;
  all 35 of our serving flags verified present in the tree)
- model: agentionai FAST imatrix -> kingjones777 STRIX single GGUF
  (on-host copy sha256 == HF LFS oid c6770d74...49fa verified)
- args unchanged from our validated profile (401408x2, b4096/ub512,
  t16/tb16, f16 KV, fa on, fit off); mmap stays default per card
  (PLE streams via page cache; --no-mmap = silent cgroup OOM)
2026-08-30 02:05:03 -07:00
Frank f69efcdde6 fix serve script: restore : (not ::) for default-value assignments
The edit tool mangled : to :: on 4 lines. Bash requires : for default
value assignment (:: is not a valid command). Fixes exit code 127.
2026-08-30 01:34:21 -07:00
Frank 1ca3db4ae2 frank-default-large: ubatch 512, disable speculative decoding
- UBATCH_SIZE: 4096 -> 512 (reduce GPU memory pressure)
- SPEC_TYPE: ngram-mod -> none (disable speculative decoding entirely)
2026-08-30 01:24:40 -07:00
Frank e3bceb0506 docs(arr-chain): TV + music search chains proven live; all-off new-row gotcha 2026-08-30 00:43:30 -07:00
Frank 3626c7f40d docs(arr-chain): Prowlarr feed contract cracked, qB PBKDF2 persistence recipe, chain proof 2026-08-30 00:38:09 -07:00
Frank a04c9d7d02 frank-default-large: CTX_SIZE 32768→401408, PARALLEL 1→2 (untested experiment) 2026-08-30 00:04:47 -07:00
Frank a98cec3216 docs: log entry for the guarded-host sign-in fix (providers, :8016 uniform proxy, Anubis transport exemption, auth DNS record) 2026-08-29 23:58:09 -07:00
Frank ee1e038f30 docs: authentik/anubis/network corrected to live-verified outpost behavior
Meta-header routing and forward_auth removed from the described design — both
disproved live. Documents: Host-based provider routing with per-guarded-host
proxy providers (lockstep rule), the null-flows wedge that starves the
embedded outpost config fetch, the Anubis /outpost.goauthentik.io/* transport
exemption, the single-file bind-mount stale-policy inode gotcha, Invalid next
URL as a dead design, and the public-DNS record requirement per guarded host.
2026-08-29 23:55:48 -07:00
Frank 2a72efad44 anubis policy: allow /outpost.goauthentik.io/* transport paths
Login/PoW/POST traffic to the outpost URL space cannot solve a fresh PoW
challenge per request; exempt the transport prefix (authentik's own session
gate still protects it) so the guarded-host sign-in journey completes.
2026-08-29 23:49:42 -07:00
Frank 29f9a86884 caddy+authentik tests: uniform Host-preserving :8016 proxy through embedded outpost
The redirect-to-flow design with an absolute cross-host next= was rejected by
the flow's open-redirect guard at completion ("Request has been denied:
Invalid next URL"), and meta-header routing does not exist in this build
(providers route on the ORIGINAL Host). :8016 now proxies every request whole
to authentik-server:9000 preserving Host; the embedded outpost serves login
in place and forwards authenticated traffic back to :8017. Requires one proxy
provider per guarded host (provisioned live: search/hermes/prometheus/romm/
files + www, flows backfilled). Tests flipped from the meta-header claim to
the upheld contract: Host preserved, no absolute next= redirects.
2026-08-29 23:49:39 -07:00
Frank 7cb6eeb67a frank-default-large: re-enable --flash-attn on via QSA F32-mask patch
fa on aborted graph build: build_attn_qsa called ggml_fill on the
F16-converted kq mask (GGML_ASSERT F32 at ggml.c:5371). Patch mirrors the
MLA sparse path in llm_graph_context::build_attn (F32 base mask for the
top-k surgery, F16 cast only at the attention call, gated on
cparams.flash_attn). Vendored at scripts/patches/qwen4exp-qsa-f32-mask.patch
(sha256 f2f4a486...); the build-qwen38 binary on Dankclaw is rebuilt with
it and validated live at CTX_SIZE=32768 (health 200, real completion).
2026-08-29 23:43:25 -07:00
Frank 101b1f8be2 frank-default-large: cap CTX_SIZE at 32768 (author quick-start) — 87 GiB fully-resident model + context buffer OOMs HIP heap (50.8 GiB at 131072, 103 GiB at 262144) 2026-08-29 23:14:50 -07:00
Frank 7ac172c4f4 frank-default-large: cap CTX_SIZE at 131072 — 262144 OOMs SVM pool (cudaMalloc 103176 MiB failed with fully GPU-resident 87 GiB model) 2026-08-29 22:48:17 -07:00
Frank 2f153c89f4 frank-default-large: restore SPLIT_MODE/FIT/NGL env vars + NGL/FIT/REASONING_FORMAT script defaults (dropped in prior edit; crash-loop with 'NGL: unbound variable') 2026-08-29 22:06:05 -07:00
Frank 9865f9e307 frank-default-large: verify v2 main on build-qwen38 — flash-attn off required, MTP draft incompatible (ngram-mod fallback)
- Main model (v2 joined PLE layout, sha256 1e4160ca…) loads and serves on
  build-qwen38 only with --flash-attn off: the QSA indexer path calls
  ggml_fill on the F16-cast kq mask and aborts graph build (GGML_ASSERT
  F32 at ggml.c:5371). Verified live: model loaded, health 200, real
  completion.
- MTP draft head (agentionai FAST, 2.28 GiB) does not load on this binary:
  qwen4exp loader requires full per-layer blk.<il>.hc_attn_norm.weight,
  head carries only blk.48. Third confirmation (EasiiX Q8_0 + agentionai
  FAST vs kingjones STRIX_LEAN previously, now own-matched pair). Spec
  decoding runs ngram-mod only; head retained for a future compatible build.
- FLASH_ATTN=off and SPEC_TYPE=ngram-mod wired through env + serve script;
  runbook and AGENTS.md updated with the verified constraints.
2026-08-29 21:39:37 -07:00
Frank 3c2a5d2fe7 frank-default-large: swap to agentionai Qwen3.8-Flash-Next ROCmFP4-FAST imatrix + own MTP draft
Model: agentionai/Qwen3.8-Flash-Next-ROCmFP4-FAST-imatrix-GGUF (87.06 GiB,
Q4_0_ROCMFP4_FAST imatrix-calibrated, per-head PLE table inside the GGUF —
fully VRAM-resident). Draft: agentionai/Qwen3.8-Flash-Next-MTP-ROCmFP4-FAST
(2.28 GiB, matched ROCmFP4-FAST quant, ~66% acceptance at n-max 3).
Vision tower (f16) loaded via mmproj/.

Runtime: charlie12345/ROCmFPX build-qwen38 (HIP, ROCm 7.2.4, gfx1151) —
the only binary with the ROCmFP4 FAST types. Replaces the jcbtc CIRU v1.1
deployment (ciru-ai runtime).

Profile: card sampling table (temp 0.7, top_p 0.8, top_k 20, min_p 0.0,
presence 1.5) mixed with the validated build-qwen38 serving profile:
262144 ctx x 1 slot, b 4096 / ub 4096, t 16 / tb 16, f16 KV, fa on,
cont-batching, prompt cache + 8 GiB cache-ram + idle slots, 16 ctx
checkpoints (no --checkpoint-min-step — unsupported on this binary),
--fit off (auto-fit segfaults on qwen4exp hyper-connection config),
--spec-type draft-mtp,ngram-mod with n-max 3 (adaptive p-split measured
worse per author card), p-min 0.75. Qwen-Sharp chat template kept;
reasoning stays in DeepSeek format.

The 2026-08-28 failure of this draft head on the kingjones STRIX_LEAN
model was a cross-vendor pairing problem: the head carries
blk.48.hc_attn_norm.weight (verified in-GGUF, same shape as the working
CIRU draft). Docs: new runbook agentionai-fast-imatrix, index updates.
2026-08-29 20:41:12 -07:00
Frank e22631d892 frank-default-large: swap to CIRU v1.1 runtime + jcbtc Qwen3.8-Flash-CIRU-STRIX-IU4
Runtime: ciru-ai/Qwen3.8-Flash-CIRU-STRIX-IU4 llama.cpp at tag v1.1, built
with the vendor build-linux-amd.sh flag set plus GGML_CUDA_FA +
GGML_CUDA_FA_ALL_QUANTS (the Q4_1_MOE_FORCE_J path is live in the hipified
mmq kernels). Model: jcbtc/Qwen3.8-Flash-CIRU-STRIX-IU4 (Q4_1 IU4 experts,
MTP Q8_0 draft, CIRUPLE1 PLE sidecar) with checksums verified at download.

Vendor production profile verbatim on first deployment: 262144 ctx x 1 slot,
b 2048 / ub 512, t 8 / tb 8, f16 KV, fa on, cont-batching, prompt cache +
8 GiB cache-ram + idle slots, 32 ctx checkpoints (min step 8192), MTP depth
3 with p-split 0.10, PLE pager env knobs (Q41_MOE_FORCE_J=32,
PLE_WORKERS=16, PLE_STRICT_SHA=0, HIPBLASLT=1). Qwen-Sharp chat template
kept deliberately; reasoning stays in DeepSeek format.
2026-08-29 19:30:04 -07:00
Frank d29d2ea29e caddy: guarded vhosts route to Authentik's flow endpoint, not the outpost prefix
This 2026.x build does not mount /outpost.goauthentik.io/* for proxy-provider
meta-header routing (probed live: ping answers 204, app paths with full meta
headers 404). Guarded vhosts now redirect to /if/flow/default-authentication-flow/
with next= back to the app — unauthenticated sees the login page in place,
authenticated sessions complete instantly and bounce straight to the app.
2026-08-29 18:29:57 -07:00
Frank 8898740ca4 caddy: www holds the homepage; naked domain redirects at the TLS edge
The proxy provider matches external_host exactly, so Host=www matched zero
providers and Authentik answered its error page while the naked domain
served the homepage. Flip the topology: www.franksdank.cloud is the one
guarded vhost carrying the homepage through Anubis -> Authentik -> :8017;
franksdank.cloud (plus legacy names and the bare-IP catch-all) 308-redirects
onto www before any challenge. Tests flipped to assert the new contract.
2026-08-29 15:11:18 -07:00
Frank 808349bc2b docs(arr): chain complete 4/4 + auth-config pitfall (v1 DryIoc 500 sprawl)
All four arr roots live: /tv, /movies via API; /music, /books created after
v1 validator layers peeled back (quality/metadata profile ids must reference
existing profiles; v1 has no /api/v1/profile endpoint). Auth pitfall: the
literal AuthenticationRequired=Disabled string throws DryIoc.ScopedItemException
in v1 AuthorizationMiddleware on every request — the valid no-auth state is
ABSENCE of the key. Fixed live on four containers, verified boot clean.
2026-08-29 08:19:39 -07:00
Frank 151495c978 docs(arr): chain completion — roots live on sonarr/radarr, PUID=0 identity fix rationale
Sonarr/Radarr root folders created via API (201). Lidarr/Readarr deferred to
first-run wizard (v1 validators demand quality-profile ids that don't exist
yet). Root cause of the write failures: LSIO PUID=1000 under rootless podman
maps to host uid 101000 — a phantom user owning nothing; PUID=0 maps to the
host user that owns every media dir.
2026-08-29 07:30:03 -07:00
Frank b9de886509 fix(dankserver): run arr stack and qb as container-root (PUID=0)
Under rootless podman, LSIO's PUID=1000 maps to host uid 101000 — a phantom
user owning nothing: Sonarr's own validator rejected /tv because the process
could not write it. Container-root under a single-user userns maps to the host
user (dank), which owns every /mnt/entropy media dir and /opt config dir.
Identity is now mapping-independent instead of depending on subuid offsets.
calibre-web untouched (working; churn avoided).
2026-08-29 07:18:50 -07:00
Frank 3a361385a1 docs(arr): record live chain wiring — per-arr qB clients, Prowlarr Applications rows, test-before-enable policy
Verified live: indexers->Prowlarr->per-arr QBittorrent download clients with
category routing. Readarr's client parked (qB proxy auth defect in abandoned
upstream). EZTV/1337x parked with reason (CloudFlare-blocked at this egress);
Nyaa/sukebei/RuTor/Byrutor/YTS passed live tests and are enabled. Prowlarr v2
Applications rows now carry real arr baseUrl+apiKeys; before this every sync
attempt threw NullConfig in GetIndexerMappings.
2026-08-29 06:31:10 -07:00
Frank e4644de722 docs(arr): record live auth posture — zero-user login gate, first-account-wins
Verified live: AuthenticationMethod=None + AuthenticationRequired=Enabled on
all five arrs; unauthenticated API probes return 401 both locally and through
the vhosts. Also records the as-built divergences (entropy mounts, LA timezone,
readarr tag pin, prowlarr mount fix) that supersede the design compose.
2026-08-29 04:58:33 -07:00
Frank eb7448815b docs(caddy): record the h2c-transport gotcha behind the jellyfin vhost 502
Kestrel rejects the h2c upgrade preface; Caddy surfaced that as a 502 per
request while the backend answered 200 on plain HTTP/1.1 from Caddy's network.
Contrast recorded: Plex PMS needs the TLS transport, Jellyfin needs none.
2026-08-29 04:26:37 -07:00
Frank 7e900f6400 fix(caddy): drop h2c transport on jellyfin vhost
Jellyfin's Kestrel backend rejects the h2c upgrade preface, so Caddy turned
every request into a 502 while the same port answers 200 over plain HTTP/1.1
from Caddy's network. Default transport (HTTP/1.1) restores the vhost.
2026-08-29 04:23:59 -07:00
Frank 7190720a9e docs(dankserver): copyparty doc matches the ac edition's real interface
The page claimed an env-var interface (COPAY_PASS/COPAY_MODE) and a /data
volume convention that this image does not implement: the ac edition consumes
accounts and volumes as argv flags (-a USER:PASS, -v SRC:DST:PERMS), serves
the LUKS array at /w, and answers HTTP 500 with its own error page when zero
volumes are configured. Image ref corrected to copyparty-ac (bare latest tag
does not exist on ghcr).
2026-08-29 03:39:23 -07:00
Frank 2c8e65e82f fix(dankserver): copyparty ac edition takes accounts and volumes as argv flags
Ground truth from the image itself: python3 -m copyparty --help-accounts shows
-a takes USER:PASS and -v takes SRC:DST:PERMS:FLAGS — the COPAY_PASS/COPAY_MODE
envs this compose carried are not consumed by this image at all (zero COPAY_
references in the package). With no accounts and no volumes configured, the ac
edition serves its cwd read/write for everyone: the stack was running an
unauthenticated world-writable share of the 91T array. Now: admin account dank
with the A flag on volume /w; anonymous gets nothing.
2026-08-29 03:32:18 -07:00
Frank f3ebe0943a revert(dankserver): drop copyparty command override
The ac edition's entrypoint (cpp.sh -> python3 -m copyparty) consumes config
via /z/initcfg, not argv; -v is the volume flag and ate the next arg,
crash-looping argparse. The image mounts everything under /cfg/copyparty as
volumes by marker-file convention — plant the marker on the host instead.
2026-08-29 03:26:46 -07:00
Frank 9cbd8b8822 fix(dankserver): copyparty ac edition takes volumes as command args
The image entrypoint is /bin/ash /z/cpp.sh -c /z/initcfg: copyparty flags are
passed through command, and the ac edition mounts volumes with -m, not via a
COPAY_VOL env. Without any volume mounted every request resolved zero volumes
and the app answered HTTP 500 with its own error page. Verbose flag included
for the bring-up; drop it once stable.
2026-08-29 03:19:41 -07:00
Frank c70d203ad9 fix(dankserver): copyparty volume root via COPAY_VOL
The ac edition maps volumes from COPAY_* envs; without a volume root every
request resolves zero volumes and the app answers HTTP 500 with its own error
page. Point it at /w where the LUKS array is mounted.
2026-08-29 03:17:02 -07:00
Frank 03fd9e244d fix(dankserver): copyparty serves /w from the array; image is the ac edition
ghcr.io/9001/copyparty:latest does not exist — ghcr answers 403 on the token
request for nonexistent repos, which is why the bring-up failed at pull. The
upstream publishes ghcr.io/9001/copyparty-ac (ac = full edition) and serves
whatever is mounted at /w, so the compose mounts the LUKS array there; config
persists in /opt/copyparty/cfg per upstream layout.
2026-08-29 03:09:27 -07:00
Frank d74ea5bf39 fix(dankserver): jellyfin user-mapping and readarr image tag
Jellyfin ran as user 1000:1000 inside a rootless user namespace, where host
uid 1000 maps to container uid 0 — the pinned uid is unmapped, so /config/log
creation EACCES'd and the service SIGSEGV'd on boot. Container root (= host
dank) is correct under rootless podman; group_add 992 (render) still maps via
the userns group map, so GPU access is preserved.

Readarr: lscr.io/linuxserver/readarr:latest does not exist — LSIO ships only
arch-prefixed nightly/version tags for readarr (verified against the ghcr tag
API). Pin amd64-1.0.980, the newest stable tag.
2026-08-29 03:03:55 -07:00
Frank 67de5cfe9b fix(dankserver): point media mounts at the real entropy array paths
The compose stack mounted /mnt/media/{movies,shows,tv,music,books,downloads}
— a prefix that has never existed on this host. The 91T LUKS array mounts at
/mnt/entropy and its libraries are case-exact (Movies, TVShows, Music,
eBooks). Also restores prowlarr's config mount (eaten by an off-by-one in the
prior edit) and fixes TZ New_York->Los_Angeles on the arr stack.
2026-08-29 02:54:24 -07:00
Frank 2523f3ed43 docs(dankserver): document Plex (native systemd) and Tautulli (snap) as they actually run
Live-verified 2026-08-29: plexmediaserver.service is an enabled native
systemd unit on Dankserver (not a quadlet; updates via apt, not deploy.sh),
and tautulli runs as a snap with no auth wall on 0.0.0.0:8181. The hunt list
named both; neither had a page. Index linked per the docs-completeness seam.
2026-08-29 02:12:08 -07:00
Frank e006ad1f0f docs(anubis): record the challenge-flow contract and the JS-canary gotcha
The policy must ALLOW Anubis's own endpoints before the challenge-all rule
or the challenge challenges itself: browsers POST-solve via make-challenge
then GET-navigate pass-challenge, and a challenged answer renders as the
browser's own fetch-failure error page. Non-JS clients never reach the
verify step, which is why every curl probe looked green while every human
hit a wall. Also records that v1.13.0's valid action verbs are
ALLOW/CHALLENGE/DENY (ACCEPT crash-looped the unit on first deploy).
2026-08-29 01:57:39 -07:00
Frank e10ebf3df4 fix(anubis): ALLOW is the allow action in v1.13.0, not ACCEPT
The pinned binary's policy parser rejects unknown actions and exits 1 on
startup (verified against the image binary: the valid action literals are
ALLOW, CHALLENGE, DENY). The first cut of the own-endpoints accept rule
used ACCEPT and crash-looped the unit on deploy.
2026-08-29 01:20:19 -07:00
Frank a8206a42ae fix(anubis): accept Anubis's own endpoints before the challenge-all rule
The catch-all path_regex "." policy challenged everything, including
Anubis's own API namespace /.within.website/x/cmd/anubis/*
(make-challenge, pass-challenge, static assets). In whole-request proxy
mode a JS-capable browser POSTs its challenge solution to
pass-challenge and received another challenge page (HTML) instead of the
verification verdict (JSON): the challenge app's fetch failed and the
browser rendered its own error — 'the request filters did not return an
alternative response'. Non-JS clients never POST the solution, so every
curl probe looked green while every human hit a wall.

Accept rule ordered above the catch-all; policy is volume-mounted ro from
this checkout, so the deploy updates it in place.
2026-08-29 01:13:17 -07:00
Frank 999cecda7b fix(deploy): allow caddy in the explicit-service gate
deploy-remote.sh already implements dankclaw:caddy (render + reload);
the top-level gate rejected it before dispatch could reach the handler.
2026-08-28 22:30:44 -07:00
Frank ef07e7d01a fix(caddy,authentik): auth guarded vhosts by proxying the whole request through authentik
The forward_auth target /outpost.goauthentik.io/auth/caddy does not exist
in authentik 2026.8 (verified live: 404 for every header combination; the
URL tree has no outpost auth prefix at all — the outpost worker dials back
to core over ws/outpost/<uuid>/ instead). Unauthenticated subrequests got
404, Caddy treated >=400 as 'not authenticated' and redirected browsers to
the dead path — users saw authentik's own Not-Found page.

Fix: guarded vhosts now proxy the whole request through authentik-server:9000
with X-authentik-meta-outpost/-provider/-app headers (the exact header set
this build's Traefik controller emits, adapted for Caddy). Unauthenticated
clients get the login flow served in place; once a session exists,
authentik proxies on to the provider's internal_host — now
http://caddy:8017, a new post-auth internal listener that routes straight to
the app. The chain terminates; nothing dials back into authentik.

Live DB changes applied alongside (postgres is the source of truth):
provider pk=1 authentication_flow=default-authentication-flow (was NULL —
the actual cause of the 404s) and internal_host=http://caddy:8017 (was
self-referential http://authentik-server:9000).
2026-08-28 22:28:32 -07:00
Frank 3a16375cfe deploy: accept authentik-outpost in the top-level service gate 2026-08-28 19:10:35 -07:00
Frank 8be69c0631 deploy: map authentik quadlet units to their own services
The quadlet-dir allowlist silently dropped every change under
dankclaw/authentik/ — the new outpost unit (and its siblings) now route
by exact unit name, mirroring the searxng/gitea pattern. Adds
authentik-outpost to the service inventories; it is a quadlet unit, not
a compose service, so it stays out of the compose set.
2026-08-28 19:09:23 -07:00
Frank 2bccf2f207 authentik: deploy outpost as worker-role container with dial-back
In authentik 2026.8.x the proxy-provider URL space (/outpost.goauthentik.io/*)
is served by the Rust worker binary, not the server — verified live: with a
tenant-matching Host header, /if/flow/... answers 200 on the server while
/auth/caddy still 404s. The outpost instance existed in the DB but no process
ever served it, so Caddy's forward_auth subrequests hit a 404 and browsers got
authentik's own Not-Found page instead of the authentication flow.

The new unit runs the same image as server/worker with Exec=worker and flips
into outpost mode via AUTHENTIK_HOST/AUTHENTIK_INSECURE/AUTHENTIK_TOKEN (the
token is read from the DB into the host .env at deploy time; it never enters
the repo). It dials back to core over websocket and serves forward-auth on its
own 9000. Deploy handler restarts only this unit — the rest of the stack is
untouched.
2026-08-28 19:05:20 -07:00
Frank cf2fe4435c docs: topology diagram as-it-stands — two-tier chain Caddy-TLS, Anubis verify, internal listener 8016
Replaces the pre-Anubis direct-routing diagram with the live chain:
Caddy TLS -> Anubis :8923 verify -> accepted traffic back to Caddy's
internal listener :8016, where per-vhost routing resumes and Authentik's
embedded outpost gates wrapped apps via forward_auth. Guarded set
(homepage, search, hermes, prometheus, romm, files) and the
exempt-by-construction set (auth, plex, gitea git paths, grafana) are
listed explicitly. Access Policy bullet updated: basic auth was replaced
by the challenge-then-forward_auth layering.
2026-08-28 11:56:50 -07:00
Frank 21fa38e6a8 anubis: fix image ref typo in deploy pull, health probe exec form, unit alias tag
Three compounding deploy-handler bugs found by live deployment:
- podman image exists/pull used a mixed-case ghcr.io ref that podman's
  reference parser rejects before any network I/O (repository name must be
  lowercase); all refs now lowercase — ghcr resolves case-insensitively.
- the unit runs the image under a localhost/ alias tag: this host's podman
  5.7 quadlet path fails container create with 'image not known' for
  registry-prefixed refs even when present in storage (verified live).
- the health probe was shell-form and the distroless ko-build image has no
  shell; HealthCmd is now a JSON exec-form array, which passes through the
  quadlet generator's argv assembly verbatim.

Tests updated to assert the alias-tag reality plus the pull guard.
2026-08-28 11:18:41 -07:00
Frank 799441a8be anubis: health probe in JSON exec form for the distroless image
The image has no shell (ko-build distroless; entrypoint /ko-app/anubis),
and this host's quadlet generator joins a bare HealthCmd into one
\x20-escaped argv element that podman then runs shell-form — exec of a
missing shell exits 1 and the container sits unhealthy. A JSON array in
HealthCmd passes through the generator verbatim and executes the binary
directly.
2026-08-28 10:39:09 -07:00
Frank cc1ae18cf2 anubis: declare the health probe in the unit
The v1.13.0 image is distroless: no shell, no HEALTHCHECK in the image
config (verified live: Healthcheck config is null on this host's image),
so quadlet inherits nothing. Declare the probe explicitly in exec form;
'anubis --healthcheck' exits 0 when the challenge server answers.
2026-08-28 10:26:40 -07:00
Frank b83b706f23 anubis: run the image under a localhost/ alias tag
On this host's podman 5.7 quadlet path, a registry-prefixed Image ref
fails to resolve from local storage at container create time even when
the image is present ('image not known'); the identical argv works from
a shell or transient unit. Verified live: an identical unit with
Image=localhost/techarohq/anubis:v1.13.0 starts immediately. The deploy
handler now tags the alias after the guarded pull; upgrades re-tag
after pulling the new tag.
2026-08-28 10:19:48 -07:00
Frank 2e82fc10b9 anubis: guard the pull with podman image exists
image inspect on an absent reference can exit non-zero for reasons
unrelated to existence in a non-tty deploy context; image exists is
the boolean primitive and cannot misreport. Absent-image behavior
(pull from ghcr) is unchanged.
2026-08-28 08:26:37 -07:00
Frank 91532a3805 anubis: repin the image ref to the lowercase ghcr path
podman's reference parser rejects mixed-case repository refs before any
network I/O ('repository name must be uppercase'); upstream's own install
docs pull lowercase (ghcr resolves case-insensitively). The first deploy
of this chain exit-2'd on that parse error — the image was not yet
pulled, so the guarded vhosts 502 until this deploy lands.
2026-08-28 07:05:24 -07:00
Frank 761b70002b deploy-remote: allow anubis in the dankclaw service inventory
The first deploy run dispatched caddy (hot reload, no restart) but exit-2'd
on the inventory guard before deploy_anubis could run: deploy.sh's
valid_service was extended but the remote runner's per-target inventory
was not. Guarded vhosts 502 until this lands.
2026-08-28 06:45:06 -07:00
Frank 0ae3650414 anubis: bot-challenge proxy in front of the guarded vhosts (two-tier chain)
Bots and AI scrapers hit the SSO-fronted vhosts and eat Authentik auth
work for 403s. Anubis verifies clients first: DENY for known AI scraper
UAs, JS challenge for everyone else; verified traffic resumes the normal
Authentik forward_auth chain on Caddy's internal listener (:8016).

Chain: client -> Caddy (TLS) -> Anubis :8923 (verify) -> Caddy :8016
(auth + routing resumes; Host survives both hops) -> app. Reverse-proxy
through Anubis, not forward_auth: at the pinned tag a CHALLENGE answers
HTTP 200 + HTML, and forward_auth treats 200 as "authenticated" — the
page would pass through unchallenged. Exempt by construction (never
routed through Anubis): plex (plain HTTP legacy), auth.* (IdP login
must be reachable ungated), gitea (git smart-HTTP clients cannot solve
JS challenges), and every app with its own login.

Pinned ghcr.io/TecharoHQ/anubis:v1.13.0 — the tag was source-verified
before pinning: at this tag the policy parser is JSON-only with a
minimal bots[] schema (no imports, no CEL expressions), flags are the
config surface via flagenv (TARGET/POLICY_FNAME map to --target/
--policy-fname), and the image's exec-form HEALTHCHECK is inherited by
the quadlet unit (dodges the v1 generator's HealthCmd space-split quirk).

Rollback nuance: stopping anubis 502s everything behind the chain (the
public listeners proxy through it); a true rollback is reverting the
Caddyfile and redeploying, not just systemctl --user stop anubis.
2026-08-28 06:38:24 -07:00
Frank 7b38ea63ef authentik: quote-free health probes — v1 quadlet generator space-splits HealthCmd
Verified live on dankclaw: the podman 5.x v1 quadlet generator splits
HealthCmd values on spaces into the container healthcheck argv array, so
quoted probes (sh -c "pg_isready ...") arrive mangled and every probe
exits 127 — postgres read 'unhealthy' with the database actually up.
Probe forms now use only space-free argv tokens: pg_isready passes its
args directly; the server probe runs manage.py check --database default
(verified exit-0 standalone in the image).
2026-08-28 03:42:28 -07:00
Frank 09db633dca authentik: fix quadlet v1 generator dialect on this host
Probed the live generator on dankclaw: podman 5.x v1 hard-fails unit
generation on dash-prefixed options (--shm-size killed authentik-server's
unit) and rejects Command= entirely; Exec= is the accepted role selector.
Drop --shm-size (uwsgi uses unix sockets under /run, not /dev/shm; default
64m shm is fine) and drop systemctl --user enable — generated units are
transient and cannot be enabled; WantedBy=default.target in each unit file
is what makes them boot-persistent on this generator.
2026-08-28 03:21:31 -07:00
Frank 3c041e4066 test: route comfyui path changes to docs+scoping seams 2026-08-28 03:09:53 -07:00
Frank 914e251ebc authentik: boot-persistent quadlet conversion with deploy handler
The compose-era authentik trio died at every boot (rootless podman does not
auto-start bare containers). Convert the trio to Quadlet units installed by
deploy-remote.sh into ~/.config/containers/systemd/, enabled + WantedBy=
default.target:

- 3 unit files under dankclaw/authentik/quadlet/ (postgres -> server -> worker
  ordering via Wants=/After=; secrets stay in the git-ignored host .env,
  expanded by systemd at start time exactly like the gitea units)
- deploy_authentik() handler: install units, daemon-reload, retire stale
  compose-era containers only while units are inactive, enable once, then
  start in dependency order with podman-health gates between stages
- compose.yaml: authentik service definitions retired to a comment pointing at
  the quadlet dir (same pattern as searxng/open-webui/gitea/caddy)
- tests/authentik.bats rewritten from compose assertions to unit-file
  contract assertions (images, Exec roles, ordering, loopback-only publish,
  pg_isready probe, secret hygiene, dispatch routing)
2026-08-28 03:09:19 -07:00
Frank 5829a0bfe8 docs: rename docs/ -> serverAppConfigs-docs/ per system-wide PROJECTNAME-docs convention
All in-repo path references (AGENTS.md, README, old scripts, report artifacts,
test code paths) rewritten in the same commit; focused-test map extended for
.gitignore, old/scripts/*, dankclaw/comfyui/*, and report/* per this repo's
own convention ('add one before committing'). Obsidian wikilinks are
title-based and unaffected.
2026-08-28 02:20:06 -07:00
Frank 6d4a96a423 frank-default-large: ngram-mod only — both MTP draft heads incompatible
Both the EasiiX Q8_0 and agentionai ROCmFP4-FAST MTP draft heads fail
with 'missing tensor blk.0.hc_attn_norm.weight' against the STRIX_LEAN
Uncensored model. The qwen4exp binary expects this hyper-connection
attn norm tensor which neither draft GGUF carries. Using ngram-mod
only until a compatible MTP head is found.
2026-08-28 01:11:23 -07:00
Frank b1f30e5235 frank-default-large: switch MTP draft head to agentionai ROCmFP4-FAST
The EasiiX Q8_0 draft was incompatible with STRIX_LEAN (missing
blk.0.hc_attn_norm.weight). Switched to agentionai/Qwen3.8-Flash-Next-
MTP-ROCmFP4-FAST-GGUF (2.28 GiB, ROCmFP4-FAST quant matched to target).
n_max 3 per the HF card (n_max 4 measured worse). SPEC_TYPE=draft-mtp,ngram-mod.
2026-08-28 01:01:36 -07:00
Frank 214bad946f bench: fix spec-decode script — use local commands, not nested SSH
The script runs ON Dankclaw but was calling ssh() to SSH back to
dankclaw for service management. Nested SSH fails (no key auth for
localhost). Replaced ssh() with local() using subprocess.run(shell=True)
for systemctl and sed commands.
2026-08-28 00:28:02 -07:00
Frank 5fdf7e706c frank-default-large: fix STRIX_LEAN model paths + add mmproj
The env file had the old STRIX paths (directory doesn't exist on disk).
Updated to the actual running STRIX_LEAN (Uncensored) variant:
- MODEL_DIR → kingjones-Qwen3.8-Flash-Next-Uncensored-ROCmFP4-STRIX_LEAN
- MODEL_FILE → 3-shard Q4_0 (00001-of-00003)
- MMPROJ_PATH → mmproj-Qwen3.8-Flash-Next-Uncensored-BF16.gguf
- serve-frank-large.sh: add --mmproj $MMPROJ_PATH

Reconciles the uncommitted local changes that were on Dankclaw.
2026-08-28 00:05:27 -07:00
Frank 0ff35e6620 bench: spec decode sweep + fix serve script ngram-only path
serve-frank-large.sh: only pass --model-draft when SPEC_TYPE contains
'draft-mtp'. ngram-only configs (ngram-mod, ngram-simple, etc.) do not
need a draft model file — loading one wastes VRAM and may error.

bench-spec-decode.py: new benchmark script. Sweeps 4 configs (none,
ngram-mod, draft-mtp, draft-mtp+ngram-mod), generates 16k tokens per
config at temp 0 from a minimal prompt. Uses HTTP API (llama-bench
cannot target --spec-type). Follows benchmarking policy: stop+mask
the production service, bench, unmask+restart, health check.
2026-08-28 00:04:21 -07:00
Frank fc75ca6ed8 frank-default-large: add MTP draft head (EasiiX Q8_0, draft-mtp+ngram-mod)
Add speculative decoding via EasiiX/Qwen3.8-Flash-Next-MTP-Strix-Halo-GGUF
Q8_0 sidecar (4.1 GB, qwen4exp architecture matching the main model).

- serve-frank-large.sh: SPEC_ARGS array, draft model existence check,
  spec defaults (n_max 4, p_min 0.75, ngl 999, ROCm0)
- frank-default-large.env: MTP tunables section, fix 'no MTP' comment
- kingjones-strix-rocmfp4.md: MTP Draft Head section, spec flags in
  serving profile, EasiiX + PR #27739 citations

Set SPEC_TYPE=none in the env to disable speculative decoding.
2026-08-27 23:52:42 -07:00
Frank cadc7f2768 frank-default-large: threads-batch=16 confirmed optimal + decode benchmark results
Decode sweep (TB=8,16,32, 8K+ prompt, 256 gen tokens):
  TB=8:  3.3 tok/s (76.6s)
  TB=16: 3.4 tok/s (74.8s)
  TB=32: 3.4 tok/s (75.0s)

Decode is flat; TB=16 wins on prefill (5.1% faster) with zero decode tradeoff.

Also:
- Fix :: syntax error in serve-frank-large.sh
- Document systemctl disable destroys unit file; use mask/unmask instead
2026-08-27 13:20:52 -07:00
Frank 26173df1fd docs: update LLM index for STRIX model swap (STRIX_LEAN → STRIX) 2026-08-27 03:31:01 -07:00
Frank fbef13aebe fix: single-colon for shell defaults (:: → :) 2026-08-27 03:28:25 -07:00
Frank 263fa99c16 docs: link STRIX runbook from top-level index 2026-08-27 03:01:27 -07:00
Frank 0cf1f02bb2 merge: FreeToken v0.1.2 replaces llama.cpp for frank-default-small 2026-08-27 02:01:27 -07:00
Frank 6efb0fb231 feat: replace llama.cpp Tiel-Coder with FreeToken v0.1.2 serving Qwen3.6-35B-A3B-FP8
Replace the llama.cpp Tiel-Coder-35B-A3B (UD-Q6_K_XL) deployment with
FreeToken v0.1.2 — an edge-native MoE serving engine that keeps expert
weights in host RAM with an LRU GPU cache and paged KV with radix prefix
sharing.

Model: Qwen/Qwen3.6-35B-A3B-FP8 (modelopt block-fp8, 128×128 weight_block_size).
Architecture: qwen3_5_moe — 40 layers (10 attention + 30 GDN), 256 experts/8 used.

Production config:
- 2048 MoE cache slots (20% of 10240 expert instances, ~6.0 GiB VRAM)
- 453005 KV tokens (~8.6 GiB, ~20 KiB/token for 10 attn layers)
- 4 max-running-requests (mamba slot pool: 24 slots)
- bs4 decode: ~87 tok/s aggregate, ~30 tok/s bs1
- Prefill: ~1,130 tok/s at 4k prompt

Quirk: FreeToken v0.1.2 supports modelopt block-fp8, NVFP4, and per-tensor
FP8, but NOT compressed-tensors per-channel FP8 (Huihui/Ornith both failed).
Live KV resize via ft ctl cache grows KV by shrinking MoE; reverse needs restart.

Old llama.cpp scripts archived to .AGENTTRASH/.
Full benchmark sweep (bs1/bs3/bs4/bs6, 8k–700k KV) documented in
docs/LLM/freetoken-benchmark-sweep.md.
2026-08-27 02:01:16 -07:00
Frank e219fdf939 feat: frank-default-large 2x 256k context slots (CTX_SIZE=524288, PARALLEL=2)
Total KV: ~12.8 GiB (6.4 GiB per slot × 2). Model is 98.5 GiB mmap'd
into 116 GiB SVM-backed unified memory.
2026-08-26 21:26:39 -07:00
Frank e376d6a34e merge: Tiel-Coder-35B (dankserver) + Flash-Next 56B (dankclaw) + config-assertion test removal
Dankserver: ik_llama Ornith CRACK Q8_0 → stock llama.cpp Tiel-Coder-35B-A3B (qwen35moe hybrid MoE, --cpu-moe, 200700 ctx / 3 parallel)
Dankclaw: kingjones Q6_0 AGENT → Flash-Next 56B MoE (qwen4exp, PR #27742 merged into ROCmFPX fork, --fit off, 128k ctx)
Removed tests/frank_{small,large}_config.bats (config-assertion tests, not unit tests)
All 199 remaining tests pass. Both services live and verified.
2026-08-26 21:09:01 -07:00
Frank 2cd30de542 docs: update AGENTS.md LLM fleet topology for Tiel-Coder + Flash-Next swap 2026-08-26 20:49:16 -07:00
Frank 215d74f6ce feat: swap both LLM services + remove config-assertion tests
Dankserver (frank-default-small):
- Stock llama.cpp (latest master, CUDA build) replaces ik_llama.cpp
- Model: peculiar-ragdoll/Tiel-Coder-35B-A3B UD-Q6_K_XL (30 GB)
  Architecture: qwen35moe, 40 layers (10 attn + 30 SSM), 256 experts/8 used
- CTX_SIZE=200700 (66900/slot × 3 parallel), Q8_0 KV (~2 GB cache buffer)
- --cpu-moe for expert offload, -ngl 999 (non-expert on GPU)
- mmap enabled; removed ik_llama-specific flags

Dankclaw (frank-default-large):
- ROCmFPX fork with PR #27742 (qwen4exp arch) merged and built
  at /home/dank/GitPackages/ROCmFPX/build-qwen38
- Model: kingjones777/Qwen3.8-Flash-Next-ROCmFP4-STRIX_LEAN (98.5 GiB)
  Architecture: qwen4exp, 48 layers (12 attn + 36 SSM), 512 experts/10 used
  QSA indexer, hyper-connections, PLE, Q4_0_ROCMFP4 quant
- CTX_SIZE=131072 (128k) × 1 parallel, f16 KV
- --fit off (auto-fit segfaults on qwen4exp hyper-connection config)
- Removed MTP draft, mmproj; kept Qwen-Sharp chat template + reasoning

Tests:
- Removed tests/frank_small_config.bats and tests/frank_large_config.bats
  (moved to .AGENTTRASH/). These were config-assertion tests that checked
  'is variable X set to Y' rather than testing the serve script's actual
  behavior. All 199 remaining tests pass.
- Updated test-changed.sh mappings and changed_tests.bats to reflect the
  removal.
2026-08-26 20:37:46 -07:00
Frank 868c8cfbca caddy: drop the systemctl enable; the quadlet generator already enables it
podman's quadlet generator creates the default.target.wants symlink from
the unit's [Install] section at every user-manager start (is-enabled
reports 'generated' for all healthy quadlets, pihole included). An
explicit 'systemctl --user enable caddy.service' fails with 'transient or
generated' and aborted the deploy after the unit was already installed.
2026-08-26 20:19:03 -07:00
Frank f3c1ac1823 pihole: also neutralize the /etc/sysctl.conf sysctl override
systemd-sysctl applies /etc/sysctl.conf AFTER all of sysctl.d/, so the
stale ip_unprivileged_port_start=80 line there beat every drop-in and kept
the runtime value at 80 after the first deploy pass. deploy_pihole now
comments out an active line in /etc/sysctl.conf (idempotent) and generalizes
the sysctl.d conflict scan to any file setting a value above 53.
2026-08-26 20:08:30 -07:00
Frank 8660209c35 caddy + pihole: make both boot-persistent and self-healing
Caddy left compose.yaml for a quadlet unit (caddy.service): joins
dankclaw_default for container-name upstreams, Pull=never for the local-only
image, Porkbun credentials via /home/dank/Containers/caddy/env written by
deploy from .env, bare-IP-catch-all health check. deploy_caddy installs,
enables, and starts the unit after retiring the bare container (state is in
host volumes).

Pi-hole: the repo now owns the sysctl drop-in
(dankclaw/pihole/sysctl/99-dnsmasq.conf, ip_unprivileged_port_start=53);
deploy_pihole installs it to /etc/sysctl.d/, stages conflicting later-sorted
drop-ins in the home trash, and applies 'sysctl --system' (the boot code
path). This fixes the 2026-08-26 crash loop (port-53 bind denied, restart
counter 1645) caused by a second drop-in resetting the value to 80.

Tests: new caddy_quadlet.bats seam; caddyfile/deployment_flow assertions
repointed from the removed compose service to the quadlet; pihole.bats pins
the sysctl drop-in and its deployment; test-changed.sh routes dankclaw/caddy/
and the pihole subtree to their seams. Docs: Caddy, Pi-hole, Dankclaw Stack,
work log.
2026-08-26 19:52:57 -07:00
Frank 66b58a127b AGENTS.md: mandate self-healing, boot-persistent quadlet units for every container
A bare compose container dies silently at reboot (rootless Podman has no
boot-time auto-start; 'restart: unless-stopped' does not survive shutdown's
explicit stop). Rule established by the 2026-08-26 Dankclaw reboot, which
left Caddy down and Pi-hole crash-looping on a sysctl conflict.
2026-08-26 19:29:12 -07:00
Frank 6fdb49b86f docs: log the hermes-webui (Hermex) deployment and iOS tunnel pattern 2026-08-26 08:28:37 -07:00
Frank a6601e527e hermes-webui: bind 0.0.0.0 inside the container (host publish stays loopback)
Podman's port forwarder dials the container bridge IP; a container-internal
loopback bind made every tunneled connection reset.
2026-08-26 08:25:40 -07:00
Frank 105a4cdfef deploy.sh: accept hermes-webui in the top-level service inventory 2026-08-26 08:22:23 -07:00
Frank bb0404c310 hermes-webui: add Hermex server as loopback compose service on Dankclaw
- compose: hermes-webui runs bootstrap.py --foreground in the agent image,
  shares /home/dank/hermes-data (same uid mapping as the gateway), publishes
  127.0.0.1:8787 only; password from host .env
- planner/remote deployer: hermes-webui is an isolated Dankclaw service
- tests: deployment integrity seam (loopback, auth, volume, healthcheck,
  planner narrowing, deployer dispatch)
- docs: Hermes-WebUI page with the iOS SSH-tunnel access pattern
2026-08-26 08:17:27 -07:00
Frank 24c5eb4e28 frank-default-small: KV K default f16 (no hadamard), V q6_0 + hadamard
K cache back to default f16 full precision with K_CACHE_HADAMARD=0;
V cache at q6_0 with V_CACHE_HADAMARD=1. Env, serve script, bats, and
KB updated.
2026-08-26 05:53:01 -07:00
Frank 8f860d24a6 frank-default-small: 491520 context pool (163840 per slot x 3)
--ctx-size is the total pool divided among --parallel slots, so with
PARALLEL=3 the 160k target must be a 491520 pool (163840/slot) to
actually deliver 160k context per slot. Env, serve default, bats updated.
2026-08-26 05:48:28 -07:00
Frank 04a8fb7806 frank-default-small: PARALLEL=3 160k, KV K q8_0 / V q5_0 + Hadamard
Reconfigure to 3 parallel 160000-ctx slots (was 1 slot). KV cache is
now asymmetric: K at q8_0, V at q5_0, both with Hadamard. The 3 slots
triple the KV, so expert blocks 27-34 are pinned back to CPU (freed
~8.7 GiB) leaving output-adjacent 35-40 on CUDA. Q8_0 model,
split-mode none, spec disabled (ik limits spec to 1 slot). AGENTS.md:
drop the scratch-port rule (test/bench processes cannot share the
single GPU with the running service anyway). Env, serve script, bats,
docs updated.
2026-08-26 05:43:35 -07:00
Frank 8c41c821c4 frank-default-small: Q8_0 model, 160k single slot, q6_0 KV + Hadamard
Switch to CRACK Q8_0 model (37.8 GB, mmproj f16). Servt as a single
160000-ctx slot (PARALLEL=1); speculative decoding stays disabled. KV
cache is q6_0 + Hadamard. iq5_k is not a valid KV-cache type in ik_llama
(cache whitelist excludes iq5_*); q8_KV reproduces the ggml.c:5427 view
assert even at PARALLEL=1/no-spec, so q6_0 is the verified working 6-bit
choice. Env, serve script, tests, and KB updated. Deployment was left
down after the isolation test; this commit redeploys it with the
verified Q8_0/160k/q6_0 config.
2026-08-26 05:01:58 -07:00
Frank 23e905c712 frank-default-small: pull expert blocks 28,29 back to GPU
Fused MoE (fused_moe / fused_up_gate / fused_mmad) is on by default in
ik_llama - no flag needed; only -no-fmoe disables it. Slide two more
expert blocks (28,29) from CPU onto the RTX 3090 (~1.26 GiB) for the
fused-MoE run, leaving ~1.6 GiB headroom. Update override regex, test,
and KB (including a note that the headroom is now tighter than the
2-3 GiB ideal).
2026-08-26 04:05:24 -07:00
Frank 3f3b1bdb27 frank-default-small: render Qwen-Sharp via --jinja; pull more experts to GPU
--jinja: ik_llama only renders a custom --chat-template-file as real Jinja
when --jinja is passed (inputs.use_jinja ? apply_jinja : non-jinja
fallback). Without it the Qwen-Sharp template's thinking/image/tool
formatting is silently dropped. Add USE_JINJA=1 -> --jinja.

Experts: the RTX 3090 had ~5.4 GiB headroom; pull expert blocks 30,32,33,34
back to CUDA (each block ~630 MiB) from CPU for lower latency. Update the
OVERRIDE_EXPERT regex, its test, and the KB with the headroom-check note.
2026-08-26 03:46:54 -07:00
Frank 4144f0f0e8 frank-default-small: disable speculative decoding to keep 3 slots
ik_llama limits speculative decoding to a single server slot (-np 1) and
hard-exits at load if a spec stage is configured with --parallel > 1:
"Speculative decoding is currently limited to a single server slot
(-np 1)." This service keeps PARALLEL=3 (one slot per user), so set
SPEC_TYPE=none to disable speculation. It is a low-parameter MoE (3B
active of 35B), so throughput is fine without it. MTP stays disabled.
KB and tests updated.
2026-08-26 03:38:25 -07:00
Frank 0e5c37f2f8 frank-default-small: use q8_0 (not q8_KV) KV cache on ik_llama
The ik_llama fast q8_KV cache type crashes at model init on this
QWEN3MOE/MTP + multimodal arch at commit b166e269 with a ggml_view size
assert (ggml.c:5427) right after the output buffer is created, looping
the service. Empirically isolated by toggling cache type: q8_0 and f16
load and serve (smoke-tested correct output), q8_KV asserts. Switch to
q8_0 + Hadamard (K/V), which still gives the 8-bit cache + Hadamard
quality win. KB updated with the crash lesson.
2026-08-26 03:23:18 -07:00
Frank e2085f1f7d frank-default-small: use split-mode none on single-GPU dankserver
--split-mode graph requires >= 2 devices in ik_llama.cpp and errors hard
at model load on a single RTX 3090 ("It is not possible to use split mode
'graph' with less than 2 devices"), looping the service. Set SPLIT_MODE
=none (the default), which works with the --override-tensor CPU expert
offload. KB updated with the two-device requirement so we do not assume
graph degrades gracefully on one GPU.
2026-08-26 03:06:30 -07:00
Frank b8de25d70b frank-default-small: run on ik_llama.cpp with q8_KV + Hadamard cache
Switch frank-default-small on Dankserver from upstream llama.cpp (b10121)
to ikawrakow/ik_llama.cpp (b166e269, built with the staged pip cu13
toolkit). The model (Ornith-1.5-35B-A3B CRACK Q6_K) is unchanged and the
old binary is kept for rollback.

Runtime changes:
- KV cache quantized to q8_KV with --k-cache-hadamard/--v-cache-hadamard
- --split-mode graph (single-GPU no-op, retained per operator pref)
- migrate ngram speculation to canonical
  --spec-type ngram-mod:n_min=48,n_max=64,ngram_size_n=24
  (legacy --spec-ngram-mod-* are rejected in ik main)
- N_GPU_LAYERS 999 (ik -ngl rejects the literal "all"); experts stay
  pinned to CPU via --override-tensor

Build: GGML_CUDA_FA_ALL_QUANTS=ON + GGML_IQK_FA_ALL_QUANTS=ON mandatory
for the quantized KV cache; recipe + reasoning baked into
docs/LLM/ikllama-ornith.md. Tests and both indices updated.
2026-08-26 02:59:34 -07:00
Frank c8d7ed65c6 fix: use tls_insecure_skip_verify token (this Caddy build has no tls{} transport sub-block)
Caddy v2.11.4's caddyfile adapter rejects 'tls { insecure_skip_verify }'
inside transport http (unrecognized subdirective '{') and also rejects
the handler-level form. The working form, verified live against PMS with
a throwaway caddy (200 OK), is the bare token:

    reverse_proxy 192.168.0.235:32400 {
        transport http {
            tls_insecure_skip_verify
        }
    }
2026-08-26 02:16:42 -07:00
Frank aa473aeddb fix: proxy Plex through a TLS upstream (PMS drops plain HTTP off-loopback)
PMS 1.43.x on :32400 accepts, reads, and silently closes every non-TLS
connection from a non-loopback source (loopback exempt). The plain-HTTP
reverse_proxy therefore got EOF and Caddy answered an empty-body 502.
Both Plex vhosts now dial the upstream over TLS with
insecure_skip_verify (PMS self-signed cert, pinned LAN hop only;
client-facing Let's Encrypt TLS unchanged).
2026-08-26 01:27:33 -07:00
Frank daeeb0d198 test: pin TLS upstream transport for Plex vhosts 2026-08-26 01:27:33 -07:00
Frank 67483982c3 Pin dankware resolver to Pi-hole on both default-route links
dankware carries two +DefaultRoute links (enp47s0 eth, wlan0 wifi) that
both receive the same DHCP DNS list. systemd-resolved picked a different
'current' resolver per link, so flat-name resolution flapped between the
LAN IP (192.168.0.143 via wlan0/Pi-hole) and the WAN IP (Comcast v6 via
enp47s0).

Pin 192.168.0.143 as the static IPv4 resolver on both NetworkManager
connections (ipv4.method=auto keeps the DHCP address/gateway/route,
ignore-auto-dns=yes uses only Pi-hole, ignore-auto-routes=no keeps the
DHCP routes), then restart systemd-resolved. resolved.conf's own DNS= is
a per-link fallback, so the link must be pinned.

Flat names now resolve to 192.168.0.143 on every query; apex/unknown
subdomains still fall through to Porkbun (71.227.227.172).
2026-08-26 01:21:36 -07:00
Frank 0150333a6a serve-frank-large: restore Qwen-Sharp chat template + reformat for readability
The v6 restore commits dropped --chat-template-file and
--chat-template-kwargs while the artifact check still validated the
template file, so the Sharp template was checked but never applied.
Re-add both flags (with --jinja) so reasoning is emitted in DeepSeek
format and preserve_thinking/preserve_reasoning are honoured.

Reformat the script one-flag-per-line with long flag aliases
(--gpu-layers, --ctx-size, --ubatch-size, --cache-type-k/v) like
serve-frank-small, and update the 2 frank_large_config assertions
that grep the old short-flag forms (-ctk/-ctv, -ub).

- scripts/serve-frank-large.sh
- tests/frank_large_config.bats
2026-08-26 00:38:39 -07:00
Frank 8d8a5f7842 test-changed: map reorganized dankclaw quadlet subtrees
The pre-commit hook runs scripts/test-changed.sh, which requires a
focused-test mapping for every staged path. The quadlets were moved into
dankclaw/<svc>/quadlet/ but the mapping still only matched top-level
dankclaw/*.container, so any nested quadlet or the pihole addn-hosts/env
data files fell through to the catch-all 'die' and blocked commit.
Add the nested-quadlet mappings (pihole-specific first, then the
dankclaw/*/quadlet/* fallback) plus the pihole data-file paths.
2026-08-25 22:59:54 -07:00
Frank ab0ef3f4b7 Add change log metadata 2026-08-25 22:08:34 -07:00
Frank 786ef4a5cf Preserve retired proxy state during migration 2026-08-25 22:02:14 -07:00
Frank 29af0c751f Make canonical Caddy recreate safely 2026-08-25 22:01:12 -07:00
Frank 8110884e8e Document canonical Caddy migration 2026-08-25 21:59:59 -07:00
Frank d52bcc15f8 Migrate Gitea proxy to canonical Caddy 2026-08-25 21:56:04 -07:00
Frank 35e3e1a636 Set equal batch and ubatch at 8k context 2026-08-25 21:04:00 -07:00
Frank d5b5b6a914 Cover independent observability planning 2026-08-25 20:36:04 -07:00
Frank bc718f04f4 Fix scoped deployment state and config ownership 2026-08-25 20:24:47 -07:00
Frank 3a56a07883 Guard target-owned deployment handlers 2026-08-25 20:13:33 -07:00
Frank a07b8c9306 Deploy services independently by changed paths 2026-08-25 20:13:33 -07:00
Frank 1ba47e4d22 Deploy services independently by changed paths 2026-08-25 20:13:33 -07:00
Frank 2277a2e9ac docs(kv): add bit-density KV cache quant table across 3 runtimes
- Place ik's Trellis (IQ*_KT), IQK (IQ*_K), MXFP4, and dedicated fast KV type
  Q8_KV in a single high->low bits-per-value table with ROCmFPX TurboQuant KV.
- Mark mainline-provided types (*), KV-native vs weight-family-reused-for-KV.
- Hadamard annotated as a transform on top of a base quant, not a row.
- bpw read from exact ggml block structs (QK_K=256, fp16 scale): type_size x 8 / QK,
  verified against the struct comment "6.5625 bits per weight" and the trait table.
  Notes where legacy nominal figures (4.0/4.25) diverge.
- AGENTS.md: add benchmark directive (use llama-bench / vllm bench; ask before
  rolling your own).
2026-08-25 20:03:13 -07:00
Frank 4308f62004 Normalize Hermes local provider names 2026-08-25 19:42:46 -07:00
Frank 8fb5bd7a5a Remove unrelated Hermes reasoning overrides 2026-08-25 19:33:56 -07:00
Frank 5766ff7dab Set ubatch sweep to requested 32k matrix 2026-08-25 19:12:42 -07:00
Frank c02d67513d Update benchmark documentation after harness replacement 2026-08-25 19:08:42 -07:00
Frank 863897f92a Clarify llama-bench harness scope 2026-08-25 19:07:46 -07:00
Frank dc69e2b4b4 Support fixed ubatch batch sweep at 16K context 2026-08-25 18:54:09 -07:00
Frank dfdd4bb6a8 Set Hermes local model reasoning levels 2026-08-25 18:48:04 -07:00
Frank 4e8ea24b2f Record complete 32K ubatch benchmark findings 2026-08-25 18:40:32 -07:00
Frank 929796352a Research llama.cpp runtimes + SGLang; fix AGENTS.md OKF spec link
Fan out subagents to compare mainline llama.cpp, charlie12345/ROCmFPX,
and ik_llama.cpp, then add their findings to the project knowledge base:
- docs/Research/Llama Cpp Runtimes.md  (positioning + quantization + server fit)
- docs/Research/Llama Cpp Code Diff.md (code-level quant diff, table, recommendations)
- docs/Research/SGLang.md              (what SGLang is, comparison, our-boxes fit)
- docs/Research/index.md               (Research progressive-disclosure index)
Cross-linked from docs/index.md (root) and docs/LLM/index.md.

AGENTS.md:
- Add Documentation Authoring section: docs/ is an OKF bundle; point doc authors
  at the canonical spec in the system-config knowledge base
  (~/.pi/agent/docs/references/okf-spec.md), not the orphaned
  docs/references/okf-spec.md; consolidate read-index/one-concept-per-file/
  progressive-disclosure/citation rules.
- Map AGENTS.md to docs_completeness.bats in the focused pre-commit hook (it was
  last committed before the hook existed and had no mapping).
2026-08-25 18:23:19 -07:00
Frank 2693259227 configure anonymous SearXNG provider defaults 2026-08-25 17:58:22 -07:00
Frank e6670f914f docs: record live SearXNG provider baseline 2026-08-25 17:58:22 -07:00
Frank a05584b155 tune SearXNG VPN request timeouts and retries 2026-08-25 17:58:22 -07:00
Frank e1f05bd3c2 Configure Hermes access to small LLM 2026-08-25 17:55:52 -07:00
Frank 96f5ae6fe1 Raise ubatch benchmark context to 32K 2026-08-25 17:44:45 -07:00
Frank 4cb67d7b33 Set ubatch sweep context and batch to 16K 2026-08-25 17:32:12 -07:00
Frank babc823f11 Avoid shell variable collision in bench parser 2026-08-25 17:26:32 -07:00
Frank 56ee7cca45 Pass run number into ubatch benchmark parser 2026-08-25 17:12:51 -07:00
Frank c072c75fa9 Make ubatch benchmark cleanup failure safe 2026-08-25 17:11:42 -07:00
Frank 119ca2493f feat: add tmux session snapshot and restore utility 2026-08-25 17:04:15 -07:00
Frank a8e0b88d99 Parse current llama timing output in ubatch bench 2026-08-25 16:57:02 -07:00
Frank 835996a67b Keep deployment moving when Caddy restart is blocked 2026-08-25 16:55:10 -07:00
Frank eae5bc2565 Continue LLM deployment when compose restart fails 2026-08-25 16:54:18 -07:00
Frank c7a72b465e Fix privileged shared config deployment 2026-08-25 16:53:13 -07:00
Frank d6ea71b9a4 Configure DeepSeek reasoning and large-model ubatch sweep 2026-08-25 16:52:45 -07:00
Frank 8c8c3f7cd0 Enable focused tests as repository pre-commit hook 2026-08-25 16:18:01 -07:00
Frank 63ff1b6b43 Add focused changed-path test wrapper 2026-08-25 16:05:59 -07:00
Frank 7a0feb39aa Add CLI helper for entropy volume 2026-08-25 15:38:57 -07:00
Frank 6ee195dd8f Restore historical Q6 large model runtime 2026-08-25 14:23:47 -07:00
Frank ddfc64013f Fix Q6 AGENT projector path 2026-08-25 13:58:50 -07:00
Frank 86d5f38142 Restore Q6 AGENT large model 2026-08-25 13:36:07 -07:00
Frank 205b11aae4 Clarify historical ROCmFPX payload sizes 2026-08-25 13:28:38 -07:00
Frank 6c9e71efd2 Document Q8 AGENT set_rows serving blocker 2026-08-25 12:16:47 -07:00
Frank 54b2d84613 Upgrade frank-default-large to kingjones Q8_0 AGENT at full 262144 context
frank-default-large now serves the kingjones Qwen3.8-27B-ROCmFPX-Q8_0-AGENT
model with a standalone MTP draft head (Q4_0), f16 K + TurboQuant V KV cache,
and the Sharp chat template. Context is maxed to 262144 (one slot); reasoning
is on with preserve_thinking + preserve_reasoning in the chat kwargs.

- frank-default-large.env: Q8_0 AGENT model paths, CTX_SIZE=262144, REASONING=on,
  REASONING_FORMAT=none, CHAT_TEMPLATE_KWARGS {enable_thinking, preserve_thinking,
  preserve_reasoning}
- serve-frank-large.sh: standalone draft-mtp profile (already switched off the
  Kairic/PromptForge path)
- download-q8agent.sh: reproducible HF fetch of the three payload files
- docs/LLM/kingjones-q8agent-rocmfpx.md: Q8_0 AGENT runbook
- AGENTS.md: topology + ACTIVE runtime section + TTM/GTT load note
- qwen3.8-strix-optimizations, Dankclaw Improvements: TTM pages_limit now 114 GiB
  (applied 2026-08-25), full 262k KV + Q8 payload fit in one slot
- frank_large_config.bats: assert 262144 context, reasoning + preserve_* enabled

The TTM/GTT was raised to 114 GiB (29,883,536 pages) to hold the full 262k KV.
2026-08-25 07:12:02 -07:00
Frank 24efa98d56 docs: add SearXNG providers guide for unblocking Google behind Gluetun VPN
SearXNG queries all ~50 upstream engines from one Gluetun VPN exit IP, so
rate-sensitive engines (Google, DuckDuckGo) answer with reCAPTCHA/429 and
SearXNG suspends the offending engine for hours-to-days, showing up as
"unavailable". Add docs/Dankclaw/SearXNG Providers.md covering: the
suspension model, the two failure modes (CAPTCHA vs selector drift), engine
config (settings.yml engines: list — duplicate google, timeout,
retry_on_http_error, JSON feed), request tuning (outgoing block, UA rotation,
suspended_times — correcting the old rate_limit/useragents blog myths),
routing/proxies (per-engine proxies, dedicated 2nd Gluetun, dedicated IP), and
verification (/json, /config, check.searx.space). Crosslink into SearXNG.md
and index.md.
2026-08-25 06:22:57 -07:00
Frank b457457bfe Switch large service to Kairic Edge v1.2 2026-08-25 04:41:04 -07:00
Frank f86ed70c71 Use conservative four-token MTP drafts 2026-08-25 04:20:15 -07:00
Frank 3cf6353050 Reduce large model context to 64K 2026-08-25 04:13:00 -07:00
Frank b0eb571321 Reduce large model context to 128K 2026-08-25 04:09:12 -07:00
Frank ee3c3de750 Reduce frank-default-large context for KV headroom 2026-08-25 04:05:33 -07:00
355 changed files with 27393 additions and 5958 deletions
@@ -1,60 +0,0 @@
# Tunables for frank-default-large (upstream llama.cpp PR #27342 DFlash2,
# Vulkan backend, dankclaw, Strix Halo gfx1151).
#
# Runtime: llama.cpp PR #27342 built with GGML_VULKAN=ON serving
# unsloth/Qwen3.8-27B-UD-Q8_K_XL (target) with a z-lab DFlash2 drafter
# (Qwen3.8-27B-DFlash2-Q4_K_M). Target at 31.4 GB, DFlash2 drafter at 2.0 GB.
# Alias frank-default-large, port 52625.
#
# DFlash2 runs on the Vulkan (RADV) backend. Its candidate selector needs
# TOP_K + ARGMAX, which the HIP backend does not implement. This replaced
# the CIRU HIP runtime on 2026-08-21. See docs/dflash2-large-vulkan.md.
#
# WARNING: do not name any variable TEMP, HOST, or GPU_MAX_HEAP_SIZE. The
# ROCm HIP runtime reads those bare env vars (see the 2026-08-13 TEMP
# incident). This deployment uses Vulkan, not HIP, but the rule stays for
# portability.
# Upstream llama.cpp DFlash2 binary (PR #27342, Vulkan build).
LLAMA_BIN=/home/dank/llama.cpp-dflash2/build-dflash2/bin/llama-server
# Model files. The target (UD-Q8_K_XL) was already on disk; the DFlash2
# drafter was added for this deployment. Both live in /home/dank/models.
MODEL_DIR=/home/dank/models
MODEL_FILE=Qwen3.8-27B-UD-Q8_K_XL.gguf
DRAFT_FILE=Qwen3.8-27B-DFlash2-Q4_K_M.gguf
ALIAS=frank-default-large
BIND_ADDR=0.0.0.0
LISTEN_PORT=52625
# Serving: full native 262144 context, 1 slot. The Vulkan device reports
# 113942 MiB; KV at 262144 x 64 KiB/cell is ~16 GiB. rocm-smi reports the
# 64 GiB budget; trust the runtime for the addressable SVM pool.
CTX_SIZE=262144
PARALLEL=1
BATCH_SIZE=4096
UBATCH_SIZE=4096
CACHE_TYPE_K=f16
CACHE_TYPE_V=f16
CTX_CHECKPOINTS=16
CACHE_RAM=8192
# DFlash2 speculative decoding. The drafter's trained block size is 8;
# SPEC_DRAFT_N_MAX clamps to it. TOP_K selector (top 16 candidates).
SPEC_TYPE=draft-dflash
SPEC_DRAFT_N_MAX=8
SPEC_DRAFT_N_MIN=0
SPEC_DRAFT_P_MIN=0.0
SPEC_DRAFT_P_SPLIT=0.10
# Sampling (model card defaults, thinking-general).
TEMPERATURE=0.7
TOP_P=0.95
# Thinking enabled. deepseek format moves the thinking reasoning into
# message.reasoning_content (OpenAI-compatible).
REASONING=on
REASONING_FORMAT=deepseek
VERBOSITY=4
@@ -0,0 +1,71 @@
# Tunables for frank-default-small (Tiel-Coder-35B-A3B UD-Q6_K_XL,
# qwen35moe hybrid MoE, Dankserver, RTX 3090).
#
# Runtime: upstream llama.cpp (latest master, CUDA build with
# -DGGML_CUDA_FA_ALL_QUANTS=ON -DCMAKE_CUDA_ARCHITECTURES=86) on RTX 3090
# 24 GB; Ryzen 7 7700X; 62 GB RAM. Replaced the ik_llama.cpp Ornith
# CRACK Q8_0 deployment on 2026-09-01.
#
# Model: peculiar-ragdoll/Tiel-Coder-35B-A3B-GGUF, UD-Q6_K_XL quant
# (30 GB). Architecture: qwen35moe — 40 layers, full_attention_interval=4
# (10 attention + 30 SSM/Mamba), 256 experts / 8 used, 2 KV heads × 256
# dim, embedding 2048, rope dimension_count 64, rope freq_base 10 000 000.
# Text-only (no mmproj). Built-in chat template in the GGUF.
#
# Expert offload: --cpu-moe keeps all 256 expert weights in CPU RAM.
# Non-expert weights (~1.6 GB) fit entirely on the 24 GB card.
#
# KV cache: Q8_0 for both K and V. With CTX_SIZE=200700 (66 900 per slot
# × 3 parallel) and 10 attention layers, the KV cache is ~2 GB — the
# requested 2 GB VRAM buffer.
#
# WARNING: do not name any variable TEMP, HOST, or GPU_MAX_HEAP_SIZE. The
# ROCm HIP runtime reads those bare env vars (see the 2026-08-13 TEMP
# incident). This box is NVIDIA/CUDA, but the rule keeps the env portable.
# Model file (UD-Q6_K_XL, 30 GB, text-only MoE hybrid).
MODEL_DIR=/home/dank/models/tiel-coder-35b
MODEL_FILE=Tiel-Coder-35B-A3B-UD-Q6_K_XL.gguf
# Upstream llama.cpp llama-server (CUDA build, latest master).
# Built with -DGGML_CUDA_FA_ALL_QUANTS=ON -DCMAKE_CUDA_ARCHITECTURES=86
# using the staged CUDA 13.2 toolkit at /home/dank/cuda13-stage.
LLAMA_SERVER=/home/dank/llama.cpp/build/bin/llama-server
ALIAS=frank-default-small
BIND_ADDR=0.0.0.0
LISTEN_PORT=7770
# Total context budget across all parallel slots. 200700 / 3 = 66 900
# per slot. The model's context_length is 262 144, so 66 900 is well
# within range.
CTX_SIZE=200700
PARALLEL=3
# Expert offload: all MoE expert weights pinned to CPU RAM.
CPU_MOE=1
# All non-expert layers on GPU (auto-fit will determine the exact count;
# with --cpu-moe the non-expert footprint is ~1.6 GB, leaving ample room).
N_GPU_LAYERS=999
# KV cache: Q8_0 for both K and V. ~2 GB total for 200700 ctx × 3 slots
# × 10 attention layers × (2 KV heads × 256 dim × 1 byte) × 2 (K+V).
CACHE_TYPE_K=q8_0
CACHE_TYPE_V=q8_0
VERBOSITY=2
# Prefill batch sizing.
BATCH_SIZE=4096
UBATCH_SIZE=1024
# Sampling.
TEMPERATURE=1.0
TOP_K=20
TOP_P=0.95
MIN_P=0.0
# Compute threads lowered so other processes keep a core.
THREADS=7
THREADS_HTTP=8
@@ -1,5 +1,5 @@
[Unit]
Description=llama.cpp Ornith-1.5-35B-A3B CRACK Q6_K ngram-mod + Qwen-Sharp template (dankserver, frank-default-small)
Description=llama.cpp Tiel-Coder-35B-A3B Q6_K_XL qwen35moe hybrid MoE (dankserver, frank-default-small)
After=network-online.target
Wants=network-online.target
+126
View File
@@ -0,0 +1,126 @@
# Tunables for frank-large (kingjones777/Qwen3.8-Flash-Next-ROCmFP4-
# STRIX-GGUF on the kingjones30/ROCmFPX llama.cpp runtime, Dankclaw,
# Strix Halo gfx1151, unified memory).
#
# Runtime: kingjones30/ROCmFPX (fork of charlie12345/ROCmFPX with the
# qwen4exp-on-rocmfpx-d3ca537 patch applied — the model card's pinned
# runtime: ROCmFP4 tensor types AND the qwen4exp architecture in one
# tree; stock llama.cpp and charlie's fork alone cannot load this GGUF).
# Built per the card recipe:
# cmake -B build -DGGML_HIP=ON -DGPU_TARGETS=gfx1151 -DGGML_NATIVE=ON -DCMAKE_BUILD_TYPE=Release
# at /home/dank/GitPackages/kingjones-ROCmFPX/build.
# Replaced the agentionai ROCmFP4-FAST imatrix deployment (charlie's
# build-qwen38 runtime) on 2026-08-30.
#
# Model: Qwen3.8-Flash-Next base (qwen4exp MoE: 512 experts / 10 used,
# 48 layers = 12 attention + 36 SSM, QSA indexer, hyper-connections, PLE
# at layer 1), Q4_0_ROCMFP4_STRIX (file_type 105), 113.47 GiB single
# file. STRIX is the top quality tier: PLE table at Q8_0 (50.66 GiB),
# token embeddings + lm head at Q6_K. The PLE table NEVER enters GPU
# memory — it streams from the file through the OS page cache. Two hard
# card constraints:
# 1. mmap MUST stay enabled (default) — --no-mmap forces the 50.66 GiB
# table into anonymous memory and the cgroup killer takes the
# process with nothing in the server log (only dmesg).
# 2. Do NOT pin the PLE tensor to CPU with -ot — measured gen fell
# ~23 -> 13.4 tok/s; the kernel streams it better.
# NOTE: the card's own build_attn_qsa still calls ggml_fill on the
# F16-cast kq mask (the path that aborted our charlie build with
# --flash-attn on before scripts/patches/qwen4exp-qsa-f32-mask.patch).
# If a clean kingjones-tree build aborts graph build with
# GGML_ASSERT(a-type == GGML_TYPE_F32), port that fix (F32 base mask +
# cast at the attention call) into this tree and rebuild.
#
# WARNING: do not name any variable TEMP, HOST, or GPU_MAX_HEAP_SIZE. The ROCm HIP runtime reads those bare env vars (see the 2026-08-13 TEMP
# incident). llama.cpp reads --host/--port/--temp as CLI flags, never as
# env vars.
# kingjones30/ROCmFPX runtime binary (card recipe build).
LLAMA_BIN=/home/dank/GitPackages/kingjones-ROCmFPX/build/bin/llama-server
# Model package from
# https://huggingface.co/kingjones777/Qwen3.8-Flash-Next-ROCmFP4-STRIX-GGUF
# Layout: single unsharded GGUF, text-only (the repo ships no mmproj;
# the vision tower below is borrowed from the agentionai FAST package —
# same base model, f16 — kept only if it loads clean, drop
# MMPROJ_PATH if it does not).
MODEL_DIR=/home/dank/models/kingjones-Qwen3.8-Flash-Next-ROCmFP4-STRIX
MODEL_FILE=Qwen3.8-Flash-Next-Q4_0-ROCmFP4-STRIX.gguf
MMPROJ_PATH=/home/dank/models/agentionai-Qwen3.8-Flash-Next-ROCmFP4-FAST/mmproj/mmproj-Qwen3.8-Flash-Next-f16.gguf
# Qwen-Sharp chat template (tracked in the repo, staged on the host). The
# Sharp template drives thinking on by default (medium effort); the server
# emits reasoning in DeepSeek format so clients receive reasoning_content
# separately instead of literal thinking tags in content.
CHAT_TEMPLATE_FILE=/home/dank/serverAppConfigs/scripts/chat-template.jinja
CHAT_TEMPLATE_KWARGS='{"enable_thinking":true,"preserve_thinking":true,"preserve_reasoning":true}'
# Alias + network.
ALIAS=frank-large
BIND_ADDR=0.0.0.0
LISTEN_PORT=7770
# Serving: 401408-token context, TWO slots (parallel 2), batch 4096 /
# ubatch 512, 16+16 threads, f16 KV, 16 context checkpoints, 8 GiB
# prompt cache. --fit off: auto-fit segfaults on this model's
# hyper-connection/indexer config; -ngl 999 puts every layer on the
# SVM-backed GPU pool. ubatch 512 matches the card's re-measurement
# (-ub 1024/2048/4096 showed no effect; -t 32 vs 16 is noise).
#
# CTX_SIZE=401408 / PARALLEL=2: carried over from the FAST deployment
# where it loaded clean (~74 s, health 200). The card measures the full
# 262144 window at ~71.7 GiB GTT at load on the same 128 GB board; QSA
# caps KV growth so the extra window is cheap. KV footprint scales
# with the 12 attention layers only. Verify load on first STRIX boot.
CTX_SIZE=401408
PARALLEL=2
BATCH_SIZE=4096
UBATCH_SIZE=512
NUM_THREADS=16
THREAD_BATCH=16
CACHE_TYPE_K=f16
CACHE_TYPE_V=f16
CTX_CHECKPOINTS=16
CACHE_RAM=8192
SLOT_SAVE_PATH=/home/dank/serverAppConfigs/scripts/slot-state
SPLIT_MODE=none
FIT=off
NGL=999
FLASH_ATTN=on
# Sampling (card instruct table, non-thinking).
TEMPERATURE=0.7
TOP_K=20
TOP_P=0.8
MIN_P=0.0
PRESENCE_PENALTY=1.5
# Reasoning on (Sharp template drives it; preserve_thinking + preserve_reasoning
# keep the reasoning trail in the conversation). DeepSeek format projects the
# reasoning into the API's reasoning_content field instead of literal thinking
# tags.
REASONING=on
REASONING_FORMAT=deepseek
# Strix Halo unified memory + native gfx1151.
HSA_OVERRIDE_GFX_VERSION=11.5.1
VERBOSITY=4
# Speculative decoding: disabled. The card's Usage block ships no spec
# flags. The Flash-Next MTP heads do not load on this loader (requires
# per-layer blk.N.hc_attn_norm; every head carries only blk.48 —
# verified 3x on build-qwen38). DRAFT_MODEL_* point at the retired
# agentionai FAST head and are unused while SPEC_TYPE=none.
DRAFT_MODEL_DIR=/home/dank/models/mtp-Qwen3.8-Flash-Next-FAST
DRAFT_MODEL_FILE=Qwen3.8-Flash-Next-MTP-ROCmFP4-FAST.gguf
SPEC_TYPE=none
SPEC_DRAFT_N_MAX=3
SPEC_DRAFT_N_MIN=0
SPEC_DRAFT_P_MIN=0.75
SPEC_DRAFT_NGL=999
SPEC_DRAFT_DEVICE=ROCm0
SPEC_DRAFT_TYPE_K=f16
SPEC_DRAFT_TYPE_V=f16
@@ -1,5 +1,5 @@
[Unit]
Description=CIRU llama.cpp Qwen3.8-27B ROCmFP4 STRIX MTP + Sharp template (dankclaw, frank-default-large)
Description=ROCmFPX (kingjones30 fork) llama.cpp Qwen3.8-Flash-Next ROCmFP4 STRIX 177B MoE (dankclaw, frank-large)
After=network-online.target
Wants=network-online.target
@@ -7,7 +7,7 @@ Wants=network-online.target
Type=simple
User=dank
Group=dank
Environment="FRANK_LARGE_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-default-large.env"
Environment="FRANK_LARGE_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-large.env"
ExecStart=/home/dank/serverAppConfigs/scripts/serve-frank-large.sh
Restart=on-failure
RestartSec=10
+23
View File
@@ -0,0 +1,23 @@
# Tunables for frank-npu (FastFlowLM NPU endpoint, Dankclaw)
# Runtime: FLM 1.0.1 (/usr/bin/flm) on the XDNA2 NPU (50 TOPS). Boot tag is
# nuextract3:4b (NuExtract3-4B-NPU2, Qwen3.5-4B skeleton, structured-extraction
# model); serve routes every request by the exact FLM registry tag in the
# request's model field, so any registered tag (gemma4-it:e4b, qwythos:9b, ...)
# is reachable on this port regardless of the boot tag. The NPU is a
# single-lane device: generations serialize FIFO, and the first request after a
# model swap pays the ~6-11 s load cost. The NPU draws from the unified RAM
# pool shared with frank-large (iGPU).
FLM_MODEL=nuextract3:4b
FLM_HOST=0.0.0.0
FLM_PORT=7771
# turbo = max NPU power mode (xrt-smi configure --pmode turbo at serve start).
# No other tenant uses the NPU on this host, so the top mode costs nothing to
# neighbours; drop to `performance` if iGPU power-budget contention ever shows.
FLM_PMODE=turbo
# FLM_ASR=1 additionally loads Whisper-V3-Turbo-NPU2 (+0.62 GB) and exposes
# /v1/audio/transcriptions. NuExtract3 answers text-only; audio stays off.
FLM_ASR=0
# -1 = no --ctx-len flag: every pack loads with its own trained context
# (nuextract3:4b -> 32768, gemma4-it:e4b -> 131072). A global override would
# over-allocate KV for the small packs.
FLM_CTX_LEN=-1
+17
View File
@@ -0,0 +1,17 @@
[Unit]
Description=FastFlowLM NPU sight+sound inference (frank-npu) on Dankclaw
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=dank
Group=dank
Environment="FRANK_NPU_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-npu.env"
ExecStart=/home/dank/serverAppConfigs/scripts/serve-frank-npu.sh
LimitMEMLOCK=infinity
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
@@ -1,104 +0,0 @@
#!/usr/bin/env bash
# Serve unsloth/Qwen3.8-27B-UD-Q8_K_XL as frank-default-large with a
# z-lab DFlash2 drafter (Qwen3.8-27B-DFlash2-Q4_K_M) on the Vulkan backend.
#
# Runtime: upstream llama.cpp PR #27342 (DFlash2 support) built with
# GGML_VULKAN=ON for the Strix Halo gfx1151 (RADV). DFlash2 selects its
# candidates through TOP_K + ARGMAX ops that the HIP backend does not
# implement, so the whole stack runs on Vulkan0.
#
# The target and the drafter share the Qwen3.8 arch. DFlash is lossless
# under strict rejection sampling. --spec-draft-n-max clamps to the drafter's
# trained block size (8).
#
# Tunables come from frank-default-large.env in this directory unless
# FRANK_LARGE_ENV_FILE overrides that path.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${FRANK_LARGE_ENV_FILE:-$SCRIPT_DIR/frank-default-large.env}"
if [[ -f "$ENV_FILE" ]]; then
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
fi
# --- defaults (used when the env file does not set them) -------------------
: "${LLAMA_BIN:=/home/dank/llama.cpp-dflash2/build-dflash2/bin/llama-server}"
: "${MODEL_DIR:=/home/dank/models}"
: "${MODEL_FILE:=Qwen3.8-27B-UD-Q8_K_XL.gguf}"
: "${DRAFT_FILE:=Qwen3.8-27B-DFlash2-Q4_K_M.gguf}"
: "${ALIAS:=frank-default-large}"
: "${BIND_ADDR:=0.0.0.0}"
: "${LISTEN_PORT:=52625}"
: "${CTX_SIZE:=262144}"
: "${PARALLEL:=1}"
: "${BATCH_SIZE:=4096}"
: "${UBATCH_SIZE:=4096}"
: "${CACHE_TYPE_K:=f16}"
: "${CACHE_TYPE_V:=f16}"
: "${CTX_CHECKPOINTS:=16}"
: "${CACHE_RAM:=8192}"
: "${SPEC_TYPE:=draft-dflash}"
: "${SPEC_DRAFT_N_MAX:=8}"
: "${SPEC_DRAFT_N_MIN:=0}"
: "${SPEC_DRAFT_P_MIN:=0.0}"
: "${SPEC_DRAFT_P_SPLIT:=0.10}"
: "${TEMPERATURE:=0.7}"
: "${TOP_P:=0.95}"
: "${REASONING:=on}"
: "${REASONING_FORMAT:=deepseek}"
: "${VERBOSITY:=4}"
MODEL="$MODEL_DIR/$MODEL_FILE"
DRAFT="$MODEL_DIR/$DRAFT_FILE"
if [[ ! -f "$MODEL" ]]; then
echo "missing required file: $MODEL" >&2
exit 1
fi
if [[ ! -f "$DRAFT" ]]; then
echo "missing DFlash2 drafter: $DRAFT" >&2
exit 1
fi
if [[ ! -x "$LLAMA_BIN" ]]; then
echo "missing llama-server binary: $LLAMA_BIN" >&2
exit 1
fi
exec "$LLAMA_BIN" \
--model "$MODEL" \
--alias "$ALIAS" \
--host "$BIND_ADDR" \
--port "$LISTEN_PORT" \
-dev Vulkan0 \
-ngl 999 \
-c "$CTX_SIZE" \
-b "$BATCH_SIZE" \
-ub "$UBATCH_SIZE" \
-fa on \
-ctk "$CACHE_TYPE_K" \
-ctv "$CACHE_TYPE_V" \
-np "$PARALLEL" \
-ctxcp "$CTX_CHECKPOINTS" \
--cache-ram "$CACHE_RAM" \
--cache-prompt \
--metrics \
--no-mmap \
--spec-type "$SPEC_TYPE" \
--spec-draft-model "$DRAFT" \
--spec-draft-device Vulkan0 \
--spec-draft-ngl 999 \
--spec-draft-type-k f16 \
--spec-draft-type-v f16 \
--spec-draft-n-max "$SPEC_DRAFT_N_MAX" \
--spec-draft-n-min "$SPEC_DRAFT_N_MIN" \
--spec-draft-p-min "$SPEC_DRAFT_P_MIN" \
--spec-draft-p-split "$SPEC_DRAFT_P_SPLIT" \
--temp "$TEMPERATURE" \
--top-p "$TOP_P" \
--reasoning "$REASONING" \
--reasoning-format "$REASONING_FORMAT" \
--reasoning-budget -1 \
-lv "$VERBOSITY"
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env bash
# Serve kingjones777/Qwen3.8-Flash-Next-ROCmFP4-STRIX-GGUF as
# frank-large via the kingjones30/ROCmFPX llama.cpp runtime on
# Dankclaw (Strix Halo gfx1151, unified memory).
#
# Runtime: kingjones30/ROCmFPX (fork of charlie12345/ROCmFPX with the
# model card's qwen4exp-on-rocmfpx patch) built at
# /home/dank/GitPackages/kingjones-ROCmFPX/build (HIP, gfx1151,
# ROCm 7.2.4, card recipe). The ROCmFP4 quant types plus the qwen4exp
# architecture must be in one tree; stock llama.cpp cannot load this
# GGUF.
#
# Model: Qwen3.8-Flash-Next base (qwen4exp MoE: 512 experts / 10 used,
# 48 layers = 12 attention + 36 SSM, QSA indexer, hyper-connections, PLE
# at layer 1) quantized Q4_0_ROCMFP4_STRIX, 113.47 GiB single file. The
# 50.66 GiB PLE n-gram table stays OUT of GPU memory and streams via
# mmap from the OS page cache — never add --no-mmap or -ot for it.
#
# Profile: card sampling table (temp 0.7, top_p 0.80, top_k 20, min_p 0.0,
# presence 1.5 — non-thinking instruct) mixed with the validated serving
# profile: ctx 401408 x 2 slots, b 4096 / ub 512, t 16 / tb 16, f16 KV,
# fa on (if the graph build aborts on the QSA F16 mask, see the note in
# the env file: port scripts/patches/qwen4exp-qsa-f32-mask.patch into
# this tree and rebuild), cont-batching, prompt cache + 8 GiB cache-ram
# + idle-slot cache, 16 ctx checkpoints, --fit off (auto-fit segfaults
# on qwen4exp hyper-connection config). Speculative decoding off: the
# Flash-Next MTP heads do not load on this loader; the card ships no
# spec flags.
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${FRANK_LARGE_ENV_FILE:-$SCRIPT_DIR/frank-large.env}"
if [[ -f "$ENV_FILE" ]]; then
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
fi
# --- defaults (used when the env file does not set them) -------------------
: "${LLAMA_BIN:=/home/dank/GitPackages/kingjones-ROCmFPX/build/bin/llama-server}"
: "${MODEL_DIR:=/home/dank/models/kingjones-Qwen3.8-Flash-Next-ROCmFP4-STRIX}"
: "${MODEL_FILE:=Qwen3.8-Flash-Next-Q4_0-ROCmFP4-STRIX.gguf}"
: "${MMPROJ_PATH:=/home/dank/models/agentionai-Qwen3.8-Flash-Next-ROCmFP4-FAST/mmproj/mmproj-Qwen3.8-Flash-Next-f16.gguf}"
: "${CHAT_TEMPLATE_FILE:=$SCRIPT_DIR/chat-template.jinja}"
: "${CHAT_TEMPLATE_KWARGS:={}}"
: "${ALIAS:=frank-large}"
: "${BIND_ADDR:=0.0.0.0}"
: "${LISTEN_PORT:=7770}"
: "${CTX_SIZE:=401408}"
: "${PARALLEL:=2}"
: "${BATCH_SIZE:=4096}"
: "${UBATCH_SIZE:=512}"
: "${NUM_THREADS:=16}"
: "${THREAD_BATCH:=16}"
: "${CACHE_TYPE_K:=f16}"
: "${CACHE_TYPE_V:=f16}"
: "${CTX_CHECKPOINTS:=16}"
: "${CACHE_RAM:=8192}"
: "${SLOT_SAVE_PATH:=$SCRIPT_DIR/slot-state}"
: "${SPLIT_MODE:=none}"
: "${FIT:=off}"
: "${NGL:=999}"
: "${FLASH_ATTN:=on}"
: "${TEMPERATURE:=0.7}"
: "${TOP_K:=20}"
: "${TOP_P:=0.8}"
: "${MIN_P:=0.0}"
: "${PRESENCE_PENALTY:=1.5}"
: "${REASONING:=on}"
: "${REASONING_FORMAT:=deepseek}"
: "${HSA_OVERRIDE_GFX_VERSION:=11.5.1}"
: "${SPEC_TYPE:=none}"
: "${DRAFT_MODEL_DIR:=/home/dank/models/mtp-Qwen3.8-Flash-Next-FAST}"
: "${DRAFT_MODEL_FILE:=Qwen3.8-Flash-Next-MTP-ROCmFP4-FAST.gguf}"
: "${SPEC_DRAFT_N_MAX:=3}"
: "${SPEC_DRAFT_N_MIN:=0}"
: "${SPEC_DRAFT_P_MIN:=0.75}"
: "${SPEC_DRAFT_NGL:=999}"
: "${SPEC_DRAFT_DEVICE:=ROCm0}"
: "${SPEC_DRAFT_TYPE_K:=f16}"
: "${SPEC_DRAFT_TYPE_V:=f16}"
MODEL="$MODEL_DIR/$MODEL_FILE"
for f in "$LLAMA_BIN" "$MODEL" "$CHAT_TEMPLATE_FILE"; do
if [[ ! -r "$f" ]]; then
echo "missing or unreadable required artifact: $f" >&2
exit 1
fi
done
if [[ ! -x "$LLAMA_BIN" ]]; then
echo "missing executable runtime: $LLAMA_BIN" >&2
exit 1
fi
# --- speculative decoding (MTP draft head) ---------------------------------
# The MTP draft head is a separate GGUF sidecar loaded via --spec-draft-model
# (alias --model-draft). ngram-only specs do not need a draft file.
# Set SPEC_TYPE=none in the env to disable speculative decoding entirely.
SPEC_ARGS=()
if [[ -n "$SPEC_TYPE" && "$SPEC_TYPE" != "none" ]]; then
SPEC_ARGS=(
--spec-type "$SPEC_TYPE"
--spec-draft-n-max "$SPEC_DRAFT_N_MAX"
--spec-draft-n-min "$SPEC_DRAFT_N_MIN"
--spec-draft-p-min "$SPEC_DRAFT_P_MIN"
)
if [[ "$SPEC_TYPE" == *draft-mtp* ]]; then
DRAFT_MODEL="$DRAFT_MODEL_DIR/$DRAFT_MODEL_FILE"
if [[ ! -r "$DRAFT_MODEL" ]]; then
echo "missing or unreadable draft model: $DRAFT_MODEL" >&2
exit 1
fi
SPEC_ARGS+=(
--spec-draft-model "$DRAFT_MODEL"
--spec-draft-ngl "$SPEC_DRAFT_NGL"
--spec-draft-device "$SPEC_DRAFT_DEVICE"
--spec-draft-type-k "$SPEC_DRAFT_TYPE_K"
--spec-draft-type-v "$SPEC_DRAFT_TYPE_V"
)
fi
if [[ -n "${SPEC_DRAFT_P_SPLIT:-}" ]]; then
SPEC_ARGS+=(--spec-draft-p-split "$SPEC_DRAFT_P_SPLIT")
fi
fi
# The STRIX repo ships no mmproj; this is the agentionai FAST package's
# f16 vision tower for the same base model. Passed only when readable.
MMPROJ_ARGS=()
if [[ -n "${MMPROJ_PATH:-}" && -r "${MMPROJ_PATH:-}" ]]; then
MMPROJ_ARGS=(--mmproj "$MMPROJ_PATH")
fi
SERVER_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
mkdir -p "$SLOT_SAVE_PATH"
ROCM_PATH="${ROCM_PATH:-/opt/rocm}"
LIBRARY_PATH="$SERVER_DIR:$ROCM_PATH/lib:$ROCM_PATH/lib/rocm_sysdeps/lib:$ROCM_PATH/lib/llvm/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
exec /usr/bin/env \
ROCM_PATH="$ROCM_PATH" \
HIP_VISIBLE_DEVICES=0 \
LD_LIBRARY_PATH="$LIBRARY_PATH" \
HSA_OVERRIDE_GFX_VERSION="$HSA_OVERRIDE_GFX_VERSION" \
"$LLAMA_BIN" \
--model "$MODEL" \
--alias "$ALIAS" \
--chat-template-file "$CHAT_TEMPLATE_FILE" \
--chat-template-kwargs "$CHAT_TEMPLATE_KWARGS" \
--host "$BIND_ADDR" \
--port "$LISTEN_PORT" \
--jinja \
--device ROCm0 \
--gpu-layers "$NGL" \
--split-mode "$SPLIT_MODE" \
--parallel "$PARALLEL" \
--ctx-size "$CTX_SIZE" \
--batch-size "$BATCH_SIZE" \
--ubatch-size "$UBATCH_SIZE" \
--fit "$FIT" \
--flash-attn "$FLASH_ATTN" \
--cache-type-k "$CACHE_TYPE_K" \
--cache-type-v "$CACHE_TYPE_V" \
--threads "$NUM_THREADS" \
--threads-batch "$THREAD_BATCH" \
--ctx-checkpoints "$CTX_CHECKPOINTS" \
--cache-ram "$CACHE_RAM" \
--slot-save-path "$SLOT_SAVE_PATH" \
--cache-prompt \
--cache-idle-slots \
--metrics \
--temp "$TEMPERATURE" \
--top-p "$TOP_P" \
--top-k "$TOP_K" \
--min-p "$MIN_P" \
--presence-penalty "$PRESENCE_PENALTY" \
--reasoning "$REASONING" \
--reasoning-format "$REASONING_FORMAT" \
--reasoning-budget -1 \
"${MMPROJ_ARGS[@]}" \
"${SPEC_ARGS[@]}"
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# Serve the FastFlowLM (FLM) sight+sound endpoint — frank-npu.
#
# Runs `flm serve <tag>` on the XDNA2 NPU (50 TOPS, Dankclaw). The NPU is a
# single-lane device: generations serialize in FIFO order, so this is a
# background/triage endpoint, not an interactive multi-user chat. The NPU
# draws from the unified RAM pool shared with frank-large (the iGPU
# llama.cpp service); see serverAppConfigs-docs/LLM/frank-npu.md
# for the memory budget and the model-selection tradeoff.
#
# NOTE: FLM requires unlimited memlock. The unit sets LimitMEMLOCK=infinity.
# NOTE: an unknown model tag does NOT 404 — /v1/chat/completions answers HTTP 200 with a
# flat {"error":"Failed to load <tag> model!"} body and no choices; /v1/completions returns a
# nested OpenAI-shaped server_error (code 500) also under HTTP 200. Client model IDs must be the exact FLM registry
# tag (e.g. gemma4-it:e4b), never a service alias. Custom tags only resolve
# when FLM_CONFIG_PATH points at the user registry that carries them and their
# kernels are symlinked into /home/dank/.config/flm/xclbins/<PackName>.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${FRANK_NPU_ENV_FILE:-$SCRIPT_DIR/frank-npu.env}"
if [[ -f "$ENV_FILE" ]]; then
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
fi
# --- defaults (used when the env file does not set them) -------------------
: "${FLM_BIN:=/usr/bin/flm}"
: "${FLM_MODEL:=gemma4-it:e4b}"
: "${FLM_HOST:=0.0.0.0}"
: "${FLM_PORT:=7771}"
: "${FLM_PMODE:=turbo}"
: "${FLM_ASR:=0}"
: "${FLM_CTX_LEN:=131072}"
# The user registry is a superset of the system registry (it carries the
# custom tags too), and its model_path resolves relative to the registry file,
# so exports keep a tag swap in the env file from needing script edits.
export FLM_CONFIG_PATH="${FLM_CONFIG_PATH:-/home/dank/.config/flm/model_list.json}"
export FLM_XCLBIN_PATH="${FLM_XCLBIN_PATH:-/home/dank/.config/flm/xclbins}"
if [[ ! -x "$FLM_BIN" ]]; then
echo "missing flm binary: $FLM_BIN" >&2
exit 1
fi
# --- optional flags ----------------------------------------------------------
OPT_ARGS=()
if [[ "${FLM_ASR}" != "0" ]]; then
OPT_ARGS+=(--asr "${FLM_ASR}")
fi
if [[ "${FLM_CTX_LEN}" != "-1" ]]; then
OPT_ARGS+=(--ctx-len "${FLM_CTX_LEN}")
fi
exec "$FLM_BIN" serve "$FLM_MODEL" \
--host "$FLM_HOST" \
-p "$FLM_PORT" \
--pmode "$FLM_PMODE" \
"${OPT_ARGS[@]}"
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# Serve peculiar-ragdoll/Tiel-Coder-35B-A3B (UD-Q6_K_XL) as
# frank-default-small via stock llama.cpp on Dankserver (RTX 3090).
#
# Runtime: upstream llama.cpp (latest master, CUDA build with
# -DGGML_CUDA_FA_ALL_QUANTS=ON -DCMAKE_CUDA_ARCHITECTURES=86).
# The model is a 35B-total / 8-active MoE hybrid (qwen35moe arch) with
# SSM/Mamba layers (full_attention_interval=4: 10 attention + 30 SSM
# layers out of 40). 256 experts, 8 used per token.
#
# Expert offload: --cpu-moe keeps all 256 expert weights in CPU RAM.
# Non-expert weights (attention, SSM, shared expert, embeddings, output)
# are ~1.6 GB and fit entirely on the 24 GB RTX 3090.
#
# KV cache: Q8_0 for both K and V. With CTX_SIZE=200700 (66 900 per
# slot × 3 parallel) and 10 attention layers (2 KV heads × 256 dim),
# the KV cache is ~2 GB — the requested 2 GB VRAM buffer.
#
# Model is loaded with mmap (default). The 30 GB Q6_K_XL file is
# memory-mapped; only accessed pages enter RAM.
#
# Tunables come from frank-default-small.env unless FRANK_SMALL_ENV_FILE
# overrides that path.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${FRANK_SMALL_ENV_FILE:-$SCRIPT_DIR/frank-default-small.env}"
if [[ -f "$ENV_FILE" ]]; then
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
fi
# --- defaults (used when the env file does not set them) -----------------
: "${MODEL_DIR:=/home/dank/models/tiel-coder-35b}"
: "${MODEL_FILE:=Tiel-Coder-35B-A3B-UD-Q6_K_XL.gguf}"
: "${LLAMA_SERVER:=/home/dank/llama.cpp/build/bin/llama-server}"
: "${ALIAS:=frank-default-small}"
: "${BIND_ADDR:=0.0.0.0}"
: "${LISTEN_PORT:=7770}"
: "${CTX_SIZE:=200700}"
: "${PARALLEL:=3}"
: "${CACHE_TYPE_K:=q8_0}"
: "${CACHE_TYPE_V:=q8_0}"
: "${N_GPU_LAYERS:=999}"
: "${BATCH_SIZE:=4096}"
: "${UBATCH_SIZE:=1024}"
: "${VERBOSITY:=2}"
: "${CPU_MOE:=1}"
: "${TEMPERATURE:=1.0}"
: "${TOP_K:=20}"
: "${TOP_P:=0.95}"
: "${MIN_P:=0.0}"
: "${THREADS:=7}"
: "${THREADS_HTTP:=8}"
MODEL="$MODEL_DIR/$MODEL_FILE"
if [[ ! -f "$MODEL" ]]; then
echo "missing required file: $MODEL" >&2
exit 1
fi
if [[ ! -x "$LLAMA_SERVER" ]]; then
echo "missing llama-server binary: $LLAMA_SERVER" >&2
exit 1
fi
exec "$LLAMA_SERVER" \
--model "$MODEL" \
$([ "$CPU_MOE" = "1" ] && printf -- "--cpu-moe") \
--cache-type-k "$CACHE_TYPE_K" \
--cache-type-v "$CACHE_TYPE_V" \
--ctx-size "$CTX_SIZE" \
--parallel "$PARALLEL" \
--gpu-layers "$N_GPU_LAYERS" \
--batch-size "$BATCH_SIZE" \
--ubatch-size "$UBATCH_SIZE" \
--temp "$TEMPERATURE" \
--top-k "$TOP_K" \
--top-p "$TOP_P" \
--min-p "$MIN_P" \
--threads "$THREADS" \
--threads-http "$THREADS_HTTP" \
--alias "$ALIAS" \
--host "$BIND_ADDR" \
--port "$LISTEN_PORT"
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# Repository pre-commit hook: run only tests selected by staged paths.
set -euo pipefail
REPO_ROOT="$(git rev-parse --show-toplevel)"
exec "$REPO_ROOT/scripts/test-changed.sh"
+11 -1
View File
@@ -5,10 +5,20 @@
# LLM fleet tunables (paths and numbers, never credentials)
!scripts/*.env
# LLM fleet runtime state (slot save/restore files written by the server)
scripts/slot-state/
# Playwright e2e: local creds, browser storage states, node artifacts
tests/e2e/.env.local
tests/e2e/.auth/
tests/e2e/node_modules/
tests/e2e/test-results/
tests/e2e/playwright-report/
# pi subagent artifacts
.pi-subagents/
# doc-audit scratch (transient, synthesized into docs/ then trashed)
# doc-audit scratch (transient, synthesized into serverAppConfigs-docs/ then trashed)
.docaudit/
# Agent trash
+642 -265
View File
@@ -2,352 +2,729 @@
## Overview
This repository is the single source of truth for the two homelab servers. It
tracks both the Podman container stacks and the LLM inference fleet. One repo,
one checkout per host, one deploy script.
This repository is the single source of truth for the two homelab servers and
the FrankBook-Pro Mac mini. It tracks the Podman container stacks and the LLM
inference fleet. One repo, one checkout per host, one deploy script. All
services run from `/home/dank/serverAppConfigs` on each host (the 2026-08-22
merge absorbed the former `vllmScripts` repo, history preserved).
- **Dankserver** (192.168.0.235) — media/library stack (RomM, Copyparty,
Calibre-Web, the *arr stack, Jellyfin) as bare containers, plus the
`frank-default-small` LLM service.
- **Dankclaw** (192.168.0.143) — AI/search/development/observability stack
(SearXNG via Gluetun, ComfyUI, OpenWebUI, Hermes, Gitea, Grafana, Prometheus,
Loki, Alloy, Pi-hole) plus the `frank-default-large` LLM service. Caddy on
Calibre-Web, Jellyfin, Plex) plus the `frank-medium` LLM service.
- **Dankclaw** (192.168.0.143) — AI/search/dev/observability stack (SearXNG
via Gluetun, ComfyUI, OpenWebUI, Hermes, Gitea, Grafana, Prometheus, Loki,
Alloy, Pi-hole) plus TWO LLM services: `frank-large` (halogen-flash-server on
the gfx1151 iGPU) and `frank-small` (llama.cpp on the RTX 3090). Caddy on
Dankclaw reverse-proxies both hosts.
- **FrankBook-Pro** (192.168.0.112) — macOS mini running the *arr stack
(Prowlarr, Sonarr, Radarr, Lidarr, Readarr, qBittorrent) as quadlets inside
its podman machine; all egress rides the Gluetun WireGuard netns. Deployed
via the `frankbook-pro` handler.
**2026-08-22 merge:** the former `vllmScripts` repository (LLM fleet scripts,
units, env tunables, and runbook) is merged into this repo. Its full git
history is preserved (two-parent import commit under the retired `llm/`
prefix, reorganized in follow-up commits). The retired target checkouts
(`/home/dank/vLLM` on Dankserver, `/home/dank/vllmScripts` on Dankclaw) are
archived. All services now run from `/home/dank/serverAppConfigs` on each
host.
## Fleet topology (ACTIVE)
### LLM fleet topology (2026-08-22)
| Target | Runtime | Service | Model | Port |
| Target | Service | Runtime | Model | Port |
|--------|---------|---------|-------|------|
| Dankserver | llama.cpp (upstream b10121) | frank-default-small | HauhauCS Qwen3.6-35B-A3B-Uncensored-Aggressive Q6_K_P + ngram-mod, 36 MoE experts on CPU | 7770 |
| Dankclaw | charlie12345/ROCmFPX llama.cpp, ROCmI4 W4A4 runtime | frank-default-large | caf-eemin Qwen3.8-27B-ROCmI4-MTP Q4_0_ROCMI4 (embedded NextN/MTP head, gfx1151 IU4 batched verification), 262K context / 1 slot | 7770 |
| Dankserver | `frank-medium` | mainline llama.cpp master fb27a525 (root systemd unit, gfx1201 HIP build) | ajgazin Swift-Qwen3.8-27B-Uncensored-Dynamic-MTP UD-Q8_K_XL + z-lab Qwen3.8-27B-DFlash2 Q8_0 drafter, 2x Radeon AI PRO R9700 in ONE layer-split process (35:30), 262144 ctx over 2 slots, batch 6144x512, text only | 7770 |
| Dankclaw | `frank-large` | halogen-flash-server 0.5.8 (rootless Quadlet) | peonist-ai Qwen3.8-Flash-Next w4b `.hgn` on the gfx1151 iGPU (Strix Halo LPDDR), 200192 ctx per conversation over one 400384-position KV pool / 2 slots, text only | 7770 |
| Dankclaw | `frank-small` | mainline llama.cpp master 37b53fd (root systemd unit) | ISTA-DASLab Qwen3.8-27B-GSQ-RCO IQ3_S (`-mtp` + BF16 mmproj) on the RTX 3090, 131072 ctx / 1 slot, MTP speculative decoding, the fleet's vision endpoint | 7771 |
History: the 2026-08-17 redo retired the pipeline-parallel split and the
FastFlowLM NPU endpoint. The 2026-08-18 swap retired the vLLM service on
Dankclaw. On 2026-08-21 a DFlash2 Vulkan deployment briefly replaced CIRU,
then was reverted the same day for speed. On 2026-08-22 the service briefly
swapped to the Kairic Edge IU4 runtime, then was reverted to v3 the same day
(operator decision). Runbooks: [docs/LLM/index.md](docs/LLM/index.md).
Retired scripts live in `old/` (reference only) and `.AGENTTRASH/`.
Retired endpoints (`frank-npu`, and every prior runtime) are in
the [runbook index](#runbook-index). The full swap history lives in
`serverAppConfigs-docs/LLM/index.md` — do not grow it here.
## Repository Layout
- `dankserver/` — compose.yaml + Caddyfile + .env.example for the media stack
- `dankclaw/` — compose.yaml + Caddyfile + .env.example for the AI stack
- `shared/` — homepage, Prometheus, Loki, Alloy, SearXNG configs
- `scripts/` — `deploy.sh` (unified deployer), LLM serve scripts, systemd
units, env tunables, bench harnesses
- `old/` — retired PP/NPU-era LLM scripts (reference only)
- `docs/` — knowledge base (Obsidian vault). `docs/LLM/` is the LLM fleet
- `tests/` — bats test suite
- `scripts/` — `deploy.sh` / `deploy-plan.py` / `deploy-remote.sh`, LLM serve
scripts, systemd units, env tunables, bench harnesses
- `old/` — retired LLM scripts (reference only)
- `serverAppConfigs-docs/` — knowledge base (Obsidian vault, OKF format)
- `tests/` — bats suite + Playwright e2e
## Git Workflow
All edits flow through git. Do not edit files on the remote machine directly.
All edits flow through git. Never edit files on a remote machine directly.
### Protocol
1. **Source the config from the target machine** — fetch the live
compose/Caddyfile/serve script from the server where it runs
2. **Place it in this repo** — commit the fetched version (redact secrets
into `.env`)
3. **Edit here** — make all changes locally in the working tree
4. **Run the tests** — `./tests/run.sh` must pass
5. **Commit and push** — push to origin (Gitea on Dankclaw)
6. **Pull on the target** — `git pull` on the remote machine
7. **Restart** — `scripts/deploy.sh` pulls, restarts the stack and the LLM
service, and checks health
1. **Source** the live config from the target machine
2. **Commit** the fetched version here (redact secrets into `.env`)
3. **Edit** locally
4. **Test** — `./tests/run.sh` must pass
5. **Commit and push** — origin is Gitea on Dankclaw
6. **Pull** on the target (`deploy.sh` does this)
7. **Deploy** — `scripts/deploy.sh` plans and dispatches only changed services
## Deployment
Use `scripts/deploy.sh` to deploy committed changes to a target. One script
covers both workloads per host: the podman stack and the LLM service.
```
./scripts/deploy.sh dankclaw # AI stack + frank-default-large (:7770)
./scripts/deploy.sh dankserver # media stack + frank-default-small (:7770)
./scripts/deploy.sh dankclaw --dry-run # preview without changes
./scripts/deploy.sh dankclaw
./scripts/deploy.sh dankserver
./scripts/deploy.sh dankclaw --service searxng
./scripts/deploy.sh dankserver --service frank-medium
./scripts/deploy.sh dankclaw --dry-run
```
Set `FRANK_SUDO_PW` for headless runs on a host without passwordless sudo
(remote sudo reads it from stdin via `sudo -S`). The value is never stored
in this repo.
The planner maps changed paths to services; the remote runner restarts only
the selected service and records its revision outside the checkout.
Documentation-only changes are a no-op. LLM services are selected only by
their own files — frank-large by `dankclaw/frank-large/quadlet/`, frank-small
and frank-medium by their unit/env/serve trio in `scripts/` — so unrelated
container changes cannot interrupt inference. Set `FRANK_SUDO_PW` for headless
runs on a host without passwordless sudo; the value never enters the repo.
### Deployment completeness (MANDATORY)
A deploy is **not complete** when `deploy.sh` exits green. It requires:
1. `./tests/run.sh` — all seams green, and
2. the Playwright e2e suite (`tests/e2e/run.sh`) — runs **automatically** as
the `[3/3]` gate at the end of `deploy.sh` against the LIVE fleet; a
behavior-affecting deploy that fails it exits `DEPLOY INCOMPLETE`. Bypass
only with `--skip-e2e` in an emergency.
```mermaid
graph LR
A[Edit locally] --> B[Tests pass]
B --> C[Commit]
C --> D[Push to origin]
D --> E[deploy.sh: pull on target]
E --> F[restart podman stack]
F --> G[relink + restart LLM unit]
G --> H[health: homepage + LLM]
A[Edit locally] --> B[Tests pass] --> C[Commit + push]
C --> D[deploy.sh plan] --> E[Target pulls ff-only]
E --> F[Dispatch selected service] --> G[Per-service health]
G --> H[Playwright e2e gate] --> I{green?}
I -->|yes| J[Deploy complete]
I -->|no| K[DEPLOY INCOMPLETE]
```
## Boot Persistence & Self-Healing (MANDATORY)
Every deployed, working Podman container MUST have a self-healing,
boot-persistent Quadlet unit. A container that runs only from `compose.yaml`
or a manual `podman run` dies silently at reboot — rootless Podman does not
auto-start bare containers, and `restart: unless-stopped` does not survive the
explicit stop that shutdown performs. Never report such a service as "up".
Unit requirements:
- **Boot persistence** — `[Install] WantedBy=default.target`, installed to
`~/.config/containers/systemd/<name>.container`, enabled in the user
manager, with linger on for the deploy user (`loginctl show-user dank -p
Linger` → `Linger=yes`).
- **Self-healing** — `Restart=always` (or `on-failure`) with a short
`RestartSec`; quadlet health check where the service has one.
- **Repo provenance** — the `.container` file lives in this repo
(`<host>/<svc>/quadlet/`), is installed by the deployer, and is covered by
the quadlet test seam. The host-side unit is generated state; the repo file
is the source of truth.
Before declaring a service deployed: `systemctl --user is-enabled <name>` →
`enabled`, `is-active` → `active`, and the container answers its health check.
A running container with no unit is a gap: write the unit, test it, deploy it.
This rule exists because reboots found gaps twice (2026-08-26: Caddy as a
bare compose container never returned; 2026-09-11: five observability
services had no units). The observability services now have units under
`dankclaw/observability/quadlet/` and their compose definitions were removed —
one owner per container name. The stack is **configured as of 2026-09-16**: a
Grafana datasource and the `llm-fleet` dashboard are provisioned from
`shared/grafana/provisioning/`, and `shared/prometheus/rules/llm-fleet.yml`
loads 5 alert rules + 1 recording rule. Nothing is *delivered* — there is no
Alertmanager, so a firing alert reaches nobody (an operator decision, not an
oversight). Five Prometheus targets read down for structural reasons
(HTML-scraped "probes", Gitea metrics off); the remedy is a blackbox exporter.
See `serverAppConfigs-docs/Infrastructure/Observability.md`.
## Testing
Tests are mandatory before commit. `bats` must be installed on the
development machine.
Tests are mandatory before commit (`bats` required on the dev machine):
```
./tests/run.sh
```
Test seams: compose validity, env consistency (no leaked secrets), Caddyfile
validity, quadlet units, docs completeness, script syntax (all of `scripts/`,
including the LLM serve scripts), LLM unit integrity (units point at files in
this repo), LLM secret hygiene, deployment flow. See
[docs/Architecture/Testing Policy.md](docs/Architecture/Testing%20Policy.md).
Seams: compose validity, env consistency (no leaked secrets), Caddyfile
validity, quadlet units, docs completeness **including vault link integrity**,
script syntax (shell + python), LLM unit integrity, LLM secret hygiene,
deployment flow, service-scoped planning, homepage config/pages/deploy/checker,
and `e2e_scaffold` — which fails if a Playwright spec file's number prefix is in
no project, because such a spec silently never runs.
See `serverAppConfigs-docs/Architecture/Testing Policy.md`.
## Secret Rules
- Never commit real secrets. The live Gitea database password and the live
sudo password are test-enforced as leak checks.
- `.env` files are git-ignored. Commit only `.env.example` with placeholders.
The LLM env tunables in `scripts/` are tracked by exception (`!scripts/*.env`).
They hold paths and numbers, never credentials.
- Do not document passwords in this repo, including sudo passwords. SSH keys
are the auth mechanism. Headless sudo passes the password via the
`FRANK_SUDO_PW` environment variable. The value never enters the repo.
- Never commit real secrets. The live Gitea DB password and sudo password are
test-enforced leak checks.
- `.env` files are git-ignored; commit only `.env.example` with placeholders.
Exception: `scripts/*.env` LLM tunables (paths and numbers, never
credentials).
- Do not document passwords anywhere in the repo. SSH keys are the auth
mechanism; `FRANK_SUDO_PW` covers headless sudo and never enters the repo.
- **A token passed as `curl -H "Authorization: Bearer …"` is world-readable.**
It lands in `/proc/<pid>/cmdline`, so any `ps`/`pgrep -a` on the host prints
it — that leaked an HF token into a session transcript on 2026-09-16, while
merely checking whether a download was still running. Pass secrets out of
band (`curl -K <mode-600 file>` carrying the header, or `HF_TOKEN` in the
environment for `hf`/`hf_transfer`) and never `pgrep -a` a fetch command that
carries an auth header.
## External Exposure Policy (MANDATORY)
Every Caddy-proxied service is either behind the auth chain (Anubis :8923 →
Authentik embedded outpost) or explicitly internal-only — no public DNS name,
no vhost. New services default to internal-only until guarded.
When guarding a new vhost — provider first, then route:
1. Create the outpost proxy provider FIRST (`external_host` = exact vhost URL
with trailing slash; `internal_host = http://caddy:8017`). If the vhost
flips first, authenticated traffic hits the outpost error page.
2. Flip the vhost to `reverse_proxy anubis:8923` with
`header_up X-Real-Ip {remote_host}`, and add the post-auth
`@<svc> host <vhost>` handler on the `http://:8017` listener routing to
the real upstream.
3. Anubis guard set, Caddy TLS edge, and outpost provider list change
together in one deploy.
4. Service-to-service traffic targets internal addresses (`loki:3100`), never
the public hostname — non-browser clients cannot solve challenges.
Exempt categories (document the reason in the vhost comment): the IdP itself
(`auth.`), git smart-HTTP endpoints, services with their own complete auth.
## Deletion & Trash Policy (MANDATORY)
Never run `rm` to delete files. The command deletes permanently. Move every
item you delete into trash instead. The trash keeps the deletion recoverable
and reviewable.
Never `rm`. Move deletions into trash so they stay recoverable:
1. In-repo files: move the item to `.AGENTTRASH/` beside its directory.
2. Home and other paths: create a dated staging directory
`~/.AGENTTRASH/YYYYMMDD-topic` and move each item there.
3. List the staged items and their sizes for the user.
4. Wait for explicit user permission before you empty the trash.
1. In-repo files → `.AGENTTRASH/` beside the item's directory.
2. Elsewhere → dated staging dir `~/.AGENTTRASH/YYYYMMDD-topic`.
3. List staged items and sizes; wait for explicit user approval to purge.
Rules:
- `rm`, `rm -rf`, and `git clean -f` are forbidden without explicit user
permission.
- `git * --no-verify` is forbidden.
- A moved item stays in the trash until the user approves its purge.
- The trash is a temporary hold, not a long-term archive.
`rm`, `rm -rf`, and `git clean -f` are forbidden without explicit permission.
`git * --no-verify` is forbidden. The trash is a temporary hold, not an
archive.
## Benchmarking Policy (MANDATORY)
**STOP the production service BEFORE running any benchmark on a target.**
These servers serve real users (about 3 people). Running a bench while the
service is up starves the GPU and corrupts results. Restart the service when
the bench finishes.
**STOP the production service BEFORE benchmarking** — these servers serve
real users, and a bench beside a live service starves the GPU and corrupts
results. Use the runtime's own harness: `llama-bench` (llama.cpp), `vllm
bench` (vLLM), and halogen-flash-server's own `bench`/`sweep` modes for
frank-large, which is the active runtime there again. Rolling your own bench
requires asking first.
Scratch ports are not used on LLM hosts — a
test process cannot share the single GPU anyway; validate against the service
itself.
1. Stop the service before any benchmark:
`sudo systemctl stop frank-default-small` (Dankserver) or
`sudo systemctl stop frank-default-large` (Dankclaw).
2. Run the bench on a scratch port (never the LLM service port 7770).
3. Restart the service when the bench completes: `sudo systemctl start <svc>`.
4. Verify health before reporting:
`curl http://192.168.0.235:7770/health` or
`curl http://192.168.0.143:7770/health`.
This covers **any GPU experiment, not just benchmarks** — including a bare
2-rank RCCL/`torch.distributed` probe. On 2026-09-16 one such probe was run
against the live service "because it only needs 200 MB of VRAM"; the host went
hard down ~20 minutes later (no ARP reply, 100% ICMP loss from two subnets, no
recovery — a power cycle was required). A hung AQL dispatch wedging the GPU
driver is the plausible mechanism, and this host has precedent for going
unreachable until a power cycle. If an experiment needs GPUs, stop and mask
first, or use a host that serves nobody. Telling an off machine from a wedged
one: `ip neigh show <ip>` from a peer — FAILED means off/NIC dead, while a
merely wedged kernel usually still answers ARP.
## Pi Model Config (`~/.pi/agent/models.json`)
1. Stop and mask (self-healing would restart it):
- Dankclaw `frank-large` (rootless Quadlet again since 2026-09-16):
`systemctl --user stop frank-large`, then move its
`~/.config/containers/systemd/frank-large.container` aside and
`systemctl --user daemon-reload` — the quadlet generator re-creates that
unit from the file on every reload, so the source is the lever, not `mask`.
- Dankclaw `frank-small` (root unit, llama.cpp on the 3090):
`sudo systemctl stop frank-small && sudo systemctl mask frank-small`
- Dankserver `frank-medium` (system unit):
`sudo systemctl stop frank-medium && sudo systemctl mask frank-medium`
2. Bench.
3. Unmask and start; verify `curl http://192.168.0.<host>:7770/health`.
The Pi agent reaches the fleet through `/home/dank/.pi/agent/models.json`.
This file lives outside the repository. Each provider is one host endpoint.
Each model `id` is the served alias. The two aliases are
`frank-default-small` (Dankserver `:7770`) and `frank-default-large`
(Dankclaw `:7770`).
**CRITICAL:** never `systemctl disable` a system unit on these hosts — it
deletes the unit file from `/etc/systemd/system/`, not just the symlink.
`mask` symlinks to `/dev/null` and is safe/reversible.
The model `id` values are stable. The file
`/home/dank/.pi/agent/settings.json` references them. Two resolution rules
apply (verified in `@earendil-works/pi-coding-agent` `model-resolver.js`):
## Model Config (harness)
- The default model resolves as a `defaultProvider` + `defaultModel` pair.
The `defaultProvider` must name the provider that serves the `defaultModel`.
- A subagent override uses a bare model `id`. A bare `id` resolves only when
it is unique across all providers.
The harness reaches the fleet through `.omp/agent/models.yml` in the
operator's home — `/home/blank/.omp/agent/models.yml` on the dev box, which is
where the harness runs; there is no copy on the servers. Each provider is one
HOST:PORT; each model `id` is the served alias: `frank-medium` →
`192.168.0.235:7770`, `frank-large` → `192.168.0.143:7770`, `frank-small` →
`192.168.0.143:7771`. Per-role routing lives beside it in `config.yml` under
`modelRoles`.
When you rename a service or move a model to a new host, update
`models.json`. Update the provider key, the model `id`, `contextWindow`, and
`input`. If the default model moved hosts, also update `defaultProvider` in
`settings.json`. Set `contextWindow` to the per-slot context (both hosts
`262144`). The full schema is in the agent knowledge base page
`~/.pi/agent/docs/pi/models.md`.
Resolution rules: the default model resolves as a `defaultProvider` +
`defaultModel` pair (the provider must serve the model); a subagent override
uses a bare model `id`, which resolves only when unique across providers. Two
ports on one host need two providers — the file cannot express "same host,
different port".
## Runtime & Troubleshooting
When renaming a service, retiring an endpoint, or moving a model: update the
provider block, model `id`, `contextWindow`, `maxTokens`, `input` in
`models.yml`, then fix every `modelRoles` entry that named the old endpoint —
a role pointing at a provider the file does not define fails at call time. As
of 2026-09-16 the image path is `frank-small` (`input: [text, image]`), and
`frank-large` is text-only because its halogen deployment loads no vision
tower. There is **no audio** input anywhere in the fleet: that capability left
with the FLM NPU endpoint on 2026-09-11.
### charlie12345/ROCmFPX ROCmI4 W4A4 llama.cpp (Dankclaw, ACTIVE)
### frank-large — halogen-flash-server (Dankclaw, ACTIVE)
The v4 runtime serves `cafonz/Qwen3.8-27B-ROCmI4-MTP-GGUF` (`eemin` namespace,
public) from `/home/dank/rocmi4-qwen38/ROCmFPX/build-rocmi4-w4a4/bin/llama-server`
(charlie12345/ROCmFPX commit `c49ebdbd` built with `-DGGML_HIP_ROCMI4_W4A4=ON`
against system ROCm 7.2.4 / amdclang 22). The model is `Q4_0_ROCMI4` with an
embedded NextN/MTP head (no separate draft file); the gfx1151 native IU4 WMMA
instruction batch-verifies proposals. Profile: strict Qwen MTP
(`--spec-mtp-strict-qwen`, n_max 16, p_min 0.60, backend sampling, all layers
on ROCm0), `-t 16 -tb 32`, batch 512 / ubatch 256 (ubatch swappable via the
ubatch bench on 8k context), f16 KV, full 262144 context / 1 slot, TEMPERATURE
0.7 / TOP_P 0.95, reasoning on (deepseek, budget -1), `--no-mmap`. Two-file
payload (~14.4 GiB) in `/home/dank/models/eemin-Qwen3.8-27B-ROCMI4-MTP/`. Full
runbook:
[docs/LLM/eemin-rocmi4-mtp.md](docs/LLM/eemin-rocmi4-mtp.md).
halogen-flash-server 0.5.8 as a **rootless Quadlet again since 2026-09-16**
(`dankclaw/frank-large/quadlet/frank-large.container`), serving
`peonist-ai/halogen-qwen3.8-flash-next` (115.55 GiB w4b `.hgn` checkpoint + a
quality overlay that re-fetched to 2.40 GiB on 2026-09-16) on port 7770 from
the gfx1151 iGPU. Two conversations of 200,192 tokens resident (pool 400,384,
2 slots), greedy default, reasoning extracted to `reasoning_content`, and **no
vision tower** — that moved to frank-small. It replaced ik_llama.cpp, which had
replaced it for two days.
The engine **fetches its own weights on first start** (`HALOGEN_DOWNLOAD`, into
the `/home/dank/models/halogen` bind mount): clearing that directory and
restarting the unit is the supported way to pick up upstream updates, and it
needs the directory to exist **empty** — a missing directory fails the
container with `Error: statfs /home/dank/models/halogen: no such file or
directory` and the unit loops on `Restart=on-failure` until `mkdir -p` brings
it back. The sidecars are versioned in place, so a stale copy is invisible from
the file name (both overlays were ~95 MB larger upstream than the copies this
host had been serving).
Measured boot ledger (2026-09-16, `HALOGEN_VERBOSE=1` ships in the unit):
device budget `121 − 67.7 − 20 = 33.5 GiB` against a need of `~27.2 GiB`, so
the configured pool was **not clamped**; 68.0 GiB of weights locked + 11.0 GiB
of KV pool + 16.5 GiB of working memory = 95.4 GiB in all, leaving 14.7 GiB
(475 contiguous 2 MiB blocks) for everything else: frank-small on the 3090 and
~28 co-tenant containers. The engine printed its co-tenant stall NOTE and
logged 4,350 compaction stalls (285 failed) while reserving the pool, so read
that as "tight and fragmented", not "out of memory".
Runbook: `serverAppConfigs-docs/LLM/halogen-flash-server.md`.
Rollback: [[frank-large-ik-uncensored]].
### frank-small — llama.cpp on the RTX 3090 (Dankclaw, ACTIVE)
Mainline llama.cpp master `37b53fd` as a **root systemd unit** (repo
`scripts/frank-small.service`, `relink_unit`ed into `/etc/systemd/system/`),
serving `ISTA-DASLab/Qwen3.8-27B-GSQ-RCO-GGUF` at IQ3_S — the `-mtp` build, so
the model's own Multi-Token Prediction head drives speculative decoding — with
the BF16 mmproj, on port 7771, 131,072 ctx / 1 slot, f16 KV, greedy default.
Deployed flags are in `scripts/frank-small.env`; the serve script refuses to
start if the weights, the projector or the binary are missing.
Verified at deploy (2026-09-16): `/v1/models` reports `frank-small`; a chat
completion returns content plus `reasoning_content`; a tool call returns
`tool_calls`; the slot timing lines show `draft acceptance = 0.66–0.94, mean
len ≈ 3` (MTP engaged); an image request is answered from the projector while
the halogen side refuses images with a named reason; 22,362 MiB of the card's
24,576 MiB is in use; `n_slots = 1, n_ctx_slot = 131072`.
### frank-medium — llama.cpp layer split, DFlash2 + ngram-mod (Dankserver, ACTIVE)
Mainline llama.cpp at **`fb27a525`** (a gfx1201 HIP build, `Root=`-less root
systemd unit `scripts/frank-medium.service`) serving
`ajgazin/Swift-Qwen3.8-27B-Uncensored-Dynamic-MTP-GGUF` at **UD-Q8_K_XL**
(29.29 GiB) with the **z-lab DFlash2** `Q8_0` drafter chained to the
draftless **ngram-mod** matcher, in **one process across both R9700s**,
`--split-mode layer` at a **35:30 tensor split** (the balance point for the
deployed batch: the drafter rides ROCm1, so it takes fewer target layers),
262144 ctx over **2 slots** of 131072, batch 6144x512, text only. DSpark was
put up in the drafter's place the same day and reverted: it declares no sliding
window, so its draft KV spanned the whole context (5.0 GiB) and cost ROCm1 four
layers of the split.
**Layer split is why both cards now serve ONE model.** Tensor parallelism is
impossible (see the PCIe lesson below), and so is vLLM's pipeline parallelism —
its stage handoff is an RCCL `send`/`recv` of the hidden state, the exact
primitive that cannot be dispatched on card #2. llama.cpp's layer split needs
no collective at all: layers are distributed in order and the boundary
activation is a per-backend copy, once per forward pass. Measured on this host
(the predecessor runtime): 1991 t/s prefill layer-split vs 272 tensor.
This is the box's **only** LLM runtime: the vllm-radiance replica pair and its
routing proxy (`frank-medium-a/-b/-proxy`) were retired the same day they went
live, with their units moved to `~/.AGENTTRASH/20260916-vllm-radiance-retire/`.
Files: `scripts/build-llama-rdna4.sh` (builds the runtime, gfx1201 only, private
ROCm 7.14 toolchain bundled beside the binary), `scripts/download-frank-medium-gguf.sh`
(resumable, byte-verified payload), `scripts/frank-medium.env`,
`scripts/serve-frank-medium.sh`, `scripts/frank-medium.service`. The deploy
handler refuses to start a unit whose runtime or payload is missing, retires
the vLLM quadlets, waits for 7770 to be released (the retired proxy held it) and
gates on `/health`.
The ref is pinned by two features and must not float: **DFlash2**
(ggml-org/llama.cpp#27342) and **qwen35 MTP**. The ROCmFPX fork the endpoint
used earlier that day has neither.
Runbook: `serverAppConfigs-docs/LLM/frank-medium-llamacpp-dflash2.md`.
Retired runtimes: [[frank-medium-radiance-mxfp4]] (vLLM replicas, same day),
[[frank-medium-rocmfpx]] (llama.cpp fork, earlier that day).
## Key Lessons Learned
Durable operational lessons from the fleet's history. Full detail in the
linked runbooks.
**PCIe / multi-GPU**
- **RCCL collectives need PCIe atomics on every participating GPU.** RCCL's
kernels carry a hidden hostcall-buffer parameter; ROCr refuses to dispatch
them when the device has no atomics, and the failure surfaces as
`hipErrorIllegalState` ("the operation cannot be performed in the present
state") from `hipLaunchKernel`, not as anything that names atomics. The
value is consumed at driver probe time, so writing the AtomicOp Requester
bit with `setpci` at runtime changes nothing. Check the *chain*:
`lspci -vv -s <gpu>` and every upstream bridge must show
`AtomicOpsCap: Routing+` (a `Routing-` bridge is a hardware wall), and the
endpoint's Device Control 2 (`setpci -s <gpu> CAP_EXP+28.w`) must have bit
6 set. AMD desktop platforms put one card on CPU root ports (atomics work)
and the rest behind the chipset (usually not) — that asymmetry is what
blocked frank-medium's TP=2. A one-card collective proves nothing: NCCL
short-circuits at `world_size=1`.
- **The fix is physical — and cheap on this board.** Two paths put both cards
on CPU root ports, which restores the atomics routing and therefore TP=2: an
M.2→PCIe x4 riser into a *CPU-attached* M.2 slot (on dankserver's TUF
GAMING B650-E WIFI that is M.2_2, which is free — per ASUS's spec page;
`PCIEX16_2`, where card #2 sits today, is chipset and x1-mode), or a board
whose CPU x16 bifurcates to x8/x8. Bandwidth is not the constraint: a 27B
TP=2 allreduce at batch 4 is ≈10 MB per token-step, ≈1.4 ms at Gen4 x4.
RCCL's P2P `send`/`recv` fails on such a card too, so pipeline parallelism is
not an escape either. Full evidence matrix, the open rebuild hypothesis, and
the test plan: [[Dual-R9700-Cooperation]].
- **When a GPU collective dies, read the runtime, not the framework.** The
message that identified the cause came from `AMD_LOG_LEVEL=3`
(`Pcie atomics not enabled, hostcall not supported` → `AQL dispatch
failed!`), not from vLLM or RCCL. `AMD_LOG_LEVEL` costs one env var and
prints the failing HIP call with its return code.
- **The one software path that lets both cards serve ONE model: llama.cpp's
layer split.** vLLM cannot do it here in any of its parallelisms — TP needs
an allreduce, PP needs the same p2p primitive, and both die on the card that
cannot dispatch an RCCL kernel. `--split-mode layer` in llama.cpp needs no
collective: layers are distributed in order and only the boundary activation
crosses PCIe, once per forward pass. Measured here: 1991 t/s prefill /
18.8 tok/s decode layer-split, against 272 / 26.3 tensor (butterfly
fallback). That is what `frank-medium` runs — see
[[frank-medium-llamacpp-dflash2]].
- **Layer split is also the cross-vendor answer — and `-ot` is what cannot be
combined with it.** On Dankclaw (2026-09-16) a CUDA 3090 + Vulkan iGPU layer
split ran a real two-device graph: `-dev CUDA0/Vulkan1 -sm layer -ts 0.15/0.85`
held 14,994 MiB on the 3090 and gave 27.2 tok/s with 268.5 t/s prefill at 64k
depth (iGPU alone: 25.8 / 231.8). What does NOT work is per-tensor placement
across devices: `--override-tensor <group>=<device>` sets
`has_tensor_overrides()`, a REQUIRED term of the scheduler's
`pipeline_parallel` flag (llama-context.cpp:433 — the only place in the tree
that reads it), and that flag is what sets `n_copies` in
`ggml_backend_sched_new`; with `n_copies == 1` a pre-allocated weight on a
non-primary device has no backend and the graph aborts (`ggml-backend.cpp:941`,
reported with the leaf's own op name `(NONE)`). `=CPU` is the only override
target that works, because the CPU backend is last in the list. Pure Vulkan
changes nothing here — the gate is the override, not the driver mix. See
[[x4-pair-experiment]].
- **A host↔3090 link at x4 measures 6.72 GB/s, and that is a design
constraint, not a detail.** `pcie-link-test.cu` on Dankclaw (2026-09-16,
services stopped): pinned H2D 6.72 GB/s, D2H 6.60 GB/s — 85 % of the Gen4 x4
ceiling — with async no better than sync and pageable within 1 % of pinned,
i.e. the copies are link-limited rather than staging-limited (the usual
"pageable costs 2x" rule does not apply here). The card is x16-capable and the
Strix Halo port beneath it is x8-capable (`LnkCap: 16GT/s, Width x8` on
`00:03.1`), so the x4 lives in the adapter or cable; the link idles at
2.5 GT/s x4 and trains up under load. Consequence for any engine that
*streams* weights across it: a 3 MiB slab costs 474 us, and a 111 GiB A3B
model needs ~1.70 GiB of active experts per token ⇒ **3.7 tok/s** (4.6 with a
20 % resident cache), against **20.46 tok/s** for the same model with the
experts *computed* in host RAM by llama.cpp (`-ot exps=CPU`, whence the link
carries activations only) and 25–26.5 on the iGPU. Streaming over PCIe loses;
computing in RAM does not care. FreeToken — expert LRU in host RAM, exactly
this streaming shape — also loads only modelopt block-FP8 safetensors for
`qwen3_5_moe`, so a Q4_K_M GGUF is unloadable there before bandwidth is even
the question. See [[x4-pair-experiment]].
### Kairic Edge llama.cpp (Dankclaw, RETIRED experiment 2026-08-22)
**ROCm / GPU**
The Kairic Edge llama-server serves `jcbtc/Qwen3.8-27B-IU4-Kairic-Edge`
(pinned tag `kairic-edge-qwen38-27b-v1`). It was deployed and reverted to v3
the same day (operator decision). The runtime routes qualified prompt and
verification shapes through the RDNA 3.5 native IU4 matrix instruction.
- **Layer split is the deployed shape on both multi-device llama.cpp services,
for two different reasons.** On Dankserver it is the *only* way both R9700s
serve one model (no collective can be dispatched on card #2). On a
RCCL-less fork build it is also the fast one: tensor parallel falls back to
a butterfly allreduce and collapses prefill ~7x (measured 271.8 vs 1991 t/s).
Pin the spec-decode drafter to one device — an unpinned draft under tensor
mode aborts the KV build, and on a layer split the natural home is the card
that ends up holding the target's last layers.
- **Bundling a ROCm runtime next to a llama.cpp binary: `cp -r SRC DST/` is a
trap.** With a missing `DST`, cp creates `DST` as a copy of `SRC` and drops
one directory level. rocBLAS and hipBLASLt look for their device code at a
fixed depth (`../lib/rocblas/library`, `../lib/hipblaslt/library/<arch>`),
and the flattened copy fails at *request* time, not load time: the engine
reads the weights, answers `/health` and dies on the first completion with
`Cannot read .../TensileLibrary.dat: No such file or directory for GPU arch
: gfx1201`. Declare the destination, then copy the contents.
- **mmap must stay on** for the big-PLE models: `--no-mmap` forces the
~50 GiB PLE table into anonymous memory → silent cgroup kill, dmesg is the
only evidence. mmap pages ARE page cache. Never pin the PLE tensor with
`-ot` (23 → 13.4 t/s).
- **HIP reads bare env vars** `TEMP`, `HOST`, `GPU_MAX_HEAP_SIZE`. Serve
scripts export env files with `set -a`, so a tunable named `TEMP` leaks
into HIP (`TEMP=1.0` sent the JIT cache to path `1.0` and segfaulted every
load, fixed 2026-08-13 by renaming to `TEMPERATURE`). Never name a tunable
these; check with
`strings /opt/rocm-*/lib/libamdhip64.so.* | grep -xE "TEMP|HOST|GPU_MAX_HEAP_SIZE"`.
- **`-DGGML_CUDA_FA_ALL_QUANTS=ON` is required** for q8_0 KV + flash
attention on CUDA builds; without it prefill collapses ~12x (95 vs 1300+
t/s, measured 2026-08-17).
- **Flash-Next MTP heads do not load on any current loader** — they need full
per-layer `blk.<il>.hc_attn_norm.weight` and ship only `blk.48` (verified
3x). Don't re-attempt without new loader support.
- **QSA flash-attn assert:** `GGML_ASSERT(a-type == GGML_TYPE_F32)` on
qwen4exp needs `scripts/patches/qwen4exp-qsa-f32-mask.patch` — but only on
the agentionai FAST layer set; the kingjones STRIX set works unpatched.
- **GPU budget on Dankclaw is the rocm-smi figure** (64 GiB, SVM-backed).
`free`/`dmidecode` show the CPU-side carve-out — intentional, do not
change.
**Release gap — CK `config.h`:** the pinned CK commit ships only
`config.h.in`. The release script and guide never generate
`third_party/composable_kernel/include/ck/config.h`. Generate it via a CK
CMake configure and copy it in before building (full runbook in
[docs/LLM/kairic-edge.md](docs/LLM/kairic-edge.md)). The file is untracked
in CK git. A `git clean` removes it. Local rebuilds are byte-identical
(`d7625a0c...`).
**Load / crash diagnosis**
**Target greedy argmax fast path:** the pinned runner sets
`LLAMA_TARGET_GREEDY_ARGMAX_FASTPATH=1`. The fast path rejects non-greedy
requests (400) and the card's own chat sampling is non-greedy, so the
production attempt ran it at 0. It still rejects reasoning budgets unless
compatibility mode is used. Do not re-deploy Kairic without re-testing chat
first.
- Model load is slow (minutes cold); health returns 503 while loading; do
not kill the process. `deploy.sh` waits up to 20 minutes (`LLM_WAIT`). On
halogen, the FIRST request after a (re)start can go silent ~45–60 s
(health `engine_unresponsive`, 503s) and then self-recovers — do not
restart over it.
- Crash-loop procedure: (1) `journalctl -u <svc> -n 100` + kernel line via
`journalctl -k` (segfault at address 8 in `libamdhip64` = NULL unref in
HIP); (2) run serve script, then raw binary — different outcome isolates
the env file; (3) `gdb -batch -ex run -ex "bt 25" --args bash
scripts/serve-<svc>.sh` — llama-server buffers stdout and loses the log
tail on SIGSEGV; trust gdb, not the log; (4) `amdgpu: SVM mapping failed`
spam is a symptom, not a root cause.
- Journal signatures: client abort logs `W srv stop: cancel task, id_task =
N`; natural stop logs only `slot release` with `truncated = 0`. The server
never preempts a running generation; excess tasks queue when slots are
busy.
**Rollback target:** the v3 model files remain in
`/home/dank/models/CIRU-Qwen3.8-27B-ActiveFPX-v3/` and the v3 binary in
`/home/dank/ciru-qwen38/ROCmFPX/build-promptforge/bin/llama-server`.
Restored to v3 on 2026-08-22 (commit `c60156f`).
**Host memory / swap**
### DFlash2 llama.cpp (Dankclaw, retired 2026-08-21)
- Swap resize ordering: create and `swapon` the replacement BEFORE `swapoff`
of the old one, or live pages must land in RAM that `MemAvailable`
overstates. Dankclaw swap (64 GiB at `/swap.img`, fstab-driven) protects
the co-tenants, not frank-large — pinned weights don't swap, and paging
the KV pool is worse than the OOM it prevents.
- Host swap state is invisible to `deploy.sh` (no repo file) — check
`swapon --show` before reasoning about headroom.
A DFlash2 Vulkan deployment briefly replaced CIRU on 2026-08-21, then was
reverted the same day for speed. Its serve script, env, and the build
worktree are archived in `.AGENTTRASH/` and documented in
[docs/LLM/dflash2-large-vulkan.md](docs/LLM/dflash2-large-vulkan.md).
**Runtime-specific guards**
### FastMTP patched llama.cpp (Dankserver, retired 2026-08-18)
- **Kairic Edge** (if ever re-deployed): the pinned CK commit needs
`third_party/composable_kernel/include/ck/config.h` generated manually
(untracked; `git clean` removes it), and its greedy-argmax fastpath rejects
non-greedy requests and reasoning budgets. Re-test chat before production.
- **FastMTP sidecar**: build must be commit `4df29be4` + the HauhauCS patch;
stock binaries fail the draft load with a tensor-shape mismatch.
Historical: the Qwen3.8-27B IQ4_XS FastMTP service was retired on 2026-08-18
(files in `.AGENTTRASH/`). The build notes are kept for reference. The
HauhauCS FastMTP draft sidecar required a llama.cpp build from commit
`4df29be4f4c3673f428170fda944a5b19f743bb8` plus the
`HauhauCS-FastMTP-llama.cpp.patch` (from the HF repo). The build lives at
`/home/dank/llama.cpp-fastmtp/build` on Dankserver. Loading the draft with a
stock binary fails with `expected 5120, 248320, got 5120, 32768`. Do not
rebuild from another commit without re-applying the patch.
**llama.cpp serving**
**REQUIRED build flags:** `-DGGML_CUDA_FA=ON -DGGML_CUDA_FA_ALL_QUANTS=ON`.
Without `GGML_CUDA_FA_ALL_QUANTS` the q8_0 KV cache + flash attention falls
back to a slow path and prefill collapses ~12x (measured 2026-08-17: 95 t/s
vs 1300+ t/s at an 8K prompt on the 3090). See the HauhauCS HF repo README
for the base recipe.
- **MTP is a property of the FILE, not of the flags.** The `-mtp` GGUF build
carries the head as `blk.<n>.nextn.*` (`qwen35.nextn_predict_layers = 1`);
`--spec-type draft-mtp` then sets `load_mtp`, and the draft context runs on
the MAIN model's weights (`shares_model`), so there is no second copy of a
12 GiB model in VRAM and no `-md` argument. The plain build has no MTP
tensors at all, and pointing `--model-draft` at the file would load it twice.
- **What can be chained, and what cannot.** `--spec-type a,b` builds every
enabled implementation and chains drafting through them, in a fixed internal
order (ngram types first, then the drafters — CLI order is irrelevant). The
gate is `ctx_dft`: **draftless** types (the ngram family) are added
unconditionally, while `draft-mtp`, `draft-dflash`, `draft-dspark` and
`draft-eagle3` all require a draft CONTEXT. So a chain may hold one
context-backed drafter plus any number of ngram types — `draft-dflash,ngram-mod`
is legal and measured (2.6x the drafter alone on prompt-echo traffic, byte-
identical to it on prose at the fleet's 48/64 window) — but
`draft-mtp,draft-dflash` dies at load: *"context type MTP requested but model
doesn't contain MTP layers"*, because MTP builds its context against whatever
model the draft context holds. Two context-backed types cannot share one
process, and MTP's head only loads when MTP **is** the draft model.
- **A drafter's KV scales with the TARGET's context, and its window policy
decides how much that costs.** The draft context is built with
`cparams.n_ctx = llama_n_ctx(ctx_tgt)` (`common/speculative.cpp`) — there is
no flag to cap it — so a drafter that declares NO sliding window caches the
whole window on whatever device `--spec-draft-device` names. Swapping DFlash2
(`dflash.attention.sliding_window = 2048`, ~40 MiB of draft KV) for DSpark
(no window; 5 layers x 2 x 8 kv heads x 128 dim x 2 B = 20 KiB per position,
so **5.0 GiB at 262144**) put ROCm1 at 33.9 GB of 34.2 GB and killed the
FIRST draft graph capture with `ROCm error: out of memory` — after the model
had loaded and `/health` had answered. Read `sliding_window` out of the
drafter's GGUF header before sizing any spec-decode deployment, and prefer a
skewed `-ts` over a context cut when the full window matters. The pair was
swapped DFlash2 -> DSpark -> DFlash2 in one day (2026-09-16) and the round
trip moved the balance point FOUR layers (~4.3 GiB of headroom, 40:25 ->
36:29), which is the practical size of the effect. See
[[frank-medium-llamacpp-dflash2]].
- **`--ubatch-size` has an OPTIMUM on this stack, not a plateau — and its
compute buffers land about TWICE as heavy on the card that holds the output
layers and the drafter.** Measured on frank-medium 2026-09-16 at the deployed
shape (262144 over two slots, a 64k prefill measured at 64k depth,
`scripts/bench-batch-ubatch.sh`): prefill runs 1046 tok/s at ubatch 256, 1068
at 384, **1193 at 512 (the peak)**, 1135 at 768, 1152 at 1024, 1151 at 1280,
1165 at 1536, 1148 at 1792 and 1078 at 2048. Both ends lose — below 512 the
SMs are not saturated, and 2048 is 10 % off the peak — and 512 -> 1536 costs
ROCm0 1.30 GiB and ROCm1 2.41 GiB to go 2.4 % SLOWER. The logical
`--batch-size` does not matter: 2048 through 8192 land within 2 % of each
other at a fixed ubatch and use identical VRAM. Decode never moves at all
(15.88 tok/s at depth 65536 on every cell). Because the buffers are
asymmetric, a split balanced at one batch is unbalanced at another: 512 ->
1536 shifted frank-medium's balance by a layer, so 36:29 became 35:30.
Re-measure the balance after changing the batch. (8192/2048 failed to allocate
its 2020 MiB pp buffer here under the DSpark drafter, when ROCm1 had 0.08 GiB
free. That was a CONTIGUITY failure, not a total-bytes one, and no totals
model — the sweep's or the seam's — can see it.)
- **One `-ts` step is one LAYER, and a hybrid stack's layers are not the same
size.** qwen35 is 16 x (3 Gated DeltaNet + 1 Gated Attention), so a step is
worth ~0.43 GiB on a DeltaNet boundary and ~1.43 GiB on a full-attention one
(0.45 GiB of weights plus ~1.0 GiB of its own f16 KV at 262144) — both
measured on frank-medium. Balance by measurement, never by interpolating
between two splits; an affine fit reproduces the deployed point and overstates
the gaps in between. `tests/frank_medium_unit.bats` encodes that fit as a
gate. See [[frank-medium-llamacpp-dflash2]].
- **ngram-mod prints no startup banner.** The load log shows the drafter's
`adding speculative implementation 'draft-dflash'` and nothing at all for the
matcher, so a chain can look unengaged while it is working. Identify it
behaviourally (`draft_n` > 0 only where text repeats) or by the per-task
`draft acceptance = ... mean len = ...` line. It costs no VRAM (a 64 MiB
host-RAM hash table) and its window decides which traffic it can see: too
short and it proposes on ordinary prose fragments, which costs verification
without acceptance.
- **`--jinja` is what `tools` needs.** On 2026-09-16 a subagent died with
`500 tools param requires --jinja flag`, from the ik_llama.cpp runtime that
then served frank-large: every tool-using agent on that endpoint was broken
and the failure read like a model fault. Mainline llama.cpp 0.4.1 defaults
`--jinja` on and the serve script pins it; halogen also accepts `tools` and
returns `tool_calls`. Check tool support before routing a role at an endpoint.
- **Quantized KV on CUDA needs `GGML_CUDA_FA_ALL_QUANTS=ON`** — the same
option and the same ~12x prefill collapse described in the ROCm note above.
f16 KV needs nothing, which is why frank-small runs f16.
- **llama-bench separates a LIST with `/` and SWEEP values with `,`.** Both
`-dev` and `-ts` follow this: `-dev CUDA0,Vulkan1` is two *single-device*
configurations, never one two-device run, and `-ts 0.85,0.15` is two sweep
values each becoming `[0.85, 0, …]` / `[0.15, 0, …]`, i.e. device 0 takes
everything. Write `-dev A/B -ts x/y`. This artifact produced a wrong fleet
conclusion on 2026-09-16 — "CUDA + Vulkan cannot serve one model", with two
bogus OOMs that were really "all 111 GiB on the 24 GiB card" — and the bench
table's own `dev` column was printing the truth the whole time. Any
placement-dependent number needs an independent check: sample the other
device's memory (`nvidia-smi`) while the run is loaded.
- **A model's own template can REJECT the harness's thinking level.** Qwen3.8's
template accepts only `xhigh`, `medium` and `low` for `reasoning_effort` and
raises on anything else — HTTP 500, not a clamp — so the harness's levels
must be folded onto that set in `models.yml` (`compat.reasoningEffortMap`).
A role whose level is unmapped fails on its FIRST request, and the failure
names Jinja, not the model: every subagent on `frank-small` died with
`Unexpected reasoning effort high` until `high: xhigh` was added on
2026-09-16. Halogen accepts the wider set, so the two entries legitimately
differ.
### Env var naming rule — HIP runtime collision
**Ops**
The ROCm HIP runtime reads the bare environment variables `TEMP`, `HOST`,
and `GPU_MAX_HEAP_SIZE`. The serve scripts export the env file with
`set -a`. A tunable named `TEMP` leaks into HIP. On 2026-08-13 `TEMP=1.0`
made HIP use the path `1.0` for its JIT kernel cache, then segfaulted every
model load. Commit `227ce01` fixed it by renaming the sampling variable to
`TEMPERATURE`.
- Never `pkill -f` a pattern that appears in your own command line — it kills
your ssh session. Use `pkill -x <exact-name>`.
- Both hosts allow passwordless sudo for the deploy user. Use plain `sudo` in
committed scripts (no `echo pw | sudo -S`); `FRANK_SUDO_PW` covers hosts
without it.
- ComfyUI on Dankclaw runs with **GPU access** (`/dev/kfd` + `/dev/dri` via
`AddDevice`, `GroupAdd=keep-groups`, plus the udev rule that makes those nodes
world-writable — user-unit spawns carry no supplementary groups). It must NOT
pass `--cpu`: the gfx1151 ROCm wheels exist and the unit's `--cpu` era ended
2026-09-16, when a stale device-less container was found restarted 9,146
times (`RuntimeError: No HIP GPUs are available`).
- SearXNG outbound traffic must stay on the Gluetun tunnel; do not move its
ports off the gluetun service.
- `amd_iommu=off` on Dankclaw (bought 13–16 % prefill for frank-large,
2026-09-11) permanently retired the XDNA2 NPU: the driver's SVA/PASID bind
needs an enabled IOMMU (`/dev/accel/accel0` fails `err=-19`). Revert path
in `serverAppConfigs-docs/LLM/dankclaw-iommu-translation-off.md`.
- Rootless Quadlet containers run with `--replace --rm`: `podman logs` covers
only the CURRENT instance; history across recreations lives in
`journalctl --user -u <name>.service`. Read rootless storage WITHOUT sudo
(per-user storage — `sudo podman logs` finds nothing).
- **A dead rootless podman pause process breaks ALL container management on the
host, and `podman system migrate` panics in 5.7.0.** Symptoms: every `podman`
command fails with `invalid internal status … could not find any running
process`, every container start fails binding podman's bridge addresses
(`Error starting server failed to bind udp listener on 10.89.x.1:53: Cannot
assign requested address`), and every `Restart=always` quadlet loops. Container
storage is intact; what is gone is `/run/user/<uid>/libpod/tmp/alive` (a UUID
token, not a pid) and the rootless netns holding the bridges — deleting the
stale token is not enough, and `podman system migrate` segfaults in
`teardownNetworkBackend`. Repair: **reboot** (`/run/user` clears and all 28
quadlets return on their own — check `Linger=yes` and `WantedBy=default.target`
first). Seen 2026-09-16, hours into heavy GPU work on the same host — but the
trigger is NOT established (`journalctl -k` recorded no OOM kill; the kernel
had no other word on it). Treat the pause process as host state that can die
for its own reasons, and reach for the reboot when it does.
- **A container cannot reach a `Network=host` service at the host's LAN IP.**
netavark DNATs *published* ports only: from a bridge container
`192.168.0.143:<published>` answers while `192.168.0.143:<host bind>` and the
gateway `10.89.1.1` are both refused. Use `host.containers.internal`
(169.254.1.2). This is what 502'd `hermes.franksdank.cloud` until the Caddyfile
upstream was changed on 2026-09-16 (its vhost comment had claimed the LAN IP
was open from that bridge — it never was).
- **A `*.gguf` filter misses mradermacher's payloads: large quants ship as
`.partNofM` fragments.** `mradermacher/Qwen3.8-Flash-Next-Uncensored-i1-GGUF`
carries Q4_K_M (37.00 + 37.00 + 36.97 = 110.97 GiB) and Q4_K_S (35.00 + 35.00
+ 34.09 = 104.09 GiB) as parts, while the smaller rungs (Q2_K, Q3_K_*, IQ3_*,
IQ4_XS) are single files. Fetch the parts, verify each against the Hub's LFS
sha256, concatenate in numeric order — that is how the deployed 111 GiB file
was assembled. Listing the Hub tree and filtering `endswith(".gguf")` makes
Q4_K_S look like it does not exist.
- **A fetch script must `-f` and verify, or it reports success on an access
error.** `curl -o FILE URL` exits 0 on HTTP 401/403 and writes the error body
as the "artifact": a gated repo yielded three 153-byte shards with identical
sha256 values and a cheerful `DOWNLOAD_DONE` in 20 seconds for 104 GiB. Use
`-f`/`--fail-with-body`, compare each file's byte count to the Hub's declared
size, and check the LFS sha256 — `scripts/download-frank-medium-gguf.sh`
already does all three; ad-hoc fetches tend not to. Gated repos (e.g.
`orcarouter/Qwen3.8-Flash-Next-Uncensored-GGUF`, same 104.09 GiB Q4_K_S)
return 403 even with the host's HF token until that account accepts the gate.
Never name a tunable `TEMP`, `HOST`, or `GPU_MAX_HEAP_SIZE`. Check for
collisions with this command:
## Runbook Index
```
strings /opt/rocm-*/lib/libamdhip64.so.* | grep -xE "TEMP|HOST|GPU_MAX_HEAP_SIZE"
```
Active:
### Crash-loop diagnostic procedure
| Service | Runbook |
|---------|---------|
| frank-large (halogen-flash-server, Dankclaw) | `serverAppConfigs-docs/LLM/halogen-flash-server.md` |
| frank-small (llama.cpp Qwen3.8-27B GSQ-RCO, Dankclaw) | `serverAppConfigs-docs/LLM/frank-small-qwen38-gsq-rco.md` |
| frank-medium (llama.cpp + DFlash2, Dankserver) | `serverAppConfigs-docs/LLM/frank-medium-llamacpp-dflash2.md` |
| Dankclaw kernel posture (`amd_iommu=off`) | `serverAppConfigs-docs/LLM/dankclaw-iommu-translation-off.md` |
| Deployment flow + incidents | `serverAppConfigs-docs/LLM/deployment.md` |
When a llama service segfaults during model load, follow these steps.
Retired (rollback targets and history):
```mermaid
graph TD
A[Service SEGVs at load] --> B[Read journal + kernel segfault line]
B --> C{Segfault in libamdhip64?}
C -- yes --> D[Bisect script vs raw binary]
C -- no --> E[Check config, memory, model files]
D --> F[Run gdb for backtrace]
F --> G[Find root cause]
```
| Runtime | Host | Window | Runbook |
|---------|------|--------|---------|
| kingjones STRIX Qwen3.8-Flash-Next ROCmFP4 | Dankclaw | 2026-08-30 → 09-11 | `kingjones-strix-rocmfp4.md` |
| ik_llama.cpp Qwen3.8-Flash-Next-Uncensored i1-Q4_K_M (`frank-large`) | Dankclaw | 2026-09-15 → 09-16 | `frank-large-ik-uncensored.md` |
| agentionai ROCmFP4-FAST imatrix | Dankclaw | 2026-08-29 → 08-30 | `agentionai-fast-imatrix.md` |
| eemin Qwen3.8-27B ROCmI4 W4A4 MTP | Dankclaw | 2026-08-18 → 08-25 | `eemin-rocmi4-mtp.md` |
| Kairic Edge IU4 | Dankclaw | 2026-08-22 (same-day revert) | `kairic-edge.md` |
| CIRU ActiveFPX v3 / PromptForge | Dankclaw | through 2026-08-22 | `ciru-activefpx-promptforge.md` |
| DFlash2 Vulkan | Dankclaw | 2026-08-21 (same-day revert) | `dflash2-large-vulkan.md` |
| FastFlowLM NPU (`frank-npu`) | Dankclaw | twice, retired 2026-09-11 | `frank-npu.md`, `FastFlowLM NPU Inference.md` |
| FreeToken (official + Huihui FP8, `frank-small`) | Dankserver | 2026-09-02 → 09-15 | `freetoken-frank-small.md` |
| FastMTP patched llama.cpp | Dankserver | retired 2026-08-18 | `qwen3.8-spec-matrix.md` |
| ik_llama.cpp Ornith CRACK | Dankserver | retired 2026-09-02 | `ikllama-ornith-dankserver.md` |
1. Read the service journal with `journalctl -u <service> -n 100`. Then read
the kernel segfault line with `journalctl -k`. A segfault at address 8 in
`libamdhip64` means a NULL unref in the HIP runtime.
2. Run the serve script directly. Then run the raw binary with the same
flags. A different outcome isolates the env file as the cause.
3. Run gdb for the backtrace with this command:
```
gdb -batch -ex run -ex "bt 25" --args bash scripts/serve-frank-large.sh
```
`llama-server` buffers its timed stdout. On SIGSEGV the log tail is lost.
Trust gdb, not the log.
4. Kernel spam about `amdgpu: SVM mapping failed` is a symptom, not the root
cause. Investigate other causes before assuming memory pressure.
### Operational quirks
- Both hosts allow passwordless sudo for the deploy user (`(ALL) NOPASSWD:
ALL`, verified 2026-08-22). Use plain `sudo` in committed scripts. Do not
use `echo password | sudo -S` in committed scripts. The `FRANK_SUDO_PW`
env var covers headless runs on a host without that.
- Never run `pkill -f` with a pattern that appears in your own command line.
It kills your ssh session. Use `pkill -x <exact-name>`.
- Model load is slow: Dankserver loads the 30.6 GB Q6_K_P through RAM
(`--no-mmap`, MoE experts on CPU) in a few minutes cold. Dankclaw v3
loads the ~36.7 GiB five-file payload in ~18 s with a warm page cache,
a few minutes cold. Health returns 503 while loading. Do not kill the
process. `deploy.sh` waits up to 20 minutes on both targets (see
`LLM_WAIT`).
- A client abort and a server release look different in the journal. A
client abort logs `W srv stop: cancel task, id_task = N`. A natural stop
(EOS or limit) logs only `slot release` with `truncated = 0`. The server
never preempts a running generation. When all slots are busy, new tasks
defer into a queue. See the 2026-08-13 incident in
[docs/LLM/deployment.md](docs/LLM/deployment.md).
- ComfyUI on Dankclaw runs CPU-only (`--cpu`). ROCm wheels do not support
gfx1151. Do not use CUDA images.
- SearXNG outbound traffic must stay on the Gluetun tunnel. Do not move
its ports off the gluetun service.
- Retired hardware history: the Dankserver 35B cuBLAS crash and the DFlash
premature-EOS bug are historical. See
[docs/LLM/dankserver-vram-growth.md](docs/LLM/dankserver-vram-growth.md)
and [docs/LLM/qwen3.8-spec-matrix.md](docs/LLM/qwen3.8-spec-matrix.md).
The FastFlowLM NPU endpoint is retired. See
[docs/LLM/FastFlowLM NPU Inference.md](docs/LLM/FastFlowLM%20NPU%20Inference.md).
### Dankclaw memory model
The GPU budget on Dankclaw is the rocm-smi figure: 64 GiB VRAM reported by
rocm-smi (SVM-backed). The docs' older 96 GiB figure was the addressable
SVM pool. The GPU node reports `local_mem_size 0`, so all allocations are
GTT/SVM-backed. Trust rocm-smi and the runtime. Do not use `free` or
`dmidecode` for the GPU budget. They show the CPU-side carve-out. This is
intentional. Do not change it.
Retired runbooks are under `serverAppConfigs-docs/LLM/`. Retired scripts live
in `old/` and `.AGENTTRASH/`.
## Why
Direct edits on the remote drift from version control. Future agents will not
know what changed. Git is the single source of truth. The two workloads share
two hosts, one Caddy, and one Gitea. One repo keeps them in one history.
> User request → Fetch from remote → Commit here → Edit → Test → Commit → Push → Pull on remote → Deploy + restart
Direct edits on the remote drift from version control, and future agents
won't know what changed. Git is the single source of truth. The two workloads
share two hosts, one Caddy, and one Gitea; one repo keeps them in one
history.
## Summary
| Context | Action |
|---------|--------|
| Editing a compose/Caddyfile/serve script | **Work through git** — fetch, commit, edit, test, push, pull, deploy. Never edit on the remote directly. |
| Config exists only on remote | **Pull it into the repo first** — commit the fetched version (redacted) before making changes. |
| Unstaged local changes | **Commit or discard** — check `git status` before starting work. Ask the user if unsure. |
| Deploying a committed change | **Run `scripts/deploy.sh <target>`** — it pushes, pulls on the target, restarts the stack and the LLM service, and checks both health endpoints. See [docs/LLM/deployment.md](docs/LLM/deployment.md). |
| Benchmarking | **Stop the service first** — scratch port, restart, verify health. See the Benchmarking Policy above. |
| Editing a compose/Caddyfile/serve script | **Through git** — fetch, commit, edit, test, push, pull, deploy. Never edit on the remote. |
| Config exists only on remote | **Pull it into the repo first** — commit the fetched version (redacted) before editing. |
| Unstaged local changes | **Commit or discard** — check `git status` before starting work; ask if unsure. |
| Deploying a committed change | **`scripts/deploy.sh <target>`** — pushes, pulls ff-only, plans, dispatches, health-checks. |
| Container without a Quadlet unit | **Give it one** — boot-persistent, self-healing `.container` via git. |
| Benchmarking | **Stop and mask the service first**, bench, unmask, restart, verify health. |
| Deleting a file | **Move it to trash** — `.AGENTTRASH/` (in-repo) or `~/.AGENTTRASH/YYYYMMDD-topic`. Never `rm`. |
| Tests exist | **Run them** — verify tests pass after every change. Do not commit failing tests. |
| Real secrets | **Never commit** — use host `.env` files. `.env.example` holds placeholders only. `FRANK_SUDO_PW` covers headless sudo. |
| Real secrets | **Never commit** — host `.env` files; `.env.example` holds placeholders only. |
+16 -15
View File
@@ -12,8 +12,8 @@ one deploy script.
| Host | User | IP | Podman stack | LLM service | LLM port |
|------|------|----|--------------|-------------|----------|
| dankserver | dank | 192.168.0.235 | media and library | frank-default-small (llama.cpp, Qwen3.6-35B-A3B) | 7770 |
| dankclaw | dank | 192.168.0.143 | AI, search, development, observability | frank-default-large (CIRU ROCmFPX, Qwen3.8-27B) | 7770 |
| dankserver | dank | 192.168.0.235 | media and library | frank-medium (ROCmFPX llama.cpp, Qwen3.8-27B AGENT) | 7770 |
| dankclaw | dank | 192.168.0.143 | AI, search, development, observability | frank-large (CIRU ROCmFPX, Qwen3.8-27B) | 7770 |
## Active Services
@@ -22,7 +22,7 @@ one deploy script.
| RomM | dankserver | romm.dankserver.local | 8085 |
| Copyparty | dankserver | files.dankserver.local | 3923 |
| Calibre-Web | dankserver | books.dankserver.local | 8083 |
| frank-default-small | dankserver | — (bare endpoint) | 7770 |
| frank-medium | dankserver | — (bare endpoint) | 7770 |
| SearXNG | dankclaw | search.dankclaw.local | 8081 |
| ComfyUI | dankclaw | comfyui.dankclaw.local | 8188 |
| OpenWebUI | dankclaw | chat.dankclaw.local | 3000 |
@@ -30,33 +30,34 @@ one deploy script.
| Gitea | dankclaw | gitea.dankclaw.local | 3001 |
| Grafana | dankclaw | grafana.dankclaw.local | 3002 |
| Prometheus | dankclaw | prometheus.dankclaw.local | 9090 |
| frank-default-large | dankclaw | — (bare endpoint) | 7770 |
| frank-large | dankclaw | — (bare endpoint) | 7770 |
## Structure
- `dankserver/` — compose.yaml + Caddyfile + .env.example for the Dankserver stack
- `dankclaw/` — compose.yaml + Caddyfile + .env.example for the Dankclaw stack
- `shared/` — homepage, prometheus config, deploy helpers
- `scripts/` — `deploy.sh` (unified: stack + LLM), LLM serve scripts, systemd units, env tunables, bench harnesses
- `scripts/` — `deploy.sh` and `deploy-remote.sh` (service-scoped deployment), the planner, LLM serve scripts, systemd units, env tunables, and bench harnesses
- `old/` — retired PP/NPU-era LLM scripts (reference only)
- `docs/` — project knowledge base (Obsidian vault, wikilinks). `docs/LLM/` is the LLM fleet
- `serverAppConfigs-docs/` — project knowledge base (Obsidian vault, wikilinks). `serverAppConfigs-docs/LLM/` is the LLM fleet
- `tests/` — bats test suite (see [[Testing Policy]])
## Deployment
See [docs/Architecture/Deployment Flow.md](docs/Architecture/Deployment%20Flow.md)
and [docs/LLM/deployment.md](docs/LLM/deployment.md). Run
`./scripts/deploy.sh <dankclaw|dankserver>` after pushing committed changes.
It pushes, pulls on the target, restarts the podman stack and the LLM
service, and checks both health endpoints.
See [serverAppConfigs-docs/Architecture/Deployment Flow.md](serverAppConfigs-docs/Architecture/Deployment%20Flow.md)
and [serverAppConfigs-docs/LLM/deployment.md](serverAppConfigs-docs/LLM/deployment.md). Run
`./scripts/deploy.sh <dankclaw|dankserver>` after committing changes. It
plans and deploys only the changed services. Use `--service NAME` for an
explicit single-service retry; inference is restarted only when its own files
(or the shared chat template) changed.
## Knowledge Base
The `docs/` directory is an Obsidian vault. Start at
[docs/index.md](docs/index.md). The LLM fleet knowledge base lives at
[docs/LLM/index.md](docs/LLM/index.md).
The `serverAppConfigs-docs/` directory is an Obsidian vault. Start at
[serverAppConfigs-docs/index.md](serverAppConfigs-docs/index.md). The LLM fleet knowledge base lives at
[serverAppConfigs-docs/LLM/index.md](serverAppConfigs-docs/LLM/index.md).
## Testing
Run `./tests/run.sh` before every push. See
[docs/Architecture/Testing Policy.md](docs/Architecture/Testing%20Policy.md).
[serverAppConfigs-docs/Architecture/Testing Policy.md](serverAppConfigs-docs/Architecture/Testing%20Policy.md).
+27 -7
View File
@@ -20,16 +20,18 @@ WIREGUARD_PERSISTENT_KEEPALIVE_INTERVAL=25s
# Gitea database
GITEA_DB_PASSWORD=changeme
# Grafana admin
GRAFANA_ADMIN_USER=admin
GRAFANA_ADMIN_PASSWORD=changeme
# Grafana OAuth2 client secret (authentik OAuth2Provider "grafana", pk 9).
# Created via ak shell on authentik-server; the live .env must hold the
# provider's generated client_secret verbatim. Rotation = rotate the provider
# secret in authentik, restart grafana after updating the live .env value.
GRAFANA_OAUTH_CLIENT_SECRET=changeme
# Caddy (the homelab's single reverse proxy, on this host)
# bcrypt hash (caddy hash-password). deploy.sh renders it into
# /home/dank/Containers/caddy/Caddyfile.
# CADDY_BASICAUTH_HASH: the shared family password, still used by the
# vhosts that carry basicauth rather than Authentik forward_auth
# (grafana, pihole). Username: dankuser.
# /home/dank/Containers/caddy/Caddyfile, which the container mounts.
# CADDY_BASICAUTH_HASH: the shared family password, now used only by the
# pihole vhost (grafana moved behind the Authentik guard chain 2026-09-01).
# Username: dankuser.
CADDY_BASICAUTH_HASH=changeme
# (The homepage's per-user hashes were retired: the homepage and its /admin/
# page are now behind Authentik forward_auth, not basicauth.)
@@ -40,6 +42,11 @@ SIGNAL_ACCOUNT=+15551234567
# Comma-separated E.164 numbers allowed to message the agent.
SIGNAL_ALLOWED_USERS=+15551234567
# Hermes web UI (Hermex server) login password. Required: the webui binds to
# loopback and is reached only through the SSH tunnel, but Hermex still signs
# in with this password. Set a strong value in the live .env.
HERMES_WEBUI_PASSWORD=changeme
# Porkbun API (franksdank.cloud + franksdank.casa zones). Caddy uses these
# for DNS-01 Let's Encrypt wildcard cert issuance. Porkbun API keys are
# account-wide; rotate them here rather than in the dashboard.
@@ -54,3 +61,16 @@ AUTHENTIK_PG_PASS=changeme
# AUTHENTIK_SECRET_KEY: the server's secret key (64+ chars).
# Generate: openssl rand -base64 48
AUTHENTIK_SECRET_KEY=changeme
# Marreta (content-cleaning proxy, behind the Authentik guard chain).
# deploy-remote.sh seeds /home/dank/Containers/marreta/env from these three
# values (only if that file does not already exist) and the marreta quadlet
# maps them to the app's APP_KEY / ADMIN_EMAIL / ADMIN_PASSWORD.
# MARRETA_APP_KEY: Laravel encryption key. Generate:
# openssl rand -base64 32
# Changing it after first boot invalidates encrypted session/cookie data.
MARRETA_APP_KEY=changeme
# Admin panel login (marreta.franksdank.cloud/admin). The app creates this
# account on first boot from these values.
MARRETA_ADMIN_EMAIL=admin@franksdank.cloud
MARRETA_ADMIN_PASSWORD=changeme
+262 -125
View File
@@ -6,18 +6,32 @@
# published LAN port (quadlet services on isolated networks)
# - dankserver services (192.168.0.235): by published LAN port
#
# Naming: flat franksdank.cloud scheme (see docs/Infrastructure/LAN-DNS.md).
# Naming: flat franksdank.cloud scheme (see serverAppConfigs-docs/Infrastructure/LAN-DNS.md).
# DNS for these names is served by the Pi-hole quadlet on this host
# (dankclaw/pihole/addn-hosts — every name resolves to 192.168.0.143)
# and handed to LAN clients by the SURFboard G36 router.
#
# Authentication: Caddy forward_auth (Authentik SSO) protects the vhosts that
# do not have their own application-level auth (homepage, SearXNG, Hermes,
# Prometheus, RomM, Copyparty). The basicauth hash is a placeholder; deploy.sh
# renders the real hash (CADDY_BASICAUTH_HASH from the host .env, git-ignored)
# into /home/dank/Containers/caddy/Caddyfile, which the container mounts.
# Apps with their own auth (Grafana, Gitea, OpenWebUI, Jellyfin, *arr, Pi-hole)
# are not wrapped in forward_auth — they handle their own login.
# Authentication chain (2026-08-29): the guarded vhosts (homepage, SearXNG,
# Prometheus, RomM, Copyparty) proxy EVERYTHING through the Anubis
# bot-challenge proxy first, then the whole request is proxied THROUGH
# Authentik (2026.8 proxy-provider mode: this build serves no forward_auth
# endpoint; the authentication flow runs inline — unauthenticated clients get
# the login UI served in place, and once a session exists Authentik proxies
# the request on to the app via the provider's internal_host). Chain: client →
# Caddy (TLS, public listener) → Anubis :8923 (verify: DENY known AI scrapers,
# CHALLENGE everyone else) → Caddy's internal listener http://:8016 (auth
# decision; Host headers survive every hop) → authenticated traffic is
# forwarded back to http://:8017 and routed straight to the app.
# Apps with their own auth (Gitea, OpenWebUI, Jellyfin, *arr, Pi-hole) and the
# Authentik IdP itself (auth.franksdank.cloud — its login flow must never sit
# behind a challenge) are exempt by construction: their vhosts never route
# through Anubis. ComfyUI left this exempt set on 2026-08-31 and Grafana left
# it on 2026-09-01 (OAuth2 relying-party mode against authentik): both now sit
# behind the guard chain; Grafana authenticates users itself via the OAuth2
# code flow, so no identity header is forwarded to it.
# The pihole vhost keeps its basicauth edge; the hash placeholder below is
# rendered by deploy.sh from CADDY_BASICAUTH_HASH (host .env, git-ignored) into
# /home/dank/Containers/caddy/Caddyfile, which the container mounts.
#
# TLS: real Let's Encrypt certs. Site addresses carry no http:// prefix,
# so Caddy serves HTTPS (and auto-redirects HTTP). Certs are issued via
@@ -28,7 +42,7 @@
{
# Admin stays ON: it binds to 127.0.0.1:2019 inside the container and the
# port is not published, so nothing outside the container can reach it.
# It powers the config file watcher, so a rendered Caddyfile change
# It powers the config file watcher, so a rendered-config change
# hot-reloads without a container restart. 'admin off' once left a broken
# basicauth hash sitting on disk unapplied for hours (2026-08-22).
email admin@franksdank.cloud
@@ -44,59 +58,38 @@
# --- Bare-IP access (health checks, convenience) ---
:80 {
redir https://franksdank.cloud/ 302
redir https://www.franksdank.cloud/ 308
}
# --- Homepage (Authentik SSO) ---
franksdank.cloud www.franksdank.cloud {
root * /srv/homepage
# --- Homepage (Anubis → Authentik SSO) ---
# www holds the homepage; the naked domain redirects onto it at the TLS edge.
# A redirect needs no challenge, so unauthenticated naked traffic never enters
# the auth chain. The proxy provider's external_host must match the host that
# actually carries traffic (https://www.franksdank.cloud) or Authentik matches
# zero providers and answers its error page instead.
franksdank.cloud {
redir https://www.franksdank.cloud/ 308
}
route {
# Authentik outpost (login UI + session management).
reverse_proxy /outpost.goauthentik.io/* authentik-server:9000
# Forward auth: check the Authentik session before serving the page.
forward_auth authentik-server:9000 {
uri /outpost.goauthentik.io/auth/caddy
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name
trusted_proxies private_ranges
}
# Operator-only admin page. Auth is checked on the original path,
# before any rewrite, so the path matcher is reliable. 'handle'
# preserves order.
handle /admin* {
try_files /admin.html
file_server
}
# Shared data + assets: fetched by both the user page and the admin
# page. With forward_auth, any authenticated user can access them
# (the browser's Authentik session covers these fetches).
@data path /services.json /status.json /app.js /style.css /deployed.js
handle @data {
file_server
}
# Everything else (the user page).
handle {
try_files /index.html
file_server
}
www.franksdank.cloud {
reverse_proxy anubis:8923 {
header_up X-Real-Ip {remote_host}
}
}
# --- Legacy name: the old homepage name redirects to the naked domain ---
# --- Legacy name: the old homepage name redirects straight onto www ---
dankclaw.franksdank.cloud {
redir https://franksdank.cloud/ 302
redir https://www.franksdank.cloud/ 308
}
# --- Legacy name: the media host has no Caddy of its own anymore ---
dankserver.franksdank.cloud {
redir https://franksdank.cloud/ 302
redir https://www.franksdank.cloud/ 308
}
# --- Authentik (SSO / OIDC provider) ---
# Exempt by construction: the IdP login flow must be reachable without a
# challenge, so this vhost never routes through Anubis.
auth.franksdank.cloud {
reverse_proxy authentik-server:9000
}
@@ -107,69 +100,66 @@ auth.franksdank.cloud {
# --- SearXNG (quadlet; behind the Gluetun tunnel, published 8081) ---
search.franksdank.cloud {
route {
reverse_proxy /outpost.goauthentik.io/* authentik-server:9000
forward_auth authentik-server:9000 {
uri /outpost.goauthentik.io/auth/caddy
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name
trusted_proxies private_ranges
}
reverse_proxy 192.168.0.143:8081
reverse_proxy anubis:8923 {
header_up X-Real-Ip {remote_host}
}
}
# --- ComfyUI ---
comfyui.franksdank.cloud {
reverse_proxy comfyui:8188
}
# --- OpenWebUI (quadlet on slirp4netns; published 3005) ---
chat.franksdank.cloud {
ai.franksdank.cloud {
reverse_proxy 192.168.0.143:3005
}
# --- Hermes ---
hermes.franksdank.cloud {
route {
reverse_proxy /outpost.goauthentik.io/* authentik-server:9000
forward_auth authentik-server:9000 {
uri /outpost.goauthentik.io/auth/caddy
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name
trusted_proxies private_ranges
}
reverse_proxy hermes:8090
}
}
# --- Gitea (quadlet; published 3000) ---
# Exempt by construction: Gitea has its own login, and git smart-HTTP
# clients (git push/fetch over HTTPS) cannot solve JS challenges. The
# policy would need a bypass for /info/refs + /git-*-pack paths if this
# ever moves behind Anubis.
gitea.franksdank.cloud {
reverse_proxy 192.168.0.143:3000
}
# --- Grafana ---
# --- Grafana (guarded: Anubis → Authentik outpost gate; login inside Grafana) ---
# Auth model = OAuth2 relying party against authentik (OAuth2Provider "grafana",
# confidential client). The guard chain gates unauthenticated traffic; the
# outpost carries no provider token in this deployment, so identity headers
# never reach Grafana — RP mode needs none and works regardless of topology.
# The basicauth edge this replaces (CADDY_BASICAUTH_HASH) is retired.
grafana.franksdank.cloud {
basicauth {
dankuser REPLACE_WITH_HASH
reverse_proxy anubis:8923 {
header_up X-Real-Ip {remote_host}
}
reverse_proxy grafana:3000
}
# --- Prometheus ---
prometheus.franksdank.cloud {
route {
reverse_proxy /outpost.goauthentik.io/* authentik-server:9000
forward_auth authentik-server:9000 {
uri /outpost.goauthentik.io/auth/caddy
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name
trusted_proxies private_ranges
}
reverse_proxy prometheus:9090
reverse_proxy anubis:8923 {
header_up X-Real-Ip {remote_host}
}
}
# --- Loki ---
# --- Loki (guarded: Anubis → Authentik; loki-proxy provider, internal_host caddy:8017) ---
loki.franksdank.cloud {
reverse_proxy loki:3100
reverse_proxy anubis:8923 {
header_up X-Real-Ip {remote_host}
}
}
# --- Seerr (guarded: Anubis → Authentik; seerr provider, internal_host caddy:8017) ---
seerr.franksdank.cloud {
reverse_proxy anubis:8923 {
header_up X-Real-Ip {remote_host}
}
}
# --- Marreta (guarded: Anubis → Authentik; marreta provider, internal_host caddy:8017) ---
# Content-cleaning proxy. Any authentik-authenticated user may use it (no
# group gate). Egress rides the searxng gluetun tunnel; reached via the
# host-published port 8082 (gluetun netns), not by container name.
marreta.franksdank.cloud {
reverse_proxy anubis:8923 {
header_up X-Real-Ip {remote_host}
}
}
# --- Pi-hole (quadlet on slirp4netns; published 8083) ---
@@ -180,6 +170,32 @@ pihole.franksdank.cloud {
reverse_proxy 192.168.0.143:8083
}
# --- Hermes WebUI (Hermex webui; host-netns container, published 8787) ------
# Exempt by construction: the webui authenticates itself (HERMES_WEBUI_PASSWORD),
# so no Anubis challenge and no Authentik proxy provider is required — nothing
# here enters the http://:8016 auth chain. Internal-only by DNS absence: there is
# no public Porkbun A record for this name, so only LAN clients (Pi-hole
# addn-hosts → 192.168.0.143) can resolve it at all; DNS-01 issuance via the
# Porkbun module needs no public record. Upstream is `host.containers.internal`
# — netavark's host address, 169.254.1.2 — NOT the host LAN IP: hermes-webui is
# `Network=host`, so 8787 is a host-netns bind and not a published port, and
# netavark DNATs only published ports (192.168.0.143:9119, the dashboard below,
# answers from this bridge; 192.168.0.143:8787 does not). Measured live
# 2026-09-16 inside the Caddy container: 192.168.0.143:8787 and 10.89.1.1:8787
# both REFUSED, host.containers.internal:8787 → 200.
hermes.franksdank.cloud {
reverse_proxy host.containers.internal:8787
}
# --- Hermes dashboard (admin UI; published 9119 on dankclaw_default) --------
# Exempt by construction: the app enforces its own complete auth — verified live
# from Caddy's own network, an unauthenticated GET / returns 302 → /login?next=%2F
# (uvicorn), and basic_auth.username + a scrypt password_hash are set in the
# dashboard's /data/config.yaml. No outpost provider needed.
hermesadmin.franksdank.cloud {
reverse_proxy 192.168.0.143:9119
}
# ═══════════════════════════════════════════════════════════════
# Services on dankserver (192.168.0.235) — published LAN ports
# ═══════════════════════════════════════════════════════════════
@@ -190,11 +206,24 @@ pihole.franksdank.cloud {
# Let's Encrypt cert for it. It stays plain-HTTP legacy; the canonical
# name is plex.franksdank.cloud (HTTPS, below).
http://plex.franksdank.casa {
reverse_proxy 192.168.0.235:32400
reverse_proxy 192.168.0.235:32400 {
transport http {
tls_insecure_skip_verify
}
}
}
# PMS requires TLS from every non-loopback client: plain-HTTP off-loopback
# is read and silently closed (diagnosed 2026-08-26), which surfaced as an
# empty-body 502 here. Dial the upstream over TLS; PMS's self-signed cert is
# skipped on this pinned-LAN hop only (client-facing LE TLS is untouched).
# The web UI lives at /web/; / returns the XML MediaContainer index.
plex.franksdank.cloud {
reverse_proxy 192.168.0.235:32400
reverse_proxy 192.168.0.235:32400 {
transport http {
tls_insecure_skip_verify
}
}
}
# --- Calibre-Web ---
@@ -204,62 +233,170 @@ books.franksdank.cloud {
# --- RomM ---
romm.franksdank.cloud {
route {
reverse_proxy /outpost.goauthentik.io/* authentik-server:9000
forward_auth authentik-server:9000 {
uri /outpost.goauthentik.io/auth/caddy
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name
trusted_proxies private_ranges
}
reverse_proxy 192.168.0.235:8085
reverse_proxy anubis:8923 {
header_up X-Real-Ip {remote_host}
}
}
# --- Copyparty ---
files.franksdank.cloud {
route {
reverse_proxy /outpost.goauthentik.io/* authentik-server:9000
forward_auth authentik-server:9000 {
uri /outpost.goauthentik.io/auth/caddy
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name
trusted_proxies private_ranges
}
reverse_proxy 192.168.0.235:3923
reverse_proxy anubis:8923 {
header_up X-Real-Ip {remote_host}
}
}
# --- Jellyfin ---
jellyfin.franksdank.cloud {
reverse_proxy 192.168.0.235:8096 {
transport http {
versions h2c 1.1
}
}
jelly.franksdank.cloud {
reverse_proxy 192.168.0.235:8096
}
# --- *arr Stack (exempt by construction: own auth) ---
prowlarr.franksdank.cloud {
reverse_proxy 192.168.0.112:9696
}
# --- *arr Stack ---
radarr.franksdank.cloud {
reverse_proxy 192.168.0.235:7878
reverse_proxy 192.168.0.112:7878
}
sonarr.franksdank.cloud {
reverse_proxy 192.168.0.235:8989
}
# --- Sonarr: internal-only by construction (retired 2026-09-04) -----------
# No vhost and no public DNS record for sonarr.franksdank.cloud: the TV-library
# admin UI is reachable only on the LAN at 192.168.0.112:8989 (users request TV
# through Seerr, which sits behind the guard chain; admins reach Sonarr's own
# UI directly). A brief unguarded public vhost lived here and served the
# zero-auth UI to the internet via manual DNS — retired.
lidarr.franksdank.cloud {
reverse_proxy 192.168.0.235:8686
reverse_proxy 192.168.0.112:8686
}
readarr.franksdank.cloud {
reverse_proxy 192.168.0.235:8787
}
prowlarr.franksdank.cloud {
reverse_proxy 192.168.0.235:9696
reverse_proxy 192.168.0.112:8787
}
qbittorrent.franksdank.cloud {
reverse_proxy 192.168.0.235:8080
reverse_proxy 192.168.0.112:8080
}
# --- Internal listener: auth decision layer ---------------------------------
# Anubis dials http://caddy:8016 after verifying a client (TARGET in
# dankclaw/anubis/quadlet/anubis.container). Host headers survive the hop.
# Every request here is proxied whole-request to Authentik with Host
# preserved. This build's embedded outpost routes ONLY on the ORIGINAL Host
# against each proxy provider's external_host — meta headers are ignored and
# cross-host absolute next= redirects are rejected by the flow's open-redirect
# guard ("Request has been denied: Invalid next URL"), verified live.
# Unauthenticated clients get the login flow served in place; authenticated
# sessions are forwarded by the provider's internal_host back to
# http://:8017 and routed straight to the app. Every host that may enter this
# listener MUST have a proxy provider registered with the embedded outpost —
# the Anubis guard set, the Caddy TLS edge, and the provider list stay in
# lockstep. Exempt vhosts never enter this block.
http://:8016 {
reverse_proxy authentik-server:9000 {
header_up X-Forwarded-Proto https
}
}
# --- Post-auth listener: where Authentik forwards authenticated traffic ----
# The proxy provider's internal_host is http://caddy:8017, so every request
# that passes authentication lands here (Host header preserved) and routes
# straight to its app. This is the end of the chain — nothing dials back
# into Authentik from here, so the chain cannot loop.
http://:8017 {
@home host www.franksdank.cloud
handle @home {
root * /srv/homepage
# RBAC (2026-09-01): /admin requires membership in the homepage-admins
# authentik group. In proxy-provider mode the outpost injects
# X-Authentik-Groups on its upstream request to this listener and strips
# any client-supplied X-Authentik-* headers first, so browsers cannot
# spoof it. Regex is a substring match, insensitive to the group-list
# separator.
handle /admin* {
@adminok header_regexp X-Authentik-Groups homepage-admins
handle @adminok {
try_files /admin.html
file_server
}
respond "Forbidden: homepage admins only" 403
}
@data path /services.json /status.json /app.js /style.css /deployed.js
handle @data {
file_server
}
handle {
try_files /index.html
file_server
}
}
@search host search.franksdank.cloud
handle @search {
reverse_proxy 192.168.0.143:8081
}
@loki host loki.franksdank.cloud
handle @loki {
reverse_proxy loki:3100
}
@seerr host seerr.franksdank.cloud
handle @seerr {
reverse_proxy seerr:5055
}
@grafana host grafana.franksdank.cloud
handle @grafana {
reverse_proxy grafana:3000
}
@prometheus host prometheus.franksdank.cloud
handle @prometheus {
reverse_proxy prometheus:9090
}
@romm host romm.franksdank.cloud
handle @romm {
reverse_proxy 192.168.0.235:8085
}
@files host files.franksdank.cloud
handle @files {
reverse_proxy 192.168.0.235:3923
}
@prowlarr host prowlarr.franksdank.cloud
handle @prowlarr {
reverse_proxy 192.168.0.112:9696
}
@radarr host radarr.franksdank.cloud
handle @radarr {
reverse_proxy 192.168.0.112:7878
}
@lidarr host lidarr.franksdank.cloud
handle @lidarr {
reverse_proxy 192.168.0.112:8686
}
@readarr host readarr.franksdank.cloud
handle @readarr {
reverse_proxy 192.168.0.112:8787
}
@qbittorrent host qbittorrent.franksdank.cloud
handle @qbittorrent {
reverse_proxy 192.168.0.112:8080
}
@marreta host marreta.franksdank.cloud
handle @marreta {
reverse_proxy 192.168.0.143:8082
}
}
# --- Optional: Jackett ---
+25
View File
@@ -0,0 +1,25 @@
{
"bots": [
{
"name": "deny AI scrapers",
"user_agent_regex": "(?i)^(GPTBot|ChatGPT-User|OAI-SearchBot|CCBot|anthropic-ai|ClaudeBot|Claude-Web|PerplexityBot|PerplexityUser|Google-Extended|Applebot-Extended|Bytespider|Amazonbot|meta-externalagent|DuckAssistBot|cohere-ai|YouBot|KimiBot)",
"action": "DENY"
},
{
"name": "serve anubis own endpoints",
"path_regex": "^/\\.within\\.website",
"action": "ALLOW"
},
{
"name": "serve authentik outpost transport",
"path_regex": "^/outpost\\.goauthentik\\.io/",
"action": "ALLOW"
},
{
"name": "challenge everything else",
"path_regex": ".",
"action": "CHALLENGE"
}
],
"dnsbl": false
}
+70
View File
@@ -0,0 +1,70 @@
# Quadlet unit: Anubis bot-challenge proxy (rootless, boot-persistent).
#
# Install path on dankclaw: ~/.config/containers/systemd/anubis.container
# Unit name: anubis.service (container name: anubis)
#
# Two-tier chain: the public Caddy listeners for the guarded vhosts proxy
# everything to this container (port 8923, internal only — no published port,
# so metrics on :9090 stay reachable only inside dankclaw_default); Anubis
# verifies the client (challenge/DENY per policy) and forwards accepted
# traffic back to Caddy's internal listener (TARGET below), where the normal
# per-vhost routing — including Authentik forward_auth — resumes. Host headers
# survive both hops, so vhost matching on the internal listener still works.
#
# Image pin: the image is pulled from ghcr.io/techarohq/anubis (source of
# truth), but the unit runs it under a localhost/-aliased tag: on this host's
# podman 5.7 quadlet path, registry-prefixed refs fail to resolve from local
# storage at container create time ("image not known") even when present; the
# localhost alias resolves (verified live 2026-08-28). v1.27.0 pinned 2026-09-02: --help
# verified on the image itself (cookie-domain, ed25519-private-key-hex,
# cookie-expiration-time default 168h, difficulty default 4); JSON policy
# schema unchanged; X-Real-Ip is now REQUIRED (missing header => HTTP 500) —
# the Caddy chain always sets it (bats seam enforces the header_up line).
# Upgrading = pull new tag, re-tag the localhost alias, restart the unit.
#
# No secrets in this repo. COOKIE_DOMAIN makes the challenge cookie valid for
# all *.franksdank.cloud vhosts: solve the proof-of-work once (e.g. on www)
# and every guarded vhost honors it until expiry (default 168h, aligned with
# authentik's 7-day session) or re-login. ED25519_PRIVATE_KEY_HEX pins the
# JWT signing key (host file /home/dank/Containers/anubis/env, mode 0600,
# gitea-style ${VAR} expansion via the [Service] EnvironmentFile): without
# it every restart invalidates everyone's solved challenges. See
# serverAppConfigs-docs/Infrastructure/Anubis.md.
[Unit]
Description=Anubis bot-challenge proxy container
After=network-online.target
Wants=network-online.target
[Container]
ContainerName=anubis
Image=localhost/techarohq/anubis:v1.27.0
Pull=never
Network=dankclaw_default
# flagenv maps env names to flag names (TARGET -> --target, POLICY_FNAME ->
# --policy-fname). TARGET points back at Caddy's internal listener; the
# container DNS name resolves on dankclaw_default.
Environment=TARGET=http://caddy:8016
Environment=POLICY_FNAME=/policy/policy.json
Environment=COOKIE_DOMAIN=franksdank.cloud
EnvironmentFile=/home/dank/Containers/anubis/env
Environment=ED25519_PRIVATE_KEY_HEX=${ED25519_PRIVATE_KEY_HEX}
Volume=/home/dank/serverAppConfigs/dankclaw/anubis/policy.json:/policy/policy.json:ro
# The image ships no HEALTHCHECK (distroless ko-build: entrypoint is
# /ko-app/anubis, no shell in the image), so the probe is declared here.
# JSON exec form on purpose: this quadlet generator joins HealthCmd into a
# single \x20-escaped argv element and podman then runs it as shell-form —
# which needs a shell the distroless image does not have. A JSON array is
# passed through verbatim and executes /ko-app/anubis directly.
HealthCmd=["/ko-app/anubis","--healthcheck"]
HealthInterval=15s
HealthTimeout=5s
HealthRetries=3
[Service]
EnvironmentFile=/home/dank/Containers/anubis/env
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
@@ -0,0 +1,40 @@
# Quadlet unit: Authentik LDAP outpost (rootless, boot-persistent).
#
# Install path on dankclaw: ~/.config/containers/systemd/authentik-outpost-ldap.container
# Unit name: authentik-outpost-ldap.service (podman 5.x drops the container- prefix)
#
# Serves the authentik LDAP provider ("Jellyfin LDAP", Base DN
# dc=franksdank,dc=cloud) so dankserver's Jellyfin can authenticate its users
# via the LDAP-Auth plugin. The type-specific ghcr.io/goauthentik/ldap image
# listens on 3389 (ldap) / 6636 (ldaps) inside the container; published to
# host 389/636 for cross-host binds from dankserver. The outpost token lives
# in the host .env (AUTHENTIK_LDAP_OUTPOST_TOKEN), never in this repo. The
# outpost dials core over websocket at AUTHENTIK_HOST; auth. is guard-chain
# exempt (IdP).
[Unit]
Description=Authentik LDAP outpost container
After=network.target authentik-server.service
Wants=authentik-server.service
[Container]
ContainerName=authentik-outpost-ldap
Network=dankclaw_default
# Image=ghcr.io/goauthentik/ldap:2026.8.0 (amd64 digest; podman 5.7.0 refuses
# the tag's nested manifest list, so pin the single-platform digest).
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
Image=ghcr.io/goauthentik/ldap@sha256:fa33bc8f522113830e1662f4c034352f9eb08e0a7d3da2ddadad9a4b33b4cbf4
Environment=AUTHENTIK_HOST=https://auth.franksdank.cloud
Environment=AUTHENTIK_INSECURE=false
Environment=AUTHENTIK_TOKEN=${AUTHENTIK_LDAP_OUTPOST_TOKEN}
PublishPort=389:3389
PublishPort=636:6636
User=0:0
[Service]
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
@@ -0,0 +1,44 @@
# Quadlet unit: Authentik outpost (rootless, boot-persistent).
#
# Install path on dankclaw: ~/.config/containers/systemd/authentik-outpost.container
# Unit name: authentik-outpost.service (podman 5.x drops the container- prefix)
#
# In authentik 2026.8.x the proxy-provider URL space (/outpost.goauthentik.io/*)
# is served by the Rust worker binary, not the server — verified live: with a
# tenant-matching Host header, /if/flow/... answers 200 on the server while
# /auth/caddy still 404s. This container runs the same image as the server and
# worker units; Exec=worker selects the Rust worker role, and the
# AUTHENTIK_HOST/AUTHENTIK_TOKEN env flips it into outpost mode: it dials back
# to core over websocket (ws/outpost/<uuid>/) and serves forward-auth for Caddy
# on its own 9000. No published port — Caddy reaches it by container DNS on
# dankclaw_default; the token lives in the host .env, never in this repo.
[Unit]
Description=Authentik outpost container
After=network.target authentik-postgres.service authentik-server.service
Wants=authentik-server.service
[Container]
ContainerName=authentik-outpost
Image=ghcr.io/goauthentik/server:2026.8.0
Exec=worker
Network=dankclaw_default
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
Environment=AUTHENTIK_POSTGRESQL__HOST=authentik-postgres
Environment=AUTHENTIK_POSTGRESQL__NAME=authentik
Environment=AUTHENTIK_POSTGRESQL__USER=authentik
Environment=AUTHENTIK_POSTGRESQL__PASSWORD=${AUTHENTIK_PG_PASS}
Environment=AUTHENTIK_SECRET_KEY=${AUTHENTIK_SECRET_KEY}
Environment=AUTHENTIK_HOST=https://auth.franksdank.cloud
Environment=AUTHENTIK_INSECURE=false
Environment=AUTHENTIK_TOKEN=${AUTHENTIK_OUTPOST_TOKEN}
Volume=/home/dank/Containers/authentik/data:/data
User=0:0
[Service]
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
@@ -0,0 +1,42 @@
# Quadlet unit: Authentik PostgreSQL 16 (rootless, boot-persistent).
#
# Install path on dankclaw: ~/.config/containers/systemd/authentik-postgres.container
# Unit name: authentik-postgres.service (podman 5.x drops the container- prefix)
#
# Replaces the compose service retired when this stack went boot-persistent
# (rootless Podman does not auto-start bare containers at boot). Secrets stay
# out of this file: systemd expands ${AUTHENTIK_PG_PASS} from the git-ignored
# host .env at start time (same pattern as gitea-postgres).
[Unit]
Description=Authentik PostgreSQL container
After=network.target
[Container]
ContainerName=authentik-postgres
Image=docker.io/library/postgres:16-alpine
Network=dankclaw_default
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
Environment=POSTGRES_DB=authentik
Environment=POSTGRES_USER=authentik
Environment=POSTGRES_PASSWORD=${AUTHENTIK_PG_PASS}
Volume=/home/dank/Containers/authentik/postgres:/var/lib/postgresql/data
# Unquoted argv: this quadlet generator space-splits HealthCmd into the
# healthcheck's argv array, so a quoted probe would arrive mangled (the old
# `/bin/sh -c "pg_isready..."` form arrived as /bin/sh + broken args and
# failed with exit 1 forever — the health status was a false negative while
# the DB itself accepted connections). Bare argv works under both exec forms.
HealthCmd=pg_isready -d authentik -U authentik
HealthInterval=30s
HealthTimeout=10s
HealthRetries=5
HealthStartPeriod=20s
[Service]
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
TimeoutStartSec=900
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
@@ -0,0 +1,45 @@
# Quadlet unit: Authentik core + embedded Caddy outpost (rootless, boot-persistent).
#
# Install path on dankclaw: ~/.config/containers/systemd/authentik-server.container
# Unit name: authentik-server.service (podman 5.x drops the container- prefix)
#
# The embedded outpost serves /outpost.goauthentik.io/* including the
# /auth/caddy forward_auth endpoint the Caddyfile chains to — no outpost token
# exists for this deployment by design. Resolved by name (A2A DNS) from every
# container on dankclaw_default, including caddy. The 9000 publish is
# loopback-only on purpose: LAN clients reach Authentik through Caddy, never
# around it; the loopback publish exists so host-side tooling can probe
# /-/health/. Runs as the image default user (same userns mapping the compose
# era used, so the existing /data ownership carries over unchanged).
[Unit]
Description=Authentik server container
After=network.target authentik-postgres.service
Wants=authentik-postgres.service
[Container]
ContainerName=authentik-server
Image=ghcr.io/goauthentik/server:2026.8.0
Exec=server
Network=dankclaw_default
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
Environment=AUTHENTIK_POSTGRESQL__HOST=authentik-postgres
Environment=AUTHENTIK_POSTGRESQL__NAME=authentik
Environment=AUTHENTIK_POSTGRESQL__USER=authentik
Environment=AUTHENTIK_POSTGRESQL__PASSWORD=${AUTHENTIK_PG_PASS}
Environment=AUTHENTIK_SECRET_KEY=${AUTHENTIK_SECRET_KEY}
PublishPort=127.0.0.1:9000:9000
# Probe must contain no quotes: this host's quadlet v1 generator space-splits
# HealthCmd into the healthcheck argv array, and quoted probes arrive mangled
# (verified live 2026-08-28). `python /manage.py check --database default`
# exits nonzero iff Django (incl. the DB connection) cannot serve.
HealthCmd=python /manage.py check --database default
[Service]
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
TimeoutStartSec=900
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
@@ -0,0 +1,37 @@
# Quadlet unit: Authentik worker — background tasks, email, webhook/log outbox
# (rootless, boot-persistent).
#
# Install path on dankclaw: ~/.config/containers/systemd/authentik-worker.container
# Unit name: authentik-worker.service (podman 5.x drops the container- prefix)
#
# Same image as the server; the Exec= command selects the worker role. Runs as
# user 0:0 (rootless: maps to the invoking host user, matching the compose-era
# semantics). No published ports — the worker talks to PostgreSQL in-network
# and has no listener of its own.
[Unit]
Description=Authentik worker container
After=network.target authentik-postgres.service authentik-server.service
Wants=authentik-server.service
[Container]
ContainerName=authentik-worker
Image=ghcr.io/goauthentik/server:2026.8.0
Exec=worker
Network=dankclaw_default
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
Environment=AUTHENTIK_POSTGRESQL__HOST=authentik-postgres
Environment=AUTHENTIK_POSTGRESQL__NAME=authentik
Environment=AUTHENTIK_POSTGRESQL__USER=authentik
Environment=AUTHENTIK_POSTGRESQL__PASSWORD=${AUTHENTIK_PG_PASS}
Environment=AUTHENTIK_SECRET_KEY=${AUTHENTIK_SECRET_KEY}
Volume=/home/dank/Containers/authentik/data:/data
User=0:0
[Service]
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
+1 -1
View File
@@ -4,7 +4,7 @@
# directly through the Porkbun API, so the wildcard cert for
# *.franksdank.cloud / *.franksdank.casa can be issued and renewed without
# any port 80/443 reachability from the internet (see the Caddyfile in
# ../Caddyfile and docs/Infrastructure/LAN-DNS.md).
# ../Caddyfile and serverAppConfigs-docs/Infrastructure/LAN-DNS.md).
#
# Build on dankclaw (podman rootless, same as every other container here):
# podman build -t franksdank/caddy-porkbun:latest -f dankclaw/caddy/Containerfile .
+7
View File
@@ -0,0 +1,7 @@
# Caddy environment (placeholders only). The deploy writes the live file at
# /home/dank/Containers/caddy/env from the checkout's .env; the quadlet reads
# it via EnvironmentFile. Consumed by the Caddyfile's acme_dns block
# (DNS-01 wildcard cert issuance). PORKBUN_API_SECRET_KEY is the name the
# caddy-dns/porkbun module expects.
PORKBUN_API_KEY=changeme
PORKBUN_API_SECRET_KEY=changeme
+54
View File
@@ -0,0 +1,54 @@
# Quadlet unit: canonical Caddy reverse proxy + ACME (rootless, boot-persistent).
#
# Install path on dankclaw: ~/.config/containers/systemd/caddy.container
# Unit name: caddy.service (container name: caddy)
#
# The homelab's single Caddy: it fronts every service on both hosts and issues
# Let's Encrypt wildcard certs via DNS-01 (Porkbun). It left compose.yaml on
# 2026-08-26 after a reboot exposed that the bare compose container never came
# back: rootless Podman has no boot-time auto-start for bare containers, and
# `restart: unless-stopped` does not survive the explicit stop that shutdown
# performs. See AGENTS.md "Boot Persistence & Self-Healing" and
# serverAppConfigs-docs/Infrastructure/Caddy.md.
#
# It joins the existing compose network (dankclaw_default) so the Caddyfile's
# container-name upstreams (authentik-server, grafana, ...) still resolve; if
# no compose service has created that network yet at boot, quadlet creates it
# first and compose reuses it by name later. The image is built locally from
# dankclaw/caddy/Containerfile and exists only on this host, so Pull=never: a
# boot must not stall on a registry that does not have it. Porkbun credentials
# come from /home/dank/Containers/caddy/env (deploy writes it from the
# checkout's .env; secrets never enter the repo).
[Unit]
Description=Caddy reverse proxy + ACME (Porkbun DNS-01) container
After=network-online.target
Wants=network-online.target
[Container]
ContainerName=caddy
Image=franksdank/caddy-porkbun:latest
Pull=never
Network=dankclaw_default
PublishPort=0.0.0.0:80:80/tcp
PublishPort=0.0.0.0:443:443/tcp
EnvironmentFile=/home/dank/Containers/caddy/env
Volume=/home/dank/Containers/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
Volume=/home/dank/Containers/caddy/data:/data
Volume=/home/dank/Containers/caddy/config:/config
# The homepage: the host's /srv/homepage (deploy-managed, not the repo's
# shared/homepage).
Volume=/srv/homepage:/srv/homepage:ro
# The bare-IP catch-all answers 302 (curl -f passes on <400); this proves
# Caddy itself is serving without touching any upstream.
HealthCmd=curl -sf -o /dev/null http://localhost:80/
HealthInterval=30s
HealthTimeout=10s
HealthRetries=3
[Service]
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
-55
View File
@@ -1,55 +0,0 @@
# ComfyUI on ROCm for the AMD Strix Halo APU (gfx1151).
#
# The official `rocm/comfyui` and pytorch.org ROCm wheels compile kernels for
# datacenter archs (gfx942/gfx950) and omit gfx1151. This base — AMD's
# gfx1151-tagged PyTorch build (torch 2.9.1+rocm7.12.0rc1) — is the verified
# working GPU path on Dankclaw (2026-08-14: avail=True, Radeon 8060S, cap (11,5)).
#
# The base is heavier than a dedicated ComfyUI image (it carries vLLM), but it
# is the only prebuilt image that ships gfx1151 torch kernels. See
# docs/Research/ComfyUI ROCm and Vulkan.md.
FROM docker.io/rocm/vllm:rocm7.12.0_gfx1151_ubuntu24.04_py3.12_pytorch_2.9.1_vllm_0.16.0
# ComfyUI source + runtime requirements (non-GPU deps install from PyPI).
ENV PYTHONUNBUFFERED=1 \
COMFYUI_DIR=/opt/ComfyUI
WORKDIR /opt
# git ships in the base; clone the stable ComfyUI tag.
RUN git clone --depth 1 --branch v0.3.6 \
https://github.com/Comfy-Org/ComfyUI.git "$COMFYUI_DIR"
WORKDIR "$COMFYUI_DIR"
# Install ComfyUI Python requirements. Do NOT reinstall torch/torchvision —
# keep the gfx1151-enabled torch from the base. The requirements pin torch,
# so install everything else from a deps-only pass.
RUN /opt/python/bin/pip install --no-cache-dir \
-r requirements.txt \
--no-deps 2>/dev/null || true
# Ensure the managed deps that are missing get installed without touching torch.
# (First pass is best-effort; a reinstall of torch is deliberately avoided.)
RUN /opt/python/bin/python -m pip install --no-cache-dir \
requests==2.32.3 \
aiohttp==3.10.10 \
yarl==1.13.1 \
pillow==10.4.0 \
numpy>=1.24 \
huggingface-hub>=0.24 \
einops \
comfyui-frontend-package>=1.0 \
model-management>=1.0.3 \
opaque>=0.0.10 \
pydantic>=2.6 \
websockets>=15 \
imageio
# Run ComfyUI as the host user so bind mounts /opt/comfyui are writable.
USER 1000:1000
EXPOSE 8188
ENTRYPOINT ["/opt/python/bin/python", "main.py", "--listen", "0.0.0.0", "--port", "8188"]
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env python3
"""ComfyUI container healthcheck: the UI must answer on :8188.
Runs INSIDE the container, where the UI listens (the unit publishes that port on
the host loopback for the homepage status card). ComfyUI only answers once
torch, the custom nodes and the graph are loaded, so a 200 here means the
service is usable rather than merely a live process — the distinction that
matters, because the failure this replaced was a container that stayed "up"
while dying on every start.
HealthCmd in the quadlet splits on spaces into an argv array (podman 5.x v1
generator), which is why this is a file and not an inline `python3 -c`.
"""
import sys
import urllib.request
try:
with urllib.request.urlopen("http://127.0.0.1:8188/", timeout=10) as resp:
sys.exit(0 if resp.status == 200 else 1)
except Exception:
sys.exit(1)
@@ -0,0 +1,68 @@
# Quadlet unit: ComfyUI (rootless, boot-persistent).
#
# Install path on dankclaw: ~/.config/containers/systemd/comfyui.container
# Unit name: comfyui.service (container name: comfyui)
#
# WHY THIS FILE EXISTS. ComfyUI ran from `dankclaw/compose.yaml` through a
# hand-written `~/.config/systemd/user/comfyui.service` that called
# `podman compose up -d`. Its container was created 2026-09-15 14:12, BEFORE the
# GPU declarations were added to the compose service, and `restart:
# unless-stopped` then kept restarting that stale container — so it started
# without /dev/kfd, without /dev/dri and without keep-groups, died on
# `RuntimeError: No HIP GPUs are available`, and had restarted 9,146 times when
# this was found. A recreate via compose would have fixed the container; a unit
# fixes the class: one owner per container name, repo-tracked, boot-persistent
# through the generator.
#
# GPU ACCESS. /dev/kfd + /dev/dri plus keep-groups. A literal `render` group
# entry cannot work under rootless Podman (the group name does not resolve
# inside the user namespace), and the companion udev rule
# (dankclaw/udev/99-comfyui-gpu-access.rules) keeps those nodes MODE 0666 so a
# user-unit spawn — which carries no supplementary groups — can still open them.
# Both halves are asserted by tests/comfyui_rocm.bats.
#
# EXPOSURE. Loopback only. ComfyUI has no authentication and executes arbitrary
# node graphs, and it has no Caddy vhost, so the repository's rule applies:
# an unguarded service is internal-only. The homepage status card probes
# 127.0.0.1:8188, which this satisfies. To reach the UI from another machine,
# guard it behind the auth chain and give it a vhost — do not widen the bind.
#
# DATA. Models, input, output and custom_nodes live under
# /home/dank/comfyui-data (host state, not in the repo).
[Unit]
Description=ComfyUI (gfx1151 ROCm image generation)
After=network-online.target
Wants=network-online.target
[Container]
ContainerName=comfyui
Image=localhost/comfyui-rocm:gfx1151
Pull=never
Network=dankclaw_default
PublishPort=127.0.0.1:8188:8188
AddDevice=/dev/kfd
AddDevice=/dev/dri
GroupAdd=keep-groups
Volume=/home/dank/comfyui-data/models:/opt/ComfyUI/models
Volume=/home/dank/comfyui-data/input:/opt/ComfyUI/input
Volume=/home/dank/comfyui-data/output:/opt/ComfyUI/output
Volume=/home/dank/comfyui-data/custom_nodes:/opt/ComfyUI/custom_nodes
Volume=/home/dank/serverAppConfigs/dankclaw/comfyui/comfyui-healthcheck.py:/usr/local/bin/comfyui-healthcheck.py:ro
Exec=python main.py --listen 0.0.0.0 --port 8188
# The probe lives in a file because the podman 5.x v1 generator splits
# HealthCmd on spaces into an argv array; an inline `python3 -c "..."` becomes
# a puzzle of quoting that fails silently.
HealthCmd=python3 /usr/local/bin/comfyui-healthcheck.py
HealthInterval=30s
HealthTimeout=10s
HealthRetries=3
# First start loads torch and the node graph; give it room before the probe
# counts against it.
HealthStartPeriod=300s
[Service]
Restart=always
RestartSec=10
[Install]
WantedBy=default.target
-315
View File
@@ -1,315 +0,0 @@
# Dankclaw stack — AI, search, development & observability services.
# Deploy: cd /home/dank/serverAppConfigs && podman-compose -f dankclaw/compose.yaml up -d
# See ../docs/Dankclaw/ and ../docs/Infrastructure/ for service documentation.
services:
# --- SearXNG stack (gluetun + searxng + valkey) --------------------------
# These three services are now boot-persistent Quadlet units, not compose
# services. They live in searxng/quadlet/ and install to
# ~/.config/containers/systemd/ on the host. searxng reuses gluetun's netns
# (Network=gluetun.container) so its traffic exits the WireGuard tunnel.
# Deploy with: scripts/deploy-searxng-quadlet.sh dankclaw
# See ../docs/Dankclaw/SearXNG.md, Gluetun.md, Valkey.md.
# --- AI image generation (ROCm GPU backend, gfx1151) ---------------------
# Built from Containerfile.comfyui on AMD's gfx1151 torch base. The GPU is
# reached via the /dev/kfd + /dev/dri passthrough (checked host-side; the run
# user is already in the render group). Only the 'video' group is added:
# rootless podman cannot map the render gid into the container namespace.
# Models + data persist under /home/dank/comfyui-data. See ../docs/Dankclaw/ComfyUI.md.
comfyui:
build:
context: .
dockerfile: Containerfile.comfyui
image: comfyui-rocm:gfx1151
container_name: comfyui
restart: unless-stopped
command: ["python", "main.py", "--listen", "0.0.0.0", "--port", "8188"]
ports:
- "8188:8188"
devices:
- /dev/kfd:/dev/kfd
- /dev/dri:/dev/dri
group_add:
- video
volumes:
- /home/dank/comfyui-data/models:/opt/ComfyUI/models
- /home/dank/comfyui-data/input:/opt/ComfyUI/input
- /home/dank/comfyui-data/output:/opt/ComfyUI/output
- /home/dank/comfyui-data/custom_nodes:/opt/ComfyUI/custom_nodes
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8188/')"]
interval: 30s
timeout: 10s
retries: 5
start_period: 180s
# --- Chat UI (open-webui) ------------------------------------------------
# Now a boot-persistent Quadlet unit (quadlet, slirp4netns, port 3005), not
# a compose service. See open-webui/quadlet/open-webui.container and
# ../docs/Dankclaw/OpenWebUI.md.
hermes:
image: docker.io/nousresearch/hermes-agent:latest
container_name: hermes
restart: unless-stopped
# The image's default s6 entrypoint runs the interactive TUI, which exits
# without a TTY and crash-loops the container. Override it to run the
# headless gateway (the documented Docker mode). The gateway drops to the
# unprivileged hermes user (uid 10000), so the data dir must be writable by it.
entrypoint: ["bash", "-c", "hermes gateway run --force -v"]
environment:
HERMES_DATA_DIR: /data
# Point hermes's config/.env at the mounted data dir (persistent).
HERMES_HOME: /data
# Signal adapter -> the host signal-cli daemon. The slirp host alias
# (host.containers.internal) can drop at runtime; the host LAN IP is the
# stable container->host path. 127.0.0.1 inside the container is not the host.
SIGNAL_HTTP_URL: "http://192.168.0.143:8080"
SIGNAL_ACCOUNT: "${SIGNAL_ACCOUNT}"
SIGNAL_ALLOWED_USERS: "${SIGNAL_ALLOWED_USERS}"
ports:
- "8090:8090"
volumes:
- /home/dank/hermes-data:/data
# No healthcheck: 'hermes gateway run' starts the messaging gateway (Signal
# SSE), not the :8090 web dashboard, so there is no HTTP endpoint to probe.
# The container's running state + the daemon's SSE connection are the signal.
# --- Hermes web dashboard (management UI: config, API keys, sessions) -----
# A separate process from the Signal gateway (the gateway entrypoint does not
# start the dashboard). Bound to the LAN with basic auth: the username + a
# scrypt password_hash live in the git-ignored /home/dank/hermes-data config.
# Port 9119 is published so a browser on the LAN can reach it.
hermes-dashboard:
image: docker.io/nousresearch/hermes-agent:latest
container_name: hermes-dashboard
restart: unless-stopped
entrypoint: ["bash", "-c", "hermes dashboard --host 0.0.0.0 --port 9119 --no-open --skip-build"]
environment:
HERMES_DATA_DIR: /data
HERMES_HOME: /data
ports:
- "9119:9119"
volumes:
- /home/dank/hermes-data:/data
# --- Git hosting (Gitea) -------------------------------------------------
# Now boot-persistent Quadlet units (private bridge network, DNS enabled),
# not compose services. See gitea/quadlet/ and ../docs/Dankclaw/Gitea.md.
# --- Observability -------------------------------------------------------
prometheus:
image: docker.io/prom/prometheus:latest
container_name: prometheus
restart: unless-stopped
# Rootless: container root maps to the invoking user (dank), which
# owns /opt/prometheus/data. The image's default user (nobody) maps to
# a subuid that cannot write it.
user: "0:0"
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=30d
- --web.enable-lifecycle
ports:
- "9090:9090"
volumes:
- /opt/prometheus/config:/etc/prometheus
- /opt/prometheus/data:/prometheus
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:9090/-/healthy"]
interval: 30s
timeout: 10s
retries: 3
grafana:
image: docker.io/grafana/grafana:latest
container_name: grafana
restart: unless-stopped
# Rootless: container root maps to the invoking user (dank), which
# owns /opt/grafana/data. The image's default user (472) maps to a
# subuid that cannot write it.
user: "0:0"
environment:
GF_SERVER_ROOT_URL: "https://grafana.franksdank.cloud"
GF_SECURITY_ADMIN_USER: "${GRAFANA_ADMIN_USER}"
GF_SECURITY_ADMIN_PASSWORD: "${GRAFANA_ADMIN_PASSWORD}"
ports:
- "3002:3000"
volumes:
- /opt/grafana/data:/var/lib/grafana
- /opt/grafana/provisioning:/etc/grafana/provisioning:ro
depends_on:
- prometheus
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
loki:
# Local build: grafana/loki + static busybox (healthcheck).
image: franksdank/loki:latest
container_name: loki
restart: unless-stopped
# Rootless: container root maps to the invoking user (dank), which
# owns /opt/loki/data. The image's default user (10001) maps to a
# subuid that cannot write it.
user: "0:0"
command: ["-config.file=/etc/loki/loki-config.yaml"]
ports:
- "3100:3100"
volumes:
- /opt/loki/config:/etc/loki
- /opt/loki/data:/loki
healthcheck:
test: ["CMD", "/usr/local/bin/busybox", "wget", "--spider", "-q", "http://localhost:3100/ready"]
interval: 30s
timeout: 10s
retries: 3
alloy:
# Local build: grafana/alloy + static busybox (healthcheck).
image: franksdank/alloy:latest
container_name: alloy
restart: unless-stopped
volumes:
- /opt/alloy/config:/etc/alloy
- /run/systemd/journal/socket:/run/systemd/journal/socket:ro
- /var/log:/var/log:ro
- /var/lib/containers:/var/lib/containers:ro
depends_on:
- loki
healthcheck:
test: ["CMD", "/usr/local/bin/busybox", "wget", "--spider", "-q", "http://localhost:12345/-/healthy"]
interval: 30s
timeout: 10s
retries: 3
node-exporter:
image: docker.io/prom/node-exporter:latest
container_name: node-exporter
restart: unless-stopped
network_mode: host
pid: host
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro
command:
- --path.procfs=/host/proc
- --path.sysfs=/host/sys
- --path.rootfs=/rootfs
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:9100/metrics"]
interval: 30s
timeout: 10s
retries: 3
# --- Authentik (SSO / OIDC provider) -------------------------------------
# 3-service stack: PostgreSQL + server + worker. Redis was removed in
# 2025.10 (sessions/caching moved to PostgreSQL). The Caddyfile's
# forward_auth vhosts proxy to authentik-server:9000 (compose network).
# See ../docs/Infrastructure/Authentik.md.
authentik-postgres:
image: docker.io/library/postgres:16-alpine
container_name: authentik-postgres
restart: unless-stopped
environment:
POSTGRES_DB: authentik
POSTGRES_USER: authentik
POSTGRES_PASSWORD: ${AUTHENTIK_PG_PASS}
volumes:
- /home/dank/authentik/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -d authentik -U authentik"]
interval: 30s
retries: 5
start_period: 20s
authentik-server:
image: ghcr.io/goauthentik/server:2026.8.0
container_name: authentik-server
restart: unless-stopped
command: server
environment:
AUTHENTIK_POSTGRESQL__HOST: authentik-postgres
AUTHENTIK_POSTGRESQL__NAME: authentik
AUTHENTIK_POSTGRESQL__USER: authentik
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_PG_PASS}
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
ports:
- "9000:9000"
volumes:
- /home/dank/authentik/data:/data
shm_size: 512mb
depends_on:
authentik-postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:9000/-/health/')"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
authentik-worker:
image: ghcr.io/goauthentik/server:2026.8.0
container_name: authentik-worker
restart: unless-stopped
command: worker
environment:
AUTHENTIK_POSTGRESQL__HOST: authentik-postgres
AUTHENTIK_POSTGRESQL__NAME: authentik
AUTHENTIK_POSTGRESQL__USER: authentik
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_PG_PASS}
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
user: "0:0"
volumes:
- /home/dank/authentik/data:/data
depends_on:
authentik-postgres:
condition: service_healthy
# --- Reverse proxy + homepage (the homelab's single Caddy) ---------------
# The Caddyfile mounted here is the RENDERED copy (basicauth hash filled in
# from the host .env by scripts/deploy.sh); the repo copy carries the
# REPLACE_WITH_HASH placeholder. Certs/config live under
# /home/dank/Containers/caddy (rootless-writable).
caddy:
# Locally built: stock Caddy + the Porkbun DNS provider
# (dankclaw/caddy/Containerfile). See that file for build instructions.
image: franksdank/caddy-porkbun:latest
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
environment:
# Consumed by the Caddyfile's acme_dns block (DNS-01 wildcard certs).
PORKBUN_API_KEY: ${PORKBUN_API_KEY}
PORKBUN_API_SECRET_KEY: ${PORKBUN_API_SECRET_KEY}
volumes:
- /home/dank/Containers/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- /home/dank/Containers/caddy/data:/data
- /home/dank/Containers/caddy/config:/config
# The homepage: the host's /srv/homepage. The deploy copies the static
# files there; the status checker writes status.json and the deploy
# writes deployed.js there. (Not the repo's shared/homepage, which
# lacks the generated files.)
- /srv/homepage:/srv/homepage:ro
depends_on:
- authentik-server
- comfyui
- hermes
- grafana
- prometheus
- loki
healthcheck:
# The bare-IP catch-all answers 302 (curl -f passes on <400); this
# proves Caddy itself is serving without touching any upstream.
test: ["CMD", "curl", "-sf", "-o", "/dev/null", "http://localhost:80/"]
interval: 30s
timeout: 10s
retries: 3
@@ -0,0 +1,219 @@
# Quadlet unit: frank-large (rootless, boot-persistent).
#
# Install path on dankclaw: ~/.config/containers/systemd/frank-large.container
# Unit name: frank-large.service (container name: frank-large)
#
# The large inference service. The halogen-flash-server runtime (since
# 2026-09-11, restored 2026-09-16 after a two-day ik_llama.cpp hybrid
# interlude) serving peonist-ai/halogen-qwen3.8-flash-next (Qwen3.8-Flash-Next,
# w4b .hgn checkpoint + quality overlay sidecar). It replaced the llama.cpp /
# kingjones30-ROCmFPX STRIX deployment because the engine is ~4x faster on
# prefill (the dominant cost of any real prompt) on this exact SKU. The RTX
# 3090 it briefly shared with frank-large is now frank-small's card.
#
# SHAPE. The image runs both halves in one container: the engine on container
# loopback (:8730, UNAUTHENTICATED — never publish it) and the OpenAI
# front-end on :8731, published here as host 7770 so the fleet's clients,
# services.json probe and docs keep the port they have always used. Same for
# HALOGEN_MODEL_ID=frank-large: it is the id at /v1/models, in /health and in
# every response, so no client had to be renamed. Halogen never rejects a
# request for naming a different model, it just serves it.
#
# DEVICES. gfx1151 through the amdgpu/KFD stack: /dev/kfd plus the DRM render
# node. Both are 0666 on this host, so the rootless user namespace can open
# them (id dank is also in render/video). seccomp=unconfined and --ipc=host are
# the image's documented requirements; memlock must be unlimited.
#
# DO NOT name an environment variable TEMP, HOST or GPU_MAX_HEAP_SIZE: the HIP
# runtime reads bare TEMP for its JIT cache path (2026-08-13 incident).
#
# MEMORY. Weights are pinned (HALOGEN_FLASH_PIN_TRUNK default 1) — that is the
# configuration the published decode numbers were measured with, and `free`,
# MemAvailable and every monitor built on them will overstate free memory by
# the size of the model (~68 GiB) because the kernel counts the locked weights
# as reclaimable page cache. Halogen prints the corrected figure at startup.
# The pinned trunk means this container holds most of the host; a co-tenant
# that then takes the remainder can stall both for minutes at full CPU with no
# disk activity and no output. That is documented behaviour, not a crash, and
# the escape hatch is HALOGEN_FLASH_PIN_TRUNK=0 (at several times the decode
# cost). Sizing is chosen for a SHARED 121 GiB host, and HALOGEN_KV_POOL_FIT
# stays on as the safety net: if the machine refuses the configured pool the
# engine lowers it itself and prints what it settled on. Read that ledger on
# every restart — it is the ground truth for sizing, not this comment.
#
# POOL RETUNED 2026-09-16: 400,384 positions -- exactly two 200,192-token
# conversations -- beside a per-conversation ceiling of 200,192 and two slots.
# It replaces the 401,408 the 2026-09-13 trim left behind, which held one full
# native-262k conversation beside a ~139k one, or three at 131k.
#
# MEASURED AT 401,408 (2026-09-13 deploy ledger): 68.0 weights + 11.0 KV +
# 21.0 working = 100.0 GiB held, 9.1 GiB / 1396 contiguous 2 MiB blocks left,
# engine need printed ~31.5 against the modelled ~33.4, and NO stall-warning
# NOTE. Fit-check budget here: 121.2 - 67.7 - 20 = 33.5. That boot is the
# baseline the 09-16 retune is measured against: the vision tower (1.97 GiB)
# and roughly 4.2 GiB of arena come back to the host, which is what pays for
# the co-tenant below.
#
# CO-TENANT since 2026-09-16: this host also serves frank-small on the RTX
# 3090 (llama.cpp, ~12.2 GiB of weights that load through the page cache and
# then live in VRAM, plus a CUDA context). It is the first real co-tenant this
# service has had, which is why the arena was halved and the reserve raised.
#
# BOOT NOTE: on the first request after a (re)start the engine can go silent
# for ~45-60 s ("has not answered PING") while it first-touches the freshly
# reserved pool; /health reads engine_unresponsive during it and recovers by
# itself. The quadlet health check's HealthStartPeriod=300s covers it; do not
# restart the unit over it.
#
# WEIGHTS. HALOGEN_DOWNLOAD fetches the whole weights repo on FIRST START ONLY
# (the entrypoint checks whether the checkpoint is absent) into the bind mount
# below, which must therefore be read-WRITE. huggingface_hub resumes partial
# files natively, and the quadlet runs the container with --replace --rm, so a
# restart mid-fetch continues rather than starting over. Roughly 127 GB: the
# 115.55 GiB checkpoint, the 2.31 GiB quality overlay, the 0.84 GiB vision
# tower and the tokenizer. The repo is public and ungated, so no token is
# needed; later starts open no outbound connections at all.
#
# VISION. Deliberately NOT set since 2026-09-16. The tower is a 1.97 GiB
# charge against the same device budget as the pool and the arena, and the
# fleet's image path moved to frank-small (llama.cpp on the RTX 3090, loaded
# with --mmproj mmproj-Qwen3.8-27B-BF16). This server refuses image content
# parts, and models.yml declares frank-large text-only to match.
#
# CLIENT CONTRACT (see serverAppConfigs-docs/LLM/halogen-flash-server.md).
# There are no server-side sampler defaults: a request that sends no
# temperature decodes GREEDY. The per-request budget defaults to 8,192 tokens
# and REASONING COUNTS AGAINST IT, so a budget that runs out mid-thought
# returns finish_reason "length" with empty content and the partial reasoning
# in reasoning_content. The cap is 65,536; above it a request is a 400, never a
# silent truncation.
#
# IMAGE. The unit runs the localhost/-aliased tag: this host's podman quadlet
# path cannot resolve registry-prefixed refs from local storage at create
# time. deploy-remote.sh pulls the ghcr.io ref and re-tags it before the
# restart. Pull=never keeps boots off the registry.
[Unit]
Description=frank-large — halogen-flash-server, Qwen3.8-Flash-Next (w4b) on gfx1151
After=network-online.target
[Container]
ContainerName=frank-large
Image=localhost/peonist-ai/halogen-flash-server:0.5.8
# Published port stays 7770: Hermes, open-webui, the harness and the homepage
# probe all point here. 8730 (the engine) is deliberately not published.
PublishPort=7770:8731
AddDevice=/dev/kfd
AddDevice=/dev/dri
# Read-write: the first start downloads into it. Contains only weights.
Volume=/home/dank/models/halogen:/models
# Fetch the weights on first start only (absent checkpoint gates it).
Environment=HALOGEN_DOWNLOAD=peonist-ai/halogen-qwen3.8-flash-next
# Fleet identity: the id at /v1/models, /health and every response body.
Environment=HALOGEN_MODEL_ID=frank-large
# Per-conversation ceiling: exactly half the pool, so two conversations of the
# full window are always resident. 200,192 rather than a round 200,000 is
# pool/2. The model's native window is 262,144; a conversation past this
# ceiling is refused, never silently truncated. Clients declare 200192 (see
# the frank-large contextWindow in models.yml).
Environment=HALOGEN_CTX=200192
# The KV pool: 400,384 positions = 2 x 200,192 (the 2026-09-16 retune, down
# from 401,408). ~28 KiB per position including block scratch, so ~11.0 GiB
# held -- the 401,408 ledger entry measured exactly 11.0. The two slots still
# OVERSUBSCRIBE the pool for any real request, because admission reserves
# prompt + max_tokens: two full-window conversations each holding an output
# budget are pool-bound, and the second waits in arrival order for the first
# to release its region. Read the `kv pool:` lines on every boot;
# HALOGEN_KV_POOL_FIT lowers the pool itself if the budget refuses the
# configured one, and a green health check does not mean the pool you asked
# for is the pool you got.
Environment=HALOGEN_KV_POOL_POSITIONS=400384
# Concurrency cap: two, one per half of the pool. Each slot is 111.0 MiB of
# O(1) state, so this hands ~111 MiB back against the three set on 2026-09-12
# for the fleet size of the time.
Environment=HALOGEN_KV_SLOTS=2
# Sizing is budget arithmetic, all of it. ONE budget covers the KV pool, the
# per-call prefill arena and the vision tower together:
#
# budget = 121.2 host - 67.7 resident weights - reserve
# need = KV (~28 KiB/position) + arena + vision
# arena = 3.80 GiB + 8.40 GiB x (HALOGEN_MAX_TOK / 16384)
#
# The arena term is linear in max_tok, and it reproduces five live restarts
# AND upstream's three published "on the device" figures to within 0.1 GiB —
# note those published figures are pool+arena totals, not pool alone, which is
# the misreading that first made a doubling look affordable here. Corners this
# box offered at a reserve of 24, at the old 262,144-position pool:
#
# 16384 -> need 28.6 vs 29.5 FITS, 0.9 spare (doubling, 8-9% prefill cost)
# 32768 -> need 37.0 vs 29.5 POOL CLAMPED to 262144, deploy still green
# 65536 -> need 46.5 vs 29.5 OOM, will not boot; unit still reads ACTIVE
#
# LOWERED 32768 -> 24576 on 2026-09-16 for the frank-small co-tenant on this
# same host. The arena's variable term goes 8.40 x 2 -> 8.40 x 1.5, handing
# ~4.2 GiB back to the host at roughly a 4-5 % prefill cost -- the same lever
# at 16,384 costs 8-9 %. The vendor's own notes name 24,576 as the largest
# arena that allocated with headroom to spare, so this stays inside the range
# it has tested.
#
# The arena is a PER-CALL PREFILL CHUNK, not a prompt ceiling: the
# 200,192-token conversation is prefilled in arena-sized pieces, so a smaller
# arena means more pieces and a slower prefill, not a smaller conversation.
#
# THE POOL, ARENA AND RESERVE ARE A MATCHED SET, NOT independent tunables --
# the fit check's printed ledger on each boot is the arbiter, not these
# comments.
Environment=HALOGEN_MAX_TOK=24576
# RAM the fit check refuses to count on, watched over by the engine's own
# n-gram lookup table (47.7 GiB, read through the page cache and never held)
# and, since 2026-09-16, by frank-small on the same host.
#
# RAISED 15 -> 20 with the 2026-09-16 retune. This knob feeds the fit-check
# budget and is NOT an allocation: raising it cannot hand memory back to the
# host, because the actual footprint is weights + KV + arena + vision, none of
# which it touches — it can only take the pool away. What it does is keep the
# fit check honest at the smaller arena. With the arena at 24,576 and no
# vision tower, the engine's printed need falls to roughly 25 GiB against a
# budget of 121.2 - 67.7 - 20 = 33.5, leaving ~8 GiB of margin, so the pool is
# not clamped.
#
# The 2026-09-13 profile for comparison: 103.5 GiB held and "7.1 GiB left for
# everything else" (310 contiguous 2 MiB blocks) at the doubled 524,288 pool,
# which is why the pool came down; at 401,408 the same ledger read 9.1 GiB /
# 1396 blocks. The 09-16 retune gives back the vision tower (1.97) and ~4.2 of
# arena on top of that for the co-tenant, so the expected figure at this boot
# is ~15 GiB left. Read the ledger; do not trust this arithmetic.
#
# To free host memory, change the POOL or the ARENA, and the reserve then
# becomes free to rise: HALOGEN_KV_POOL_POSITIONS=262144 hands back 3.6 GiB,
# HALOGEN_MAX_TOK=16384 hands back another 4.2. Both are one-line edits the
# fit check confirms on the next boot.
Environment=HALOGEN_HOST_RESERVE_GIB=20
# Verbose engine logging: the vendor's first-line evidence for load,
# allocation and admission questions. Shipping it on means an incident report
# already has the full startup account instead of needing a restart-into-debug.
# HALOGEN_DMALLOC_LOG is deliberately NOT set — it logs every allocation
# over 64 MB, which is steady-state noise here; raise it only for a live
# OOM hunt.
Environment=HALOGEN_VERBOSE=1
# Required by the image: the engine maps the checkpoint and registers it with
# the GPU in place, which needs host IPC and an unconfined seccomp profile.
PodmanArgs=--ipc=host --security-opt seccomp=unconfined
Ulimit=memlock=-1:-1
# The image ships its own HEALTHCHECK; this pins it. /health asks the ENGINE
# for a PONG before answering, so 200 means "ready to serve", not "process
# alive". During the first-start download it stays unhealthy by design.
HealthCmd=halogen-healthcheck api
HealthInterval=30s
HealthTimeout=10s
HealthRetries=3
HealthStartPeriod=300s
Pull=never
[Service]
# The first start reads ~68 GiB off disk and pins it before serving.
TimeoutStartSec=900
Restart=on-failure
RestartSec=10
[Install]
WantedBy=default.target
@@ -1,31 +0,0 @@
# Quadlet unit: Gitea Caddy reverse proxy (rootless, boot-persistent).
#
# Install path on dankclaw: ~/.config/containers/systemd/gitea-caddy.container
# Unit name: gitea-caddy.service (podman 5.x drops the container- prefix)
#
# Serves gitea.franksdank.cloud (public domain, DNS pending). The LAN
# reaches Gitea directly on port 3000. This file holds no secrets.
# See docs/Dankclaw/Gitea.md.
[Unit]
Description=Gitea Caddy reverse proxy container
Wants=gitea-server.service
After=gitea-server.service network.target
[Container]
ContainerName=gitea-caddy
Network=gitea.network
Image=docker.io/library/caddy:latest
PublishPort=80:80
PublishPort=443:443
Volume=/opt/gitea/data/caddy/Caddyfile:/etc/caddy/Caddyfile
Volume=/opt/gitea/data/caddy/site:/srv
Volume=/opt/gitea/data/caddy/config:/config
Volume=/opt/gitea/data/caddy/data:/root/.local/share/caddy
[Service]
TimeoutStartSec=900
Restart=always
[Install]
WantedBy=default.target
@@ -3,8 +3,8 @@
# Install path on dankclaw: ~/.config/containers/systemd/gitea-postgres.container
# Unit name: gitea-postgres.service (podman 5.x drops the container- prefix)
#
# The DB password lives in /opt/gitea/.env on the host (mode 0600).
# This file holds no secrets. See docs/Dankclaw/Gitea.md.
# The DB password lives in /home/dank/Containers/gitea/env on the host (mode 0600).
# This file holds no secrets. See serverAppConfigs-docs/Dankclaw/Gitea.md.
# The [Service] EnvironmentFile= line lets systemd expand
# ${GITEA_DB_PASSWORD} in the generated ExecStart.
@@ -16,14 +16,14 @@ After=network.target
ContainerName=gitea-postgres
Network=gitea.network
Image=docker.io/library/postgres:16-alpine
EnvironmentFile=/opt/gitea/.env
EnvironmentFile=/home/dank/Containers/gitea/env
Environment=POSTGRES_USER=gitea
Environment=POSTGRES_PASSWORD=${GITEA_DB_PASSWORD}
Environment=POSTGRES_DB=gitea
Volume=/opt/gitea/data/gitea-postgres:/var/lib/postgresql/data
Volume=/home/dank/Containers/gitea/postgres:/var/lib/postgresql/data
[Service]
EnvironmentFile=/opt/gitea/.env
EnvironmentFile=/home/dank/Containers/gitea/env
TimeoutStartSec=900
Restart=always
@@ -5,8 +5,8 @@
#
# The [Service] EnvironmentFile= line lets systemd expand
# ${GITEA_DB_PASSWORD} in the generated ExecStart.
# The DB password lives in /opt/gitea/.env on the host (mode 0600).
# This file holds no secrets. See docs/Dankclaw/Gitea.md.
# The DB password lives in /home/dank/Containers/gitea/env on the host (mode 0600).
# This file holds no secrets. See serverAppConfigs-docs/Dankclaw/Gitea.md.
#
# Ports (live): 3000 = HTTP UI/API, 2222 = git SSH. The docs table in
# Dankclaw Stack.md historically said 3001; the live host port is 3000.
@@ -20,7 +20,7 @@ After=gitea-postgres.service network.target
ContainerName=gitea-server
Network=gitea.network
Image=docker.io/gitea/gitea:latest
EnvironmentFile=/opt/gitea/.env
EnvironmentFile=/home/dank/Containers/gitea/env
Environment=DATABASE_TYPE=postgres
Environment=DATABASE_HOST=gitea-postgres
Environment=DATABASE_NAME=gitea
@@ -31,12 +31,12 @@ Environment=USER_UID=1000
Environment=USER_GID=1000
PublishPort=0.0.0.0:3000:3000
PublishPort=2222:22
Volume=/opt/gitea/data/gitea-gitea:/data
Volume=/home/dank/Containers/gitea/data:/data
Volume=/etc/timezone:/etc/timezone:ro
Volume=/etc/localtime:/etc/localtime:ro
[Service]
EnvironmentFile=/opt/gitea/.env
EnvironmentFile=/home/dank/Containers/gitea/env
TimeoutStartSec=900
Restart=always
+2 -2
View File
@@ -4,7 +4,7 @@
# Unit name: gitea-network.service (podman network name: systemd-gitea)
#
# DNS is enabled by default for quadlet networks. This matters: the
# automatic "podman" default network has dns_enabled=false, so container
# names (gitea-postgres, gitea-server, gitea-caddy) would not resolve.
# automatic "podman" default network has dns_enabled=false, so the
# gitea-postgres and gitea-server container names would not resolve.
[Network]
+100
View File
@@ -0,0 +1,100 @@
# Hermes configuration backup / non-secret baseline.
#
# Source on Dankclaw: /home/dank/Containers/hermes/data/config.yaml
# This file is a sanitized Git backup, not a complete replacement for the
# live Hermes state. Do not add dashboard password hashes, phone numbers,
# OAuth credentials, API keys, sessions, memories, or generated state here.
# The live directory is bind-mounted into the containers as /data.
model:
provider: dankclaw-large
base_url: http://192.168.0.143:7770/v1
api_key: not-needed
default: frank-large
providers:
dankclaw-large:
api: http://192.168.0.143:7770/v1
discover_models: false
models:
frank-large:
context_length: 114688
dankserver-small:
api: http://192.168.0.235:7770/v1
discover_models: false
models:
frank-medium:
context_length: 114688
# Auxiliary routing from the live configuration. Named providers route via
# their `api:` URL; `custom` entries route by their explicit `base_url`.
auxiliary:
vision: {provider: dankclaw-large, model: frank-large}
web_extract: {provider: dankserver-small, model: frank-medium}
compression: {provider: dankserver-small, model: frank-medium}
skills_hub: {provider: dankserver-small, model: frank-medium}
approval: {provider: dankserver-small, model: frank-medium}
mcp: {provider: dankserver-small, model: frank-medium}
# Title generation goes out over the Responses transport on purpose: Hermes
# hardcodes response_format=json_schema on this one call, no local back-end
# implements constrained decoding (it is a 400), and this transport is the
# only one that drops the field. reasoning.effort=none is required with it —
# the call caps output at 64 tokens, which a thinking pass would consume
# entirely, leaving an empty title.
title_generation:
provider: dankserver-small
model: frank-medium
api_mode: codex_responses
extra_body:
reasoning:
effort: none
memory_query_rewrite: {provider: custom, model: frank-medium, base_url: http://192.168.0.235:7770/v1}
tts_audio_tags: {provider: custom, model: frank-medium, base_url: http://192.168.0.235:7770/v1}
triage_specifier: {provider: dankclaw-large, model: frank-large}
kanban_decomposer: {provider: dankclaw-large, model: frank-large}
profile_describer: {provider: dankserver-small, model: frank-medium}
curator: {provider: dankserver-small, model: frank-medium}
dashboard:
# basic_auth intentionally omitted; its password_hash remains live-only.
font: atkinson-hyperlegible
moa:
presets:
default:
reference_models:
- provider: openrouter
model: deepseek/deepseek-v4-pro
enabled: false
degraded_reference_policy: loud
fanout: user_turn
reference_models:
- provider: openrouter
model: deepseek/deepseek-v4-pro
enabled: false
aggregator:
provider: openrouter
model: anthropic/claude-opus-4.8
degraded_reference_policy: loud
max_tokens: 4096
fanout: user_turn
enabled: true
skills:
disabled: []
onboarding:
seen:
busy_input_prompt: true
_config_version: 34
platforms:
signal:
# Live config has the Signal adapter disabled; http_url points at the
# host signal-cli daemon. The account phone number stays live-only.
enabled: false
extra:
http_url: http://192.168.0.143:8080
mcp_servers:
chrome-devtools:
command: /usr/local/bin/npx
args:
- -y
- chrome-devtools-mcp@latest
- --browserUrl=http://127.0.0.1:9222
@@ -0,0 +1,38 @@
# Quadlet unit: Chrome DevTools Protocol daemon for the hermes agent (rootless).
#
# Install path on dankclaw: ~/.config/containers/systemd/chrome-devtools-protocol.container
# Unit name: chrome-devtools-protocol.service (container name: chrome-devtools-protocol)
#
# Headless Chrome exposing raw CDP over http://127.0.0.1:9222. The hermes
# agent's chrome-devtools MCP client (mcp_servers.chrome-devtools in
# /data/config.yaml) spawns npx chrome-devtools-mcp with --browserUrl pointing
# at this endpoint and drives the browser over it. Both consumers — the Signal
# gateway and the webui server — run in the host network namespace, so a
# loopback-only bind is reachable by exactly the processes that may drive the
# browser and by nobody else on the LAN or WAN.
#
# Entrypoint override drops the image's socat front (which would bind
# 0.0.0.0:9222 in the host namespace and hand browser control to the whole
# LAN). --no-sandbox is required because rootless podman grants Chrome no user
# namespace for its sandbox; the content processes therefore run as the uid
# that already owns this container, capping the blast radius of a hostile page.
# Image pinned by digest: upgrades are a deliberate unit edit + redeploy, not a
# tag drift at pull time.
[Unit]
Description=Chrome DevTools Protocol daemon (headless-shell) for hermes
[Container]
ContainerName=chrome-devtools-protocol
Network=host
Image=docker.io/chromedp/headless-shell@sha256:2d349b544a1ea6b5b5fd7c0fe99215ff662339c57407ee2e8c0a11af93516b04
Entrypoint=/headless-shell/headless-shell
Exec=--no-sandbox --disable-gpu --use-gl=angle --use-angle=swiftshader --remote-debugging-address=127.0.0.1 --remote-debugging-port=9222
ShmSize=1073741824
[Service]
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
@@ -0,0 +1,33 @@
# Quadlet unit: Hermes web dashboard (rootless, boot-persistent).
#
# Install path on dankclaw: ~/.config/containers/systemd/hermes-dashboard.container
# Unit name: hermes-dashboard.service (container name: hermes-dashboard)
#
# Management UI for the agent (config, API keys, sessions). Published on
# 9119 on network dankclaw_default; Caddy's hermesadmin.franksdank.cloud vhost
# reverse-proxies this port directly (no guard chain) because the dashboard
# enforces its own basic-auth login from /data/config.yaml
# (basic_auth.username + scrypt password_hash), making it exempt-by-construction.
# The name has no public DNS record, so the vhost resolves on the LAN only.
# Shares /data with the gateway: same agent state, two processes.
[Unit]
Description=Hermes web dashboard container
[Container]
ContainerName=hermes-dashboard
Image=localhost/hermes-agent:latest
Pull=never
Network=dankclaw_default
Environment=HERMES_DATA_DIR=/data
Environment=HERMES_HOME=/data
Volume=/home/dank/Containers/hermes/data:/data
PublishPort=9119:9119
Exec=bash -c 'hermes dashboard --host 0.0.0.0 --port 9119 --no-open --skip-build'
[Service]
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
@@ -0,0 +1,65 @@
# Quadlet unit: Hermex webui (Hermex) container (rootless, boot-persistent).
#
# Install path on dankclaw: ~/.config/containers/systemd/hermes-webui.container
# Unit name: hermes-webui.service (container name: hermes-webui)
#
# Runs the open-source hermes-webui (github.com/nesquena/hermes-webui) in
# foreground mode: bootstrap.py creates .venv inside the mounted checkout,
# installs WebUI + agent deps, then execs server.py as a single foreground
# process. It reads HERMES_HOME=/data, so it drives the SAME agent state
# (memory, skills, sessions) as the gateway container.
#
# Host networking on purpose: the agent's chrome-devtools MCP client spawns
# npx chrome-devtools-mcp with --browserUrl=http://127.0.0.1:9222, and the CDP
# daemon binds loopback-only in the host network namespace (see
# chrome-devtools-protocol.container). Only processes sharing that namespace
# can drive the browser; a bridge-network client could not reach it at all.
# The server binds 0.0.0.0:8787 of the host netns, and that is deliberate:
# this unit is host-networked (the CDP daemon above needs a shared loopback), so
# Caddy cannot reach it by container name and fronts the vhost
# hermes.franksdank.cloud with `reverse_proxy host.containers.internal:8787`
# (netavark's host address, 169.254.1.2) — not the host LAN IP, which Caddy's
# bridge netns cannot reach for a bare host-netns bind; the measured asymmetry
# with published ports is recorded in the hermes block of dankclaw/Caddyfile.
# A loopback bind was tried on 2026-09-16 for a tighter posture and broke that
# vhost, because a bind is also the only way an off-host process can reach a
# host-networked container: the LAN address and the tunnel's localhost hop are
# both served by the wildcard, and a loopback-only bind serves only the latter.
# Controls are the auth chain in front of the vhost plus the password.
[Unit]
Description=Hermes webui (Hermex) container
[Container]
ContainerName=hermes-webui
Image=localhost/hermes-agent:latest
Pull=never
Network=host
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
Environment=HERMES_WEBUI_HOST=0.0.0.0
Environment=HERMES_WEBUI_PORT=8787
Environment=HERMES_WEBUI_PASSWORD=${HERMES_WEBUI_PASSWORD}
Environment=HERMES_HOME=/data
# Headless host: never show the first-run wizard (a profile already
# exists in /data from the gateway).
Environment=HERMES_WEBUI_SKIP_ONBOARDING=1
Volume=/home/dank/Containers/hermes/data:/data
Volume=/home/dank/Containers/hermes/webui:/webui
Exec=python3 /webui/bootstrap.py --foreground
# JSON exec form on purpose: this quadlet generator joins a bare HealthCmd
# into ONE argv element with \x20 escapes and then shell-executes it — the
# python one-liner's quoting would be mangled. A JSON array is passed through
# verbatim. Start period covers first-boot pip installs into /webui/.venv.
HealthCmd=["python3","-c","import urllib.request;urllib.request.urlopen('http://127.0.0.1:8787/health')"]
HealthInterval=30s
HealthTimeout=10s
HealthRetries=5
HealthStartPeriod=600s
[Service]
Restart=always
RestartSec=5
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
[Install]
WantedBy=default.target
+45
View File
@@ -0,0 +1,45 @@
# Quadlet unit: Hermes agent gateway (rootless, boot-persistent).
#
# Install path on dankclaw: ~/.config/containers/systemd/hermes.container
# Unit name: hermes.service (container name: hermes)
#
# The messaging gateway (Signal adapter -> the host signal-cli daemon). The
# image's default s6 entrypoint would run the interactive TUI, which exits
# without a TTY; Exec= below replaces it with the headless gateway command,
# spliced by the v1 quadlet generator as trailing args after the image ref
# (same splice pattern proven live on authentik-worker). The gateway drops to
# the unprivileged hermes user (uid 10000) at runtime; /data is world-writable
# on purpose so that unmapped uid can write agent.log and its state there.
#
# Host networking: the adapter dials the signal-cli daemon, a HOST process
# listening on :8080. The compose era dialed the host LAN IP from a bridge
# network, where only DNAT'd published ports accept connections to that
# address — un-forwarded host ports refuse instantly (verified live
# 2026-08-30). A host-network client dials 127.0.0.1:8080 with no such
# dependency, and the gateway publishes nothing inbound.
[Unit]
Description=Hermes agent gateway container
After=network-online.target
Wants=network-online.target
[Container]
Network=host
Image=localhost/hermes-agent:latest
Pull=never
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
Environment=HERMES_DATA_DIR=/data
Environment=HERMES_HOME=/data
Environment=SIGNAL_HTTP_URL=http://127.0.0.1:8080
Environment=SIGNAL_ACCOUNT=${SIGNAL_ACCOUNT}
Environment=SIGNAL_ALLOWED_USERS=${SIGNAL_ALLOWED_USERS}
Volume=/home/dank/Containers/hermes/data:/data
Exec=bash -c 'hermes gateway run --force -v'
[Service]
Restart=always
RestartSec=5
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
[Install]
WantedBy=default.target
+18 -6
View File
@@ -1,12 +1,21 @@
# Loki + a static busybox.
#
# grafana/loki is distroless: no shell, no wget, so the compose healthcheck
# cannot run anything inside the container. This image drops a static
# busybox into it for the healthcheck command only. All Loki behavior is
# unchanged.
# grafana/loki is distroless: no shell, no wget, so nothing can run inside the
# container to check it. This image drops a static busybox into it for the
# healthcheck. All Loki behavior is unchanged.
#
# The healthcheck lives HERE, in exec form, and not in the Quadlet unit: a
# unit's HealthCmd reaches podman as CMD-SHELL, which needs /bin/sh — the one
# thing this image does not have. Specifying it here is also what lets the
# container report its own health instead of looking merely "Up" while wedged.
#
# Build on dankclaw:
# podman build -t franksdank/loki:latest -f dankclaw/loki/Containerfile .
# podman build --format docker -t franksdank/loki:latest \
# -f dankclaw/loki/Containerfile .
#
# --format docker is required: podman's default OCI image format has no
# HEALTHCHECK field, so a default-format build warns and silently discards the
# one below, leaving a container that reports no health at all.
FROM docker.io/library/alpine:latest AS tools
# busybox-static is statically linked: the target images have no dynamic
@@ -15,4 +24,7 @@ FROM docker.io/library/alpine:latest AS tools
RUN apk add --no-cache busybox-static
FROM docker.io/grafana/loki:latest
COPY --from=tools /bin/busybox.static /usr/local/bin/busybox
COPY --from=tools /bin/busybox.static /usr/local/bin/busybox
# Exec form (JSON array): no shell is involved.
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD ["/usr/local/bin/busybox", "wget", "--spider", "-q", "http://localhost:3100/ready"]
@@ -0,0 +1,34 @@
# Quadlet unit: Marreta browser companion — Lightpanda (rootless, boot-persistent).
#
# Install path on dankclaw: ~/.config/containers/systemd/marreta-browser.container
# Unit name: marreta-browser.service (container name: marreta_browser)
#
# Lightpanda is a headless browser that exposes the Chrome DevTools Protocol
# over a WebSocket on port 9222. Marreta drives it to render JS-heavy pages
# the plain curl fetch cannot. It shares gluetun's network namespace so the
# browser's OWN outbound fetches also exit the WireGuard tunnel — this is
# required because marreta's PROXY_URL only covers the curl strategy; the
# CDP session passes no proxy to the browser, so without netns sharing the
# browser would leak the host's real egress IP.
#
# Marreta reaches it on loopback (same netns): ws://127.0.0.1:9222. No
# published port — nothing outside the netns talks to it directly.
# See serverAppConfigs-docs/Dankclaw/Marreta.md.
[Unit]
Description=Marreta Lightpanda browser companion container
Wants=gluetun.service
After=gluetun.service network.target
[Container]
ContainerName=marreta_browser
Network=gluetun.container
Image=docker.io/lightpanda/browser:nightly
[Service]
TimeoutStartSec=300
Restart=always
RestartSec=10
[Install]
WantedBy=default.target
@@ -0,0 +1,66 @@
# Quadlet unit: Marreta content-cleaning proxy (rootless, boot-persistent).
#
# Install path on dankclaw: ~/.config/containers/systemd/marreta.container
# Unit name: marreta.service (container name: marreta)
#
# Marreta is a Laravel app (serversideup/php:8.4-fpm-nginx) that strips
# paywalls/tracking from a fetched page. It reuses gluetun's network
# namespace (Network=gluetun.container) so ALL of its outbound fetches exit
# the same WireGuard tunnel as SearXNG — the curl strategy AND, because the
# browser companion shares the same netns, the Lightpanda fetches too
# (marreta's PROXY_URL does NOT cover the browser: the CDP connection passes
# no proxy, so netns sharing is the only way to keep the browser's egress on
# the tunnel).
#
# Port: the shared netns already has SearXNG on 8080, so marreta must NOT
# bind 8080. NGINX_HTTP_PORT=8085 relocates it (the fpm-nginx image does
# not bake conf.d/*.conf; the site config is envsubst'd from
# ${NGINX_HTTP_PORT} at container start). Gluetun publishes host 8082 ->
# 8085 so Caddy can reach it; see dankclaw/searxng/quadlet/gluetun.container.
# The app reaches its browser companion over loopback (same netns):
# ws://127.0.0.1:9222.
#
# Secrets (APP_KEY, admin creds) live in /home/dank/Containers/marreta/env
# (mode 0600, dank-owned so the rootless unit can read it); this file holds
# none. See serverAppConfigs-docs/Dankclaw/Marreta.md.
[Unit]
Description=Marreta content-cleaning proxy container
Wants=gluetun.service marreta-browser.service
After=gluetun.service marreta-browser.service network.target
[Container]
ContainerName=marreta
Network=gluetun.container
Image=ghcr.io/manualdousuario/marreta:latest
# Relocate off SearXNG's 8080 inside the shared netns.
Environment=NGINX_HTTP_PORT=8085
Environment=APP_NAME=Marreta
Environment=APP_ENV=production
Environment=APP_DEBUG=false
Environment=APP_URL=https://marreta.franksdank.cloud
Environment=APP_LOCALE=en
Environment="APP_DESCRIPTION=Paywall-free reading"
Environment=BROWSER_WS_ENDPOINT=ws://127.0.0.1:9222
Environment=DISABLE_CACHE=false
# Secrets expanded from the EnvironmentFile below.
Environment=APP_KEY=${MARRETA_APP_KEY}
Environment=ADMIN_EMAIL=${MARRETA_ADMIN_EMAIL}
Environment=ADMIN_PASSWORD=${MARRETA_ADMIN_PASSWORD}
# Named volume: podman auto-creates it and copyups the image's storage/app
# ownership (www-data), so the app can write its SQLite DB + cache.
Volume=marreta_storage:/var/www/html/storage/app
HealthCmd=["curl","-fsS","-o","/dev/null","http://127.0.0.1:8085/up"]
HealthInterval=30s
HealthTimeout=10s
HealthRetries=5
HealthStartPeriod=120s
[Service]
EnvironmentFile=/home/dank/Containers/marreta/env
TimeoutStartSec=900
Restart=always
RestartSec=10
[Install]
WantedBy=default.target
@@ -0,0 +1,63 @@
# Quadlet unit: alloy (rootless, boot-persistent).
#
# Install path on dankclaw: ~/.config/containers/systemd/alloy.container
# Unit name: alloy.service (container name: alloy)
#
# Log shipper. This replaces the compose service of the same name in
# dankclaw/compose.yaml ("Observability" block).
#
# IMAGE. franksdank/alloy is built locally by deploy-remote.sh
# (build_compose_image) from dankclaw/alloy/Containerfile: upstream
# grafana/alloy plus a static busybox for the healthcheck. Pull=never keeps a
# boot from reaching for that local tag at a registry.
#
# NETWORK. It joins dankclaw_default so its config's loki.write endpoint
# resolves the container name loki:3100.
#
# THE UI PORT EXISTS ON PURPOSE. Alloy's UI listens on the container's own
# 127.0.0.1 by default and cannot be moved from the config file (alloy 1.x
# refuses the ui block), so the compose service had no reachable health
# endpoint at all: the homepage status card for alloy reported "down"
# continuously and there was nothing for a deploy to wait on. The command
# below therefore moves the UI to the container's wildcard address and the
# port is published on host loopback only, which puts alloy in the same class
# as the other three: reachable from the host, by Caddy over the private
# network, and by nothing off-host. Alloy has no authentication, which is
# exactly why it is not published wider.
#
# SOURCES. The host journald socket and the host log/container-storage trees
# are bind-mounted read-only; Alloy tails them and pushes to Loki. The config
# lives at /opt/alloy/config/config.alloy, installed from
# shared/alloy/config.alloy on every deploy.
#
# ORDERING. Wants/After loki.service: without a reachable Loki, Alloy's write
# client fails and its log pipeline backs up.
[Unit]
Description=Alloy log shipper (Dankclaw)
After=network-online.target loki.service
Wants=network-online.target loki.service
[Container]
ContainerName=alloy
Image=localhost/franksdank/alloy:latest
Pull=never
Network=dankclaw_default
Exec=run --server.http.listen-addr=0.0.0.0:12345 /etc/alloy/config.alloy
PublishPort=127.0.0.1:12345:12345
Volume=/opt/alloy/config:/etc/alloy
Volume=/run/systemd/journal/socket:/run/systemd/journal/socket:ro
Volume=/var/log:/var/log:ro
Volume=/var/lib/containers:/var/lib/containers:ro
HealthCmd=/usr/local/bin/busybox wget --spider -q http://localhost:12345/-/healthy
HealthInterval=30s
HealthTimeout=10s
HealthRetries=3
HealthStartPeriod=30s
[Service]
Restart=always
RestartSec=10
[Install]
WantedBy=default.target
@@ -0,0 +1,79 @@
# Quadlet unit: grafana (rootless, boot-persistent).
#
# Install path on dankclaw: ~/.config/containers/systemd/grafana.container
# Unit name: grafana.service (container name: grafana)
#
# Dashboards. This replaces the compose service of the same name in
# dankclaw/compose.yaml ("Observability" block).
#
# PUBLISHING. Bound to 127.0.0.1 for the same reason as prometheus: the browser
# path is https://grafana.franksdank.cloud (Caddy → Anubis → Authentik outpost →
# grafana:3000 over dankclaw_default) and the host-side homepage status timer
# probes 127.0.0.1:3002. Publishing on 0.0.0.0 as the compose service did left
# an off-host path into Grafana that skips the guard chain entirely.
#
# AUTH. Grafana is an OAuth2 relying party against Authentik (OAuth2Provider
# "grafana", confidential client). The guard chain fronts unauthenticated
# traffic; the login itself happens through the Authentik button inside
# Grafana. The client secret is NOT written here: it is expanded by systemd
# from the root .env (EnvironmentFile in [Service], never in [Container], so
# the container receives exactly this one secret and not the whole file).
# Without that .env entry the unit will not start — the same dependency every
# other rendered-secret deploy has.
#
# STATE. /opt/grafana/data holds grafana.db. /opt/grafana/provisioning is
# mounted read-only and was EMPTY as of 2026-09-11, and grafana.db reported
# zero datasources and zero dashboards: this Grafana is an empty install whose
# only configured account is the local admin. Nothing here queries Prometheus
# or Loki yet.
[Unit]
Description=Grafana dashboards (Dankclaw)
After=network-online.target prometheus.service
Wants=network-online.target prometheus.service
[Container]
ContainerName=grafana
Image=docker.io/grafana/grafana:latest
Network=dankclaw_default
User=0:0
PublishPort=127.0.0.1:3002:3000
Environment=GF_SERVER_ROOT_URL=https://grafana.franksdank.cloud
Environment=GF_AUTH_GENERIC_OAUTH_ENABLED=true
Environment=GF_AUTH_GENERIC_OAUTH_NAME=Authentik
Environment=GF_AUTH_GENERIC_OAUTH_CLIENT_ID=grafana-21ca190cde75d60f
Environment=GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET=${GRAFANA_OAUTH_CLIENT_SECRET}
# Global authorize path: this Authentik build resolves the provider by the
# client_id query parameter — /application/o/<slug>/authorize/ does not exist.
Environment=GF_AUTH_GENERIC_OAUTH_AUTH_URL=https://auth.franksdank.cloud/application/o/authorize/
Environment=GF_AUTH_GENERIC_OAUTH_TOKEN_URL=https://auth.franksdank.cloud/application/o/token/
# ?api=1 = introspection-style userinfo: returns custom claims (groups) too.
Environment=GF_AUTH_GENERIC_OAUTH_API_URL=https://auth.franksdank.cloud/application/o/userinfo/?api=1
Environment=GF_AUTH_GENERIC_OAUTH_ALLOW_SIGN_UP=false
Environment=GF_AUTH_GENERIC_OAUTH_AUTO_UPSERT=true
Environment=GF_AUTH_GENERIC_OAUTH_ALLOW_ASSIGN_ORG=true
Environment=GF_AUTH_GENERIC_OAUTH_ORG_ID=1
Volume=/opt/grafana/data:/var/lib/grafana
Volume=/opt/grafana/provisioning:/etc/grafana/provisioning:ro
HealthCmd=wget --spider -q http://localhost:3000/api/health
HealthInterval=30s
HealthTimeout=10s
HealthRetries=3
HealthStartPeriod=60s
[Service]
# systemd-side only: supplies ${GRAFANA_OAUTH_CLIENT_SECRET} for the expansion
# above. The container never receives this file.
#
# It is the per-service env file the deployer seeds from dankclaw/.env — the
# fleet convention (caddy, pihole, copyparty, romm and seerr all read one).
# The repo-root .env that the compose service happened to pick up is a gluetun
# env file: it carries HOME=/root and the WireGuard keys, and importing it into
# this service makes podman look for its configuration under /root, which it
# cannot read, so the unit fails to start. Seed the value into dankclaw/.env.
EnvironmentFile=/home/dank/Containers/grafana/env
Restart=always
RestartSec=10
[Install]
WantedBy=default.target
@@ -0,0 +1,57 @@
# Quadlet unit: loki (rootless, boot-persistent).
#
# Install path on dankclaw: ~/.config/containers/systemd/loki.container
# Unit name: loki.service (container name: loki)
#
# Log store. This replaces the compose service of the same name in
# dankclaw/compose.yaml ("Observability" block).
#
# IMAGE. franksdank/loki is built locally by deploy-remote.sh
# (build_compose_image) from dankclaw/loki/Containerfile: upstream
# grafana/loki plus a static busybox, because loki is distroless and the
# healthcheck has nothing to run inside it. Pull=never keeps a boot from
# trying to fetch that local tag from a registry. The localhost/ prefix is
# required — this host's quadlet path cannot resolve a registry-prefixed ref
# from local storage at create time.
#
# PUBLISHING. Bound to 127.0.0.1: Alloy pushes to loki:3100 over
# dankclaw_default, Caddy proxies the guarded vhost to loki:3100 the same way,
# and the homepage status timer probes 127.0.0.1:3100. Loki has
# auth_enabled: false, so the compose service's 0.0.0.0 binding was an
# unauthenticated log-ingest and read API on every host interface.
#
# CONFIG. /opt/loki/config/loki-config.yaml is installed from
# shared/loki/loki-config.yaml on every deploy (single-binary, filesystem
# storage, 30-day retention). Chunks and rules live in /opt/loki/data.
#
# Rootless: container root maps to the invoking user (dank), who owns
# /opt/loki/data; the image's default user (10001) maps to a subuid that
# cannot write it.
[Unit]
Description=Loki log store (Dankclaw)
After=network-online.target
Wants=network-online.target
[Container]
ContainerName=loki
Image=localhost/franksdank/loki:latest
Pull=never
Network=dankclaw_default
User=0:0
Exec=-config.file=/etc/loki/loki-config.yaml
PublishPort=127.0.0.1:3100:3100
Volume=/opt/loki/config:/etc/loki
Volume=/opt/loki/data:/loki
# No HealthCmd here on purpose. A unit's HealthCmd reaches podman as
# CMD-SHELL, which means `/bin/sh -c <command>` — and this image is
# distroless, with no shell at all. The healthcheck therefore ships in the
# image (dankclaw/loki/Containerfile) in exec form, where it also carries its
# interval, timeout, start period and retry count.
[Service]
Restart=always
RestartSec=10
[Install]
WantedBy=default.target
@@ -0,0 +1,54 @@
# Quadlet unit: node-exporter (rootless, boot-persistent).
#
# Install path on dankclaw: ~/.config/containers/systemd/node-exporter.container
# Unit name: node-exporter.service (container name: node-exporter)
#
# Host metrics source for Prometheus. This replaces the compose service of the
# same name in dankclaw/compose.yaml ("Observability" block).
#
# HOST NAMESPACE. It must run in the host network namespace to report the
# host's own interfaces/connections, and --pid=host for the processes and
# cgroups collectors. "localhost" inside the container is therefore the host's
# loopback, which is what the homepage status timer probes.
#
# LISTEN ADDRESSES. Until 2026-09-11 this bound :9100 on every interface. It
# now binds exactly two: loopback for the homepage status probe, and the LAN
# address because Prometheus — which runs in a container on dankclaw_default —
# scrapes http://192.168.0.143:9100 (shared/prometheus/prometheus.yml,
# job "node-dankclaw"). node_exporter itself has no authentication, so keeping
# the wildcard bind off the table matters: this is the second half of the
# pairing that removes the unauthenticated observability surface, and both
# consumers are accounted for. Dankserver runs its own copy of this container
# (scraped by the "node-dankserver" job) — since 2026-09-16 as a Quadlet unit in
# the same shape (dankserver/observability/quadlet/), because the compose-only
# version it replaced had been dead for a day and a half before anyone noticed.
#
# MOUNTS. The container reads the host's /proc, /sys and / as /host/* read-only
# and is pointed at them with --path.*; it writes nothing anywhere.
[Unit]
Description=Prometheus node exporter (Dankclaw host metrics)
After=network-online.target
Wants=network-online.target
[Container]
ContainerName=node-exporter
Image=docker.io/prom/node-exporter:latest
Network=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
Volume=/proc:/host/proc:ro
Volume=/sys:/host/sys:ro
Volume=/:/rootfs:ro
HealthCmd=wget --spider -q http://localhost:9100/metrics
HealthInterval=30s
HealthTimeout=10s
HealthRetries=3
HealthStartPeriod=30s
[Service]
Restart=always
RestartSec=10
[Install]
WantedBy=default.target
@@ -0,0 +1,63 @@
# Quadlet unit: prometheus (rootless, boot-persistent).
#
# Install path on dankclaw: ~/.config/containers/systemd/prometheus.container
# Unit name: prometheus.service (container name: prometheus)
#
# The homelab metrics store. This replaces the compose service of the same
# name in dankclaw/compose.yaml ("Observability" block): these five containers
# were started by hand with podman-compose and never came back after a reboot,
# which the 2026-09-11 reboot for the amd_iommu=off change made obvious.
#
# PUBLISHING. The published port is bound to 127.0.0.1 deliberately. Prometheus
# is reached by Caddy over the dankclaw_default network (the Caddyfile's
# post-auth handler proxies to prometheus:9090) and by the host-side homepage
# status timer over 127.0.0.1. The compose service published on 0.0.0.0, which
# put an UNAUTHENTICATED metrics API — with the lifecycle endpoint enabled —
# on every host interface: a bypass around the Anubis + Authentik guard on
# prometheus.franksdank.cloud. No off-host consumer needs it.
#
# CONFIG. /opt/prometheus/config/prometheus.yml is installed from
# shared/prometheus/prometheus.yml by deploy-remote.sh on every deploy, so the
# repo copy is the source of truth. STATE (30-day retention) lives in
# /opt/prometheus/data.
#
# Rootless: container root maps to the invoking user (dank) in this user
# namespace and dank owns /opt/prometheus/data. The image's default user
# (nobody) maps to a subuid that cannot write it.
#
# RULES. Five LLM alert rules and one recording rule load from
# /etc/prometheus/rules/ (installed from shared/prometheus/rules/ by the
# deployer). There is still **no Alertmanager**, so nothing is delivered: a
# firing alert is visible in Prometheus and on the Grafana dashboard's ALERTS
# panel and reaches nobody. Delivery is an operator decision, not an oversight;
# the fragility is that an undelivered alert looks identical to a quiet one.
#
# The config volume is also why rules belong in git: editing them in the UI is
# lost on the next deploy, since the deployer overwrites the whole directory.
[Unit]
Description=Prometheus metrics store (Dankclaw)
After=network-online.target
Wants=network-online.target
[Container]
ContainerName=prometheus
Image=docker.io/prom/prometheus:latest
Network=dankclaw_default
User=0:0
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
Volume=/opt/prometheus/config:/etc/prometheus
Volume=/opt/prometheus/data:/prometheus
HealthCmd=wget --spider -q http://localhost:9090/-/healthy
HealthInterval=30s
HealthTimeout=10s
HealthRetries=3
HealthStartPeriod=30s
[Service]
Restart=always
RestartSec=10
[Install]
WantedBy=default.target
@@ -8,7 +8,7 @@
# its OpenAI connection at the live llama.cpp backend on this host via the
# host LAN IP (192.168.0.143:7770, model frank-default-large). It publishes 3005
# (3000 is Gitea's HTTP port). Data persists under
# /home/dank/Containers/open-webui/data. See docs/Dankclaw/OpenWebUI.md.
# /home/dank/Containers/open-webui/data. See serverAppConfigs-docs/Dankclaw/OpenWebUI.md.
#
# Network history (2026-08-22): this unit used Network=slirp4netns. When the
# slirp4netns helper process died, the container kept running but lost its tap
+6 -4
View File
@@ -1,6 +1,6 @@
# A records for the flat franksdank.name scheme (dnsmasq addn-hosts format:
# <IP> <name> [<name> ...]). Served by the Pi-hole quadlet (FTL is a
# dnsmasq fork): the quadlet mounts this file into /etc/dnsmasq.d/ read-only.
# dnsmasq fork): the quadlet mounts this file into /etc/pihole/hosts/ read-only.
#
# Caddy is consolidated on dankclaw (192.168.0.143): it fronts every service
# on both hosts, so every name resolves to that one IP. Keep in sync with the
@@ -25,9 +25,8 @@
192.168.0.143 books.franksdank.cloud
192.168.0.143 romm.franksdank.cloud
192.168.0.143 files.franksdank.cloud
192.168.0.143 jellyfin.franksdank.cloud
192.168.0.143 jelly.franksdank.cloud
192.168.0.143 radarr.franksdank.cloud
192.168.0.143 sonarr.franksdank.cloud
192.168.0.143 lidarr.franksdank.cloud
192.168.0.143 readarr.franksdank.cloud
192.168.0.143 prowlarr.franksdank.cloud
@@ -36,10 +35,13 @@
# --- AI / search / dev services (dankclaw, local) ---
192.168.0.143 search.franksdank.cloud
192.168.0.143 comfyui.franksdank.cloud
192.168.0.143 chat.franksdank.cloud
192.168.0.143 ai.franksdank.cloud
192.168.0.143 hermes.franksdank.cloud
192.168.0.143 hermesadmin.franksdank.cloud
192.168.0.143 gitea.franksdank.cloud
192.168.0.143 grafana.franksdank.cloud
192.168.0.143 prometheus.franksdank.cloud
192.168.0.143 loki.franksdank.cloud
192.168.0.143 pihole.franksdank.cloud
192.168.0.143 seerr.franksdank.cloud
192.168.0.143 marreta.franksdank.cloud
+2 -2
View File
@@ -6,7 +6,7 @@
# The LAN's authoritative DNS for the flat franksdank.cloud / franksdank.casa
# name scheme. It retired the dnsmasq quadlet (2026-08-24): Pi-hole FTL is a
# dnsmasq fork, so the addn-hosts record file carries over unchanged and is
# mounted into /etc/dnsmasq.d/ (the FTL dnsmasq config dir). It serves one A
# mounted into /etc/pihole/hosts/ (the FTL hostsdir) read-only. It serves one A
# record per Caddy vhost and forwards everything else to the pinned upstream
# resolvers (DNS1/DNS2 in the env file). The SURFboard G36 router hands this
# server out to DHCP clients (DNS statically configured -> 192.168.0.143),
@@ -15,7 +15,7 @@
# Runs on slirp4netns with port 53 (DNS) and 8083 (web UI) published to the
# LAN (the same pattern as open-webui). Config + state mount from
# /home/dank/Containers/pihole. The web UI password is WEBPASSWORD in the env
# file. See docs/Infrastructure/LAN-DNS.md.
# file. See serverAppConfigs-docs/Infrastructure/LAN-DNS.md.
[Unit]
Description=Pi-hole LAN DNS container
+8
View File
@@ -0,0 +1,8 @@
# Rootless DNS must bind port 53: lower the unprivileged-port threshold so a
# rootless container may publish 0.0.0.0:53 (80/443 stay allowed; 53 < 80).
# Single source of truth for net.ipv4.ip_unprivileged_port_start on dankclaw;
# deploy_pihole installs it to /etc/sysctl.d/ and applies it with
# `sysctl --system` (the same code path boot uses via systemd-sysctl).
# A later-sorted drop-in that reset this to 80 took LAN DNS down after the
# 2026-08-26 reboot — deploy_pihole neutralizes such conflicts.
net.ipv4.ip_unprivileged_port_start=53
+2 -1
View File
@@ -6,7 +6,7 @@
# Creates the WireGuard tunnel to ProtonVPN (US-WA). SearXNG reuses this
# container's network namespace (see searxng.container), so its outbound search
# traffic exits here. The WireGuard credentials live in /opt/searxng/.env
# (mode 0600); this file holds no secrets. See docs/Dankclaw/Gluetun.md.
# (mode 0600); this file holds no secrets. See serverAppConfigs-docs/Dankclaw/Gluetun.md.
[Unit]
Description=Gluetun VPN tunnel container
@@ -29,6 +29,7 @@ Environment=WIREGUARD_ENDPOINT_PORT=${WIREGUARD_ENDPOINT_PORT}
Environment=WIREGUARD_ALLOWED_IPS=${WIREGUARD_ALLOWED_IPS}
Environment=WIREGUARD_PERSISTENT_KEEPALIVE_INTERVAL=${WIREGUARD_PERSISTENT_KEEPALIVE_INTERVAL}
PublishPort=8081:8080
PublishPort=8082:8085
[Service]
EnvironmentFile=/opt/searxng/.env
@@ -5,7 +5,7 @@
#
# The limiter's rate-limit state store. Joins the searxng network and is
# reachable by the name "valkey" (settings.yml points there). Data persists in
# /opt/searxng/valkey. See docs/Dankclaw/Valkey.md.
# /opt/searxng/valkey. See serverAppConfigs-docs/Dankclaw/Valkey.md.
[Unit]
Description=SearXNG Valkey cache container
+1 -1
View File
@@ -6,7 +6,7 @@
# Reuses gluetun's network namespace (Network=gluetun.container) so its
# outbound traffic exits the WireGuard tunnel. It publishes no port of its own;
# the port lives on gluetun. It reaches valkey by name on the searxng network.
# Config mounts from /opt/searxng/config. See docs/Dankclaw/SearXNG.md.
# Config mounts from /opt/searxng/config. See serverAppConfigs-docs/Dankclaw/SearXNG.md.
[Unit]
Description=SearXNG search engine container
+43
View File
@@ -0,0 +1,43 @@
# Quadlet unit: Seerr media request manager (rootless, boot-persistent).
#
# Install path on dankclaw: ~/.config/containers/systemd/seerr.container
# Unit name: seerr.service (container name: seerr)
#
# Seerr is the unified Overseerr/Jellyseerr successor. It fronts the
# *arr stack now living on FrankBook-Pro (192.168.0.112, resolved via the
# Pi-hole addn-hosts record frankbook-pro.lan): requests are placed here,
# routed through the arrs over the LAN, and downloads run inside the Mac's
# gluetun tunnel. SQLite lives under /home/dank/Containers/seerr/data —
# no separate database unit (decision D8).
#
# Exposure contract: the ONLY published port is loopback (127.0.0.1:5055)
# for the homepage probe and host-local checks. User traffic must traverse
# the guard chain: Caddy TLS edge → Anubis :8923 → Authentik outpost →
# Caddy :8017 post-auth listener → seerr:5055 on dankclaw_default. Never
# publish 5055 on a LAN or public address.
#
# Image pin: ghcr.io/seerr-team/seerr:v3.4.1 (manifest-verified on GHCR
# 2026-09-01; released 2026-07-30). Upgrade = bump the tag, redeploy.
#
# No secrets in this file: OIDC (Authentik) and arr API keys live in
# Seerr's own settings store inside the data volume, configured at runtime.
[Unit]
Description=Seerr media request manager container
After=network-online.target
Wants=network-online.target
[Container]
ContainerName=seerr
Image=ghcr.io/seerr-team/seerr:v3.4.1
Network=dankclaw_default
PublishPort=127.0.0.1:5055:5055
Volume=/home/dank/Containers/seerr/data:/app/config
[Service]
Restart=always
RestartSec=5
TimeoutStartSec=900
[Install]
WantedBy=default.target
+21
View File
@@ -0,0 +1,21 @@
# /etc/udev/rules.d/99-comfyui-gpu-access.rules
#
# Why world-writable compute nodes: the comfyui container is spawned by a
# systemd user unit (comfyui.service, Type=oneshot). User-unit processes are
# spawned by the user manager with NO supplementary groups, so any spawn path
# through systemd loses `render`/`video` membership regardless of how the
# container is launched (keep-groups and named group_add both depend on the
# spawning process already holding the group — verified 2026-08-31: keep-groups
# preserved an empty group list under the user manager and /dev/kfd open still
# failed with EACCES). A mode that grants access to "everyone in this uid
# domain" is the only spawn-context-independent option short of running the
# stack rootful.
#
# Blast radius: any local process (every container here runs as the same uid
# domain) can already ptrace the GPU userspace through an existing holder's
# fd inheritance; on a private single-administrator home server this trades a
# capability gate that could never be held by the intended user anyway.
# Note the missing SUBSYSTEM= guard on the kfd line: misc-class devices have no
# subsystem for udev matching, so a SUBSYSTEM=="misc" guard never matches.
KERNEL=="kfd", MODE="0666"
KERNEL=="renderD*", SUBSYSTEM=="drm", MODE="0666"
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""HTTP front door for the frank-medium single-GPU replicas.
Why two replicas instead of TP=2: one vLLM process spanning both R9700s needs
RCCL collectives, and RCCL's kernels require PCIe atomics on every
participating GPU. The second card sits behind chipset bridges advertising
`AtomicOpsCap: Routing-`, which no software can enable (see the runbook), so
no collective can launch on it. Two single-GPU replicas use both cards; this
service keeps the fleet contract of one endpoint on :7770, streaming included.
Routing decisions live in routing.py — spread single-turn requests, pin
continuations to the replica whose prefix cache holds their context. This file
only moves bytes: parse enough of the request to route it, forward it verbatim,
stream the answer back, and report replica health on /health.
"""
from __future__ import annotations
import asyncio
import os
from contextlib import suppress
from aiohttp import ClientSession, ClientTimeout, web
import routing
PORT = int(os.environ.get("PORT", "7770"))
HEALTH_TTL = float(os.environ.get("HEALTH_TTL", "10"))
# Hop-by-hop headers must not be forwarded (RFC 9110 §7.6.1) — forwarding
# transfer-encoding or content-length of a rewritten body breaks clients.
HOP_BY_HOP = {
"host", "content-length", "transfer-encoding", "connection",
"keep-alive", "proxy-authenticate", "proxy-authorization", "te",
"trailers", "upgrade",
}
UPSTREAMS = routing.UPSTREAMS
# Replica health, refreshed by the watcher. Module state rather than aiohttp app
# state: the application is frozen once the site is up.
_UP: list[bool] = [True] * len(UPSTREAMS)
async def health(session: ClientSession) -> web.Response:
results = await asyncio.gather(
*(session.get(u + "/health", timeout=ClientTimeout(total=5)) for u in UPSTREAMS),
return_exceptions=True,
)
lines, up = [], 0
for url, res in zip(UPSTREAMS, results):
if isinstance(res, Exception) or res.status != 200:
lines.append(f"{url} DOWN")
else:
up += 1
lines.append(f"{url} UP")
return web.Response(status=200 if up else 503, text="\n".join(lines) + "\n")
async def watch_health() -> None:
"""Refresh the replica up/down table so routing never targets a dead one.
One HTTP request per replica per interval, in exchange for never handing a
client a 502 after a restart.
"""
global _UP
session = ClientSession()
try:
while True:
res = await asyncio.gather(
*(session.get(u + "/health", timeout=ClientTimeout(total=5)) for u in UPSTREAMS),
return_exceptions=True,
)
_UP = [not isinstance(r, Exception) and r.status == 200 for r in res]
await asyncio.sleep(HEALTH_TTL)
finally:
await session.close()
async def proxy(request: web.Request) -> web.StreamResponse:
session: ClientSession = request.app["session"]
if request.path == "/health":
return await health(session)
body = await request.read()
headers = {k: v for k, v in request.headers.items() if k.lower() not in HOP_BY_HOP}
# Fail over before anything is written to the client. A replica that
# restarts is invisible to the health watcher for up to HEALTH_TTL, and a
# deploy restarts both replicas — without this retry a 10 s window of
# requests would 502 on a service that has a live card the whole time.
# vLLM's OpenAI endpoints are stateless per request, so re-sending a
# request that never reached an engine is safe; a request that failed
# mid-stream is not retried (that path is below).
tried: set[int] = set()
upstream = None
last_error = "no healthy frank-medium replica"
while len(tried) < len(UPSTREAMS):
idx = routing.route(body, request.remote, _UP)
if idx < 0 or idx in tried:
break
tried.add(idx)
target = UPSTREAMS[idx] + request.path_qs
try:
upstream = await session.request(
request.method, target, headers=headers, data=body or None,
timeout=ClientTimeout(total=None, sock_connect=10, sock_read=3600),
)
break
except Exception as exc:
# Demote at once: the watcher will confirm, but every request until
# then should skip this replica rather than pay the same timeout.
# Logged because a silent failover hides a card that is down.
_UP[idx] = False
last_error = f"{target} unreachable: {exc}"
print(f"proxy: failover — {target} unreachable ({exc}); retrying "
f"another replica", flush=True)
if upstream is None:
# 503 when no replica was even reachable through the routing table,
# 502 when replicas were tried and all of them failed.
status = 502 if tried else 503
return web.Response(status=status, text=f"proxy: {last_error}\n")
out = web.StreamResponse(status=upstream.status, reason=upstream.reason)
for k, v in upstream.headers.items():
if k.lower() not in HOP_BY_HOP:
out.headers[k] = v
await out.prepare(request)
try:
async for chunk in upstream.content.iter_any():
await out.write(chunk)
except Exception:
# The client went away mid-stream (an aborted generation). Nothing to
# report: the engine sees its own cancellation.
pass
finally:
upstream.release()
with suppress(Exception):
await out.write_eof()
return out
async def main() -> None:
app = web.Application(client_max_size=1024 ** 3)
app["session"] = ClientSession()
app.router.add_route("*", "/{tail:.*}", proxy)
runner = web.AppRunner(app)
await runner.setup()
await web.TCPSite(runner, "0.0.0.0", PORT).start()
print(f"proxy: :{PORT} -> {', '.join(UPSTREAMS)} routing={routing.ROUTING} "
f"key={routing.AFFINITY_BYTES}B known_max={routing.KNOWN_MAX}", flush=True)
watcher = asyncio.create_task(watch_health())
with suppress(asyncio.CancelledError):
await asyncio.gather(watcher)
if __name__ == "__main__":
with suppress(KeyboardInterrupt):
asyncio.run(main())
@@ -0,0 +1,98 @@
# Quadlet unit: frank-medium replica A (rootless, boot-persistent).
#
# Install path on dankserver: ~/.config/containers/systemd/frank-medium-a.container
# Unit name: frank-medium-a.service (container name: frank-medium-a)
#
# WHY TWO REPLICAS INSTEAD OF TP=2. The intended shape for frank-medium is one
# tensor-parallel process across both R9700s (see frank-medium.container, kept
# as the target). That shape cannot run on this host: RCCL's collective kernels
# carry a hidden hostcall-buffer parameter, and the ROCr runtime refuses to
# dispatch them on a device without PCIe atomics ("Pcie atomics not enabled,
# hostcall not supported" -> AQL dispatch failed -> hipErrorIllegalState). The
# second R9700 sits behind the chipset bridges 05:00.0/06:09.0, which advertise
# `AtomicOpsCap: Routing-` — a read-only capability, so no software can enable
# atomics on that path; the amdgpu driver's pci_enable_atomic_ops_to_root()
# therefore fails for that GPU and KFD marks the CPU<->GPU2 link NO_ATOMICS.
# The first R9700 sits on CPU root port 00:01.1 (Routing+) and does have
# atomics. Result: every collective that touches GPU 2 dies at launch.
# Two independent single-GPU replicas use both cards instead; the proxy unit
# keeps the single :7770 endpoint. Full evidence: the frank-medium runbook.
#
# SHAPE. vLLM binds 0.0.0.0:7771 on the host network directly; no PublishPort.
# MAXLEN is 114688, not the TP=2 deployment's 262144: one card carries the
# whole model (~20 GiB of weights + drafter), leaving ~5.2 GiB of KV, and
# 131072 does not fit (measured: needs 5.58 GiB). KV_MEM=0 lets vLLM profile
# this shape instead of using the 2x7551-32624 pin, which is only valid at
# TP=2/262144.
#
# DEVICES. /dev/kfd + /dev/dri; HIP_VISIBLE_DEVICES=0 (the first R9700). The
# 2 GiB Raphael iGPU enumerates as index 2 and is excluded by the pin.
# Only HIP_VISIBLE_DEVICES is set, never ROCR_VISIBLE_DEVICES: ROCR filters
# first and HIP indexes into the filtered list, so a single-valued pair
# (ROCR=1,HIP=1) leaves an empty device set.
#
# DO NOT name an environment variable TEMP, HOST or GPU_MAX_HEAP_SIZE: the
# HIP runtime reads bare TEMP for its JIT cache path (2026-08-13 incident).
[Unit]
Description=frank-medium replica A — vllm-radiance MXFP4 Qwen3.8-27B on R9700 #1
After=network-online.target
[Container]
ContainerName=frank-medium-a
Image=localhost/radiance-vllm:0.9.3
AddDevice=/dev/kfd
AddDevice=/dev/dri
GroupAdd=keep-groups
# The upstream launcher's exact podman posture (rootless).
PodmanArgs=--privileged --ipc=host --security-opt seccomp=unconfined --cap-add SYS_PTRACE --network=host
Environment=HIP_VISIBLE_DEVICES=0
Environment=SNAP=/models/Qwen3.8-27B-Uncensored-MXFP4-awq
Environment=DRAFTER=/models/Qwen3.8-27B-DFlash2-FP8
Environment=CHAT_TEMPLATE=/patches/qwen-fixed-v22.3.jinja
Environment=SERVED_NAMES=frank-medium
Environment=PORT=7771
Environment=TP=1
Environment=MAXSEQS=4
Environment=CHUNK=8192
Environment=MAXLEN=114688
Environment=GPU_UTIL=0.98
Environment=KV_MEM=0
Environment=SPEC_METHOD=dflash
Environment=SPEC=7
# Checkpoints (rw: nothing downloads at runtime, but vLLM mmaps the shards).
Volume=/home/dank/models:/models
# Tokenizer files ride the HF cache; HF_HUB_OFFLINE=1 is set in the entrypoint.
Volume=/home/dank/.cache/huggingface:/root/.cache/huggingface
# Compile cache. Shared with the other replica and with the TP=2 target: the
# entries are content-addressed, so shapes simply add subdirectories.
Volume=/home/dank/.radiance-cache-w4a8-093-gdnm-nqft-fp8s-gnq:/cache
# The pinned radiance clone: patches applied at every container start.
Volume=/home/dank/GitPackages/radiance-vllm-mxfp4:/patches:z
# The patched libr4d (pin b9e42ab + repo extras, key b9e42ab-rx6). The image's
# shipped r4d.so predates the GDN overflow fix and NaNs this model.
Volume=/home/dank/.cache/radiance-libr4d/b9e42ab-rx6:/r4d:z
# Entrypoint + healthcheck from this checkout, mounted read-only.
Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/radiance-entrypoint.sh:/usr/local/bin/frank-medium-entrypoint.sh:ro
Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/frank-medium-healthcheck.py:/usr/local/bin/frank-medium-healthcheck.py:ro
# The entrypoint must be the container command; the image's own entrypoint
# (which the patched script execs at the end) must not run unpatched.
PodmanArgs=--entrypoint /usr/local/bin/frank-medium-entrypoint.sh
# 200 only once the engine serves; during first-start compilation the
# container is legitimately unhealthy. HealthCmd is split on spaces into an
# argv array by the podman 5.x v1 generator, hence the file-based probe.
HealthCmd=python3 /usr/local/bin/frank-medium-healthcheck.py
HealthInterval=60s
HealthTimeout=30s
HealthRetries=3
HealthStartPeriod=1800s
Pull=never
[Service]
# First start compiles Triton/inductor kernels (several minutes) before the
# engine loads ~20 GiB of weights.
TimeoutStartSec=1800
Restart=on-failure
RestartSec=10
[Install]
WantedBy=default.target
@@ -0,0 +1,98 @@
# Quadlet unit: frank-medium replica B (rootless, boot-persistent).
#
# Install path on dankserver: ~/.config/containers/systemd/frank-medium-b.container
# Unit name: frank-medium-b.service (container name: frank-medium-a)
#
# WHY TWO REPLICAS INSTEAD OF TP=2. The intended shape for frank-medium is one
# tensor-parallel process across both R9700s (see frank-medium.container, kept
# as the target). That shape cannot run on this host: RCCL's collective kernels
# carry a hidden hostcall-buffer parameter, and the ROCr runtime refuses to
# dispatch them on a device without PCIe atomics ("Pcie atomics not enabled,
# hostcall not supported" -> AQL dispatch failed -> hipErrorIllegalState). The
# second R9700 sits behind the chipset bridges 05:00.0/06:09.0, which advertise
# `AtomicOpsCap: Routing-` — a read-only capability, so no software can enable
# atomics on that path; the amdgpu driver's pci_enable_atomic_ops_to_root()
# therefore fails for that GPU and KFD marks the CPU<->GPU2 link NO_ATOMICS.
# The first R9700 sits on CPU root port 00:01.1 (Routing+) and does have
# atomics. Result: every collective that touches GPU 2 dies at launch.
# Two independent single-GPU replicas use both cards instead; the proxy unit
# keeps the single :7770 endpoint. Full evidence: the frank-medium runbook.
#
# SHAPE. vLLM binds 0.0.0.0:7772 on the host network directly; no PublishPort.
# MAXLEN is 114688, not the TP=2 deployment's 262144: one card carries the
# whole model (~20 GiB of weights + drafter), leaving ~5.2 GiB of KV, and
# 131072 does not fit (measured: needs 5.58 GiB). KV_MEM=0 lets vLLM profile
# this shape instead of using the 2x7551-32624 pin, which is only valid at
# TP=2/262144.
#
# DEVICES. /dev/kfd + /dev/dri; HIP_VISIBLE_DEVICES=1 (the second R9700). The
# 2 GiB Raphael iGPU enumerates as index 2 and is excluded by the pin.
# Only HIP_VISIBLE_DEVICES is set, never ROCR_VISIBLE_DEVICES: ROCR filters
# first and HIP indexes into the filtered list, so a single-valued pair
# (ROCR=1,HIP=1) leaves an empty device set.
#
# DO NOT name an environment variable TEMP, HOST or GPU_MAX_HEAP_SIZE: the
# HIP runtime reads bare TEMP for its JIT cache path (2026-08-13 incident).
[Unit]
Description=frank-medium replica B — vllm-radiance MXFP4 Qwen3.8-27B on R9700 #2
After=network-online.target
[Container]
ContainerName=frank-medium-b
Image=localhost/radiance-vllm:0.9.3
AddDevice=/dev/kfd
AddDevice=/dev/dri
GroupAdd=keep-groups
# The upstream launcher's exact podman posture (rootless).
PodmanArgs=--privileged --ipc=host --security-opt seccomp=unconfined --cap-add SYS_PTRACE --network=host
Environment=HIP_VISIBLE_DEVICES=1
Environment=SNAP=/models/Qwen3.8-27B-Uncensored-MXFP4-awq
Environment=DRAFTER=/models/Qwen3.8-27B-DFlash2-FP8
Environment=CHAT_TEMPLATE=/patches/qwen-fixed-v22.3.jinja
Environment=SERVED_NAMES=frank-medium
Environment=PORT=7772
Environment=TP=1
Environment=MAXSEQS=4
Environment=CHUNK=8192
Environment=MAXLEN=114688
Environment=GPU_UTIL=0.98
Environment=KV_MEM=0
Environment=SPEC_METHOD=dflash
Environment=SPEC=7
# Checkpoints (rw: nothing downloads at runtime, but vLLM mmaps the shards).
Volume=/home/dank/models:/models
# Tokenizer files ride the HF cache; HF_HUB_OFFLINE=1 is set in the entrypoint.
Volume=/home/dank/.cache/huggingface:/root/.cache/huggingface
# Compile cache. Shared with the other replica and with the TP=2 target: the
# entries are content-addressed, so shapes simply add subdirectories.
Volume=/home/dank/.radiance-cache-w4a8-093-gdnm-nqft-fp8s-gnq:/cache
# The pinned radiance clone: patches applied at every container start.
Volume=/home/dank/GitPackages/radiance-vllm-mxfp4:/patches:z
# The patched libr4d (pin b9e42ab + repo extras, key b9e42ab-rx6). The image's
# shipped r4d.so predates the GDN overflow fix and NaNs this model.
Volume=/home/dank/.cache/radiance-libr4d/b9e42ab-rx6:/r4d:z
# Entrypoint + healthcheck from this checkout, mounted read-only.
Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/radiance-entrypoint.sh:/usr/local/bin/frank-medium-entrypoint.sh:ro
Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/frank-medium-healthcheck.py:/usr/local/bin/frank-medium-healthcheck.py:ro
# The entrypoint must be the container command; the image's own entrypoint
# (which the patched script execs at the end) must not run unpatched.
PodmanArgs=--entrypoint /usr/local/bin/frank-medium-entrypoint.sh
# 200 only once the engine serves; during first-start compilation the
# container is legitimately unhealthy. HealthCmd is split on spaces into an
# argv array by the podman 5.x v1 generator, hence the file-based probe.
HealthCmd=python3 /usr/local/bin/frank-medium-healthcheck.py
HealthInterval=60s
HealthTimeout=30s
HealthRetries=3
HealthStartPeriod=1800s
Pull=never
[Service]
# First start compiles Triton/inductor kernels (several minutes) before the
# engine loads ~20 GiB of weights.
TimeoutStartSec=1800
Restart=on-failure
RestartSec=10
[Install]
WantedBy=default.target
@@ -0,0 +1,21 @@
#!/usr/bin/env python3
"""frank-medium container healthcheck: the port's /health must answer 200.
Runs INSIDE the container. The vLLM /health endpoint only answers once the
engine is up (weights loaded, cudagraphs captured), so a healthy container is
a serving container, not merely a live process. PORT selects the endpoint so
the single-GPU replicas (:7771/:7772) and the round-robin proxy (:7770) can
share this probe. The HealthCmd in the quadlet splits on spaces into an argv
array (podman 5.x v1 generator), which is why this lives in a file rather
than an inline python one-liner.
"""
import os
import sys
import urllib.request
port = os.environ.get("PORT", "7770")
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=5) as r:
sys.exit(0 if r.status == 200 else 1)
except Exception:
sys.exit(1)
@@ -0,0 +1,61 @@
# Quadlet unit: frank-medium proxy (rootless, boot-persistent).
#
# Install path on dankserver: ~/.config/containers/systemd/frank-medium-proxy.container
# Unit name: frank-medium-proxy.service (container name: frank-medium-proxy)
#
# WHAT IT IS. A prefix-affinity reverse proxy in front of the two single-GPU
# frank-medium replicas (:7771 on R9700 #1, :7772 on R9700 #2). It preserves
# the fleet contract: one endpoint on host port 7770 answering as the served
# model name frank-medium, OpenAI /v1, streaming included.
#
# WHY IT EXISTS. TP=2 — one vLLM process spanning both cards — is impossible
# on this host (see frank-medium-a.container and the runbook: RCCL's kernels
# need PCIe atomics, and the second card's chipset path advertises
# AtomicOpsCap: Routing-). Two replicas are how both cards get used.
#
# ROUTING. Requests are keyed on a hash of their leading 4 KiB of prompt text,
# so a conversation family — an agent loop re-sending the same system prompt,
# tool list and file context every turn — keeps landing on the same replica,
# where --enable-prefix-caching has that prefix warm. Plain round-robin threw
# half of those cache hits away. A replica that is down is skipped, and
# ROUTING=roundrobin restores the old behaviour for A/B measurement. /health
# answers 200 while at least one replica is up and names the ones that are
# down. The proxy runs on host networking so it can bind 7770 and reach the
# replicas on loopback.
#
# The script is a few dozen lines of aiohttp, mounted read-only out of
# /home/dank/serverAppConfigs (deploy.sh pulls that checkout before dispatch).
# It reuses the same image as the replicas: aiohttp is already there and no
# extra image has to be pulled on this host.
[Unit]
Description=frank-medium proxy — prefix-affinity in front of the two single-GPU replicas
After=network-online.target
Wants=frank-medium-a.service frank-medium-b.service
After=frank-medium-a.service frank-medium-b.service
[Container]
ContainerName=frank-medium-proxy
Image=localhost/radiance-vllm:0.9.3
PodmanArgs=--network=host
Environment=PORT=7770
Environment=UPSTREAMS=http://127.0.0.1:7771,http://127.0.0.1:7772
# The proxy is two files: routing.py holds the replica-selection policy, the
# server holds the HTTP plumbing. Both are mounted because the server imports
# the policy by name from its own directory (/usr/local/bin is sys.path[0]).
Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/proxy/routing.py:/usr/local/bin/routing.py:ro
Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/proxy/affinity-proxy.py:/usr/local/bin/affinity-proxy.py:ro
Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/frank-medium-healthcheck.py:/usr/local/bin/frank-medium-healthcheck.py:ro
PodmanArgs=--entrypoint /usr/local/bin/affinity-proxy.py
HealthCmd=python3 /usr/local/bin/frank-medium-healthcheck.py
HealthInterval=60s
HealthTimeout=30s
HealthRetries=3
HealthStartPeriod=60s
Pull=never
[Service]
Restart=on-failure
RestartSec=10
[Install]
WantedBy=default.target
@@ -0,0 +1,147 @@
# Quadlet unit: frank-medium (rootless, boot-persistent).
#
# Install path on dankserver: ~/.config/containers/systemd/frank-medium.container
# Unit name: frank-medium.service (container name: frank-medium)
#
# The dankserver LLM endpoint (:7770, alias frank-medium), since 2026-09-15
# running the vllm-radiance MXFP4 stack (stilldeadcode/vllm-radiance:0.9.3
# image + the ggz14/radiance-vllm-mxfp4 patches) serving
# just1moremodel/Qwen3.8-27B-Uncensored-MXFP4-awq on the 2x Radeon AI PRO
# R9700 (gfx1201) cards. It replaced the charlie12345/ROCmFPX llama.cpp
# deployment, which served the same alias from the Q8_0_ROCMFPX_AGENT GGUF;
# the MXFP4 stack is tensor-parallel (TP=2, R4D one-shot all-reduce) with the
# DFlash2-FP8 block-diffusion drafter (SPEC=7) — the measured production
# configuration of the upstream repo, on our exact hardware signature
# (2x7551-32624, see the KV pin below).
#
# SHAPE. The launcher the upstream repo ships binds the server with
# --network=host (vLLM takes --host 0.0.0.0 --port 7770 directly), which is
# what this unit does too — no PublishPort. The fleet contract is unchanged:
# :7770, served model name frank-medium (SERVED_NAMES), OpenAI /v1.
#
# NOT INSTALLED ON THIS HOST. This is the target shape: one tensor-parallel
# process across both R9700s. It cannot launch a collective here — RCCL's
# kernels need PCIe atomics and the second card sits behind chipset bridges
# advertising `AtomicOpsCap: Routing-` — so the active deployment is the
# two-replica trio in this directory (frank-medium-a/b/proxy) and
# deploy-remote.sh deliberately does not install this unit. Keep it for a
# host whose second card is on an atomic-capable root port; the full evidence
# is in serverAppConfigs-docs/LLM/frank-medium-radiance-mxfp4.md.
#
# DEVICES. /dev/kfd + /dev/dri through the amdgpu/KFD stack; HIP/ROCR device
# indices 0,1 (the two R9700s). The 2 GiB Raphael iGPU enumerates as index 2
# and is excluded both by the index pin and by the upstream MIN_GPU_MIB=8192
# logic this unit replaces. --privileged --ipc=host --security-opt
# seccomp=unconfined --cap-add SYS_PTRACE is the upstream launcher's exact
# podman posture.
#
# MOUNTS. /models (rw checkpoints), the HF cache (tokenizer files),
# /cache (Triton/inductor/aiter/vLLM compile cache — first start compiles for
# several minutes, later starts reuse), /patches (the pinned radiance clone —
# its patches are applied at EVERY container start by the entrypoint), /r4d
# (the patched libr4d build; the image's shipped r4d.so NaNs the
# gated-delta-net and must never be used), and the entrypoint + healthcheck
# scripts from this checkout (mounted read-only straight out of
# /home/dank/serverAppConfigs — deploy.sh git-pulls the checkout before
# dispatching).
#
# IMAGE. The unit runs the localhost/-aliased tag: this host's podman quadlet
# path cannot resolve registry-prefixed refs from local storage at create
# time (same as frank-large on dankclaw). deploy-remote.sh pulls the
# docker.io ref and re-tags it before the restart. Pull=never keeps boots off
# the registry.
#
# KEEP IN SYNC: the radiance clone is pinned (97353293...); if it is updated,
# re-diff dankserver/frank-medium/radiance-entrypoint.sh against the new
# serve-mxfp4.sh container command and bump the pin deliberately.
#
# DO NOT name an environment variable TEMP, HOST or GPU_MAX_HEAP_SIZE: the
# HIP runtime reads bare TEMP for its JIT cache path (2026-08-13 incident).
[Unit]
Description=frank-medium — vllm-radiance MXFP4 Qwen3.8-27B-Uncensored on 2x R9700 (gfx1201)
After=network-online.target
[Container]
ContainerName=frank-medium
Image=localhost/radiance-vllm:0.9.3
# vLLM binds 0.0.0.0:7770 on the host network directly; no PublishPort.
AddDevice=/dev/kfd
AddDevice=/dev/dri
GroupAdd=keep-groups
# The upstream launcher's exact podman posture (rootless; podman-only flags
# like --replace are not needed under systemd management).
PodmanArgs=--privileged --ipc=host --security-opt seccomp=unconfined --cap-add SYS_PTRACE --network=host
# The two R9700s. The Raphael iGPU (index 2, 2 GiB) stays out: shared system
# memory is not a compute target.
Environment=ROCR_VISIBLE_DEVICES=0,1
Environment=HIP_VISIBLE_DEVICES=0,1
# The uncensored MXFP4 checkpoint ships its fp8 MTP head already, so no
# fp8_mtp.py rewrite step exists or is needed (unlike amd/Qwen3.8-27B-Quark-AWQ-MXFP4).
Environment=SNAP=/models/Qwen3.8-27B-Uncensored-MXFP4-awq
Environment=DRAFTER=/models/Qwen3.8-27B-DFlash2-FP8
Environment=CHAT_TEMPLATE=/patches/qwen-fixed-v22.3.jinja
# Fleet identity: the id at /v1/models and in every response body. Every
# client (Hermes, the harness, services.json) points at frank-medium.
Environment=SERVED_NAMES=frank-medium
Environment=PORT=7770
# Fixed 2-card tensor parallel: the tested configuration of the upstream
# repo; the R4D one-shot all-reduce kernel serves it natively.
Environment=TP=2
# Measured production batch shape (upstream README): MAXSEQS 8 peaks
# aggregate throughput at 573 t/s; MAXLEN is the model's native context.
Environment=MAXSEQS=8
Environment=CHUNK=8192
Environment=MAXLEN=262144
Environment=GPU_UTIL=0.98
# Speculative decoding: the DFlash2 drafter at depth 7 is the configuration
# every published number (224 t/s single-stream decode, 4749-4810 t/s
# prefill) was measured with.
Environment=SPEC_METHOD=dflash
Environment=SPEC=7
# KV cache pin, bytes. From the upstream repo's shipped kv-profiles.tsv row
# sig=2x7551-32624 maxseqs=8 chunk=8192 maxlen=262144 spec=dflash
# which is EXACTLY this host (2x R9700 DID 0x7551, 32624 MiB each). Pinned
# explicitly because this unit does not run the launcher's lookup; valid
# only at this batch shape (change MAXSEQS/CHUNK/SPEC_METHOD => recalibrate
# with calibrate-kv.sh or set KV_MEM=0 to let vLLM profile). Worth
# 943,581 KV tokens vs ~892,799 profiled (~5.7%).
Environment=KV_MEM=18563072000
# Checkpoints (rw: nothing downloads at runtime, but vLLM mmaps the shards;
# keep parity with the launcher's rw mount).
Volume=/home/dank/models:/models
# Tokenizer files ride the HF cache; HF_HUB_OFFLINE=1 is set in the
# entrypoint.
Volume=/home/dank/.cache/huggingface:/root/.cache/huggingface
# Compile cache. Keyed on model + stack version + every graph-changing knob;
# never share it across configurations (upstream rule).
Volume=/home/dank/.radiance-cache-w4a8-093-gdnm-nqft-fp8s-gnq:/cache
# The pinned radiance clone: patches applied at every container start.
Volume=/home/dank/GitPackages/radiance-vllm-mxfp4:/patches:z
# The patched libr4d (pin b9e42ab + repo extras, key b9e42ab-rx6). The
# image's shipped r4d.so predates the GDN overflow fix and NaNs this model.
Volume=/home/dank/.cache/radiance-libr4d/b9e42ab-rx6:/r4d:z
# Entrypoint + healthcheck from this checkout, mounted read-only.
Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/radiance-entrypoint.sh:/usr/local/bin/frank-medium-entrypoint.sh:ro
Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/frank-medium-healthcheck.py:/usr/local/bin/frank-medium-healthcheck.py:ro
# The entrypoint must be the container command; the image's own entrypoint
# (which the patched script execs at the end) must not run unpatched.
PodmanArgs=--entrypoint /usr/local/bin/frank-medium-entrypoint.sh
# 200 only once the engine serves; during first-start compilation the
# container is legitimately unhealthy. HealthCmd is split on spaces into an
# argv array by the podman 5.x v1 generator, hence the file-based probe.
HealthCmd=python3 /usr/local/bin/frank-medium-healthcheck.py
HealthInterval=60s
HealthTimeout=30s
HealthRetries=3
HealthStartPeriod=1800s
Pull=never
[Service]
# First start compiles Triton/inductor kernels (several minutes) before the
# engine loads ~9.5 GiB of weights per card.
TimeoutStartSec=1800
Restart=on-failure
RestartSec=10
[Install]
WantedBy=default.target
@@ -0,0 +1,229 @@
#!/usr/bin/env bash
# frank-medium container entrypoint: vllm-radiance MXFP4 on 2x Radeon AI PRO
# R9700 (gfx1201), serving just1moremodel/Qwen3.8-27B-Uncensored-MXFP4-awq.
#
# This is a fork of the patch-and-serve block inside radiance-vllm-mxfp4's
# serve-mxfp4.sh (clone at /home/dank/GitPackages/radiance-vllm-mxfp4, pinned
# commit 9735329348ae1c9319f01cbe5f7004ec5f4dba63). The published image alone
# is NOT sufficient for this model: the shipped libr4d predates the
# gated-delta-net overflow fix and NaNs the output, and the MXFP4 gates need
# relaxing, so the repo's patches are applied at every container start from
# the /patches mount. It runs INSIDE the container (executed as the image
# command by the frank-medium quadlet); the launcher's host-side GPU/TP/KV
# detection is not needed here because this fleet is fixed 2x gfx1201 and the
# KV pin is pinned explicitly in the quadlet.
#
# KEEP IN SYNC: if the radiance clone is updated, re-diff this file against
# the new serve-mxfp4.sh container command and against the DRY_RUN output.
#
# Env contract: every knob below is ${VAR:-default}; the quadlet sets the
# fleet-specific ones (SNAP, PORT, SERVED_NAMES, TP, KV_MEM, ...).
set -euo pipefail
# ---------------------------------------------------------------- serving shape
: "${SNAP:=/models/Qwen3.8-27B-Uncensored-MXFP4-awq}"
: "${DRAFTER:=/models/Qwen3.8-27B-DFlash2-FP8}"
: "${CHAT_TEMPLATE:=/patches/qwen-fixed-v22.3.jinja}"
: "${PORT:=7770}"
: "${SERVED_NAMES:=frank-medium}"
: "${MAXLEN:=262144}"
: "${MAXSEQS:=8}"
# KV_MEM=0 is passed THROUGH as --kv-cache-memory 0, which the image's
# patched KV sizing reads as "profile the shape" (the launcher's rule).
# Blanking the variable instead omits the flag and makes vLLM size the cache
# from --gpu-memory-utilization alone: measured on the single-GPU replicas
# that overshoots and the allocation dies with torch.OutOfMemoryError right
# after the weights load (2.16 GiB requested, 116 MiB free). Do not "fix"
# this into an empty expansion.
: "${KV_MEM:=18563072000}" # shipped kv-profiles.tsv row 2x7551-32624 @ 8/8192/262144/dflash
: "${SPEC_METHOD:=dflash}"
if [ "$SPEC_METHOD" = dflash ]; then : "${SPEC:=7}"; else : "${SPEC:=4}"; fi
: "${MIN_M:=0}" # 0 = the W4A8 kernel serves every M (MIN_M=1 still routes M=1 to aiter)
: "${R4D_ATTN:=1}"
: "${ASYNC:=0}"
if [ "$R4D_ATTN" = "1" ]; then ATTN=R4D; else ATTN=ROCM_AITER_UNIFIED_ATTN; fi
if [ "$ASYNC" = 1 ]; then ASYNC_FLAG="--async-scheduling"; UNPAD=false; else
ASYNC_FLAG="--no-async-scheduling"; UNPAD=true; fi
# Drafting config. dflash: the DFlash2-FP8 block-diffusion drafter (its
# attention backend must support full cuda graphs -> TRITON_ATTN). The
# disable_padded_drafter_batch unpad is the single-stream lever (~+50%).
if [ "$SPEC_METHOD" = dflash ]; then
if [ ! -f "$DRAFTER/config.json" ]; then
echo "frank-medium: no dflash drafter at $DRAFTER" >&2
exit 1
fi
DRAFT_SAMPLE=${DRAFT_SAMPLE:-greedy}
SPEC_CFG="{\"method\":\"dflash\",\"model\":\"$DRAFTER\",\"num_speculative_tokens\":$SPEC,\"attention_backend\":\"TRITON_ATTN\",\"disable_padded_drafter_batch\":$UNPAD,\"draft_sample_method\":\"$DRAFT_SAMPLE\"}"
RADIANCE_DRAFT_RERANK=${RADIANCE_DRAFT_RERANK:-80}
RADIANCE_VERIFY_HEAD=${RADIANCE_VERIFY_HEAD:-1}
else
SPEC_CFG="{\"method\":\"mtp\",\"num_speculative_tokens\":$SPEC,\"attention_backend\":\"$ATTN\",\"disable_padded_drafter_batch\":$UNPAD}"
RADIANCE_DRAFT_RERANK=${RADIANCE_DRAFT_RERANK:-32}
RADIANCE_VERIFY_HEAD=${RADIANCE_VERIFY_HEAD:-0}
fi
# The AR size gate compares raw bf16 bytes: CHUNK x hidden(5120) x 2. Derived
# so changing CHUNK cannot silently drop prefill back onto RCCL.
export RADIANCE_AR_MAX_KB=$(( (CHUNK * 5120 * 2) / 1024 + 4096 ))
# ---------------------------------------------------------------- runtime env
export HF_HUB_OFFLINE=1
export VLLM_LOGGING_LEVEL=${VLLM_LOGGING_LEVEL:-INFO}
export VLLM_NO_USAGE_STATS=${VLLM_NO_USAGE_STATS:-1}
export VLLM_ROCM_USE_AITER=1 VLLM_ROCM_USE_AITER_UNIFIED_ATTENTION=1
export VLLM_ROCM_USE_AITER_MHA=0 VLLM_ROCM_USE_AITER_MLA=0 VLLM_ROCM_USE_AITER_MOE=0
export VLLM_ROCM_USE_AITER_LINEAR=0 VLLM_ROCM_USE_AITER_FP8BMM=0
export VLLM_ROCM_USE_AITER_FP4BMM=0 VLLM_ROCM_USE_AITER_RMSNORM=0
export NCCL_PROTO=Simple
export RADIANCE_USE_R4D=${RADIANCE_USE_R4D:-1} RADIANCE_USE_R4D_AR=${RADIANCE_USE_R4D_AR:-1}
export RADIANCE_DFLASH_SELECTOR_TOPK="${RADIANCE_DFLASH_SELECTOR_TOPK:-}"
export RADIANCE_R4D_REPORT=1
export RADIANCE_PRESHUFFLE=${RADIANCE_PRESHUFFLE:-1}
export RADIANCE_MXFP4_SANITIZE="${RADIANCE_MXFP4_SANITIZE:-0}"
export RADIANCE_MXFP4=1 RADIANCE_MXFP4_W4A8=1 RADIANCE_MXFP4_W4A8_MIN_M="$MIN_M"
export RADIANCE_FAST_DRAFT=${RADIANCE_FAST_DRAFT:-1}
export RADIANCE_DRAFT_TAU=${RADIANCE_DRAFT_TAU:-0.20}
export RADIANCE_DRAFT_RERANK
export RADIANCE_VERIFY_HEAD
export RADIANCE_VERIFY_HEAD_MAX_M=${RADIANCE_VERIFY_HEAD_MAX_M:-32}
export RADIANCE_MXFP4_DEBUG=${RADIANCE_MXFP4_DEBUG:-0}
export RADIANCE_MXFP4_PUREQUANT=${RADIANCE_MXFP4_PUREQUANT:-0}
export RADIANCE_MXFP4_SYNC=${RADIANCE_MXFP4_SYNC:-0}
export RADIANCE_MXFP4_CLONE=${RADIANCE_MXFP4_CLONE:-0}
export RADIANCE_MXFP4_CHECKX=${RADIANCE_MXFP4_CHECKX:-0}
export RADIANCE_MXFP4_PADOUT=${RADIANCE_MXFP4_PADOUT:-0}
export RADIANCE_MXFP4_TN4_MIN_M=${RADIANCE_MXFP4_TN4_MIN_M:-2048}
# The decode-kernel band must cover MAXSEQS x (SPEC+1) rows: 64 covers 8x8.
if [ "${MAXSEQS}" -gt 8 ]; then
export RADIANCE_MXFP4_DECODE_MAX_M=${RADIANCE_MXFP4_DECODE_MAX_M:-128}
fi
export RADIANCE_MXFP4_DECODE_NT=${RADIANCE_MXFP4_DECODE_NT:-1}
export RADIANCE_MXFP4_A_TILED_MIN_M=${RADIANCE_MXFP4_A_TILED_MIN_M:-513}
export RADIANCE_MXFP4_WPERM=${RADIANCE_MXFP4_WPERM:-1}
export RADIANCE_TP_PAD=${RADIANCE_TP_PAD:-0} # TP=2 here; 3 is the only non-zero value upstream supports
export RADIANCE_TP_PAD_DRAFTER=${RADIANCE_TP_PAD_DRAFTER:-1}
export RADIANCE_TP_PAD_STRICT=${RADIANCE_TP_PAD_STRICT:-1}
export RADIANCE_GDN_MERGE_INPROJ=${RADIANCE_GDN_MERGE_INPROJ:-1}
export RADIANCE_GDN_NORM_QUANT=${RADIANCE_GDN_NORM_QUANT:-1}
export RADIANCE_GDN_STRIDED_GATES=${RADIANCE_GDN_STRIDED_GATES:-0}
export RADIANCE_GDN_EMPTY_OUT=${RADIANCE_GDN_EMPTY_OUT:-0}
export R4D_ATTN_FP8=${R4D_ATTN_FP8:-3}
export RADIANCE_DFLASH_CALIB="${RADIANCE_DFLASH_CALIB:-}"
export RADIANCE_DFLASH_CALIB_TOKENS="${RADIANCE_DFLASH_CALIB_TOKENS:-200000}"
export RADIANCE_MXFP4_SHADOW="${RADIANCE_MXFP4_SHADOW:-}"
export RADIANCE_MXFP4_KERNEL_N="${RADIANCE_MXFP4_KERNEL_N:-}"
export RADIANCE_MXFP4_KERNEL_NK="${RADIANCE_MXFP4_KERNEL_NK:-}"
export RADIANCE_MXFP4_CHECKALL="${RADIANCE_MXFP4_CHECKALL:-}"
export RADIANCE_MXFP4_DECODE_KS="${RADIANCE_MXFP4_DECODE_KS:-}"
export RADIANCE_MXFP4_DECODE_BK="${RADIANCE_MXFP4_DECODE_BK:-}"
export RADIANCE_MXFP4_PERBLOCK_NK="${RADIANCE_MXFP4_PERBLOCK_NK:-}"
export RADIANCE_MXFP4_REFLINEAR="${RADIANCE_MXFP4_REFLINEAR:-0}"
export PYTORCH_CUDA_ALLOC_CONF="${PYTORCH_CUDA_ALLOC_CONF:-}"
export RADIANCE_MXFP4_EPIFAST="${RADIANCE_MXFP4_EPIFAST:-1}"
export RADIANCE_AR_OVERLAP=${RADIANCE_AR_OVERLAP:-0}
export RADIANCE_GDN_FUSED_UPDATE=${RADIANCE_GDN_FUSED_UPDATE:-1}
export RADIANCE_DYNAMIC_WIDTH=${RADIANCE_DYNAMIC_WIDTH:-1}
export RADIANCE_DYNW_ALPHA=${RADIANCE_DYNW_ALPHA:-0.35}
export RADIANCE_DYNW_MARGIN=${RADIANCE_DYNW_MARGIN:-2}
export RADIANCE_DYNW_MIN=${RADIANCE_DYNW_MIN:-2}
export RADIANCE_DYNW_MIN_BATCH=${RADIANCE_DYNW_MIN_BATCH:-3}
export RADIANCE_AR_QNB=${RADIANCE_AR_QNB:-96}
export RADIANCE_AR_QNT=${RADIANCE_AR_QNT:-1024}
export MXFP4_CUMODE=${MXFP4_CUMODE:-0}
export RADIANCE_STEP_TRACE=${RADIANCE_STEP_TRACE:-0}
export RADIANCE_AR_OVERLAP_MIN_M=${RADIANCE_AR_OVERLAP_MIN_M:-2048}
export RADIANCE_AR_OVERLAP_SLICES=${RADIANCE_AR_OVERLAP_SLICES:-4}
export RADIANCE_MXFP4_EPIFAST=${RADIANCE_MXFP4_EPIFAST:-1}
export RADIANCE_MXFP4_R4D_DECODE_MAX_M=${RADIANCE_MXFP4_R4D_DECODE_MAX_M:-0}
export RADIANCE_TOPK_TRITON_MIN_ROWS=${RADIANCE_TOPK_TRITON_MIN_ROWS:-1}
export RADIANCE_SKINNY_GEMM=${RADIANCE_SKINNY_GEMM:-1}
export RADIANCE_MXFP4_HOIST_QUANT=${RADIANCE_MXFP4_HOIST_QUANT:-1}
export RADIANCE_MXFP4_TRACED_QUANT=${RADIANCE_MXFP4_TRACED_QUANT:-1}
export RADIANCE_FP8_STREAM=${RADIANCE_FP8_STREAM:-1}
export RADIANCE_RMS_QUANT_FUSION=${RADIANCE_RMS_QUANT_FUSION:-1}
export RADIANCE_NVFP4_MXFP4=${RADIANCE_NVFP4_MXFP4:-0}
export RADIANCE_NVFP4_EXP=${RADIANCE_NVFP4_EXP:-mse}
export RADIANCE_NVFP4_FP8_LAYERS=${RADIANCE_NVFP4_FP8_LAYERS:-mxfp4}
export RADIANCE_NVFP4_BF16_LAYERS=${RADIANCE_NVFP4_BF16_LAYERS:-in_proj_ba}
export RADIANCE_NVFP4_LMHEAD=${RADIANCE_NVFP4_LMHEAD:-bf16}
export RADIANCE_GDN_PATHS=${RADIANCE_GDN_PATHS:-both}
export RADIANCE_GDN_NANTRACE=${RADIANCE_GDN_NANTRACE:-0}
export RADIANCE_MXFP4_CHECK_MAX_M=${RADIANCE_MXFP4_CHECK_MAX_M:-128}
export RADIANCE_MXFP4_MHIST=${RADIANCE_MXFP4_MHIST:-0}
export VLLM_CACHE_ROOT=/cache/vllm
export TORCHINDUCTOR_CACHE_DIR=/cache/inductor
export TRITON_CACHE_DIR=/cache/triton
export AITER_ROOT_DIR=/cache/aiter
export TRITON_CACHE_AUTOTUNING=1
# The patched libr4d built by setup-mxfp4.sh, bind-mounted at /r4d.
R4D_SO=${R4D_SO:-/r4d}
# The norm+quant fusion turns on vLLM's passes (one --compilation-config
# instance; argparse keeps the last, so entries accumulate into one flag).
CC_ITEMS=""
if [ "${RADIANCE_NORMQUANT_FUSION:-1}" != "0" ]; then
CC_ITEMS="\"pass_config\":{\"fuse_norm_quant\":true,\"fuse_act_quant\":true}"
fi
EXTRA=${EXTRA:-}
if [ -n "$CC_ITEMS" ]; then
EXTRA="$EXTRA --compilation-config {$CC_ITEMS}"
fi
# ---------------------------------------------------------------- patch block
# Byte-for-byte the launcher's container-start block. The patches come from
# the /patches mount (the pinned radiance clone). SP must match the image's
# venv layout; the image tag is pinned in the quadlet.
SP=/opt/vllm/lib/python3.12/site-packages
cd /patches
python3 patch_quark_mxfp4.py
python3 patch_nvfp4_mxfp4.py
python3 patch_tp3_pad.py
python3 patch_ar_maxbytes.py
python3 patch_topk_triton_rows.py
python3 patch_dflash_calib.py
python3 patch_dflash_mxfp4_kv.py
python3 patch_rmsquant_fusion.py
python3 patch_verify_head.py
python3 patch_kv_group_size.py
python3 patch_topk_composite.py
python3 patch_gdn_shared_build.py
python3 patch_dflash_selector_topk.py
python3 patch_gdn_merge_inproj.py
python3 patch_dynwidth.py
python3 patch_async_dynwidth.py
python3 patch_step_trace.py
python3 patch_ar_geometry.py
python3 patch_ar_3rank.py
python3 patch_gdn_glue.py
# Non-fatal: fixes content=null on thinking-off requests; not required to serve.
python3 patch_qwen3_thinkoff.py \
|| echo "[radiance] WARNING: thinkoff patch did not apply; thinking-off requests will return empty content"
cp mxfp4-configs/*.json "$SP"/aiter/ops/triton/configs/gemm/
cp radiance_preamble.py /opt/radiance_preamble.py
cp radiance_nvfp4.py radiance_mxfp4.py radiance_gdn.py radiance_rmsquant.py radiance_drafthead.py \
radiance_verifyhead.py radiance_gdnmerge.py radiance_aroverlap.py radiance_topk.py \
radiance_arnq.py radiance_tp3pad.py "$SP"/
hipcc -O3 -w -std=c++17 -fPIC -shared --offload-arch=gfx1201 $([ "${MXFP4_CUMODE:-0}" = 1 ] && echo -mcumode) \
$(python3 -m pybind11 --includes) radiance_mxfp4_fp8.hip -o "$SP"/radiance_mxfp4_fp8.so
if [ -n "${R4D_SO:-}" ] && [ -f /r4d/r4d.so ]; then
cp /r4d/r4d.so "$SP"/r4d.so
echo "[radiance] using patched r4d.so from $R4D_SO"
fi
# Leave /patches before exec: a stale radiance_mxfp4_fp8.so there would shadow
# the one just compiled into site-packages (cwd precedes it on sys.path).
cd /
exec /opt/radiance_entrypoint.sh "$SNAP" --served-model-name $SERVED_NAMES \
--host 0.0.0.0 --port "$PORT" \
--kv-cache-dtype fp8 --tensor-parallel-size "$TP" \
--gpu-memory-utilization "$GPU_UTIL" \
${KV_MEM:+--kv-cache-memory "$KV_MEM"} \
--max-model-len "$MAXLEN" --max-num-seqs "$MAXSEQS" --max-num-batched-tokens "$CHUNK" \
--attention-backend "$ATTN" \
--speculative-config "$SPEC_CFG" \
$ASYNC_FLAG $EXTRA \
--enable-prefix-caching --mamba-cache-mode align --enable-auto-tool-choice \
--tool-call-parser qwen3_coder --reasoning-parser qwen3 \
--override-generation-config '{"temperature":0.7,"top_p":0.95,"top_k":20}' \
--chat-template "$CHAT_TEMPLATE"
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""Routing policy for the frank-medium proxy: which replica serves a request.
Kept apart from the HTTP plumbing in affinity-proxy.py so the decisions can be
tested without aiohttp, a network or a GPU — the first version of these rules
shipped with a bug that only appeared on a continuation request, and a test
that loads this module catches that class of mistake directly.
Two cards, two behaviours, and the workload decides which is right:
* A **single-turn** request is one member of a batch (a benchmark run, a burst
of parallel subagents). Spreading those over both replicas lets both cards
prefill at once; measured on this host, a 16-request burst of 15k-token
prompts finishes in 18.6 s spread versus 30.1 s pinned.
* A **continuation** re-sends a prefix the replica has already prefilled. Pinned
to that replica the turn costs a cache lookup; spread, half the turns pay a
cold prefill again (measured: 1096 ms versus 2676 ms mean warm-turn TTFT on
90k-token contexts).
ROUTING picks the strategy; the default `adaptive` applies both rules above.
`affinity` and `roundrobin` exist so the choice can be re-measured, and are not
production settings.
"""
from __future__ import annotations
import collections
import hashlib
import itertools
import json
import os
UPSTREAMS = [u.strip() for u in os.environ.get(
"UPSTREAMS", "http://127.0.0.1:7771,http://127.0.0.1:7772").split(",") if u.strip()]
ROUTING = os.environ.get("ROUTING", "adaptive").strip().lower()
# Leading bytes of prompt text that identify a conversation's shared prefix.
# 4 KiB covers a system prompt plus the start of the first user turn, which is
# where agent loops keep the text they resend every turn.
AFFINITY_BYTES = int(os.environ.get("AFFINITY_BYTES", "4096"))
# Prefixes remembered for pinning. Bounded so a long-lived proxy cannot grow
# without limit; 4096 conversations is far beyond what this fleet runs at once.
KNOWN_MAX = int(os.environ.get("KNOWN_MAX", "4096"))
_counter = itertools.count()
# digest -> replica index, insertion-ordered for O(1) LRU eviction. A prefix is
# remembered as soon as any request carries it; only a continuation is pinned
# there, because only a continuation can profit from the cache entry.
_known: "collections.OrderedDict[bytes, int]" = collections.OrderedDict()
def affinity_key(body: bytes, remote: str | None) -> tuple[bytes, bool]:
"""Key a request by its shared prefix, and report whether it continues one.
Chat and completions bodies both carry their prompt text in JSON, so the
leading bytes of that text are the prefix; a body we cannot parse (or an
endpoint with no prompt, e.g. /v1/models) falls back to the client address,
which still beats nothing for a single-client loop.
"""
fallback = (remote or "anonymous").encode()
try:
payload = json.loads(body)
except Exception:
return fallback, False
text = ""
continuation = False
if isinstance(payload, dict):
if isinstance(payload.get("prompt"), str):
text = payload["prompt"]
elif isinstance(payload.get("messages"), list):
continuation = len(payload["messages"]) > 1
parts = []
for msg in payload["messages"]:
if isinstance(msg, dict):
content = msg.get("content")
if isinstance(content, str):
parts.append(content)
elif isinstance(content, list): # multimodal content blocks
parts.extend(
blk.get("text", "") for blk in content
if isinstance(blk, dict) and isinstance(blk.get("text"), str)
)
text = "\n".join(parts)
if not text:
return fallback, continuation
return text[:AFFINITY_BYTES].encode("utf-8", "replace"), continuation
def pick(key: bytes, continuation: bool, up: list[bool]) -> int:
"""Replica for a request, or -1 when none is available.
Down replicas are never selected — a rollout must not send traffic to an
engine that is still compiling its graphs — and a pin whose replica is down
is re-established on a live one rather than kept pointing at a corpse.
"""
digest = hashlib.sha1(key).digest()
if ROUTING == "roundrobin":
start = next(_counter) % len(UPSTREAMS)
elif ROUTING == "adaptive":
entry = _known.get(digest)
if continuation and entry is not None and up[entry]:
_known.move_to_end(digest)
return entry
# Anything that is not a continuation spreads by counter — including a
# batch of requests that happen to share one prefix. Hashing the prefix
# would send every member of such a batch to whichever card the hash
# names (measured: 29.8 s for a 16-request burst, against 18.6 s when
# spread). The counter is only advanced by these requests, so batch
# members alternate across the cards.
start = next(_counter) % len(UPSTREAMS)
else: # affinity
start = int.from_bytes(digest[:8], "big") % len(UPSTREAMS)
for offset in range(len(UPSTREAMS)):
idx = (start + offset) % len(UPSTREAMS)
if up[idx]:
if ROUTING == "adaptive":
_known[digest] = idx
_known.move_to_end(digest)
if len(_known) > KNOWN_MAX:
_known.popitem(last=False)
return idx
return -1
def route(body: bytes, remote: str | None, up: list[bool]) -> int:
"""Convenience wrapper: decode the request body and choose a replica."""
return pick(*affinity_key(body, remote), up)
-2
View File
@@ -20,5 +20,3 @@ COPAY_PASS=changeme
# Jellyfin
JELLYFIN_PUBLISH_SERVER_URL=http://192.168.0.235:8096
# *arr Stack
ARR_TZ=America/New_York
@@ -0,0 +1,43 @@
# Quadlet unit: Calibre-Web library server (rootless, boot-persistent).
#
# Install path on dankserver:
# ~/.config/containers/systemd/calibre-web.container
# Unit name: calibre-web.service (podman 5.x drops the container- prefix)
#
# The data roots already lived under /home/dank/Containers/calibre-web in the
# compose era; this unit keeps those exact paths. The image pin is the exact
# tag of the image the live container runs (image id 986277563e76), so the
# cutover recreation changes zero bits of application code. No secrets: this
# unit takes no EnvironmentFile.
[Unit]
Description=Calibre-Web library server container
After=network.target
[Container]
ContainerName=calibre-web
Image=lscr.io/linuxserver/calibre-web:0.6.27-ls397
Network=pasta
Environment=DOCKER_MODS=linuxserver/mods:universal-calibre
Environment=LANG=en_US.UTF-8
Environment=LANGUAGE=en_US.UTF-8
Environment=LSIO_FIRST_PARTY=true
Environment=OAUTHLIB_RELAX_TOKEN_SCOPE=1
Environment=PGID=1000
Environment=PUID=1000
Environment=QTWEBENGINE_CHROMIUM_FLAGS=--no-sandbox
Environment=S6_CMD_WAIT_FOR_SERVICES_MAXTIME=0
Environment=S6_STAGE2_HOOK=/docker-mods
Environment=TERM=xterm
Environment=TZ=America/Vancouver
Environment=VIRTUAL_ENV=/lsiopy
PublishPort=8083:8083
Volume=/home/dank/Containers/calibre-web/config:/config
Volume=/home/dank/Containers/calibre-web/books:/books
[Service]
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
+15 -133
View File
@@ -74,16 +74,20 @@ services:
# Copyparty — file sharing and streaming
# ═══════════════════════════════════════════════
copyparty:
image: ghcr.io/9001/copyparty:latest
image: ghcr.io/9001/copyparty-ac:latest
container_name: copyparty
restart: unless-stopped
environment:
COPAY_PASS: "${COPAY_PASS}"
COPAY_MODE: rw
# read-only since 2026-08-31: copyparty perms are per-field "letters[,user]"
# with no user = every unauthenticated request. Anonymous gets r (browse +
# download; the guarded files.franksdank.cloud chain reaches copyparty
# unauthenticated after authentik). dank gets ra (admin panel). No w/m/d
# anywhere: uploads, moves, deletes rejected server-side. Interface: `--help-accounts` in-image.
command: ["-a", "dank:${COPAY_PASS}", "-v", "/w::r:ra,dank"]
ports:
- "3923:3923"
volumes:
- /opt/copyparty/data:/data
- /mnt/entropy:/w:ro
- /opt/copyparty/cfg:/cfg
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:3923/')"]
interval: 30s
@@ -97,7 +101,6 @@ services:
image: jellyfin/jellyfin:latest
container_name: jellyfin
restart: unless-stopped
user: "1000:1000"
ports:
- "8096:8096/tcp"
- "8920:8920/tcp"
@@ -109,8 +112,7 @@ services:
volumes:
- /opt/jellyfin/config:/config
- /opt/jellyfin/cache:/cache
- /mnt/media/movies:/data/movies:ro
- /mnt/media/shows:/data/shows:ro
- /mnt/entropy:/data:ro
devices:
- /dev/dri:/dev/dri
group_add:
@@ -144,129 +146,9 @@ services:
timeout: 10s
retries: 3
# ═══════════════════════════════════════════════
# *arr Stack — automated media download
# ═══════════════════════════════════════════════
prowlarr:
image: lscr.io/linuxserver/prowlarr:latest
container_name: prowlarr
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
- TZ=America/New_York
- UMASK_SET=022
volumes:
- /opt/prowlarr/config:/config
- /mnt/media/downloads:/downloads:ro
ports:
- "9696:9696"
networks: [appnet]
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:latest
container_name: qbittorrent
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
- TZ=America/New_York
- UMASK_SET=022
volumes:
- /opt/qbittorrent/config:/config
- /mnt/media/downloads:/downloads
- /mnt/media/downloads/incomplete:/downloads/incomplete
ports:
- "8080:8080"
networks: [appnet]
radarr:
image: lscr.io/linuxserver/radarr:latest
container_name: radarr
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
- TZ=America/New_York
- UMASK_SET=022
volumes:
- /opt/radarr/config:/config
- /mnt/media/movies:/movies
- /mnt/media/downloads:/downloads
ports:
- "7878:7878"
networks: [appnet]
sonarr:
image: lscr.io/linuxserver/sonarr:latest
container_name: sonarr
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
- TZ=America/New_York
- UMASK_SET=022
volumes:
- /opt/sonarr/config:/config
- /mnt/media/tv:/tv
- /mnt/media/downloads:/downloads
ports:
- "8989:8989"
networks: [appnet]
lidarr:
image: lscr.io/linuxserver/lidarr:latest
container_name: lidarr
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
- TZ=America/New_York
- UMASK_SET=022
volumes:
- /opt/lidarr/config:/config
- /mnt/media/music:/music
- /mnt/media/downloads:/downloads
ports:
- "8686:8686"
networks: [appnet]
readarr:
image: lscr.io/linuxserver/readarr:latest
container_name: readarr
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
- TZ=America/New_York
- UMASK_SET=022
volumes:
- /opt/readarr/config:/config
- /mnt/media/books:/books
- /mnt/media/downloads:/downloads
ports:
- "8787:8787"
networks: [appnet]
# ═══════════════════════════════════════════════
# Node Exporter — host metrics
# ═══════════════════════════════════════════════
node-exporter:
image: prom/node-exporter:latest
container_name: node-exporter
restart: unless-stopped
network_mode: host
pid: host
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro
command:
- --path.procfs=/host/proc
- --path.sysfs=/host/sys
- --path.rootfs=/rootfs
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:9100/metrics"]
interval: 30s
timeout: 10s
retries: 3
# node-exporter left this file on 2026-09-16 for
# dankserver/observability/quadlet/node-exporter.container: a compose-only
# container does not survive a reboot here — it died `Exited (143)` and stayed
# dead, leaving Prometheus' node-dankserver job scraping nothing — and the
# compose form bound the wildcard address. One owner per container name.
@@ -0,0 +1,38 @@
# Quadlet unit: Copyparty file server (rootless, boot-persistent).
#
# Install path on dankserver: ~/.config/containers/systemd/copyparty.container
# Unit name: copyparty.service (podman 5.x drops the container- prefix)
#
# The image's default entrypoint is /bin/ash /z/cpp.sh -c /z/initcfg; the
# Command= line below reproduces the compose-era argv exactly: the admin
# account is passed as -a <user>:<password>. ${COPAY_ACCOUNT} and
# ${COPAY_PASSWORD} are expanded by systemd at ExecStart time from the
# host-local env file /home/dank/Containers/copyparty/env (mode 0600).
# This file holds no secrets. The deployer refuses to start this unit before
# that env file exists, so a missing env file fails the unit instead of
# creating an admin account with a garbage password.
#
# /w is the entropy volume, mounted read-only: nothing writes into /mnt/entropy
# anymore; every consumer of the share is a read-only client.
[Unit]
Description=Copyparty file server container
After=network.target
[Container]
ContainerName=copyparty
Image=ghcr.io/9001/copyparty-ac:latest
Network=dankserver_appnet
EnvironmentFile=/home/dank/Containers/copyparty/env
Exec=-a ${COPAY_ACCOUNT}:${COPAY_PASSWORD} -v /w::r:ra,dank
Volume=/mnt/entropy:/w:ro
Volume=/home/dank/Containers/copyparty/cfg:/cfg
PublishPort=3923:3923
[Service]
EnvironmentFile=/home/dank/Containers/copyparty/env
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
@@ -0,0 +1,57 @@
# Quadlet unit: Jellyfin media server (rootless, boot-persistent).
#
# Install path on dankserver: ~/.config/containers/systemd/jellyfin.container
# Unit name: jellyfin.service (podman 5.x drops the container- prefix)
#
# Data roots migrated from /opt/jellyfin to /home/dank/Containers/jellyfin at
# cutover. All of /mnt/entropy is mounted read-only at /data: nothing automated
# anywhere writes to the entropy volume. Every
# creation-time environment variable of the compose-era container is kept as
# an Environment= line (zero drift); none of them are secrets, so this unit
# needs no EnvironmentFile.
[Unit]
Description=Jellyfin media server container
After=network.target
[Container]
ContainerName=jellyfin
Image=ghcr.io/jellyfin/jellyfin:latest
Network=dankserver_appnet
Environment=DEBIAN_FRONTEND=noninteractive
Environment=DOTNET_SYSTEM_IO_DISABLEFILELOCKING=true
Environment=JELLYFIN_CACHE_DIR=/cache
Environment=JELLYFIN_CONFIG_DIR=/config/config
Environment=JELLYFIN_DATA_DIR=/config
Environment=JELLYFIN_FFMPEG=/usr/lib/jellyfin-ffmpeg/ffmpeg
Environment=JELLYFIN_LOG_DIR=/config/log
Environment=JELLYFIN_PublishedServerUrl=http://192.168.0.235:8096
Environment=JELLYFIN_WEB_DIR=/jellyfin/jellyfin-web
Environment=LANG=en_US.UTF-8
Environment=LANGUAGE=en_US:en
Environment=LC_ALL=en_US.UTF-8
Environment=LD_PRELOAD=/usr/lib/jellyfin/libjemalloc.so.2
Environment=MALLOC_TRIM_THRESHOLD_=131072
Environment=NVIDIA_DRIVER_CAPABILITIES=compute,video,utility
Environment=NVIDIA_VISIBLE_DEVICES=all
Environment=XDG_CACHE_HOME=/cache
PublishPort=8096:8096
PublishPort=8920:8920
PublishPort=7359:7359/udp
PublishPort=1900:1900/udp
Volume=/home/dank/Containers/jellyfin/config:/config
Volume=/home/dank/Containers/jellyfin/cache:/cache
Volume=/mnt/entropy:/data:ro
AddDevice=/dev/dri
GroupAdd=keep-groups
HealthCmd=curl -f http://localhost:8096/health
HealthInterval=30s
HealthTimeout=10s
HealthRetries=3
[Service]
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
@@ -0,0 +1,58 @@
# Quadlet unit: node-exporter (rootless, boot-persistent) — DANKSERVER.
#
# Install path on dankserver: ~/.config/containers/systemd/node-exporter.container
# Unit name: node-exporter.service (container name: node-exporter)
#
# WHY THIS FILE EXISTS. Until 2026-09-16 the Dankserver node-exporter ran only
# from `dankserver/compose.yaml` (the "Observability" block, `restart:
# unless-stopped`). Rootless Podman does not restart bare containers after the
# explicit stop that shutdown performs, so it died at a reboot and stayed dead:
# `podman ps -a` showed it `Exited (143)` and Prometheus' `node-dankserver` job
# had been scraping nothing since. The compose entry also bound the default
# wildcard address, which is the unauthenticated-metrics-API-on-every-interface
# posture that was already removed on Dankclaw. This unit replaces both: one
# owner per container name (the compose service is deleted), explicit listen
# addresses, and a generator-provided boot path.
#
# HOST NAMESPACE. Networking and PID in the host's namespaces: the collectors
# report the host's own interfaces, connections, processes and cgroups. The
# host's /proc, /sys and / are mounted read-only under /host and /rootfs, and
# node_exporter writes nothing.
#
# LISTEN ADDRESSES. Exactly two:
# - 127.0.0.1:9100 for host-local probes
# - 192.168.0.235:9100 for Prometheus, which scrapes this host from Dankclaw
# (shared/prometheus/prometheus.yml, job "node-dankserver"). It cannot use
# loopback across hosts, and node_exporter has no authentication, so the
# wildcard bind stays off the table. This is the same deliberate LAN
# listener posture the observability runbook documents.
#
# MIRROR. Keep this file in step with dankclaw/observability/quadlet/
# node-exporter.container; only the Description and the LAN address differ.
[Unit]
Description=Prometheus node exporter (Dankserver host metrics)
After=network-online.target
Wants=network-online.target
[Container]
ContainerName=node-exporter
Image=docker.io/prom/node-exporter:latest
Network=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
Volume=/proc:/host/proc:ro
Volume=/sys:/host/sys:ro
Volume=/:/rootfs:ro
HealthCmd=wget --spider -q http://localhost:9100/metrics
HealthInterval=30s
HealthTimeout=10s
HealthRetries=3
HealthStartPeriod=30s
[Service]
Restart=always
RestartSec=10
[Install]
WantedBy=default.target
+22
View File
@@ -0,0 +1,22 @@
[Unit]
Description=Plex Media Server (quadlet, rootless fleet identity)
After=network-online.target
Wants=network-online.target
[Container]
Image=lscr.io/linuxserver/plex:1.43.3.10896-cb3ebc72d-ls322
ContainerName=plex
Network=dankserver_appnet:alias=plex
Environment=PUID=0
Environment=PGID=0
Environment=TZ=America/Los_Angeles
Volume=/var/lib/plexmediaserver:/config
Volume=/mnt/entropy:/mnt/entropy:ro
PublishPort=32400:32400
[Service]
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
@@ -0,0 +1,34 @@
# Quadlet unit: MariaDB backing store for RomM (rootless, boot-persistent).
#
# Install path on dankserver: ~/.config/containers/systemd/romm-mariadb.container
# Unit name: romm-mariadb.service (podman 5.x drops the container- prefix)
#
# The alias `mariadb` on dankserver_appnet is what makes DB_HOST=mariadb in
# the RomM unit resolve. Secrets (MARIADB_ROOT_PASSWORD, MARIADB_PASSWORD)
# live in the shared host-local env file /home/dank/Containers/romm/env
# (mode 0600); this file holds no secrets.
[Unit]
Description=RomM MariaDB database container
After=network.target
[Container]
ContainerName=romm-mariadb
Image=docker.io/library/mariadb:11
Network=dankserver_appnet:alias=mariadb
EnvironmentFile=/home/dank/Containers/romm/env
Environment=MARIADB_DATABASE=romm
Environment=MARIADB_USER=romm
Volume=/home/dank/Containers/romm/mariadb:/var/lib/mysql
HealthCmd=mariadb-admin ping -h localhost
HealthInterval=10s
HealthTimeout=5s
HealthRetries=3
[Service]
EnvironmentFile=/home/dank/Containers/romm/env
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
@@ -0,0 +1,28 @@
# Quadlet unit: Redis cache for RomM (rootless, boot-persistent).
#
# Install path on dankserver: ~/.config/containers/systemd/romm-redis.container
# Unit name: romm-redis.service (podman 5.x drops the container- prefix)
#
# The alias `redis` on dankserver_appnet is what makes REDIS_HOST=redis in
# the RomM unit resolve. No secrets: this unit takes no EnvironmentFile.
[Unit]
Description=RomM Redis cache container
After=network.target
[Container]
ContainerName=romm-redis
Image=docker.io/library/redis:7-alpine
Network=dankserver_appnet:alias=redis
Volume=/home/dank/Containers/romm/redis:/data
HealthCmd=redis-cli ping
HealthInterval=10s
HealthTimeout=5s
HealthRetries=3
[Service]
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
+42
View File
@@ -0,0 +1,42 @@
# Quadlet unit: RomM library manager (rootless, boot-persistent).
#
# Install path on dankserver: ~/.config/containers/systemd/romm.container
# Unit name: romm.service (podman 5.x drops the container- prefix)
#
# The whole data root lives at /home/dank/Containers/romm and is mounted at
# /romm, so config/, library/, resources/ and assets/ sit inside it exactly as
# the compose-era bind mounts + anonymous volume did. Secrets (DB_PASSWD,
# ROMM_AUTH_SECRET_KEY, ROMM_PASSWORD, SCREENSCRAPER_DEV_*) live in the
# host-local env file /home/dank/Containers/romm/env (mode 0600); this file
# holds no secrets. The network dankserver_appnet must exist before first
# start (it is the compose-era network; on a fresh host create it first).
[Unit]
Description=RomM library manager container
Wants=romm-mariadb.service
After=romm-mariadb.service romm-redis.service network.target
[Container]
ContainerName=romm
Image=ghcr.io/rommapp/romm:latest
Network=dankserver_appnet
EnvironmentFile=/home/dank/Containers/romm/env
Environment=DB_HOST=mariadb
Environment=DB_NAME=romm
Environment=DB_USER=romm
Environment=REDIS_HOST=redis
Environment=HASHEOUS_API_ENABLED=true
PublishPort=8085:8080
Volume=/home/dank/Containers/romm:/romm
HealthCmd=curl -f http://localhost:8080/health
HealthInterval=30s
HealthTimeout=10s
HealthRetries=3
[Service]
EnvironmentFile=/home/dank/Containers/romm/env
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
@@ -0,0 +1,25 @@
[Unit]
Description=Tautulli (quadlet, rootless fleet identity)
After=network-online.target
Wants=network-online.target
[Container]
Image=lscr.io/linuxserver/tautulli:v2.18.1-ls242
ContainerName=tautulli
Network=dankserver_appnet:alias=tautulli
Environment=PUID=0
Environment=PGID=0
Environment=TZ=America/Los_Angeles
Volume=/home/dank/Containers/tautulli:/config
PublishPort=8181:8181
HealthCmd=curl -fs http://localhost:8181/home
HealthInterval=30s
HealthTimeout=10s
HealthRetries=3
[Service]
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
-60
View File
@@ -1,60 +0,0 @@
# Deployment Flow
Git is the single source of truth. A `deploy.sh` script moves committed
changes to the target hosts over SSH. One run per target covers both
workloads on that host: the podman stack and the LLM service.
## Workflow
```mermaid
graph LR
A[Edit in this repo] --> B[Commit]
B --> C[Push to origin]
C --> D[deploy.sh: pull on target]
D --> E[restart stack]
E --> F[health check]
```
1. Edit files locally in this repository.
2. Commit and push.
3. Run `./scripts/deploy.sh <dankclaw|dankserver>`.
4. The script pulls the repo on the target and restarts the podman stack
and the LLM service.
5. It verifies service health after restart (homepage and LLM endpoint).
## Deploy Script
```
./scripts/deploy.sh dankclaw # pull + restart on Dankclaw
./scripts/deploy.sh dankserver # pull + restart on Dankserver
./scripts/deploy.sh dankclaw --dry-run
```
SSH uses the existing key pair (`dank@dankclaw`, `dank@dankserver`). No passwords are stored or documented in this repo. If sudo is needed, use the interactive sudo on the target — never commit a password.
## Targets
| Target | Host | Stack |
|--------|------|-------|
| dankclaw | dank@dankclaw (192.168.0.143) | `dankclaw/` |
| dankserver | dank@dankserver (192.168.0.235) | `dankserver/` |
## Health Verification
The deploy script polls the health endpoint of each service after restart. Service healthchecks are defined in each compose file. A service that fails its healthcheck is reported, not hidden.
## Tests
Run the test suite before pushing:
```
./tests/run.sh
```
The suite validates compose YAML, env consistency, Caddyfiles, docs completeness, and script syntax. See [[Testing Policy]].
## See Also
- [[Storage Policy]]
- [[Testing Policy]]
- [LLM deployment docs](../LLM/deployment.md) — the LLM half of the same flow
-63
View File
@@ -1,63 +0,0 @@
# Network Architecture
One Caddy fronts both hosts. It runs as a container on Dankclaw (192.168.0.143) and terminates TLS for every service on the LAN. Pi-hole on Dankclaw answers the service names for LAN clients; Porkbun answers them for the WAN. The G36 router firewall governs external access. SearXNG outbound traffic exits through the Gluetun VPN container.
## Topology
```mermaid
graph TB
subgraph WAN["G36 router 192.168.0.1"]
EXT[Internet]
end
EXT -->|port forwards| C
subgraph Dankclaw[192.168.0.143]
C[Caddy :80 :443]
D[Pi-hole :53]
C -->|comfyui:8188| F[ComfyUI]
C -->|192.168.0.143:3000| G[Gitea quadlet]
C -->|192.168.0.143:8081| SE[SearXNG quadlet]
C -->|192.168.0.143:3005| O[OpenWebUI quadlet]
SE -->|network_mode service| GL[Gluetun VPN]
GL -->|tunnel| EXT
end
subgraph Dankserver[192.168.0.235]
C -->|192.168.0.235:8085| R[RomM]
C -->|192.168.0.235:3923| P[Copyparty]
C -->|192.168.0.235:8083| W[Calibre-Web]
C -->|192.168.0.235:32400| PL[Plex host service]
end
CLIENT[LAN clients] -->|DHCP DNS| D
```
Dankserver runs no proxy of its own. Its services expose published LAN ports, and the Dankclaw Caddy routes the names to those ports.
## Hostnames
| Scope | Convention | Resolved by |
|-------|------------|-------------|
| LAN | `romm.franksdank.cloud` → 192.168.0.143 | Pi-hole (`addn-hosts`) |
| WAN | `romm.franksdank.cloud` → 71.227.227.172 | Porkbun A record |
Users type a name and nothing else. Caddy maps the name to the upstream. The split between the two resolvers removes the need for router NAT loopback. See [[LAN-DNS]] for the flow and the router hand-off.
## TLS
Caddy holds one Let's Encrypt certificate per vhost, issued and renewed through the DNS-01 challenge via the Porkbun module. No inbound port 80/443 is required. Details in [[Caddy]].
## Gluetun Path (SearXNG only)
SearXNG attaches to the Gluetun network namespace (`network_mode: service:gluetun`). Only SearXNG shares the tunnel. Everything else on Dankclaw uses the normal LAN route. This matches KB Pattern 2 in [[Gluetun]].
## Access Policy
- The **G36 router firewall** governs what the WAN can reach. Port forwards are manual router steps; the repo tracks no router state.
- Caddy adds HTTP basic auth on the homepage and on sensitive services.
- LAN subnet: `192.168.0.0/24`.
## See Also
- [[LAN-DNS]] — name resolution, Pi-hole, router hand-off
- [[Caddy]] — vhost map, TLS, basic auth
- [[Dankserver Stack]], [[Dankclaw Stack]]
- [[Gluetun]]
- Knowledge base: [[Gluetun]], [[SearXNG]]
-40
View File
@@ -1,40 +0,0 @@
# Storage Policy
All persistent data uses **bind mounts**: a host directory mapped directly into the container. No named volumes. This matches the preference of this repo and the existing Gitea stack on Dankclaw.
## Why Bind Mounts
- The exact host location of every byte is visible and controllable.
- Backups are a simple `tar` of known host directories.
- The data survives container recreation and `podman-compose down`.
- Ownership is explicit: everything runs as UID 1000 (`dank`) so host tools can read it.
## Conventions
| Stack | Host root |
|-------|-----------|
| Dankserver | `/opt/<service>/...` |
| Dankclaw | `/opt/<service>/...` (Gitea keeps `/opt/gitea/data/...`) |
## Ownership
Containers run as `PUID=1000` / `PGID=1000`. Host directories must be owned by `dank`:
```bash
sudo chown -R 1000:1000 /opt/<service>
```
## Secrets
Secrets never live in bind-mounted configs that get committed. They are:
1. Set in `.env` files on the host (git-ignored, `.env.example` committed as template), or
2. Injected as environment variables from the deploy script.
The live Gitea database password exists only in `.env` on Dankclaw. A test enforces that it never appears in this repo.
## See Also
- [[Deployment Flow]]
- [[Gitea]] — the existing bind-mount stack being migrated
- [[Backblaze Integration]] — future backup target for these mounts
-23
View File
@@ -1,23 +0,0 @@
# Testing Policy
This repository tests its configuration at defined seams. The tests run with `bats`; run them with `./tests/run.sh` before every push.
## Seams Under Test
| Seam | File | What it asserts |
|------|------|-----------------|
| Compose validity | `tests/compose_validity.bats` | YAML parses, services have images, no named volumes |
| Env consistency | `tests/env_consistency.bats` | every `${VAR}` is defined in `.env.example`; no real secrets; the live gitea password never appears |
| Caddyfile validity | `tests/caddyfile.bats` | per-host Caddyfile, `.local` convention, balanced braces, basic auth present |
| Docs completeness | `tests/docs_completeness.bats` | every compose service has a docs page; index links every page |
| Script syntax | `tests/scripts_syntax.bats` | `bash -n` clean, executable bits, shellcheck when available |
| Deployment flow | `tests/deployment_flow.bats` | deploy.sh exists, targets known, SSH reachable (skips offline) |
## Red-Green
Tests are written first. A failing test means the seam is not satisfied; the config change that makes it pass is the only acceptable fix. No `--no-verify`, no deleting tests without approval.
## See Also
- [[Deployment Flow]]
- [[index]]
-61
View File
@@ -1,61 +0,0 @@
# Dankclaw Stack
Dankclaw (192.168.0.143) runs the AI, search, and development stack. It
hosts the LLM inference endpoint (`frank-default-large`, `:7770`, CIRU
ROCmFPX llama.cpp), the agent tools, the privacy search, and the
observability stack. The llama.cpp endpoint (`frank-default-small`,
`:7770`) runs on Dankserver (192.168.0.235).
```mermaid
graph LR
U[User] --> C[Caddy :80/:443]
C --> O[OpenWebUI]
C --> S[SearXNG]
C --> G[Gitea]
C --> F[ComfyUI]
C --> D[Grafana]
S --> V[Gluetun VPN]
O --> L[llama.cpp dankserver :7770]
O --> H[Hermes]
O --> E[CIRU llama.cpp :7770]
```
## Services
| Service | Hostname | Port | Docs |
|---------|----------|------|------|
| SearXNG | search.dankclaw.local | 8081 | [[SearXNG]] |
| ComfyUI | comfyui.dankclaw.local | 8188 | [[ComfyUI]] |
| OpenWebUI | chat.dankclaw.local | 3005 | [[OpenWebUI]] |
| Hermes | hermes.dankclaw.local | 8090 | [[Hermes]] |
| Gitea | gitea.dankclaw.local | 3000 | [[Gitea]] |
| Grafana | grafana.dankclaw.local | 3002 | [[Grafana]] |
| Prometheus | prometheus.dankclaw.local | 9090 | [[Prometheus]] |
| Homepage | dankclaw.local | 80/443 | — |
| frank-default-large (host service, Dankclaw) | — | 7770 | [[deployment]] |
| frank-default-small (host service, Dankserver) | — | 7770 | [[deployment]] |
The last two rows are bare-metal systemd services, not containers. This
repo owns their scripts, units, and knowledge base (see
[docs/LLM/index.md](../LLM/index.md)). Current mapping (2026-08-22):
`frank-default-large` runs the CIRU ROCmFPX llama.cpp on Dankclaw;
`frank-default-small` runs the upstream llama.cpp on Dankserver.
## Boot Persistence
The Gitea trio, the SearXNG trio (gluetun, searxng, valkey), and open-webui run as quadlet units, so they start at boot. User linger is on. ComfyUI runs as a systemd unit around a bare process. The remaining services in the table are defined in `compose.yaml` and are not yet boot-persistent.
Deploy the SearXNG trio with `scripts/deploy-searxng-quadlet.sh dankclaw` and open-webui with `scripts/deploy-open-webui-quadlet.sh dankclaw`. The Gitea units install to `~/.config/containers/systemd/` (see [[Gitea]]).
## Routing Rules
- SearXNG outbound traffic exits through the [[Gluetun]] VPN container.
- Every other service uses the normal LAN route.
- The homepage lists all services with status.
## See Also
- [[index]] — repository index
- [[Network Architecture]] — routing and proxy
- [[Observability]] — Grafana, Prometheus, logs
- [LLM fleet knowledge base](../LLM/index.md) — the model-serving endpoints on this host
-97
View File
@@ -1,97 +0,0 @@
# Gitea
Gitea is a self-hosted Git service with issues, pull requests, and a web UI. On Dankclaw it runs as three Quadlet-managed containers in the rootless podman session of user `dank`.
**Host:** Dankclaw — **LAN access:** `http://dankclaw:3000` (or `http://192.168.0.143:3000`) — **Public domain:** `gitea.franksdank.cloud` (DNS pending) — **Docs:** https://docs.gitea.com
## Containers
| Container | Unit | Image | Purpose |
|-----------|------|-------|---------|
| `gitea-postgres` | `gitea-postgres.service` | `docker.io/library/postgres:16-alpine` | Database |
| `gitea-server` | `gitea-server.service` | `docker.io/gitea/gitea:latest` | Web UI, API, git (HTTP :3000, SSH :2222) |
| `gitea-caddy` | `gitea-caddy.service` | `docker.io/library/caddy:latest` | Reverse proxy for the public domain |
The containers share the private bridge network `systemd-gitea`. The quadlet network file `gitea.network` enables DNS, so the containers resolve each other by name. The automatic `podman` default network has DNS disabled. Do not run the Gitea containers on it.
## Hosted repositories
| Repository | Purpose |
|------------|---------|
| `dank/serverAppConfigs` | this repo — Podman stacks + LLM fleet |
| `dank/vllmScripts` | retired 2026-08-22 — merged into `dank/serverAppConfigs` (history preserved) |
The LLM fleet knowledge base: [docs/LLM/index.md](../LLM/index.md)
## Management
Run all commands as `dank` on Dankclaw.
| Action | Command |
|--------|---------|
| Start all | `systemctl --user start gitea-postgres gitea-server gitea-caddy` |
| Stop all | `systemctl --user stop gitea-caddy gitea-server gitea-postgres` |
| Restart Gitea only | `systemctl --user restart gitea-server` |
| Status | `systemctl --user status gitea-postgres gitea-server gitea-caddy` |
| Container logs | `podman logs -f gitea-server` |
| Unit logs | `journalctl --user -u gitea-server -f` |
The units start in dependency order:
```mermaid
graph LR
N[gitea-network.service] --> P[gitea-postgres.service]
P --> S[gitea-server.service]
S --> C[gitea-caddy.service]
```
## Boot persistence
User-level linger is enabled for `dank` (`loginctl enable-linger dank`). The units start at host boot without a login session. Each unit carries `[Install] WantedBy=default.target`. The podman generator applies it at boot; no symlink is needed.
The containers use the quadlet fresh-start model. A stop removes the container (`podman run --rm --replace`). The next start recreates it. The bind-mounted data survives.
## Secrets
The database password lives in `/opt/gitea/.env` on the host (mode 0600). The quadlet files reference it with `EnvironmentFile=`. The password never enters this repository. Placeholders live in [`dankclaw/gitea/.env.example`](../../dankclaw/gitea/.env.example).
Do not change the password in place. Change the PostgreSQL role first, then update `/opt/gitea/.env` and the `[database] PASSWD` line in the app.ini (see Gotchas), then restart the stack.
## Configuration files
| File | Location | Repo copy |
|------|----------|-----------|
| Quadlet units | `~/.config/containers/systemd/gitea-{postgres,server,caddy}.container`, `gitea.network` | [`dankclaw/gitea/quadlet/`](../../dankclaw/gitea/quadlet/) |
| Caddyfile | `/opt/gitea/data/caddy/Caddyfile` | host-only (not in repo) |
| Secrets | `/opt/gitea/.env` (0600) | [`dankclaw/gitea/.env.example`](../../dankclaw/gitea/.env.example) |
After editing a repo copy, copy it to the target, then run `systemctl --user daemon-reload` and restart the affected units.
## Data volumes
| Container path | Host path | Contents |
|----------------|-----------|----------|
| `/data` | `/opt/gitea/data/gitea-gitea` | Git repositories, app.ini, attachments |
| `/var/lib/postgresql/data` | `/opt/gitea/data/gitea-postgres` | PostgreSQL data |
| `/etc/caddy/Caddyfile`, `/root/.local/share/caddy` | `/opt/gitea/data/caddy/...` | Caddy config and TLS state |
Back up `/opt/gitea/data/` (tar) or `pg_dump` the database. See the knowledge base page [[Gitea]].
## Gotchas
- **app.ini wins over env vars.** Gitea writes `/data/gitea/conf/app.ini` (host: `/opt/gitea/data/gitea-gitea/gitea/conf/app.ini`) on first start. The `[database] HOST` and `PASSWD` lines persist and override the `DATABASE_HOST`/`DATABASE_PASSWD` env vars. When you rename the database container, edit the `HOST` line.
- **The files in the data directory are owned by host UID 100999**, not `dank` (rootless ID mapping: container uid 1000 → host 100999). Edit them inside the container with `podman exec --user 1000 gitea-server ...`.
- **The host auto-reboots.** `unattended-upgrades` reboots Dankclaw when a package needs it. The quadlet units make Gitea survive that. See the 2026-08-16 incident below.
- **Port 3000 vs 3001.** The live host port is 3000. The full-stack [`dankclaw/compose.yaml`](../../dankclaw/compose.yaml) maps 3001. Align the two before deploying the full stack.
- **Legacy compose retired.** `/opt/gitea/podman-compose.yml` is retired to `/opt/gitea/.deprecated/`. Do not run it. Its containers collide with the quadlet units on ports 3000, 2222, 80, and 443.
- **Rootless cannot bind ports below 1024 by default.** `gitea-caddy` publishes 80 and 443. Without a host sysctl, `rootlessport` fails with `permission denied` and the unit crash-loops under `Restart=always`. The fix is a one-time sysctl: `net.ipv4.ip_unprivileged_port_start=80`, persisted in `/etc/sysctl.d/99-rootless-ports.conf`. Applied 2026-08-18 after a 13,000-restart crash loop.
## Incident: 2026-08-16 — down after unattended reboot
`unattended-upgrades` rebooted Dankclaw at 15:01 on 2026-08-16. The Gitea containers ran through podman-compose with `restart: unless-stopped` and no systemd units. Rootless podman does not auto-start containers at boot without units. Gitea stayed down until a manual start at 16:02. The fix: migrate the stack to quadlet units with linger (this page). After the fix, verify on the next reboot.
## See Also
- [[Dankclaw Stack]]
- [[Postgres]]
- Knowledge base: [[Gitea]] (tools)
-37
View File
@@ -1,37 +0,0 @@
# Hermes-Dashboard
The Hermes web dashboard is a browser UI for managing the agent. It shows config, API keys, and sessions. It is a management tool. It is not a chat window. Chat happens over Signal or the CLI. See [[Hermes]].
**Host:** Dankclaw — **Port:** `9119` — **URL:** `http://192.168.0.143:9119`
## Facts
| Item | Value |
|------|-------|
| Image | `docker.io/nousresearch/hermes-agent:latest` (same as the gateway) |
| Command | `hermes dashboard --host 0.0.0.0 --port 9119 --no-open --skip-build` |
| Port | `9119` (published to the LAN) |
| Auth | HTTP basic auth (username + scrypt password hash) |
| Data | shares `/home/dank/hermes-data` with the gateway |
## Why a separate container
The gateway container runs `hermes gateway run` (the Signal platform). That entrypoint does not start the dashboard. The dashboard is a separate process. So it runs in its own compose service (`hermes-dashboard`). Both services mount the same data volume. Both restart at boot (`restart: unless-stopped`).
## Authentication
The dashboard refuses to bind to a non-loopback address without an auth provider. There is no unauthenticated public bind. This repo uses basic auth:
- `dashboard.basic_auth.username` and `dashboard.basic_auth.password_hash` live in the git-ignored `/home/dank/hermes-data/config.yaml`.
- The stored value is a one-way scrypt hash. The plaintext password is never written to a file or the repo.
- The hash is generated with `plugins.dashboard_auth.basic.hash_password`.
## Access
Open `http://192.168.0.143:9119` in a browser on the LAN. Log in with the configured username and password.
## Gotchas
- The dashboard and the gateway share one data volume. A config change made in the dashboard takes effect when the gateway restarts.
- If the shared password is reused elsewhere, treat it as exposed. Rotate it and regenerate the hash.
- `--skip-build` uses the prebuilt web assets in the image. Do not remove it unless the assets are rebuilt.
-40
View File
@@ -1,40 +0,0 @@
# Hermes
Hermes is the self-improving AI agent from Nous Research. It runs in a terminal, Telegram, Discord, Slack, WhatsApp, or Signal, and keeps a persistent learning loop: it creates skills from experience, searches past conversations, and builds a model of the user across sessions.
**Host:** Dankclaw — **Hostname:** `hermes.dankclaw.local` — **Docs:** https://hermes-agent.nousresearch.com/docs
## Facts (from research, primary source)
| Item | Value |
|------|-------|
| Image | built from `NousResearch/hermes-agent` (official Dockerfile in repo) |
| License | MIT |
| Model backends | Nous Portal, OpenRouter, OpenAI, or any custom endpoint |
| Interfaces | TUI, Telegram, Discord, Slack, WhatsApp, Signal |
| Memory | FTS5 session search, Honcho user modeling |
| Skills | self-created, self-improving during use |
## Configuration
- Point Hermes at the local model endpoint (`http://localhost:7770/v1`) or an external provider through its provider config.
- The gateway process exposes one port for chat integration; the exact port is set in the Hermes config (`8090` convention in this repo).
- The data volume persists skills, memory, and session history.
## Volumes
| Container path | Host path (convention) |
|----------------|------------------------|
| `/data` | `/opt/hermes/data` |
## Gotchas
- Hermes is under active development; pin the image tag for reproducibility.
- Model choice drives quality; the local `frank-default-large` (Dankclaw, 7770) is the intended backend.
- Do not expose the gateway without auth — put it behind [[Caddy]] with basic auth.
## See Also
- [[Dankclaw Stack]]
- [[OpenWebUI]] — the chat UI that pairs with Hermes
- Knowledge base: [[SearXNG]] (Hermes as the search agent)
-369
View File
@@ -1,369 +0,0 @@
# *arr Stack
The *arr suite is a collection of automated media management tools for Usenet and BitTorrent. For Dankserver — which runs Copyparty, Calibre-Web, RomM, and Jellyfin — the *arr stack provides automatic downloading and library management that Copyparty alone does not cover: quality profile enforcement, series/movie discovery, metadata enrichment, and automated file organization.
The core chain is: **Prowlarr** (indexer API aggregator) → **Sonarr/Radarr/Lidarr/Readarr** (media-specific managers) → **qBittorrent** (download client) → **Jellyfin** (media server scan → auto-refresh).
## Service Overview
| Service | Role | Image | Port | Min RAM |
|---------|------|-------|------|---------|
| Prowlarr | Indexer management | `lscr.io/linuxserver/prowlarr:latest` | 9696 | 256 MB |
| Radarr | Movie management | `lscr.io/linuxserver/radarr:latest` | 7878 | 512 MB |
| Sonarr | TV show management | `lscr.io/linuxserver/sonarr:latest` | 8989 | 512 MB |
| Lidarr | Music management | `lscr.io/linuxserver/lidarr:latest` | 8686 | 256 MB |
| Readarr | Ebook/audiobook management | `lscr.io/linuxserver/readarr:latest` | 8787 | 256 MB |
| qBittorrent | Torrent download client | `lscr.io/linuxserver/qbittorrent:latest` | 8080 | 512 MB |
| Jackett | Indexer proxy (optional) | `lscr.io/linuxserver/jackett:latest` | 9117 | 256 MB |
| Whisparr | NSFW movie management (optional) | `lscr.io/linuxserver/whisparr:latest` | 6969 | 256 MB |
**Minimum total RAM:** ~3 GB for full stack (headroom recommended).
## Architecture
```mermaid
graph LR
I[Indexers<br/>Torrents, Usenet] --> P[Prowlarr<br/>Indexer Manager]
P --> S[Sonarr<br/>TV]
P --> R[Radarr<br/>Movies]
P --> L[Lidarr<br/>Music]
P --> Rd[Readarr<br/>Books]
S --> Q[qBittorrent]
R --> Q
L --> Q
Rd --> Q
Q --> M1[/tv/]
Q --> M2[/movies/]
Q --> M3[/music/]
Q --> M4[/books/]
M1 --> J[Jellyfin]
M2 --> J
M3 --> J
M4 --> CW[Calibre-Web]
```
## Docker Compose
```yaml
networks:
appnet:
driver: bridge
services:
prowlarr:
image: lscr.io/linuxserver/prowlarr:latest
container_name: prowlarr
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
- TZ=America/New_York
- UMASK_SET=022
volumes:
- /opt/prowlarr/config:/config
- /mnt/media/downloads:/downloads:ro
ports:
- "9696:9696"
networks: [appnet]
radarr:
image: lscr.io/linuxserver/radarr:latest
container_name: radarr
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
- TZ=America/New_York
- UMASK_SET=022
volumes:
- /opt/radarr/config:/config
- /mnt/media/movies:/movies
- /mnt/media/downloads:/downloads
ports:
- "7878:7878"
networks: [appnet]
sonarr:
image: lscr.io/linuxserver/sonarr:latest
container_name: sonarr
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
- TZ=America/New_York
- UMASK_SET=022
volumes:
- /opt/sonarr/config:/config
- /mnt/media/tv:/tv
- /mnt/media/downloads:/downloads
ports:
- "8989:8989"
networks: [appnet]
lidarr:
image: lscr.io/linuxserver/lidarr:latest
container_name: lidarr
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
- TZ=America/New_York
- UMASK_SET=022
volumes:
- /opt/lidarr/config:/config
- /mnt/media/music:/music
- /mnt/media/downloads:/downloads
ports:
- "8686:8686"
networks: [appnet]
readarr:
image: lscr.io/linuxserver/readarr:latest
container_name: readarr
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
- TZ=America/New_York
- UMASK_SET=022
volumes:
- /opt/readarr/config:/config
- /mnt/media/books:/books
- /mnt/media/downloads:/downloads
ports:
- "8787:8787"
networks: [appnet]
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:latest
container_name: qbittorrent
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
- TZ=America/New_York
- UMASK_SET=022
volumes:
- /opt/qbittorrent/config:/config
- /mnt/media/downloads:/downloads
- /mnt/media/downloads/incomplete:/downloads/incomplete
ports:
- "8080:8080"
networks: [appnet]
# Optional: Jackett for niche indexers
jackett:
image: lscr.io/linuxserver/jackett:latest
container_name: jackett
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
- TZ=America/New_York
- UMASK_SET=022
volumes:
- /opt/jackett/config:/config
- /opt/jackett/torrences:/torrences
ports:
- "9117:9117"
networks: [appnet]
# Optional: Whisparr for adult content
whisparr:
image: lscr.io/linuxserver/whisparr:latest
container_name: whisparr
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
- TZ=America/New_York
- UMASK_SET=022
volumes:
- /opt/whisparr/config:/config
- /mnt/media/whisparr-movies:/whisparr-movies
- /mnt/media/downloads:/downloads
ports:
- "6969:6969"
networks: [appnet]
```
## Caddy Configuration
```caddy
radarr.dankserver.local {
reverse_proxy localhost:7878
}
sonarr.dankserver.local {
reverse_proxy localhost:8989
}
lidarr.dankserver.local {
reverse_proxy localhost:8686
}
readarr.dankserver.local {
reverse_proxy localhost:8787
}
prowlarr.dankserver.local {
reverse_proxy localhost:9696
}
qbittorrent.dankserver.local {
reverse_proxy localhost:8080
}
# Optional: Jackett
jackett.dankserver.local {
reverse_proxy localhost:9117
}
# Optional: Whisparr
whisparr.dankserver.local {
reverse_proxy localhost:6969
}
```
## Configuration Steps
### Phase 1: Download Client
1. Deploy qBittorrent first (or Transmission).
2. In the qBittorrent Web UI, set **No auth mode** for LAN (Settings → Web UI).
3. Create download categories: `movies`, `tv`, `music`, `books`.
4. Change the default Web UI password.
### Phase 2: Indexer Management
1. Deploy Prowlarr.
2. Add indexers (3–5 recommended for testing). Use the **Test** button on each.
3. Add qBittorrent as a download client in Prowlarr.
4. Verify search from the Prowlarr web UI.
### Phase 3: Arr Apps (one at a time)
1. Deploy Sonarr first (TV shows are the most common use case).
2. Add Prowlarr as the indexer source (Settings → Indexers → Add Prowlarr).
3. Add qBittorrent as the download client with categories.
4. Add a test series. Wait for the episode to download. Verify in `/tv/`.
5. Repeat for Radarr, Lidarr, Readarr.
### Phase 4: Media Server Integration
1. Configure Jellyfin libraries pointing to `/movies`, `/tv`, `/music`, `/books`.
2. Set **On Download** notifications in each arr app → Jellyfin webhook URL.
3. Test end-to-end: Add a movie in Radarr → download → verify in Jellyfin.
## Quality Profiles
Each arr app uses a hierarchical quality system:
1. **Quality Profile** — Defines tiers (e.g., 2160p → 1080p BluRay → 720p).
2. **Cutoff** — The quality target. Downloads at or above cutoff are accepted.
3. **Custom Formats** — Additional filters (e.g., "Has HDR", "Is Director's Cut").
4. **Search on Grab** — When content becomes available, all indexers are queried.
### Radarr (Movies)
```
Profile: Movies 4K
Tiers:
1. 2160p BluRay REMUX
2. 2160p BluRay
3. 2160p WEB-DL
4. 1080p BluRay
5. 1080p WEB-DL
6. 720p
Cutoff: 1080p BluRay
```
### Sonarr (TV)
```
Profile: TV - Standard
Tiers: 2160p → 1080p → 720p
Min Size: 500 MB
Max Size: 50 GB
Cutoff: 1080p
Language Profile: English audio preferred
```
### Lidarr (Music)
```
Profile: Music - Standard
Tiers: Lossless (FLAC) → 320 kbps → 192 kbps
Cutoff: 320 kbps (or Lossless)
```
## Metadata Sources
| App | Primary Source | Secondary |
|-----|---------------|-----------|
| Radarr | TMDB | TheMovieDB API |
| Sonarr | TMDB | TVDB |
| Lidarr | MusicBrainz | — |
| Readarr | Goodreads, StoryGraph | — |
| Whisparr | XH | TMDB |
## *arr Stack vs Copyparty
| Feature | Copyparty | *arr Stack |
|---------|-----------|------------|
| Auto-download from torrents | Basic (RSS + scripts) | Native, integrated |
| Quality profiles | No | Multi-tier, custom formats |
| Series/movie tracking | No | Watch lists, air dates |
| Manual library management | Yes | Yes, metadata-driven |
| Multi-user watchlists | No | Yes |
| Complexity | Low | Medium |
| Resource usage | Minimal | Moderate |
Use Copyparty for curated file collections. Use *arr for automatic downloads. Use both together for maximum functionality — point *arr download directories at Copyparty's watched folders.
## Disk Layout
```
/mnt/media/
├── movies/ → Radarr root
├── tv/ → Sonarr root
├── music/ → Lidarr root
├── books/ → Readarr root
├── whisparr-movies/ → Whisparr root
├── downloads/ → qBittorrent download dir
│ ├── movies/
│ ├── tv/
│ ├── music/
│ └── books/
└── incomplete/ → qBittorrent incomplete downloads
```
## Gotchas
- **Timezone mismatch:** All containers should use the same TZ. Sonarr's scheduler is timezone-sensitive.
- **File permissions:** Set `umask=022` in qBittorrent. Ensure arr apps can read/move downloaded files.
- **Network conflicts:** *arr apps use many ports. Check for conflicts with existing Dankserver services.
- **Prowlarr API key rotation:** If rotated, update it in every arr app.
- **qBittorrent Web UI password:** Change default on first run.
- **Readarr stability:** Readarr is still in development. Do not rely on it for mission-critical collections.
- **Whisparr separate Jellyfin library:** Keep adult content separate from regular media.
- **RSS feed in qBittorrent:** Can auto-add torrents matching a pattern. Useful for podcast-like series.
## Best Practices
1. Use tags to route content to specific download clients or categories.
2. Enable the Blocklist in arr apps to prevent re-downloading failed torrents.
3. Set download limits in qBittorrent to avoid bandwidth saturation.
4. Back up arr configs regularly (they are small SQLite DBs + JSON).
5. Monitor Prowlarr indexer health — broken indexers hide new content.
6. Start with default quality profiles. Add custom formats gradually.
7. Back up arr configs: `./radarr/config`, `./sonarr/config`, etc.
## See Also
- [[Dankserver Stack]]
- [[Jellyfin]] — media server that consumes *arr downloads
- [[Copyparty]] — alternative file viewer
- [[Calibre-Web]] — manual ebook management (Readarr auto-downloads into the same library)
- [Sonarr Wiki](https://wiki.servarr.com/sonarr)
- [Radarr Wiki](https://wiki.servarr.com/radarr)
-106
View File
@@ -1,106 +0,0 @@
# Copyparty
Copyparty is a lightweight file server and media streamer that runs as a single Python binary. It handles large payloads, media streaming, resumable uploads, directory browsing, and WebDAV from one process.
**Host:** Dankserver — **Hostname:** `files.dankserver.local` — **Docs:** https://github.com/9001/copyparty
## Facts
| Item | Value |
|------|-------|
| Latest version | v1.20.21 (2026-08) |
| Image | `ghcr.io/9001/copyparty:latest` (official Dockerfile in repo) |
| Port | 3923 (host) → 3923 (container) |
| Volume | bind mount the shared root directory |
| Auth | Per-directory `.cpy` config files, HTTP basic auth, `dirkeys`, `filekeys` |
| Streaming | HTTP range requests, HLS, WebDAV, WOPI (Office editing) |
| Docker editions | `dj` (document + media), `iv` (image + video), `v` (video only) |
## Configuration
### Environment Variables
| Variable | Description |
|----------|-------------|
| `COPAY_PASS` | Master password for the web UI |
| `COPAY_MODE` | Set to `rw` for read-write shares |
### Configuration File Options
Copyparty reads its configuration from `initcfg` (or `config` in the `/data` directory). Key options:
- `use-bwrap: n` — Disable bwrap sandboxing for ffmpeg (default in Docker). Set to `y` to enable.
- `shadowing //NULL` — Full unmap of a volume from the filesystem.
- Config env-var expansion: Use `${VAR}` syntax in `[global]` section for environment variable substitution.
### Docker Editions
Different editions bundle different libraries:
| Edition | Bundled Libraries | Use Case |
|---------|-------------------|----------|
| `dj` | Document + image + video libraries | Full-featured server |
| `iv` | Image + video libraries | Image and video focused |
| `v` | Video only | Transcoding only, minimal footprint |
The `iv` edition is no longer available for arm32/armv6. Use `dj` for maximum compatibility.
## Volumes
| Container path | Host path (convention) |
|----------------|------------------------|
| `/data` | `/opt/copyparty/data` |
## Docker Compose
```yaml
services:
copyparty:
image: ghcr.io/9001/copyparty:latest
container_name: copyparty
restart: unless-stopped
environment:
COPAY_PASS: "${COPAY_PASS}"
COPAY_MODE: rw
ports:
- "3923:3923"
volumes:
- /opt/copyparty/data:/data
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:3923/')"]
interval: 30s
timeout: 10s
retries: 3
```
## Healthcheck
A simple HTTP check on port 3923 works. The root path returns HTTP 200 with the directory listing.
## WOPI Integration
Copyparty v1.20.9+ supports WOPI (Web Application Open Platform Interface) for editing Office documents in the web UI via Collabora Online. Enable with the `--wopi-accs` flag to limit WOPI access to specific paths.
## Security Advisories
| Version | CVE | Issue |
|---------|-----|-------|
| v1.20.19 | GHSA-phv8-wgjp-g4p9 | FTP upload outside volume |
| v1.20.17 | GHSA-x5pq-m9p8-f4vx | dirkey/filekey confusion |
| v1.20.9 | CVE-2026-27948 / GHSA-62cr-6wp5-q43h | XSS via file upload |
Upgrade to v1.20.19+ to cover all known CVEs. The dirkey fix (v1.20.17) matters if using both `dirkeys` and `filekeys`.
## Gotchas
- **Large transfers:** Disable TLS inside the app and let Caddy terminate TLS. This avoids double encryption overhead.
- **Dedicated share directory:** Do not give Copyparty the whole disk. Mount only the intended share path.
- **Upload tokens scoped per folder:** Prevents accidental global write access.
- **CSP nonces (v1.20.17+):** Security hardening for JavaScript execution. May break third-party JS plugins (`--js-browser` / `--html-head`).
- **Custom thumbnail extractors (v1.20.19+):** New `thumbex` framework. Requires configuration in `initcfg`.
## See Also
- [[Dankserver Stack]]
- [[Caddy]] — TLS termination and authentication in front
- [[ARR-Stack]] — Copyparty as a viewer alongside automated download management
-55
View File
@@ -1,55 +0,0 @@
# Dankserver Stack
Dankserver (192.168.0.235) runs the media and library stack. Every service is reachable by hostname through the Caddy reverse proxy on the host.
```mermaid
graph LR
U[User] --> C[Caddy :80/:443]
C --> R[RomM]
C --> P[Copyparty]
C --> W[Calibre-Web]
C --> J[Jellyfin]
C --> S[Sonarr]
C --> D[Radarr]
C --> L[Lidarr]
C --> A[Readarr]
C --> O[Prowlarr]
W --> L2[Calibre library]
```
## Services
| Service | Hostname | Port | Docs |
|---------|----------|------|------|
| RomM | romm.dankserver.local | 8085 | [[RomM]] |
| Copyparty | files.dankserver.local | 3923 | [[Copyparty]] |
| Calibre-Web | books.dankserver.local | 8083 | [[Calibre-Web]] |
| Jellyfin | jellyfin.dankserver.local | 8096 | [[Jellyfin]] |
| Sonarr | sonarr.dankserver.local | 8989 | [[ARR-Stack]] |
| Radarr | radarr.dankserver.local | 7878 | [[ARR-Stack]] |
| Lidarr | lidarr.dankserver.local | 8686 | [[ARR-Stack]] |
| Readarr | readarr.dankserver.local | 8787 | [[ARR-Stack]] |
| Prowlarr | prowlarr.dankserver.local | 9696 | [[ARR-Stack]] |
| qBittorrent | qbittorrent.dankserver.local | 8080 | [[ARR-Stack]] |
## Optional Services
These services are included in the compose file but may be disabled.
| Service | Hostname | Port | Docs |
|---------|----------|------|------|
| Jackett | jackett.dankserver.local | 9117 | [[ARR-Stack]] |
| Whisparr | whisparr.dankserver.local | 6969 | [[ARR-Stack]] |
## Removed Services
The following services ran on Dankserver and are now retired from this stack:
- OpenWebUI — moved to Dankclaw (see [[OpenWebUI]])
- llama-swap — retired; no replacement
- SearXNG — moved to Dankclaw (see [[SearXNG]])
## See Also
- [[index]] — repository index
- [[Network Architecture]] — routing and proxy
-173
View File
@@ -1,173 +0,0 @@
# Jellyfin
Jellyfin is a free, open-source media server and suite of applications for organizing, managing, and streaming personal media collections (movies, TV shows, music, photos) to networked devices. It is a community fork of Emby, written in C# (.NET), and uses FFmpeg for transcoding.
**Host:** Dankserver — **Hostname:** `jellyfin.dankserver.local` — **Docs:** https://jellyfin.org
## Facts
| Item | Value |
|------|-------|
| Latest version | Check https://jellyfin.org/downloads |
| Image | `jellyfin/jellyfin:latest` (Docker Hub) or `ghcr.io/jellyfin/jellyfin` |
| Base | Debian-based |
| Primary port | 8096 (HTTP, web UI + API) |
| HTTPS port | 8920 (optional) |
| Client discovery | 7359/udp |
| DLNA | 1900/udp (optional) |
| GPU passthrough | `/dev/dri` (Intel QSV / AMD VA-API) or NVIDIA NVENC |
| License | MIT |
## Architecture
```mermaid
graph LR
U[User] --> C[Caddy :80/:443]
C --> J[Jellyfin :8096]
J --> Lib[/media/movies]
J --> Lib2[/media/shows]
J --> Lib3[/media/music]
J --> Conf[Jellyfin config]
J --> Cache[Jellyfin cache]
```
## Docker Compose
```yaml
services:
jellyfin:
image: jellyfin/jellyfin:latest
container_name: jellyfin
restart: unless-stopped
user: "1000:1000"
ports:
- "8096:8096/tcp"
- "8920:8920/tcp"
- "7359:7359/udp"
- "1900:1900/udp"
environment:
- JELLYFIN_PublishedServerUrl=http://192.168.0.235:8096
- DOTNET_SYSTEM_IO_DISABLEFILELOCKING=true
volumes:
- /opt/jellyfin/config:/config
- /opt/jellyfin/cache:/cache
- /mnt/media/movies:/data/movies:ro
- /mnt/media/shows:/data/shows:ro
devices:
- /dev/dri:/dev/dri
group_add:
- "992" # Host render group GID — verify with `getent group render`
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8096/health"]
interval: 30s
timeout: 10s
retries: 3
```
## Caddy Configuration
```caddy
jellyfin.dankserver.local {
reverse_proxy 127.0.0.1:8096
transport http {
versions h2c 1.1
}
}
```
Set **Base URL** to `/` (or the path you configure) in Jellyfin Dashboard → Networking. Add `127.0.0.1` to **Known Proxies**.
## Storage
| Mount | Purpose | Size Estimate |
|-------|---------|---------------|
| `/opt/jellyfin/config` | SQLite database, settings, cached images | 10–100 GB |
| `/opt/jellyfin/cache` | Transcoding segments, extracted posters | Self-cleaning per session |
The database must be on local storage (SSD/NVMe preferred). Media libraries can be on NFS/mergerfs. If media is on NFS, set `DOTNET_SYSTEM_IO_DISABLEFILELOCKING=true`.
## Authentication
- **Built-in auth (default):** Per-user libraries, parental controls, max bitrate limits. Manage via Dashboard → Users.
- **Caddy basic auth:** Single login layer. Does not integrate with per-user Jellyfin permissions.
- **LDAP/OIDC:** Available via plugins (LDAP-Auth, Jellyfin SSO). Integrates with Authentik, Keycloak, etc.
For Dankserver, built-in auth is recommended. Use Caddy basic auth on top for external access.
## Metadata Scraping
| Provider | Content | Type |
|----------|---------|------|
| TheMovieDb (TMDb) | Movies, TV shows, posters, fan art, overviews | Default |
| OMDb API | Movies only, English metadata | Default |
| TheTVDB | TV shows, episodes | Plugin |
| fanart.tv | Extra artwork, banners, thumbnails | Plugin |
| AniDB | Anime metadata | Plugin |
| MusicBrainz | Music metadata | Plugin |
| Open Subtitles | Subtitle downloads | Plugin |
| Intro Skipper | TV show intro detection | Plugin |
## Clients
| Platform | App | Quality |
|----------|-----|---------|
| Web | Jellyfin Web | Excellent |
| Android | Jellyfin (Google Play / F-Droid) | Excellent |
| Android TV / Fire TV | Jellyfin | Good |
| iOS / iPadOS | Swiftfin (third-party) | Good |
| Desktop | Jellyfin Media Player (mpv-based) | Excellent |
| Roku | Jellyfin (Channel Store) | Good |
| Kodi | Jellyfin addon | Excellent |
| Samsung/LG Smart TVs | TV browser | Fair |
## Jellyfin vs Plex
| Feature | Jellyfin | Plex |
|---------|----------|------|
| Cost | Free, no tiers | Free core; Plex Pass ($5/mo or $120 lifetime) |
| License | MIT | Proprietary |
| Account required | No | Yes |
| Hardware transcoding | Free | Plex Pass required |
| Mobile sync | Free | Plex Pass |
| Live TV / DVR | Free | Plex Pass |
| Client apps | Good, improving | Excellent, widest device support |
Run Jellyfin alongside Plex for an incremental migration. Point Jellyfin at the same media folders as Plex. Watch history and metadata will not transfer cleanly.
## Resource Requirements
- **Idle / Direct Play:** ~200–500 MB RAM, <5% CPU. Negligible.
- **1080p transcode (CPU):** 40–60% CPU on a modern multi-core CPU.
- **1080p transcode (with QSV):** 3–5% CPU.
- **4K HDR transcode (with QSV):** 5–15% CPU.
- **Minimum host RAM:** 4 GB. 8 GB comfortable for transcoding.
## Deployment Checklist
1. Verify GPU on host: `ls -l /dev/dri/`, `lspci | grep -i vga`, `getent group render`.
2. Install media drivers: `sudo apt install intel-media-va-driver-non-free vainfo intel-gpu-tools`.
3. Create directories: `/opt/jellyfin/config`, `/opt/jellyfin/cache`.
4. Write compose file, verify device and GID.
5. Deploy: `podman compose up -d`.
6. Test GPU passthrough: `podman exec jellyfin /usr/lib/jellyfin-ffmpeg/vainfo --display drm --device /dev/dri/renderD128`.
7. Configure Caddy reverse proxy. Set Known Proxies in Jellyfin.
8. Add media libraries in Dashboard.
9. Install plugins: Open Subtitles, fanart.tv, Intro Skipper.
10. Test playback on Android/Chrome.
## Gotchas
- **GPU passthrough silently failing:** `/dev/dri` must be passed and the correct render node used. Verify with `vainfo` inside the container.
- **Wrong render group GID:** Common GID is 992, but varies per host. Run `getent group render` to find it.
- **Subtitle burn-in triggers full transcode:** PGS (image-based) subtitles force full video transcoding. SRT/ASS (text-based) play fine.
- **Database on NFS:** SQLite on network storage causes slow startup and corruption risk. Keep `/config` on local storage.
- **Client bitrate limit too low:** Many clients default to 20 Mbps. Set Max Streaming Bitrate to Original or Maximum.
- **Naming convention mismatches:** Non-standard folder/file names cause missing metadata. Use `Movie Title (Year)` for movies and `Show Name/Season 01/Show Name S01E01.mkv` for TV.
## See Also
- [[Dankserver Stack]]
- [[ARR-Stack]] — automated download into Jellyfin libraries
- [[Copyparty]] — alternative file viewer for existing collections
- Plex — existing media server on this host
-53
View File
@@ -1,53 +0,0 @@
# Lidarr
Lidarr manages music albums in the *arr stack on Dankserver. It automatically searches, downloads, and organizes albums from indexers into the Jellyfin music library.
**Host:** Dankserver — **Hostname:** `lidarr.dankserver.local` — **Docs:** https://wiki.servarr.com/lidarr
## Facts
| Item | Value |
|------|-------|
| Image | `lscr.io/linuxserver/lidarr:latest` |
| Port | 8686 (host) → 8686 (container) |
| Min RAM | 256 MB |
| Metadata | MusicBrainz |
## Docker Compose
```yaml
lidarr:
image: lscr.io/linuxserver/lidarr:latest
container_name: lidarr
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
- TZ=America/New_York
- UMASK_SET=022
volumes:
- /opt/lidarr/config:/config
- /mnt/media/music:/music
- /mnt/media/downloads:/downloads
ports:
- "8686:8686"
networks: [appnet]
```
## Configuration
1. Add Prowlarr as the indexer source (prefer torrent-based indexers for FLAC).
2. Add qBittorrent as the download client with the `music` category.
3. Set quality profile (Lossless → High Bitrate → Standard).
4. Set root folder to `/music`.
5. Add artists and verify downloads.
## Gotchas
- Album variants: Lidarr supports multiple releases of the same album (vinyl vs CD). Use "Preferred" and "Cutoff" quality profiles.
- Music videos can be optionally downloaded separately from albums.
## See Also
- [[ARR-Stack]] — full *arr stack documentation
- [[Jellyfin]] — media server that consumes Lidarr downloads
-47
View File
@@ -1,47 +0,0 @@
# MariaDB (RomM Backend)
MariaDB 11 provides the database backend for RomM on Dankserver. RomM 5.2.0 requires MariaDB (or PostgreSQL) for its data store.
**Host:** Dankserver — **Docs:** https://mariadb.com/kb/en/docker-mariadb-information/
## Facts
| Item | Value |
|------|-------|
| Image | `docker.io/mariadb:11` |
| Port | 3306 (internal, within `appnet`) |
| Database name | `romm` |
| Data volume | `/opt/romm/mariadb` |
## Docker Compose
```yaml
mariadb:
image: docker.io/mariadb:11
container_name: romm-mariadb
restart: unless-stopped
environment:
MARIADB_ROOT_PASSWORD: "${MARIADB_ROOT_PASSWORD}"
MARIADB_DATABASE: romm
MARIADB_USER: "${ROMM_DB_USER}"
MARIADB_PASSWORD: "${ROMM_DB_PASSWD}"
volumes:
- /opt/romm/mariadb:/var/lib/mysql
healthcheck:
test: ["CMD", "mariadb-admin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 3
```
## Backup
Back up the volume directory periodically:
```bash
sudo rsync -a /opt/romm/mariadb /mnt/backup/romm-mariadb/
```
## See Also
- [[RomM]] — service that uses this database
-55
View File
@@ -1,55 +0,0 @@
# Prowlarr
Prowlarr is the indexer management layer for the *arr stack. It aggregates indexers (torrent and Usenet) and exposes a unified API that Sonarr, Radarr, Lidarr, and Readarr consume.
**Host:** Dankserver — **Hostname:** `prowlarr.dankserver.local` — **Docs:** https://wiki.servarr.com/prowlarr
## Facts
| Item | Value |
|------|-------|
| Image | `lscr.io/linuxserver/prowlarr:latest` |
| Port | 9696 (host) → 9696 (container) |
| Min RAM | 256 MB |
| Auth | API key (Settings → General) |
## Configuration
1. Add indexers: Settings → Indexers → Add.
2. Test each indexer with the **Test** button.
3. Assign tags (e.g., `movies`, `tv`, `music`) to indexers for routing.
4. Add download client: Settings → Download Clients → Add qBittorrent.
## Docker Compose
```yaml
prowlarr:
image: lscr.io/linuxserver/prowlarr:latest
container_name: prowlarr
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
- TZ=America/New_York
- UMASK_SET=022
volumes:
- /opt/prowlarr/config:/config
- /mnt/media/downloads:/downloads:ro
ports:
- "9696:9696"
networks: [appnet]
```
## Gotchas
- Prowlarr database grows with indexer data. Set up periodic vacuum/backup.
- Indexer sync has a 15–60 minute delay. Search results may lag.
- Rotate API key: update it in every arr app.
## See Also
- [[ARR-Stack]] — full *arr stack documentation
- [[radarr]]
- [[sonarr]]
- [[lidarr]]
- [[readarr]]
-52
View File
@@ -1,52 +0,0 @@
# qBittorrent
qBittorrent is the torrent download client for the *arr stack on Dankserver. It receives download requests from Sonarr, Radarr, Lidarr, and Readarr via category tagging.
**Host:** Dankserver — **Hostname:** `qbittorrent.dankserver.local` — **Docs:** https://www.qbittorrent.org
## Facts
| Item | Value |
|------|-------|
| Image | `lscr.io/linuxserver/qbittorrent:latest` |
| Port | 8080 (host) → 8080 (container) |
| Min RAM | 512 MB |
| Auth | Web UI username/password; LAN mode available |
## Configuration
1. Change the default Web UI password (Settings → Web UI).
2. Enable "No auth mode for LAN" for arr app connectivity (Settings → Web UI → uncheck "HTTP authentication required for clients").
3. Create download categories: `movies`, `tv`, `music`, `books`.
4. Set max active downloads: Settings → Downloads (recommended: 5 global, 3 per category).
## Docker Compose
```yaml
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:latest
container_name: qbittorrent
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
- TZ=America/New_York
- UMASK_SET=022
volumes:
- /opt/qbittorrent/config:/config
- /mnt/media/downloads:/downloads
- /mnt/media/downloads/incomplete:/downloads/incomplete
ports:
- "8080:8080"
networks: [appnet]
```
## Gotchas
- qBittorrent Web UI password changes on first run (check `config/qBittorrent.conf`).
- Ensure umask is set to `022` so downloaded files are readable by arr apps.
- Monitor bandwidth limits to avoid saturating the network.
## See Also
- [[ARR-Stack]] — full *arr stack documentation
-54
View File
@@ -1,54 +0,0 @@
# Radarr
Radarr manages movies in the *arr stack on Dankserver. It automatically searches, downloads, and organizes movies from indexers into the Jellyfin media library.
**Host:** Dankserver — **Hostname:** `radarr.dankserver.local` — **Docs:** https://wiki.servarr.com/radarr
## Facts
| Item | Value |
|------|-------|
| Image | `lscr.io/linuxserver/radarr:latest` (v6 stable) |
| Port | 7878 (host) → 7878 (container) |
| Min RAM | 512 MB |
| Metadata | TMDB / TheMovieDB API |
## Docker Compose
```yaml
radarr:
image: lscr.io/linuxserver/radarr:latest
container_name: radarr
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
- TZ=America/New_York
- UMASK_SET=022
volumes:
- /opt/radarr/config:/config
- /mnt/media/movies:/movies
- /mnt/media/downloads:/downloads
ports:
- "7878:7878"
networks: [appnet]
```
## Configuration
1. Add Prowlarr as the indexer source.
2. Add qBittorrent as the download client with the `movies` category.
3. Set quality profile and cutoff quality.
4. Set root folder to `/movies`.
5. Add movies and verify downloads.
## Gotchas
- Radarr v6 is the current stable (v6.3.x as of 2026-08). `:latest` points to the current release.
- Movie renaming produces `Title (Year)` format. Jellyfin scanner handles this.
- File permissions: qBittorrent downloads as PUID; arr apps must read/move files.
## See Also
- [[ARR-Stack]] — full *arr stack documentation
- [[Jellyfin]] — media server that consumes Radarr downloads
-57
View File
@@ -1,57 +0,0 @@
# Readarr
> **RETIRED (May 2026).** The Servarr Team retired Readarr: its metadata became unusable and the community migration to Open Library stalled. There is no upstream development. Alternatives: the Bookshelf community fork, or the Calibre content server. See [Readarr (Retired)](https://wiki.servarr.com/readarr).
Readarr manages ebooks and audiobooks in the *arr stack on Dankserver. It automatically searches, downloads, and organizes books into the Calibre library.
**Host:** Dankserver — **Hostname:** `readarr.dankserver.local` — **Docs:** https://wiki.servarr.com/readarr
## Facts
| Item | Value |
|------|-------|
| Image | `lscr.io/linuxserver/readarr:latest` (v0.2.x development) |
| Port | 8787 (host) → 8787 (container) |
| Min RAM | 256 MB |
| Metadata | Goodreads / StoryGraph |
## Docker Compose
```yaml
readarr:
image: lscr.io/linuxserver/readarr:latest
container_name: readarr
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
- TZ=America/New_York
- UMASK_SET=022
volumes:
- /opt/readarr/config:/config
- /mnt/media/books:/books
- /mnt/media/downloads:/downloads
ports:
- "8787:8787"
networks: [appnet]
```
## Configuration
1. Add Prowlarr as the indexer source (torrent indexers with book content).
2. Add qBittorrent as the download client with the `books` category.
3. Set author profile (format preference: epub, mobi, etc.).
4. Set quality profile for audiobooks (320 kbps MP3, 128 kbps, lossless).
5. Set root folder to `/books`.
6. Add authors and verify downloads.
## Gotchas
- Readarr is in development (~v0.2.x). Some features may be incomplete.
- Calibre integration is folder-based, not API-based. Readarr drops files into `/books` which Calibre-Web serves.
- Not all indexers have all formats (epub, mobi, pdf). Goodreads/StoryGraph metadata helps match editions.
## See Also
- [[ARR-Stack]] — full *arr stack documentation
- [[Calibre-Web]] — ebook web frontend that serves Readarr downloads
-40
View File
@@ -1,40 +0,0 @@
# Redis (RomM Cache / Queue)
Redis 7 provides the caching and task queue backend for RomM on Dankserver. RomM 5.2.0 requires Redis (or Valkey) for its task queue.
**Host:** Dankserver — **Docs:** https://redis.io/docs/
## Facts
| Item | Value |
|------|-------|
| Image | `docker.io/redis:7-alpine` |
| Port | 6379 (internal, within `appnet`) |
| Data volume | `/opt/romm/redis` |
| Auth | None (local-only, no password needed) |
## Docker Compose
```yaml
redis:
image: docker.io/redis:7-alpine
container_name: romm-redis
restart: unless-stopped
volumes:
- /opt/romm/redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
```
## Backup
```bash
sudo rsync -a /opt/romm/redis /mnt/backup/romm-redis/
```
## See Also
- [[RomM]] — service that uses this cache
-55
View File
@@ -1,55 +0,0 @@
# Sonarr
Sonarr manages TV shows in the *arr stack on Dankserver. It automatically searches, downloads, and organizes episodes from indexers into the Jellyfin media library.
**Host:** Dankserver — **Hostname:** `sonarr.dankserver.local` — **Docs:** https://wiki.servarr.com/sonarr
## Facts
| Item | Value |
|------|-------|
| Image | `lscr.io/linuxserver/sonarr:latest` (v4 stable) |
| Port | 8989 (host) → 8989 (container) |
| Min RAM | 512 MB |
| Metadata | TMDB / TVDB |
## Docker Compose
```yaml
sonarr:
image: lscr.io/linuxserver/sonarr:latest
container_name: sonarr
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
- TZ=America/New_York
- UMASK_SET=022
volumes:
- /opt/sonarr/config:/config
- /mnt/media/tv:/tv
- /mnt/media/downloads:/downloads
ports:
- "8989:8989"
networks: [appnet]
```
## Configuration
1. Add Prowlarr as the indexer source.
2. Add qBittorrent as the download client with the `tv` category.
3. Set quality profile, cutoff, and minimum/maximum episode sizes.
4. Set root folder to `/tv`.
5. Add series and verify downloads.
## Gotchas
- Sonarr v4 is the current stable. The v3 branch uses the `:v3` tag.
- Anime has different quality profiles and naming conventions than Standard series.
- Missing episodes: set "Unmonitor Missing Episodes" to auto-unmonitor old episodes.
- Timezone-sensitive scheduler: ensure all containers use the same TZ.
## See Also
- [[ARR-Stack]] — full *arr stack documentation
- [[Jellyfin]] — media server that consumes Sonarr downloads
-47
View File
@@ -1,47 +0,0 @@
# Alloy
Alloy is Grafana's log and telemetry shipper. It replaces Promtail (EOL 2026-03-02). One Alloy instance runs per host and forwards container and journal logs to Loki, and optionally metrics to Prometheus.
**Hosts:** Dankclaw + Dankserver (one per host) — **Docs:** https://grafana.com/docs/alloy/latest
## Facts (from research)
| Item | Value |
|------|-------|
| Image | `franksdank/alloy:latest` (local build with a static busybox healthcheck) |
| Port | none published (push-only) |
| Config | River syntax, `config.alloy` |
| Successor to | Promtail (EOL 2026-03-02) |
| Inputs | journald socket, container log files |
| Output | Loki push endpoint |
## Why Alloy and not Promtail
Promtail reached end-of-life on 2026-03-02. Grafana Labs directs new installs to Alloy. This stack uses Alloy from day one.
## Configuration
River config at `shared/alloy/config.alloy`:
- Read journald logs from the host's `/run/systemd/journal/socket`.
- Read container logs from `/var/lib/containers/...` (podman) or the docker path.
- Forward to `http://loki:3100/loki/api/v1/push` with `host` and `job` labels.
## Volumes
| Container path | Host path (convention) |
|----------------|------------------------|
| `/etc/alloy/config.alloy` | `shared/alloy/config.alloy` (bind mount) |
| `/run/systemd/journal/socket` | host journald socket (read-only) |
| `/var/log` | host logs (read-only) |
## Gotchas
- The journald socket mount is required for journal scraping.
- Container log paths differ between docker (`/var/lib/docker/containers`) and podman (`/var/lib/containers/storage/...`); point the config at the right one per host.
- One Alloy per host; do not run a single instance that tries to reach both hosts' logs.
## See Also
- [[Loki]] — the log backend
- [[Observability]] — the full stack
-152
View File
@@ -1,152 +0,0 @@
# Authentik — the homelab's SSO / OIDC provider
Authentik is the single sign-on (SSO) and OpenID Connect (OIDC) provider for the
homelab. It fronts the Caddy vhosts that do not carry their own
application-level authentication. Apps that have their own login (Grafana,
Gitea, OpenWebUI, Jellyfin, the *arr stack, Pi-hole) are not wrapped in
forward_auth — they handle their own session.
- **Version:** `2026.8.0` (pinned in `dankclaw/compose.yaml`)
- **Host:** dankclaw (`192.168.0.143`)
- **Name:** `auth.franksdank.cloud`
- **Stack:** 3 compose services — `authentik-postgres`, `authentik-server`,
`authentik-worker` (see [Pi-hole](Pi-hole.md) for the LAN-DNS pattern)
## Why Authentik
The comparison that chose it (against Authelia, Keycloak, Zitadel, Ory) is in
`.docaudit/research/auth.md`. In short: full OIDC with single-logout, a web GUI
for all configuration, a native Caddy `forward_auth` outpost, and a footprint
that fits on a 3-user homelab (~1–1.5 GB idle). Redis was removed in 2025.10,
so the stack is 3 services, not 4.
## Architecture
```mermaid
graph LR
subgraph Browser
U[User]
end
subgraph Caddy [Caddy - auth gate]
FA[forward_auth]
end
subgraph AK [Authentik]
SRV[server :9000]
WKR[worker]
PG[(postgres)]
OUT[outpost /auth/caddy]
end
subgraph App [Target app]
A1[SearXNG / Hermes / Prometheus<br/>RomM / Copyparty / homepage]
end
U -->|1. GET /| FA
FA -->|2. GET /outpost.../auth/caddy| OUT
OUT -->|3. no session: 302 login| U
U -->|4. login at auth.franksdank.cloud| SRV
SRV --> PG
WKR --> PG
SRV -->|5. 2xx: session valid| FA
FA -->|6. proxy with headers| A1
```
1. The browser hits a protected vhost (e.g. `search.franksdank.cloud`).
2. Caddy `forward_auth` clones the request to the Authentik outpost
(`/outpost.goauthentik.io/auth/caddy`).
3. With no valid session, the outpost returns a `302` to the Authentik login
page (`auth.franksdank.cloud`).
4. The user logs in at Authentik (email + password, TOTP/WebAuthn MFA, or an
upstream provider).
5. On the next request the outpost returns `2xx`; Caddy copies the
`X-Authentik-*` headers and proxies to the app.
## The three compose services
| Service | Role | Image |
|---|---|---|
| `authentik-postgres` | PostgreSQL 16 (users, flows, sessions, events) | `docker.io/library/postgres:16-alpine` |
| `authentik-server` | Core + admin API + the embedded Caddy outpost (port 9000) | `ghcr.io/goauthentik/server:2026.8.0` |
| `authentik-worker` | Background tasks (event processing, provider sync, flows) | `ghcr.io/goauthentik/server:2026.8.0` |
- **Postgres** is the only stateful store. Sessions, caching, and WebSockets
moved to PostgreSQL in 2025.10 (Redis removed).
- **Server** and **worker** run the same image; the `command` selects the role
(`server` / `worker`).
- The worker runs as `0:0` (rootless: container root maps to the host user that
owns the data dir).
- Data persists under `/home/dank/authentik/` (bind mounts, repo policy — no
named volumes).
## Caddy integration
The Caddyfile wraps the no-auth vhosts in a `route` block. The pattern (one
example; the others are identical):
```caddyfile
search.franksdank.cloud {
route {
# 1. The outpost path is proxied to Authentik (login UI + session).
reverse_proxy /outpost.goauthentik.io/* authentik-server:9000
# 2. forward_auth checks the session before serving the app.
forward_auth authentik-server:9000 {
uri /outpost.goauthentik.io/auth/caddy
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name
trusted_proxies private_ranges
}
# 3. The app.
reverse_proxy 192.168.0.143:8081
}
}
```
Vhosts protected by forward_auth: the homepage (`franksdank.cloud`,
`www.franksdank.cloud`), SearXNG, Hermes, Prometheus, RomM, Copyparty. The
`auth.franksdank.cloud` vhost proxies to the server directly (no forward_auth —
it *is* the login).
## Configuration (secrets)
Two secrets live in the host `.env` (git-ignored; placeholders in
`.env.example`):
- `AUTHENTIK_PG_PASS` — the PostgreSQL password. Generate: `openssl rand -base64 32`.
- `AUTHENTIK_SECRET_KEY` — the server's signing key. Generate: `openssl rand -base64 48`.
## First-time setup
1. `./scripts/deploy.sh dankclaw` — pulls, starts the 3 services (postgres
health-gated), reloads Caddy.
2. Open `https://auth.franksdank.cloud` — the initial setup wizard runs. It
creates the superuser (email + password) and generates the DB schema.
3. In the admin interface: **Directory → Applications** → add a **Proxy**
application (or an **OAuth2/OpenID Connect** provider) for each vhost you
protect. The Caddy outpost reads the application's policy.
4. For apps with native OIDC (Grafana, Gitea, OpenWebUI): add an **OAuth2/OpenID
Connect** provider, create the app with the correct redirect URI, and paste
the `client_id` / `client_secret` into the app's SSO settings.
## Operations
- **Health:** `https://auth.franksdank.cloud/-/health/` (the server healthcheck
probes this).
- **Logs:** `podman logs authentik-server` / `podman logs authentik-worker`.
- **Backups:** `pg_dump` the `authentik-postgres` database (the single source of
truth). The `/home/dank/authentik/` dir holds media + static files.
- **Upgrades:** bump the image tag in `dankclaw/compose.yaml`, run
`./scripts/deploy.sh dankclaw`. Authentik migrates the schema on startup.
## Failure modes
| Symptom | Cause | Fix |
|---|---|---|
| Every vhost 502s at the outpost | `authentik-server` down | `podman logs authentik-server`; check postgres health |
| Login loops (302 → login → 302) | `trusted_proxies` / `X-Forwarded-*` mismatch | Confirm Caddy sets `X-Forwarded-For`; the outpost trusts `private_ranges` |
| "Invalid signature" on the outpost | `AUTHENTIK_SECRET_KEY` differs between server and worker | Both services read the same `.env` var; re-deploy |
| Postgres "password authentication failed" | `AUTHENTIK_PG_PASS` changed after first init | Reset via `pg_ctl` or re-init the data dir |
## Cross-references
- [Caddy](Caddy.md) — the reverse proxy that carries the forward_auth directives
- [Pi-hole](Pi-hole.md) — the LAN DNS that resolves `auth.franksdank.cloud`
- [Network Architecture](../Architecture/Network%20Architecture.md) — the flat naming scheme
- Research: `.docaudit/research/auth.md` — the full provider comparison
-46
View File
@@ -1,46 +0,0 @@
# Grafana
Grafana visualizes the metrics that Prometheus collects from every service. It runs on Dankclaw and is reachable at `grafana.franksdank.cloud` (via Caddy, with basicauth).
**Host:** Dankclaw — **Hostname:** `grafana.franksdank.cloud` — **Port:** 3002 (internal) — **Docs:** https://grafana.com/docs
## Facts
| Item | Value |
|------|-------|
| Image | `docker.io/grafana/grafana:latest` |
| Port | 3002 (host) → 3000 (container) |
| Data volume | dashboards, users, datasource config |
| Datasource | Prometheus (on the same host) |
| Provisioning | dashboards in `/etc/grafana/provisioning/dashboards` |
## Configuration
```yaml
environment:
GF_SECURITY_ADMIN_USER: "${GRAFANA_ADMIN_USER}"
GF_SECURITY_ADMIN_PASSWORD: "${GRAFANA_ADMIN_PASSWORD}"
GF_SERVER_ROOT_URL: "https://grafana.franksdank.cloud"
volumes:
- /opt/grafana/data:/var/lib/grafana
- /opt/grafana/provisioning:/etc/grafana/provisioning:ro
```
## Datasource
Point Grafana at the Prometheus service by name (`http://prometheus:9090`) on the shared podman network.
## Healthcheck
Grafana responds on `/api/health` with 200 and `"database":"ok"`.
## Gotchas
- Change the default admin credentials at first boot (env or UI).
- Dashboards provisioned as YAML survive container recreation; UI-created ones live in the data volume — back it up.
## See Also
- [[Dankclaw Stack]]
- [[Prometheus]] — the datasource
- [[Observability]] — the whole stack

Some files were not shown because too many files have changed in this diff Show More