Compare commits

..

88 Commits

Author SHA1 Message Date
Fizzlepoof
482a490d3d meshtastic: map usb meshcore companion into meshmonitor
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-07-03 03:10:07 +00:00
Fizzlepoof
1eb4bb9981 meshtastic: expose embedded mqtt broker
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-07-03 02:41:58 +00:00
Fizzlepoof
43b94813ca Rebalance homelab AI routing around NOMAD-first inference 2026-06-13 23:54:56 +00:00
Fizzlepoof
e19d4a5be3 Update meshtastic docs and remove stale mqtt-proxy service
Some checks failed
secret-guardrails / gitleaks (push) Has been cancelled
secret-guardrails / artifact-secret-scan (push) Has been cancelled
2026-06-13 18:57:37 +00:00
Fizzlepoof
28b2edf2a3 Stabilize Firecrawl startup on PD
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-06-07 04:14:23 +00:00
Fizzlepoof
7a67a2febc fix(schoolhouse): handle Paperless upload failures gracefully
- catch Paperless handoff exceptions during assignment intake
- preserve local submission records when archive handoff fails
- redirect HTML uploads back to the form with readable status details
- show archive-pending messaging instead of a raw 500 page
2026-06-06 02:37:20 +00:00
Fizzlepoof
db8dce8c8b docs: record PD qdrant/seerr drift cleanup incident
Some checks failed
secret-guardrails / gitleaks (push) Has been cancelled
secret-guardrails / artifact-secret-scan (push) Has been cancelled
2026-05-29 00:59:39 +00:00
Fizzlepoof
1227814277 Add Doris Barbell live workout logger
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-28 21:32:09 +00:00
Fizzlepoof
ba958f7ef6 Fix Doris Barbell band logging and nav links
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-28 21:06:46 +00:00
Fizzlepoof
dd4134b1ce Default routine log reps from targets
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-28 20:55:22 +00:00
Fizzlepoof
64e7ac82cf Simplify Doris Barbell dashboard
Some checks failed
secret-guardrails / gitleaks (push) Has been cancelled
secret-guardrails / artifact-secret-scan (push) Has been cancelled
2026-05-28 20:49:23 +00:00
Fizzlepoof
00100c5cba Correct Doris Barbell 7-7-7 and punch-out routines
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-28 18:33:05 +00:00
Fizzlepoof
a6b268def5 Fix Doris Barbell routine log defaults
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-28 18:23:31 +00:00
Fizzlepoof
3960e168bc Add dropdown exercise selection to Doris Barbell logs
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-28 18:09:51 +00:00
Fizzlepoof
6ba3fe208e Prefill Doris Barbell routine log timestamps
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-28 18:05:54 +00:00
Fizzlepoof
8a61169ab9 Add structured routine logging sheets to Doris Barbell
Some checks failed
secret-guardrails / gitleaks (push) Has been cancelled
secret-guardrails / artifact-secret-scan (push) Has been cancelled
2026-05-28 17:51:14 +00:00
Fizzlepoof
24c6a29273 docs: record full Pi-hole retirement
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-27 21:14:15 +00:00
Fizzlepoof
2ab4ff4428 docs: record PD Pi-hole retirement state
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-27 20:22:07 +00:00
Fizzlepoof
dea83b64af docs: clarify technitium sync and legacy nebula state
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-27 19:36:48 +00:00
Fizzlepoof
b98827df40 Update services directory after homelab sweep
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-27 19:29:05 +00:00
Fizzlepoof
57911b36f5 docs: update Rocinante VLAN recovery notes
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-27 18:52:33 +00:00
Fizzlepoof
77da65e64a docs: clarify current unifi protect chime state
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-26 20:10:45 +00:00
Fizzlepoof
a282fe43bd docs: close out serenity migration cleanup lane
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-26 16:15:06 +00:00
Fizzlepoof
1dddcbd37d Update Serenity migration plan with live execution state
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-26 15:48:34 +00:00
Fizzlepoof
186e6b1cc4 docs: capture Serenity majority migration plan
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-26 15:39:05 +00:00
Fizzlepoof
386911b70d Document LocalSend trusted inbox handoff
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-26 03:52:08 +00:00
Fizzlepoof
d8bdc36392 Refresh Serenity placement plan after live review
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-26 03:48:49 +00:00
Fizzlepoof
a17d432b0f Refresh Serenity audit after source app cleanup
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-26 02:52:39 +00:00
Fizzlepoof
c7eaf1ab8d Document Serenity source app container cleanup
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-26 02:49:29 +00:00
Fizzlepoof
0c33bedbae Add Trusted-LAN LocalSend receiver on NOMAD
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-26 02:44:18 +00:00
Fizzlepoof
0d7e133283 Document Serenity DB retirement after PD cutovers
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-26 02:37:32 +00:00
Fizzlepoof
59a83590c5 Add RomM cutover stack on PD
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-26 02:12:53 +00:00
Fizzlepoof
09232f7d70 Publish Headscale via Pangolin
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-26 01:50:51 +00:00
Fizzlepoof
9826548fd7 Add GameVault cutover stack on PD
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-26 01:38:46 +00:00
Fizzlepoof
e9b61ad3cd Fix Headscale pilot policy and healthchecks
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-26 01:18:19 +00:00
Fizzlepoof
12452bac64 Fix search stack update recovery notes
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-26 01:11:49 +00:00
Fizzlepoof
2a6cd8b1fd Add owner-facing Headplane pilot checklist
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-26 01:01:42 +00:00
Fizzlepoof
7e064397ec Add Headscale pilot stack for PD 2026-05-26 00:48:01 +00:00
Fizzlepoof
68bab888db media: stage dedicated RomM MariaDB target
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-26 00:38:56 +00:00
Fizzlepoof
f0764c4aa6 docs: record Serenity DB export validation results
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-25 23:51:35 +00:00
Fizzlepoof
ad8131951c docs: stage PD DB targets for Serenity migration
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-25 23:31:55 +00:00
Fizzlepoof
661f7b49b3 docs: require final quiesced Serenity DB export
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-25 23:26:55 +00:00
Fizzlepoof
de188e6047 docs: choose PD layout for Serenity GameVault and RomM
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-25 23:18:00 +00:00
Fizzlepoof
77dafc027f docs: expand Serenity DB migration kanban wave
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-25 23:05:38 +00:00
Fizzlepoof
b6bb040de6 docs: record Serenity DB dependency mapping
Some checks failed
secret-guardrails / gitleaks (push) Has been cancelled
secret-guardrails / artifact-secret-scan (push) Has been cancelled
2026-05-25 22:40:10 +00:00
Fizzlepoof
90027f9fa7 docs: record Serenity created container pruning
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-25 22:38:28 +00:00
Fizzlepoof
efb237efe7 docs: record Serenity DNS and cloudflared retirement
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-25 22:09:15 +00:00
Fizzlepoof
60de92339d docs: record Serenity cloudflared audit
Some checks failed
secret-guardrails / gitleaks (push) Has been cancelled
secret-guardrails / artifact-secret-scan (push) Has been cancelled
2026-05-25 22:00:23 +00:00
Fizzlepoof
eba18beabe docs: record Serenity wave1 guardrails
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-25 21:50:55 +00:00
Fizzlepoof
f2e751e1e6 docs: update github remote owner to fizzlepoof
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-25 21:33:32 +00:00
Fizzlepoof
ce7ed4d76c docs: note NFS remount restart requirement for media apps
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-25 21:26:55 +00:00
Fizzlepoof
ae7fce4b95 docs: record Serenity Pangolin healthcheck fix
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-25 20:48:28 +00:00
Fizzlepoof
733b661721 docs: clarify Serenity old-IP probe removal
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-25 20:37:10 +00:00
Fizzlepoof
54bf27b208 docs: record Serenity Pangolin drift workaround
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-25 20:33:09 +00:00
Fizzlepoof
cd98fcf49a docs: record Serenity Newt and wave 1 verification
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-25 19:37:36 +00:00
Fizzlepoof
493f3253b8 docs: add Serenity cleanup wave 1 plan
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-25 18:55:29 +00:00
Fizzlepoof
3811282fc3 docs: record Serenity cleanup decisions
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-25 18:29:04 +00:00
Fizzlepoof
05ad5cb27a docs: add Serenity docker audit baseline
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-25 18:13:39 +00:00
Fizzlepoof
a7b00e6742 docs: capture PD future-state architecture
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-25 18:02:25 +00:00
Fizzlepoof
dac033e263 backup: include gitea in PD postgres dumps
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-25 17:34:52 +00:00
Fizzlepoof
0878877c0d ci: move secret guardrails to self-hosted runner
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-25 15:51:08 +00:00
Fizzlepoof
2db6a23a2c home: add minerva dashboard scaffold
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-25 15:40:26 +00:00
Fizzlepoof
6659fd929f ops: record hermes dashboard trusted access slice
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-25 15:40:03 +00:00
Fizzlepoof
4c716fcd36 merge: hot fuzz graphics pass across doris family apps
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-25 15:39:39 +00:00
Fizzlepoof
13919cf4f7 Add Hot Fuzz graphics across Doris family sites
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-25 03:54:44 +00:00
Fizzlepoof
73105c7794 n8n: add calibre-web to nfs watchdog
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-25 02:41:07 +00:00
Fizzlepoof
a27ed384e7 Add Authentik SMTP configuration
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-24 19:16:48 +00:00
Fizzlepoof
2057c8aaa5 Cut Doris over to Authentik ingress
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-24 18:59:36 +00:00
Fizzlepoof
ffc1280f7c docs: update secrets backup coverage
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-24 03:20:07 +00:00
Fizzlepoof
5d88f6cf93 ops: sync dispatcharr tooling and pause noisy workflows
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-24 02:59:51 +00:00
Fizzlepoof
a4c5ee7285 doris-barbell: seed john split and routine board
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-24 02:59:14 +00:00
Fizzlepoof
9e0f66c2b6 docs: capture DNS resilience and Technitium backup sync
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-24 02:49:23 +00:00
Fizzlepoof
00d9c3ed5f donetick: allow https localhost origin for android
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-24 02:28:04 +00:00
Fizzlepoof
147889c72d Document DoneTick Pangolin and Authentik config
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-24 01:17:07 +00:00
Fizzlepoof
9bb48ee385 Add Technitium backup sync automation
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-24 00:15:58 +00:00
Fizzlepoof
db41861935 ingress: switch dns hostname to technitium native oidc
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-23 22:24:33 +00:00
Fizzlepoof
26d5407b86 technitium-pilot: document and wire native oidc sso
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-23 22:17:35 +00:00
Fizzlepoof
c1f6dce227 Expose Authentik on public Pangolin hostname
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-23 20:23:27 +00:00
Fizzlepoof
406efa102d Stage Grafana Authentik cutover
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-23 20:10:32 +00:00
Fizzlepoof
ab348932ff Add staged Authentik stack and Grafana OIDC wiring
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-23 19:37:28 +00:00
Fizzlepoof
5c6b3cab4d Add internal Traefik ingress for Pangolin and Authelia
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-23 10:59:20 +00:00
Fizzlepoof
889783e621 Add Dispatcharr IPTV stack
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-23 04:34:59 +00:00
Fizzlepoof
080ba83989 Harden UniFi restricted-lane gateway access
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-23 02:48:49 +00:00
Fizzlepoof
462b39e572 docs: record unifi firewall enforcement and closeout
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-23 02:08:15 +00:00
Fizzlepoof
bec21292de homelab: sync post-migration repo and n8n runtime audit
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-22 23:05:41 +00:00
Hermes Agent
d62a391cbf Layer secret leak guardrails
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-22 21:23:15 +00:00
Doris
0b528cbb5f Add infra artifact secret-scan guardrail 2026-05-22 21:13:16 +00:00
Doris
c2c71cfe57 Document UniFi baseline secret leak guardrails 2026-05-22 20:58:41 +00:00
203 changed files with 19802 additions and 1626 deletions

5
.githooks/pre-commit Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(git rev-parse --show-toplevel)"
exec "$repo_root/scripts/scan-secret-bearing-artifacts.sh" --staged

18
.githooks/pre-push Executable file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(git rev-parse --show-toplevel)"
scanner="$repo_root/scripts/scan-secret-bearing-artifacts.sh"
while read -r local_ref local_sha remote_ref remote_sha; do
[[ -n "$local_sha" ]] || continue
if [[ "$local_sha" =~ ^0+$ ]]; then
continue
fi
if [[ "$remote_sha" =~ ^0+$ ]]; then
"$scanner" --unpublished "$local_sha"
else
"$scanner" --git-range "$remote_sha..$local_sha"
fi
done

34
.github/workflows/secret-guardrails.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
name: secret-guardrails
on:
push:
pull_request:
workflow_dispatch:
jobs:
artifact-secret-scan:
runs-on: [self-hosted, linux, x64, nomad, truenas-stacks]
steps:
- name: Check out repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Make scanner executable
run: chmod +x scripts/scan-secret-bearing-artifacts.sh
- name: Scan all tracked infra artifacts for embedded secrets
run: scripts/scan-secret-bearing-artifacts.sh --tracked
gitleaks:
runs-on: [self-hosted, linux, x64, nomad, truenas-stacks]
steps:
- name: Check out repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run gitleaks
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

4
.gitignore vendored
View File

@@ -19,6 +19,10 @@ update-homelab-docs.py
# Generated stack config # Generated stack config
pihole/keepalived.conf pihole/keepalived.conf
# Raw secret-bearing infra exports must stay out of the main repo
home/doris-dashboard/docs/baselines/*.json
!home/doris-dashboard/docs/baselines/README.md
# Editor # Editor
.vscode/ .vscode/
.idea/ .idea/

View File

@@ -7,6 +7,7 @@ Phase 2 (AI stack) is fully deployed and verified on PlausibleDeniability (10.5.
## What's Done ## What's Done
### Phase 2 — AI Stack (COMPLETE) ### Phase 2 — AI Stack (COMPLETE)
Historical note: this handoff reflects the original PD-first AI deployment snapshot, not the current Honcho routing policy.
All 6 containers running and verified on PD (10.5.30.6): All 6 containers running and verified on PD (10.5.30.6):
- **Ollama:** port 11434 — qwen2.5:14b loaded - **Ollama:** port 11434 — qwen2.5:14b loaded
- **OpenWebUI:** port 8282 (→8080) - **OpenWebUI:** port 8282 (→8080)
@@ -58,7 +59,7 @@ Per HOMELAB_BUILDOUT_PLAN.md Phase 3:
## Machine IPs ## Machine IPs
- **PlausibleDeniability (PD):** 10.5.30.6 — TrueNAS Scale, RTX 2080 Ti - **PlausibleDeniability (PD):** 10.5.30.6 — TrueNAS Scale, RTX 2080 Ti
- **ROCINANTE:** 10.5.1.112 — RTX 4090 - **ROCINANTE:** 10.5.30.112 — RTX 4090
- **N.O.M.A.D.:** 10.5.30.7 — Ubuntu, GTX 1080 - **N.O.M.A.D.:** 10.5.30.7 — Ubuntu, GTX 1080
## LiteLLM .env Secrets (on PD at /mnt/docker-ssd/docker/compose/ai/.env) ## LiteLLM .env Secrets (on PD at /mnt/docker-ssd/docker/compose/ai/.env)

View File

@@ -575,7 +575,7 @@
575|Trigger: Cron node (every Sunday at 10:00 AM) 575|Trigger: Cron node (every Sunday at 10:00 AM)
576|Step 1: Postgres node → SELECT watch history for past 7 days 576|Step 1: Postgres node → SELECT watch history for past 7 days
577|Step 2: Format data as structured text 577|Step 2: Format data as structured text
578|Step 3: HTTP Request → Ollama API (POST to PD's qwen2.5:14b) 578|Step 3: HTTP Request → Ollama API (POST to the local/default automation model host, preferably N.O.M.A.D.-local rather than PD)
579| - Prompt: "Summarize this week's viewing in a fun digest: {data}" 579| - Prompt: "Summarize this week's viewing in a fun digest: {data}"
580|Step 4: HTTP Request → Gotify (send digest) 580|Step 4: HTTP Request → Gotify (send digest)
581|``` 581|```
@@ -1093,9 +1093,9 @@
1388| 1388|
1389|LiteLLM sits in front of your three Ollama instances and presents a single OpenAI-compatible API at `http://litellm:4000`. It handles: 1389|LiteLLM sits in front of your three Ollama instances and presents a single OpenAI-compatible API at `http://litellm:4000`. It handles:
1390| 1390|
1391|- **Model routing** — requests for `qwen3:32b` go to ROCINANTE, `qwen2.5:14b` goes to PD, `phi4` goes to N.O.M.A.D. - **Model routing** — current reliable default is N.O.M.A.D.-local Ollama for background/automation paths, with PD kept available for shared light-tier use and Rocinante treated as optional/manual heavy capacity.
1392|- **Failover** — if ROCINANTE is offline, LiteLLM can fall back to PD automatically - **Failover** — avoid designs that require Rocinante to be online; degrade to N.O.M.A.D.-local or explicitly operator-invoked PD paths instead of automatic dependence on a personal PC.
1393|- **Load balancing** — distribute requests across instances running the same model - **Load balancing** — distribute requests across instances running the same model when you have multiple dependable backends, but do not assume Rocinante qualifies as one.
1394|- **Usage tracking** — logs token counts, latency, and costs per model/user via its built-in database 1394|- **Usage tracking** — logs token counts, latency, and costs per model/user via its built-in database
1395| 1395|
1396|### 7.2 — Scaffold Directories 1396|### 7.2 — Scaffold Directories
@@ -1621,17 +1621,23 @@
1916|{ 1916|{
1917| "models": [ 1917| "models": [
1918| { 1918| {
1919| "title": "PD - qwen2.5:14b", 1919| "title": "N.O.M.A.D. - qwen2.5:3b",
1920| "provider": "ollama", 1920| "provider": "ollama",
1921| "model": "qwen2.5:14b", 1921| "model": "qwen2.5:3b",
1922| "apiBase": "http://PD_TAILSCALE_IP:11434" 1922| "apiBase": "http://NOMAD_LOCAL_OR_TAILSCALE_IP:11434"
1923| }, 1923| },
1924| { 1924| {
1925| "title": "ROCINANTE - qwen3:32b", 1925| "title": "PD - shared light tier (manual / fallback)",
1926| "provider": "ollama", 1926| "provider": "ollama",
1927| "model": "qwen3:32b", 1927| "model": "qwen2.5:14b",
1928| "apiBase": "http://ROCINANTE_TAILSCALE_IP:11434" 1928| "apiBase": "http://PD_TAILSCALE_IP:11434"
1929| } 1929| },
1930| {
1931| "title": "ROCINANTE - opportunistic heavy",
1932| "provider": "ollama",
1933| "model": "qwen3:32b",
1934| "apiBase": "http://ROCINANTE_TAILSCALE_IP:11434"
1935| }
1930| ], 1930| ],
1931| "tabAutocompleteModel": { 1931| "tabAutocompleteModel": {
1932| "title": "N.O.M.A.D. - phi4", 1932| "title": "N.O.M.A.D. - phi4",

View File

@@ -20,7 +20,8 @@ photos/ # immich server + machine learning
home/ # kitchenowl, donetick, shlink, qui, dakboard bridges, doris-dashboard home/ # kitchenowl, donetick, shlink, qui, dakboard bridges, doris-dashboard
dev/ # gitea, rackpeek dev/ # gitea, rackpeek
ai/ # ollama, openwebui ai/ # ollama, openwebui
meshtastic/ # meshmonitor, mqtt-proxy, tileserver, meshtastic-map headscale/ # pilot self-hosted tailnet control plane + Headplane UI on PD
meshtastic/ # meshmonitor, tileserver, meshtastic-map, and MeshCore/MeshMonitor integration notes
automation/ # planned: gotify, n8n automation/ # planned: gotify, n8n
search/ # planned: qdrant search/ # planned: qdrant
docs/ # all homelab documentation docs/ # all homelab documentation

View File

@@ -8,21 +8,21 @@ model_list:
- model_name: "heavy" - model_name: "heavy"
litellm_params: litellm_params:
model: "ollama/qwen3.6:latest" model: "ollama/qwen3.6:latest"
api_base: "http://10.5.1.112:11434" api_base: "http://10.5.30.112:11434"
timeout: 300 timeout: 300
stream_timeout: 300 stream_timeout: 300
- model_name: "heavy" - model_name: "heavy"
litellm_params: litellm_params:
model: "ollama/qwen2.5:32b" model: "ollama/qwen2.5:32b"
api_base: "http://10.5.1.112:11434" api_base: "http://10.5.30.112:11434"
timeout: 300 timeout: 300
stream_timeout: 300 stream_timeout: 300
- model_name: "heavy" - model_name: "heavy"
litellm_params: litellm_params:
model: "ollama/gemma3:27b" model: "ollama/gemma3:27b"
api_base: "http://10.5.1.112:11434" api_base: "http://10.5.30.112:11434"
timeout: 300 timeout: 300
stream_timeout: 300 stream_timeout: 300
@@ -59,17 +59,17 @@ model_list:
- model_name: "ollama/qwen3.6:latest" - model_name: "ollama/qwen3.6:latest"
litellm_params: litellm_params:
model: "ollama/qwen3.6:latest" model: "ollama/qwen3.6:latest"
api_base: "http://10.5.1.112:11434" api_base: "http://10.5.30.112:11434"
- model_name: "ollama/qwen2.5:32b" - model_name: "ollama/qwen2.5:32b"
litellm_params: litellm_params:
model: "ollama/qwen2.5:32b" model: "ollama/qwen2.5:32b"
api_base: "http://10.5.1.112:11434" api_base: "http://10.5.30.112:11434"
- model_name: "ollama/gemma3:27b" - model_name: "ollama/gemma3:27b"
litellm_params: litellm_params:
model: "ollama/gemma3:27b" model: "ollama/gemma3:27b"
api_base: "http://10.5.1.112:11434" api_base: "http://10.5.30.112:11434"
- model_name: "ollama/qwen2.5:14b" - model_name: "ollama/qwen2.5:14b"
litellm_params: litellm_params:

View File

@@ -28,6 +28,13 @@ UNIFI_PASSWORD=***
UNIFI_SITE=default UNIFI_SITE=default
UNIFI_VERIFY_TLS=false UNIFI_VERIFY_TLS=false
# Pangolin integration API
PANGOLIN_API_URL=https://api.paccoco.com/v1
PANGOLIN_API_KEY=CHANGE_ME
PANGOLIN_ORG_ID=paccoco
PANGOLIN_SITE_ID=4
PANGOLIN_DOMAIN_ID=domain1
# Backup automation scaffolding # Backup automation scaffolding
SERENITY_BACKUP_HOST=root@10.5.30.5 SERENITY_BACKUP_HOST=root@10.5.30.5
SERENITY_BACKUP_ROOT=/mnt/user/backups/plausible-deniability SERENITY_BACKUP_ROOT=/mnt/user/backups/plausible-deniability
@@ -36,7 +43,7 @@ PD_DATABASES_ROOT=/mnt/docker-ssd/docker/databases
PD_TANK_DOCKER_ROOT=/mnt/tank/docker PD_TANK_DOCKER_ROOT=/mnt/tank/docker
PD_DB_DUMP_ROOT=/mnt/tank/docker/backups/db-dumps PD_DB_DUMP_ROOT=/mnt/tank/docker/backups/db-dumps
POSTGRES_CONTAINER=shared-postgres POSTGRES_CONTAINER=shared-postgres
POSTGRES_DB_LIST="n8n paperless" POSTGRES_DB_LIST="gitea n8n paperless"
BACKUP_SSH_KEY=/home/truenas_admin/.ssh/serenity_backup_ed25519 BACKUP_SSH_KEY=/home/truenas_admin/.ssh/serenity_backup_ed25519
BACKUP_KNOWN_HOSTS=/home/truenas_admin/.ssh/known_hosts BACKUP_KNOWN_HOSTS=/home/truenas_admin/.ssh/known_hosts
DOCKER_BIN=/usr/bin/docker DOCKER_BIN=/usr/bin/docker

View File

@@ -30,7 +30,7 @@ automation/bin/karakeep_api.py /api/health --raw-path --pretty
## Backup scripts ## Backup scripts
- `bin/pd_backup_postgres.sh` — creates gzipped `pg_dump` exports for the configured Postgres DB list (quote space-separated DB names in `.env`, e.g. `POSTGRES_DB_LIST="n8n paperless"`) - `bin/pd_backup_postgres.sh` — creates gzipped `pg_dump` exports for the configured Postgres DB list (quote space-separated DB names in `.env`, e.g. `POSTGRES_DB_LIST="gitea n8n paperless"`)
- `bin/pd_backup_sync_to_serenity.sh``rsync`s configured backup/config roots to Serenity - `bin/pd_backup_sync_to_serenity.sh``rsync`s configured backup/config roots to Serenity
- `bin/run_pd_backups.sh` — wrapper that runs both steps in order - `bin/run_pd_backups.sh` — wrapper that runs both steps in order
- `bin/pd_restore_verify_postgres.sh` — restores the latest dump for each configured database into a throwaway Postgres container and fails on SQL errors - `bin/pd_restore_verify_postgres.sh` — restores the latest dump for each configured database into a throwaway Postgres container and fails on SQL errors
@@ -140,6 +140,7 @@ tail -100 /mnt/tank/docker/backups/pd-backups.log
## Safety notes ## Safety notes
- DB dumps are the priority restore artifacts; do not rely only on raw volume copies. - DB dumps are the priority restore artifacts; do not rely only on raw volume copies.
- For Gitea specifically, keep the logical Postgres dump in `POSTGRES_DB_LIST`; a live rsynced Postgres volume alone is not the preferred recovery artifact.
- The sync script uses `rsync --delete` inside the destination backup root, so point it at a dedicated backup path. - The sync script uses `rsync --delete` inside the destination backup root, so point it at a dedicated backup path.
- Keep `.env` and SSH material out of git. - Keep `.env` and SSH material out of git.
- If cron runs under a non-root PD account, `sudo -n /usr/bin/docker` must work or the DB dump step will fail. - If cron runs under a non-root PD account, `sudo -n /usr/bin/docker` must work or the DB dump step will fail.

View File

@@ -0,0 +1,251 @@
#!/usr/bin/env python3
"""Create or update the Pangolin resource/target for Dispatcharr.
Expected env vars:
- PANGOLIN_API_URL e.g. https://api.paccoco.com/v1
- PANGOLIN_API_KEY bearer token for Pangolin integration API
- PANGOLIN_ORG_ID e.g. paccoco
Optional env defaults:
- PANGOLIN_SITE_ID default 4 (Plausible Deniability)
- PANGOLIN_DOMAIN_ID default domain1
Default desired resource:
- host/domain: tv.paccoco.com
- site: Plausible Deniability (siteId 4)
- target: dispatcharr:9191
- Pangolin auth: disabled (sso=false)
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from typing import Any
from urllib import error, parse, request
DEFAULT_API_URL = "https://api.paccoco.com/v1"
DEFAULT_ORG_ID = "paccoco"
DEFAULT_DOMAIN_ID = "domain1"
DEFAULT_SITE_ID = 4
DEFAULT_NAME = "dispatcharr"
DEFAULT_SUBDOMAIN = "tv"
DEFAULT_TARGET_IP = "dispatcharr"
DEFAULT_TARGET_PORT = 9191
USER_AGENT = "doris-pangolin-dispatcharr/1.0"
def env(name: str, default: str | None = None) -> str | None:
value = os.environ.get(name)
if value is None or value == "":
return default
return value
def api_request(base_url: str, api_key: str, method: str, path: str, body: dict[str, Any] | None = None) -> tuple[int, Any]:
url = base_url.rstrip("/") + path
headers = {
"Authorization": f"Bearer {api_key}",
"Accept": "application/json",
"User-Agent": USER_AGENT,
}
data = None
if body is not None:
data = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json"
req = request.Request(url, data=data, headers=headers, method=method.upper())
try:
with request.urlopen(req, timeout=30) as resp:
raw = resp.read().decode("utf-8", "replace")
return resp.status, json.loads(raw) if raw else None
except error.HTTPError as exc:
raw = exc.read().decode("utf-8", "replace")
try:
payload = json.loads(raw)
except Exception:
payload = raw
return exc.code, payload
def fail(msg: str, payload: Any = None) -> int:
print(msg, file=sys.stderr)
if payload is not None:
if isinstance(payload, (dict, list)):
print(json.dumps(payload, indent=2, sort_keys=True), file=sys.stderr)
else:
print(payload, file=sys.stderr)
return 1
def find_resource(resources: list[dict[str, Any]], *, name: str, full_domain: str) -> dict[str, Any] | None:
for resource in resources:
if resource.get("fullDomain") == full_domain:
return resource
for resource in resources:
if resource.get("name") == name:
return resource
return None
def main() -> int:
parser = argparse.ArgumentParser(description="Upsert Pangolin Dispatcharr resource")
parser.add_argument("--api-url", default=env("PANGOLIN_API_URL", DEFAULT_API_URL))
parser.add_argument("--api-key", default=env("PANGOLIN_API_KEY"))
parser.add_argument("--org-id", default=env("PANGOLIN_ORG_ID", DEFAULT_ORG_ID))
site_id_default = env("PANGOLIN_SITE_ID", str(DEFAULT_SITE_ID)) or str(DEFAULT_SITE_ID)
parser.add_argument("--site-id", type=int, default=int(site_id_default))
parser.add_argument("--domain-id", default=env("PANGOLIN_DOMAIN_ID", DEFAULT_DOMAIN_ID))
parser.add_argument("--name", default=DEFAULT_NAME)
parser.add_argument("--subdomain", default=DEFAULT_SUBDOMAIN)
parser.add_argument("--target-ip", default=DEFAULT_TARGET_IP)
parser.add_argument("--target-port", type=int, default=DEFAULT_TARGET_PORT)
parser.add_argument("--hc-path", default="/")
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
if not args.api_key:
return fail("Missing PANGOLIN_API_KEY / --api-key")
full_domain = f"{args.subdomain}.paccoco.com"
status, resources_resp = api_request(
args.api_url,
args.api_key,
"GET",
f"/org/{parse.quote(args.org_id)}/resources?pageSize=200",
)
if status != 200:
return fail("Failed to list Pangolin resources", resources_resp)
resources = resources_resp.get("data", {}).get("resources", [])
resource = find_resource(resources, name=args.name, full_domain=full_domain)
create_body = {
"name": args.name,
"subdomain": args.subdomain,
"http": True,
"protocol": "tcp",
"domainId": args.domain_id,
}
if resource is None:
if args.dry_run:
print(json.dumps({"action": "create_resource", "body": create_body}, indent=2))
resource_id = None
else:
status, create_resp = api_request(
args.api_url,
args.api_key,
"PUT",
f"/org/{parse.quote(args.org_id)}/resource",
create_body,
)
if status not in (200, 201):
return fail("Failed to create Pangolin resource", create_resp)
resource_id = create_resp.get("data", {}).get("resourceId") or create_resp.get("data", {}).get("resource", {}).get("resourceId")
if not resource_id:
return fail("Create resource succeeded but resourceId was missing", create_resp)
status, resource_resp = api_request(args.api_url, args.api_key, "GET", f"/resource/{resource_id}")
if status != 200:
return fail("Failed to read back created resource", resource_resp)
resource = resource_resp.get("data", {}).get("resource") or resource_resp.get("data") or {}
else:
resource_id = resource["resourceId"]
resource_update = {
"name": args.name,
"niceId": resource.get("niceId") if resource else None,
"subdomain": args.subdomain,
"ssl": True,
"sso": False,
"blockAccess": False,
"emailWhitelistEnabled": False,
"applyRules": False,
"domainId": args.domain_id,
"enabled": True,
"stickySession": False,
"tlsServerName": None,
"setHostHeader": None,
"skipToIdpId": None,
"headers": None,
"maintenanceModeEnabled": False,
"maintenanceModeType": "forced",
"maintenanceTitle": None,
"maintenanceMessage": None,
"maintenanceEstimatedTime": None,
"postAuthPath": None,
}
target_body = {
"siteId": args.site_id,
"ip": args.target_ip,
"port": args.target_port,
"enabled": True,
"hcEnabled": True,
"hcPath": args.hc_path,
"hcScheme": "http",
"hcMode": "http",
"hcHostname": args.target_ip,
"hcPort": args.target_port,
"hcInterval": 30,
"hcUnhealthyInterval": 30,
"hcTimeout": 10,
"hcHeaders": None,
"hcFollowRedirects": True,
"hcMethod": "GET",
"hcStatus": 200,
"hcTlsServerName": None,
"hcHealthyThreshold": 1,
"hcUnhealthyThreshold": 3,
"path": None,
"pathMatchType": None,
"rewritePath": None,
"rewritePathType": None,
}
existing_targets = resource.get("targets", []) if resource else []
matching_target = None
for target in existing_targets:
if target.get("siteId") == args.site_id:
matching_target = target
break
if args.dry_run:
print(json.dumps({
"resource_id": resource_id,
"full_domain": full_domain,
"resource_update": resource_update,
"target_action": "update" if matching_target else "create",
"target_id": matching_target.get("targetId") if matching_target else None,
"target_body": target_body,
}, indent=2))
return 0
if resource_id is not None:
status, update_resp = api_request(args.api_url, args.api_key, "POST", f"/resource/{resource_id}", resource_update)
if status not in (200, 201):
return fail("Failed to update Pangolin resource", update_resp)
if matching_target:
target_id = matching_target["targetId"]
status, target_resp = api_request(args.api_url, args.api_key, "POST", f"/target/{target_id}", target_body)
if status not in (200, 201):
return fail("Failed to update Pangolin target", target_resp)
else:
status, target_resp = api_request(args.api_url, args.api_key, "PUT", f"/resource/{resource_id}/target", target_body)
if status not in (200, 201):
return fail("Failed to create Pangolin target", target_resp)
status, final_resp = api_request(args.api_url, args.api_key, "GET", f"/resource/{resource_id}")
if status != 200:
return fail("Failed to read back final Pangolin resource", final_resp)
payload = final_resp.get("data", {}).get("resource") or final_resp.get("data") or final_resp
print(json.dumps(payload, indent=2))
return 0
return fail("Unexpected state: resource_id is missing")
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,254 @@
#!/usr/bin/env python3
"""Create or update the Pangolin resource/target for Headscale.
Expected env vars:
- PANGOLIN_API_URL e.g. https://api.paccoco.com/v1
- PANGOLIN_API_KEY bearer token for Pangolin integration API
- PANGOLIN_ORG_ID e.g. paccoco
Optional env defaults:
- PANGOLIN_SITE_ID default 4 (Plausible Deniability)
- PANGOLIN_DOMAIN_ID default domain1
Default desired resource:
- host/domain: headscale.paccoco.com
- site: Plausible Deniability (siteId 4)
- target: headscale:8080
- Pangolin auth: disabled (sso=false)
- healthcheck: GET /health
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from typing import Any
from urllib import error, parse, request
DEFAULT_API_URL = "https://api.paccoco.com/v1"
DEFAULT_ORG_ID = "paccoco"
DEFAULT_DOMAIN_ID = "domain1"
DEFAULT_SITE_ID = 4
DEFAULT_NAME = "headscale"
DEFAULT_SUBDOMAIN = "headscale"
DEFAULT_TARGET_IP = "headscale"
DEFAULT_TARGET_PORT = 8080
DEFAULT_HC_PATH = "/health"
USER_AGENT = "doris-pangolin-headscale/1.0"
def env(name: str, default: str | None = None) -> str | None:
value = os.environ.get(name)
if value is None or value == "":
return default
return value
def api_request(base_url: str, api_key: str, method: str, path: str, body: dict[str, Any] | None = None) -> tuple[int, Any]:
url = base_url.rstrip("/") + path
headers = {
"Authorization": f"Bearer {api_key}",
"Accept": "application/json",
"User-Agent": USER_AGENT,
}
data = None
if body is not None:
data = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json"
req = request.Request(url, data=data, headers=headers, method=method.upper())
try:
with request.urlopen(req, timeout=30) as resp:
raw = resp.read().decode("utf-8", "replace")
return resp.status, json.loads(raw) if raw else None
except error.HTTPError as exc:
raw = exc.read().decode("utf-8", "replace")
try:
payload = json.loads(raw)
except Exception:
payload = raw
return exc.code, payload
def fail(msg: str, payload: Any = None) -> int:
print(msg, file=sys.stderr)
if payload is not None:
if isinstance(payload, (dict, list)):
print(json.dumps(payload, indent=2, sort_keys=True), file=sys.stderr)
else:
print(payload, file=sys.stderr)
return 1
def find_resource(resources: list[dict[str, Any]], *, name: str, full_domain: str) -> dict[str, Any] | None:
for resource in resources:
if resource.get("fullDomain") == full_domain:
return resource
for resource in resources:
if resource.get("name") == name:
return resource
return None
def main() -> int:
parser = argparse.ArgumentParser(description="Upsert Pangolin Headscale resource")
parser.add_argument("--api-url", default=env("PANGOLIN_API_URL", DEFAULT_API_URL))
parser.add_argument("--api-key", default=env("PANGOLIN_API_KEY"))
parser.add_argument("--org-id", default=env("PANGOLIN_ORG_ID", DEFAULT_ORG_ID))
site_id_default = env("PANGOLIN_SITE_ID", str(DEFAULT_SITE_ID)) or str(DEFAULT_SITE_ID)
parser.add_argument("--site-id", type=int, default=int(site_id_default))
parser.add_argument("--domain-id", default=env("PANGOLIN_DOMAIN_ID", DEFAULT_DOMAIN_ID))
parser.add_argument("--name", default=DEFAULT_NAME)
parser.add_argument("--subdomain", default=DEFAULT_SUBDOMAIN)
parser.add_argument("--target-ip", default=DEFAULT_TARGET_IP)
parser.add_argument("--target-port", type=int, default=DEFAULT_TARGET_PORT)
parser.add_argument("--hc-path", default=DEFAULT_HC_PATH)
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
if not args.api_key:
return fail("Missing PANGOLIN_API_KEY / --api-key")
full_domain = f"{args.subdomain}.paccoco.com"
status, resources_resp = api_request(
args.api_url,
args.api_key,
"GET",
f"/org/{parse.quote(args.org_id)}/resources?pageSize=200",
)
if status != 200:
return fail("Failed to list Pangolin resources", resources_resp)
resources = resources_resp.get("data", {}).get("resources", [])
resource = find_resource(resources, name=args.name, full_domain=full_domain)
create_body = {
"name": args.name,
"subdomain": args.subdomain,
"http": True,
"protocol": "tcp",
"domainId": args.domain_id,
}
if resource is None:
if args.dry_run:
print(json.dumps({"action": "create_resource", "body": create_body}, indent=2))
resource_id = None
else:
status, create_resp = api_request(
args.api_url,
args.api_key,
"PUT",
f"/org/{parse.quote(args.org_id)}/resource",
create_body,
)
if status not in (200, 201):
return fail("Failed to create Pangolin resource", create_resp)
resource_id = create_resp.get("data", {}).get("resourceId") or create_resp.get("data", {}).get("resource", {}).get("resourceId")
if not resource_id:
return fail("Create resource succeeded but resourceId was missing", create_resp)
status, resource_resp = api_request(args.api_url, args.api_key, "GET", f"/resource/{resource_id}")
if status != 200:
return fail("Failed to read back created resource", resource_resp)
resource = resource_resp.get("data", {}).get("resource") or resource_resp.get("data") or {}
else:
resource_id = resource["resourceId"]
resource_update = {
"name": args.name,
"niceId": resource.get("niceId") if resource else None,
"subdomain": args.subdomain,
"ssl": True,
"sso": False,
"blockAccess": False,
"emailWhitelistEnabled": False,
"applyRules": False,
"domainId": args.domain_id,
"enabled": True,
"stickySession": False,
"tlsServerName": None,
"setHostHeader": None,
"skipToIdpId": None,
"headers": None,
"maintenanceModeEnabled": False,
"maintenanceModeType": "forced",
"maintenanceTitle": None,
"maintenanceMessage": None,
"maintenanceEstimatedTime": None,
"postAuthPath": None,
}
target_body = {
"siteId": args.site_id,
"method": "http",
"ip": args.target_ip,
"port": args.target_port,
"enabled": True,
"hcEnabled": True,
"hcPath": args.hc_path,
"hcScheme": "http",
"hcMode": "http",
"hcHostname": args.target_ip,
"hcPort": args.target_port,
"hcInterval": 30,
"hcUnhealthyInterval": 30,
"hcTimeout": 10,
"hcHeaders": None,
"hcFollowRedirects": True,
"hcMethod": "GET",
"hcStatus": 200,
"hcTlsServerName": None,
"hcHealthyThreshold": 1,
"hcUnhealthyThreshold": 3,
"path": None,
"pathMatchType": None,
"rewritePath": None,
"rewritePathType": None,
}
existing_targets = resource.get("targets", []) if resource else []
matching_target = None
for target in existing_targets:
if target.get("siteId") == args.site_id:
matching_target = target
break
if args.dry_run:
print(json.dumps({
"resource_id": resource_id,
"full_domain": full_domain,
"resource_update": resource_update,
"target_action": "update" if matching_target else "create",
"target_id": matching_target.get("targetId") if matching_target else None,
"target_body": target_body,
}, indent=2))
return 0
if resource_id is not None:
status, update_resp = api_request(args.api_url, args.api_key, "POST", f"/resource/{resource_id}", resource_update)
if status not in (200, 201):
return fail("Failed to update Pangolin resource", update_resp)
if matching_target:
target_id = matching_target["targetId"]
status, target_resp = api_request(args.api_url, args.api_key, "POST", f"/target/{target_id}", target_body)
if status not in (200, 201):
return fail("Failed to update Pangolin target", target_resp)
else:
status, target_resp = api_request(args.api_url, args.api_key, "PUT", f"/resource/{resource_id}/target", target_body)
if status not in (200, 201):
return fail("Failed to create Pangolin target", target_resp)
status, final_resp = api_request(args.api_url, args.api_key, "GET", f"/resource/{resource_id}")
if status != 200:
return fail("Failed to read back final Pangolin resource", final_resp)
payload = final_resp.get("data", {}).get("resource") or final_resp.get("data") or final_resp
print(json.dumps(payload, indent=2))
return 0
return fail("Unexpected state: resource_id is missing")
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Stage UniFi firewall groups for the current network segmentation.
Default mode is dry-run. Pass --apply to create/update/delete as needed.
Reads credentials from the runtime automation .env on PD by default.
"""
from __future__ import annotations
import argparse
import importlib.util
import json
import os
from pathlib import Path
from typing import Any
DEFAULT_RUNTIME_HELPER = Path('/mnt/docker-ssd/docker/compose/automation/bin/unifi_stage_low_risk_objects.py')
DEFAULT_ENV_PATH = Path('/mnt/docker-ssd/docker/compose/automation/.env')
def load_helper(path: Path):
spec = importlib.util.spec_from_file_location('unifi_stage', path)
if spec is None or spec.loader is None:
raise RuntimeError(f'Could not load helper module from {path}')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def desired_groups() -> list[dict[str, Any]]:
return [
{'name': 'NET-MGMT', 'group_type': 'address-group', 'group_members': ['10.5.0.0/24']},
{'name': 'NET-TRUSTED', 'group_type': 'address-group', 'group_members': ['10.5.1.0/24']},
{'name': 'NET-SERVERS', 'group_type': 'address-group', 'group_members': ['10.5.30.0/24']},
{'name': 'NET-IOT', 'group_type': 'address-group', 'group_members': ['10.5.10.0/24']},
{'name': 'NET-GUEST', 'group_type': 'address-group', 'group_members': ['10.5.90.0/24']},
{'name': 'NET-CAMERAS', 'group_type': 'address-group', 'group_members': ['10.5.20.0/24']},
{'name': 'NET-LEGACY-CIA', 'group_type': 'address-group', 'group_members': ['192.168.1.0/24']},
{
'name': 'NET-RFC1918-ALL',
'group_type': 'address-group',
'group_members': [
'10.5.0.0/24',
'10.5.1.0/24',
'10.5.10.0/24',
'10.5.20.0/24',
'10.5.30.0/24',
'10.5.90.0/24',
'192.168.1.0/24',
'192.168.2.0/24',
'192.168.3.0/24',
],
},
{'name': 'PORT-DNS', 'group_type': 'port-group', 'group_members': ['53']},
{'name': 'PORT-DHCP', 'group_type': 'port-group', 'group_members': ['67-68']},
{'name': 'PORT-NTP', 'group_type': 'port-group', 'group_members': ['123']},
{'name': 'PORT-WEB-ADMIN', 'group_type': 'port-group', 'group_members': ['80', '443', '8443', '9443']},
{'name': 'PORT-SSH', 'group_type': 'port-group', 'group_members': ['22']},
{'name': 'PORT-MDNS', 'group_type': 'port-group', 'group_members': ['5353']},
]
TEMP_GROUP_NAMES = {'TEST-TMP', 'TEST-PORT-TMP', 'TEST-PORT-TMP2', 'TEST-PORT-TMP3'}
def main() -> int:
parser = argparse.ArgumentParser(description='Stage UniFi firewall groups.')
parser.add_argument('--helper', default=str(DEFAULT_RUNTIME_HELPER), help='Path to runtime UniFi helper on PD')
parser.add_argument('--env-file', default=str(DEFAULT_ENV_PATH), help='Path to runtime .env on PD')
parser.add_argument('--site', default='default')
parser.add_argument('--apply', action='store_true')
args = parser.parse_args()
helper = load_helper(Path(args.helper))
helper.load_env_file(Path(args.env_file))
client = helper.UniFiClient(
base_url=os.environ.get('UNIFI_BASE_URL', 'https://10.5.0.1'),
username=os.environ['UNIFI_USERNAME'],
password=os.environ['UNIFI_PASSWORD'],
verify_tls=helper.env_bool('UNIFI_VERIFY_TLS', False),
timeout=20,
)
client.login()
groups = client.get(client.site_path(args.site, 'rest/firewallgroup'))
by_name = {g.get('name'): g for g in groups}
result: dict[str, Any] = {'mode': 'apply' if args.apply else 'dry-run', 'changes': []}
for group in desired_groups():
existing = by_name.get(group['name'])
if not existing:
result['changes'].append({'name': group['name'], 'action': 'create', 'payload': group})
if args.apply:
status, payload = client._json_request('POST', client.site_path(args.site, 'rest/firewallgroup'), group)
if status >= 300:
result['changes'][-1]['error'] = {'status': status, 'payload': payload}
continue
current_members = existing.get('group_members', []) or []
desired_members = group.get('group_members', []) or []
current_type = existing.get('group_type')
if current_type != group['group_type'] or current_members != desired_members:
updated = dict(existing)
updated['group_type'] = group['group_type']
updated['group_members'] = desired_members
result['changes'].append({
'name': group['name'],
'action': 'update',
'before': {'group_type': current_type, 'group_members': current_members},
'after': {'group_type': group['group_type'], 'group_members': desired_members},
})
if args.apply:
status, payload = client._json_request('PUT', client.site_path(args.site, f"rest/firewallgroup/{existing['_id']}"), updated)
if status >= 300:
result['changes'][-1]['error'] = {'status': status, 'payload': payload}
else:
result['changes'].append({'name': group['name'], 'action': 'noop'})
for name in sorted(TEMP_GROUP_NAMES):
existing = by_name.get(name)
if not existing:
continue
result['changes'].append({'name': name, 'action': 'delete-temp'})
if args.apply:
status, payload = client._json_request('DELETE', client.site_path(args.site, f"rest/firewallgroup/{existing['_id']}"))
if status >= 300:
result['changes'][-1]['error'] = {'status': status, 'payload': payload}
print(json.dumps(result, indent=2, sort_keys=True))
return 0
if __name__ == '__main__':
raise SystemExit(main())

View File

@@ -0,0 +1,244 @@
#!/usr/bin/env python3
"""Stage/apply the narrow UniFi Policy Engine slice that makes the Hermes dashboard Trusted-only.
Default mode is dry-run. Pass --apply to create/update the two desired policies.
Reads credentials from the PD runtime automation .env by default.
"""
from __future__ import annotations
import argparse
import importlib.util
import json
import os
from copy import deepcopy
from pathlib import Path
from typing import Any
DEFAULT_RUNTIME_HELPER = Path('/mnt/docker-ssd/docker/compose/automation/bin/unifi_stage_low_risk_objects.py')
DEFAULT_ENV_PATH = Path('/mnt/docker-ssd/docker/compose/automation/.env')
INTERNAL_ZONE_NAME = 'Internal'
def load_helper(path: Path):
spec = importlib.util.spec_from_file_location('unifi_stage', path)
if spec is None or spec.loader is None:
raise RuntimeError(f'Could not load helper module from {path}')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def desired_policies() -> list[dict[str, Any]]:
return [
{
'name': 'Allow Trusted to Hermes Dashboard',
'enabled': True,
'action': 'ALLOW',
'protocol': 'tcp',
'ip_version': 'IPV4',
'logging': False,
'create_allow_respond': False,
'connection_state_type': 'ALL',
'connection_states': [],
'match_ip_sec': False,
'match_opposite_protocol': False,
'icmp_typename': 'ANY',
'icmp_v6_typename': 'ANY',
'description': 'Allow Trusted clients to reach the Hermes dashboard on Nomad.',
'source_zone_name': INTERNAL_ZONE_NAME,
'destination_zone_name': INTERNAL_ZONE_NAME,
'schedule': {'mode': 'ALWAYS'},
'source': {
'matching_target': 'IP',
'matching_target_type': 'SPECIFIC',
'ips': ['10.5.1.0/24'],
'match_mac': False,
'match_opposite_ips': False,
'match_opposite_ports': False,
'port_matching_type': 'ANY',
},
'destination': {
'matching_target': 'IP',
'matching_target_type': 'SPECIFIC',
'ips': ['10.5.30.7'],
'match_opposite_ips': False,
'match_opposite_ports': False,
'port_matching_type': 'SPECIFIC',
'port': '9119',
},
},
{
'name': 'Block Management and Servers to Hermes Dashboard',
'enabled': True,
'action': 'BLOCK',
'protocol': 'tcp',
'ip_version': 'IPV4',
'logging': False,
'create_allow_respond': False,
'connection_state_type': 'ALL',
'connection_states': [],
'match_ip_sec': False,
'match_opposite_protocol': False,
'icmp_typename': 'ANY',
'icmp_v6_typename': 'ANY',
'description': 'Block Management and Servers clients from reaching the Hermes dashboard on Nomad.',
'source_zone_name': INTERNAL_ZONE_NAME,
'destination_zone_name': INTERNAL_ZONE_NAME,
'schedule': {'mode': 'ALWAYS'},
'source': {
'matching_target': 'IP',
'matching_target_type': 'SPECIFIC',
'ips': ['10.5.0.0/24', '10.5.30.0/24'],
'match_mac': False,
'match_opposite_ips': False,
'match_opposite_ports': False,
'port_matching_type': 'ANY',
},
'destination': {
'matching_target': 'IP',
'matching_target_type': 'SPECIFIC',
'ips': ['10.5.30.7'],
'match_opposite_ips': False,
'match_opposite_ports': False,
'port_matching_type': 'SPECIFIC',
'port': '9119',
},
},
]
POLICY_MUTABLE_KEYS = {
'name',
'enabled',
'action',
'protocol',
'ip_version',
'logging',
'create_allow_respond',
'connection_state_type',
'connection_states',
'match_ip_sec',
'match_opposite_protocol',
'icmp_typename',
'icmp_v6_typename',
'source',
'destination',
'schedule',
'description',
}
def canonical_policy(policy: dict[str, Any]) -> dict[str, Any]:
out = {key: deepcopy(policy.get(key)) for key in POLICY_MUTABLE_KEYS if key in policy}
out.setdefault('description', '')
out.setdefault('connection_state_type', 'ALL')
out.setdefault('connection_states', [])
out.setdefault('logging', False)
out.setdefault('match_ip_sec', False)
out.setdefault('match_opposite_protocol', False)
out.setdefault('icmp_typename', 'ANY')
out.setdefault('icmp_v6_typename', 'ANY')
out.setdefault('schedule', {'mode': 'ALWAYS'})
return out
def resolve_zone_ids(zone_data: list[dict[str, Any]]) -> dict[str, str]:
by_name: dict[str, str] = {}
for zone in zone_data:
name = zone.get('name')
zid = zone.get('_id')
if name and zid:
by_name[name] = zid
return by_name
def materialize_policy(spec: dict[str, Any], zone_ids: dict[str, str]) -> dict[str, Any]:
source_zone = spec['source_zone_name']
dest_zone = spec['destination_zone_name']
if source_zone not in zone_ids:
raise KeyError(f'Missing source zone: {source_zone}')
if dest_zone not in zone_ids:
raise KeyError(f'Missing destination zone: {dest_zone}')
policy = {k: deepcopy(v) for k, v in spec.items() if not k.endswith('_zone_name')}
policy['description'] = policy.get('description', '')
policy['source'] = deepcopy(policy['source'])
policy['destination'] = deepcopy(policy['destination'])
policy['source']['zone_id'] = zone_ids[source_zone]
policy['destination']['zone_id'] = zone_ids[dest_zone]
return policy
def main() -> int:
parser = argparse.ArgumentParser(description='Stage/apply the Hermes dashboard Trusted-only UniFi Policy Engine slice.')
parser.add_argument('--helper', default=str(DEFAULT_RUNTIME_HELPER), help='Path to runtime UniFi helper on PD')
parser.add_argument('--env-file', default=str(DEFAULT_ENV_PATH), help='Path to runtime .env on PD')
parser.add_argument('--site', default='default')
parser.add_argument('--apply', action='store_true')
args = parser.parse_args()
helper = load_helper(Path(args.helper))
helper.load_env_file(Path(args.env_file))
client = helper.UniFiClient(
base_url=os.environ.get('UNIFI_BASE_URL', 'https://10.5.0.1'),
username=os.environ['UNIFI_USERNAME'],
password=os.environ['UNIFI_PASSWORD'],
verify_tls=helper.env_bool('UNIFI_VERIFY_TLS', False),
timeout=20,
)
client.login()
zones_payload = client.get(f'/proxy/network/v2/api/site/{args.site}/firewall/zone')
zone_data_raw = zones_payload['data'] if isinstance(zones_payload, dict) and 'data' in zones_payload else zones_payload
zone_data = zone_data_raw if isinstance(zone_data_raw, list) else []
zone_ids = resolve_zone_ids(zone_data)
policies_path = f'/proxy/network/v2/api/site/{args.site}/firewall-policies'
policies_payload = client.get(policies_path)
policy_data_raw = policies_payload['data'] if isinstance(policies_payload, dict) and 'data' in policies_payload else policies_payload
policy_data = policy_data_raw if isinstance(policy_data_raw, list) else []
custom_by_name = {p.get('name'): p for p in policy_data if not p.get('predefined') and p.get('name')}
result: dict[str, Any] = {
'mode': 'apply' if args.apply else 'dry-run',
'zones': sorted(zone_ids.keys()),
'changes': [],
}
for desired in desired_policies():
materialized = materialize_policy(desired, zone_ids)
existing = custom_by_name.get(materialized['name'])
desired_canonical = canonical_policy(materialized)
if existing is None:
result['changes'].append({'name': materialized['name'], 'action': 'create', 'payload': desired_canonical})
if args.apply:
status, payload = client._json_request('POST', policies_path, desired_canonical)
if status >= 300:
result['changes'][-1]['error'] = {'status': status, 'payload': payload}
continue
existing_canonical = canonical_policy(existing)
if existing_canonical != desired_canonical:
update_payload = deepcopy(existing)
for key, value in desired_canonical.items():
update_payload[key] = deepcopy(value)
result['changes'].append({
'name': materialized['name'],
'action': 'update',
'before': existing_canonical,
'after': desired_canonical,
})
if args.apply:
status, payload = client._json_request('PUT', f"{policies_path}/{existing['_id']}", update_payload)
if status >= 300:
result['changes'][-1]['error'] = {'status': status, 'payload': payload}
else:
result['changes'].append({'name': materialized['name'], 'action': 'noop'})
print(json.dumps(result, indent=2, sort_keys=True))
return 0
if __name__ == '__main__':
raise SystemExit(main())

View File

@@ -0,0 +1,394 @@
#!/usr/bin/env python3
"""Stage UniFi Policy Engine firewall policies for the current segmentation.
Default mode is dry-run. Pass --apply to create/update desired custom policies.
Reads credentials from the runtime automation .env on PD by default.
This helper targets the newer Policy Engine / zone-based firewall model via:
/proxy/network/v2/api/site/<site>/firewall/zone
/proxy/network/v2/api/site/<site>/firewall-policies
"""
from __future__ import annotations
import argparse
import importlib.util
import json
import os
from copy import deepcopy
from pathlib import Path
from typing import Any
DEFAULT_RUNTIME_HELPER = Path('/mnt/docker-ssd/docker/compose/automation/bin/unifi_stage_low_risk_objects.py')
DEFAULT_ENV_PATH = Path('/mnt/docker-ssd/docker/compose/automation/.env')
def load_helper(path: Path):
spec = importlib.util.spec_from_file_location('unifi_stage', path)
if spec is None or spec.loader is None:
raise RuntimeError(f'Could not load helper module from {path}')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def desired_policies() -> list[dict[str, Any]]:
restricted_subnets = ['10.5.10.0/24', '10.5.20.0/24', '192.168.1.0/24']
return [
{
'name': 'Allow Internal to Untrusted',
'enabled': True,
'action': 'ALLOW',
'protocol': 'all',
'ip_version': 'BOTH',
'logging': False,
'create_allow_respond': True,
'connection_state_type': 'ALL',
'connection_states': [],
'match_ip_sec': False,
'match_opposite_protocol': False,
'icmp_typename': 'ANY',
'icmp_v6_typename': 'ANY',
'source_zone_name': 'Internal',
'destination_zone_name': 'Untrusted',
'schedule': {'mode': 'ALWAYS'},
'source': {
'matching_target': 'ANY',
'port_matching_type': 'ANY',
'match_opposite_ports': False,
},
'destination': {
'matching_target': 'ANY',
'port_matching_type': 'ANY',
'match_opposite_ports': False,
},
},
{
'name': 'Block Untrusted to Gateway Admin Surfaces',
'enabled': True,
'action': 'BLOCK',
'protocol': 'tcp',
'ip_version': 'IPV4',
'logging': False,
'create_allow_respond': False,
'connection_state_type': 'ALL',
'connection_states': [],
'match_ip_sec': False,
'match_opposite_protocol': False,
'icmp_typename': 'ANY',
'icmp_v6_typename': 'ANY',
'description': 'Block IoT, Camera, and Old IoT clients from UniFi gateway admin TCP ports while preserving explicit DHCP and mDNS exceptions plus external DNS via 10.5.30.53.',
'source_zone_name': 'Untrusted',
'destination_zone_name': 'Gateway',
'schedule': {'mode': 'ALWAYS'},
'source': {
'matching_target': 'IP',
'matching_target_type': 'SPECIFIC',
'ips': restricted_subnets,
'match_mac': False,
'match_opposite_ips': False,
'match_opposite_ports': False,
'port_matching_type': 'ANY',
},
'destination': {
'matching_target': 'IP',
'matching_target_type': 'SPECIFIC',
'ips': ['10.5.0.1'],
'match_opposite_ips': False,
'match_opposite_ports': False,
'port_matching_type': 'SPECIFIC',
'port': '22,80,443,8443,9443',
},
},
{
'name': 'Allow Untrusted to DNS Resolver',
'enabled': True,
'action': 'ALLOW',
'protocol': 'tcp_udp',
'ip_version': 'IPV4',
'logging': False,
'create_allow_respond': False,
'connection_state_type': 'ALL',
'connection_states': [],
'match_ip_sec': False,
'match_opposite_protocol': False,
'icmp_typename': 'ANY',
'icmp_v6_typename': 'ANY',
'description': 'Allow IoT, Camera, and Old IoT clients to reach the Pi-hole DNS resolver on PD after DHCP DNS cutover.',
'source_zone_name': 'Untrusted',
'destination_zone_name': 'Internal',
'schedule': {'mode': 'ALWAYS'},
'source': {
'matching_target': 'IP',
'matching_target_type': 'SPECIFIC',
'ips': restricted_subnets,
'match_mac': False,
'match_opposite_ips': False,
'match_opposite_ports': False,
'port_matching_type': 'ANY',
},
'destination': {
'matching_target': 'IP',
'matching_target_type': 'SPECIFIC',
'ips': ['10.5.30.53'],
'match_opposite_ips': False,
'match_opposite_ports': False,
'port_matching_type': 'SPECIFIC',
'port': '53',
},
},
{
'name': 'Block Untrusted to Gateway Default Services',
'enabled': True,
'action': 'BLOCK',
'protocol': 'all',
'ip_version': 'BOTH',
'logging': False,
'create_allow_respond': False,
'connection_state_type': 'ALL',
'connection_states': [],
'match_ip_sec': False,
'match_opposite_protocol': False,
'icmp_typename': 'ANY',
'icmp_v6_typename': 'ANY',
'description': 'Block general access from IoT, Camera, and Old IoT to gateway services after DNS cutover, preserving only explicit DHCP and mDNS exceptions.',
'source_zone_name': 'Untrusted',
'destination_zone_name': 'Gateway',
'schedule': {'mode': 'ALWAYS'},
'source': {
'matching_target': 'IP',
'matching_target_type': 'SPECIFIC',
'ips': restricted_subnets,
'match_mac': False,
'match_opposite_ips': False,
'match_opposite_ports': False,
'port_matching_type': 'ANY',
},
'destination': {
'matching_target': 'ANY',
'match_opposite_ports': False,
'port_matching_type': 'ANY',
},
},
{
'name': 'Allow Untrusted to Gateway DHCP',
'enabled': True,
'action': 'ALLOW',
'protocol': 'udp',
'ip_version': 'BOTH',
'logging': False,
'create_allow_respond': False,
'connection_state_type': 'ALL',
'connection_states': [],
'match_ip_sec': False,
'match_opposite_protocol': False,
'icmp_typename': 'ANY',
'icmp_v6_typename': 'ANY',
'description': 'Preserve DHCP from IoT, Camera, and Old IoT to the gateway while the broader gateway shield is in place.',
'source_zone_name': 'Untrusted',
'destination_zone_name': 'Gateway',
'schedule': {'mode': 'ALWAYS'},
'source': {
'matching_target': 'IP',
'matching_target_type': 'SPECIFIC',
'ips': restricted_subnets,
'match_mac': False,
'match_opposite_ips': False,
'match_opposite_ports': False,
'port_matching_type': 'SPECIFIC',
'port': '68',
},
'destination': {
'matching_target': 'ANY',
'match_opposite_ports': False,
'port_matching_type': 'SPECIFIC',
'port': '67',
},
},
{
'name': 'Allow Untrusted to Gateway mDNS',
'enabled': True,
'action': 'ALLOW',
'protocol': 'udp',
'ip_version': 'IPV4',
'logging': False,
'create_allow_respond': False,
'connection_state_type': 'ALL',
'connection_states': [],
'match_ip_sec': False,
'match_opposite_protocol': False,
'icmp_typename': 'ANY',
'icmp_v6_typename': 'ANY',
'description': 'Preserve mDNS multicast from IoT, Camera, and Old IoT while the broader gateway shield is in place.',
'source_zone_name': 'Untrusted',
'destination_zone_name': 'Gateway',
'schedule': {'mode': 'ALWAYS'},
'source': {
'matching_target': 'IP',
'matching_target_type': 'SPECIFIC',
'ips': restricted_subnets,
'match_mac': False,
'match_opposite_ips': False,
'match_opposite_ports': False,
'port_matching_type': 'SPECIFIC',
'port': '5353',
},
'destination': {
'matching_target': 'IP',
'matching_target_type': 'SPECIFIC',
'ips': ['224.0.0.251'],
'match_opposite_ips': False,
'match_opposite_ports': False,
'port_matching_type': 'SPECIFIC',
'port': '5353',
},
},
]
POLICY_MUTABLE_KEYS = {
'name',
'enabled',
'action',
'protocol',
'ip_version',
'logging',
'create_allow_respond',
'connection_state_type',
'connection_states',
'match_ip_sec',
'match_opposite_protocol',
'icmp_typename',
'icmp_v6_typename',
'source',
'destination',
'schedule',
'description',
}
TEMP_POLICY_NAMES = {'DORIS-TEMP'}
def canonical_policy(policy: dict[str, Any]) -> dict[str, Any]:
out = {key: deepcopy(policy.get(key)) for key in POLICY_MUTABLE_KEYS if key in policy}
out.setdefault('description', '')
out.setdefault('connection_state_type', 'ALL')
out.setdefault('connection_states', [])
out.setdefault('logging', False)
out.setdefault('match_ip_sec', False)
out.setdefault('match_opposite_protocol', False)
out.setdefault('icmp_typename', 'ANY')
out.setdefault('icmp_v6_typename', 'ANY')
out.setdefault('schedule', {'mode': 'ALWAYS'})
return out
def resolve_zone_ids(zone_data: list[dict[str, Any]]) -> dict[str, str]:
by_name: dict[str, str] = {}
for zone in zone_data:
name = zone.get('name')
zid = zone.get('_id')
if name and zid:
by_name[name] = zid
return by_name
def materialize_policy(spec: dict[str, Any], zone_ids: dict[str, str]) -> dict[str, Any]:
source_zone = spec['source_zone_name']
dest_zone = spec['destination_zone_name']
if source_zone not in zone_ids:
raise KeyError(f'Missing source zone: {source_zone}')
if dest_zone not in zone_ids:
raise KeyError(f'Missing destination zone: {dest_zone}')
policy = {k: deepcopy(v) for k, v in spec.items() if not k.endswith('_zone_name')}
policy['description'] = policy.get('description', '')
policy['source'] = deepcopy(policy['source'])
policy['destination'] = deepcopy(policy['destination'])
policy['source']['zone_id'] = zone_ids[source_zone]
policy['destination']['zone_id'] = zone_ids[dest_zone]
return policy
def main() -> int:
parser = argparse.ArgumentParser(description='Stage UniFi Policy Engine firewall policies.')
parser.add_argument('--helper', default=str(DEFAULT_RUNTIME_HELPER), help='Path to runtime UniFi helper on PD')
parser.add_argument('--env-file', default=str(DEFAULT_ENV_PATH), help='Path to runtime .env on PD')
parser.add_argument('--site', default='default')
parser.add_argument('--apply', action='store_true')
args = parser.parse_args()
helper = load_helper(Path(args.helper))
helper.load_env_file(Path(args.env_file))
client = helper.UniFiClient(
base_url=os.environ.get('UNIFI_BASE_URL', 'https://10.5.0.1'),
username=os.environ['UNIFI_USERNAME'],
password=os.environ['UNIFI_PASSWORD'],
verify_tls=helper.env_bool('UNIFI_VERIFY_TLS', False),
timeout=20,
)
client.login()
zones_payload = client.get(f'/proxy/network/v2/api/site/{args.site}/firewall/zone')
zone_data_raw = zones_payload['data'] if isinstance(zones_payload, dict) and 'data' in zones_payload else zones_payload
zone_data = zone_data_raw if isinstance(zone_data_raw, list) else []
zone_ids = resolve_zone_ids(zone_data)
policies_path = f'/proxy/network/v2/api/site/{args.site}/firewall-policies'
policies_payload = client.get(policies_path)
policy_data_raw = policies_payload['data'] if isinstance(policies_payload, dict) and 'data' in policies_payload else policies_payload
policy_data = policy_data_raw if isinstance(policy_data_raw, list) else []
custom_by_name = {p.get('name'): p for p in policy_data if not p.get('predefined') and p.get('name')}
result: dict[str, Any] = {
'mode': 'apply' if args.apply else 'dry-run',
'zones': sorted(zone_ids.keys()),
'changes': [],
}
for desired in desired_policies():
materialized = materialize_policy(desired, zone_ids)
existing = custom_by_name.get(materialized['name'])
desired_canonical = canonical_policy(materialized)
if existing is None:
result['changes'].append({'name': materialized['name'], 'action': 'create', 'payload': desired_canonical})
if args.apply:
status, payload = client._json_request('POST', policies_path, desired_canonical)
if status >= 300:
result['changes'][-1]['error'] = {'status': status, 'payload': payload}
continue
existing_canonical = canonical_policy(existing)
if existing_canonical != desired_canonical:
update_payload = deepcopy(existing)
for key, value in desired_canonical.items():
update_payload[key] = deepcopy(value)
result['changes'].append({
'name': materialized['name'],
'action': 'update',
'before': existing_canonical,
'after': desired_canonical,
})
if args.apply:
status, payload = client._json_request('PUT', f"{policies_path}/{existing['_id']}", update_payload)
if status >= 300:
result['changes'][-1]['error'] = {'status': status, 'payload': payload}
else:
result['changes'].append({'name': materialized['name'], 'action': 'noop'})
for name in sorted(TEMP_POLICY_NAMES):
existing = custom_by_name.get(name)
if not existing:
continue
result['changes'].append({'name': name, 'action': 'delete-temp'})
if args.apply:
status, payload = client._json_request('DELETE', f"{policies_path}/{existing['_id']}")
if status >= 300:
result['changes'][-1]['error'] = {'status': status, 'payload': payload}
print(json.dumps(result, indent=2, sort_keys=True))
return 0
if __name__ == '__main__':
raise SystemExit(main())

View File

@@ -120,7 +120,7 @@ resources:
- ai-heavy-tier - ai-heavy-tier
- degraded - degraded
labels: labels:
lan-ip: 10.5.1.112 lan-ip: 10.5.30.112
notes: |- notes: |-
Heavy AI inference server. Heavy AI inference server.
Current operational note from 2026-05-21: Current operational note from 2026-05-21:
@@ -151,6 +151,13 @@ resources:
UniFi gateway and controller. UniFi gateway and controller.
UniFi device name: UDM Pro - Old. UniFi device name: UDM Pro - Old.
WAN uplink currently active on eth9. WAN uplink currently active on eth9.
UniFi policy zones as of 2026-05-23:
- Internal = Management + Trusted + Servers
- Untrusted = IoT + Camera + Old IoT
- Hotspot = Guest
Custom hardening on top of UniFi defaults:
- Allow Internal to Untrusted
- Block Untrusted to Gateway Admin Surfaces
- kind: Server - kind: Server
name: unifi-usw-24-poe name: unifi-usw-24-poe
@@ -272,7 +279,7 @@ resources:
type: Baremetal type: Baremetal
name: rocinante-inference name: rocinante-inference
os: baremetal os: baremetal
ip: 10.5.1.112 ip: 10.5.30.112
runsOn: runsOn:
- rocinante - rocinante
@@ -495,10 +502,10 @@ resources:
- kind: Service - kind: Service
name: ollama-heavy-tier name: ollama-heavy-tier
network: network:
ip: 10.5.1.112 ip: 10.5.30.112
port: 11434 port: 11434
protocol: TCP protocol: TCP
url: http://10.5.1.112:11434 url: http://10.5.30.112:11434
tags: tags:
- degraded - degraded
runsOn: runsOn:
@@ -507,10 +514,10 @@ resources:
- kind: Service - kind: Service
name: whisper-cuda name: whisper-cuda
network: network:
ip: 10.5.1.112 ip: 10.5.30.112
port: 8787 port: 8787
protocol: TCP protocol: TCP
url: http://10.5.1.112:8787 url: http://10.5.30.112:8787
tags: tags:
- degraded - degraded
runsOn: runsOn:

2
dispatcharr/.env.example Normal file
View File

@@ -0,0 +1,2 @@
TZ=America/Chicago
DISPATCHARR_LOG_LEVEL=info

25
dispatcharr/README.md Normal file
View File

@@ -0,0 +1,25 @@
# Dispatcharr
Dispatcharr IPTV stack for PD.
Live paths:
- Compose: `/mnt/docker-ssd/docker/compose/dispatcharr`
- Appdata: `/mnt/docker-ssd/docker/appdata/dispatcharr`
Deployment:
```bash
cd /mnt/docker-ssd/docker/compose/dispatcharr
sudo -n docker compose --env-file .env config
sudo -n docker compose --env-file .env up -d
```
Access:
- Direct LAN URL: `http://10.5.30.6:9191`
- Intended public URL: `https://tv.paccoco.com` via Pangolin with Pangolin auth disabled
Notes:
- Uses Dispatcharr's recommended all-in-one container (`DISPATCHARR_ENV=aio`).
- Data is persisted at `/mnt/docker-ssd/docker/appdata/dispatcharr`.
- First-run setup creates the initial local Dispatcharr user in the web UI.
- IPTV credentials and EPG sources are added later through the UI.
- Pangolin automation helper: `automation/bin/pangolin_upsert_dispatcharr.py` (requires Pangolin integration API key).

View File

@@ -0,0 +1,28 @@
networks:
pangolin:
external: true
services:
dispatcharr:
image: ghcr.io/dispatcharr/dispatcharr:latest
container_name: dispatcharr
restart: unless-stopped
networks:
- default
- pangolin
ports:
- "9191:9191"
environment:
TZ: ${TZ}
DISPATCHARR_ENV: aio
REDIS_HOST: localhost
CELERY_BROKER_URL: redis://localhost:6379/0
DISPATCHARR_LOG_LEVEL: ${DISPATCHARR_LOG_LEVEL:-info}
volumes:
- /mnt/docker-ssd/docker/appdata/dispatcharr:/data
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9191/ >/dev/null 2>&1 || curl -sf http://127.0.0.1:9191/ >/dev/null 2>&1 || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 90s

View File

@@ -17,7 +17,8 @@ Central index for all homelab infrastructure documentation.
|------|-------------| |------|-------------|
| [Architecture Overview](architecture/ARCHITECTURE_OVERVIEW.md) | Full stack diagram and service map | | [Architecture Overview](architecture/ARCHITECTURE_OVERVIEW.md) | Full stack diagram and service map |
| [Services Directory](architecture/SERVICES_DIRECTORY.md) | All services with hosts, ports, and status | | [Services Directory](architecture/SERVICES_DIRECTORY.md) | All services with hosts, ports, and status |
| [Network Map](architecture/NETWORK.md) | VLANs, Tailscale, DNS | | [Networking Model](architecture/NETWORKING_MODEL.md) | VLANs, firewall policy, DHCP DNS, and remote access |
| [PD Future-State Topology](architecture/pd-future-state-topology.html) | Visual target-state diagram for the PD rebuild, cyber VM lane, and Serenity retirement |
| [AI Services Network](architecture/AI_SERVICES_NETWORK.md) | Cross-stack Docker networking for AI services | | [AI Services Network](architecture/AI_SERVICES_NETWORK.md) | Cross-stack Docker networking for AI services |
## Stacks ## Stacks
@@ -34,4 +35,18 @@ Central index for all homelab infrastructure documentation.
| File | Description | | File | Description |
|------|-------------| |------|-------------|
| [Expansion Plan](planning/HOMELAB_EXPANSION_PLAN.md) | 6-phase buildout — all phases complete as of 2026-05-06 | | [Expansion Plan](planning/HOMELAB_EXPANSION_PLAN.md) | 6-phase buildout — all phases complete as of 2026-05-06 |
| [PD Future-State Architecture](planning/PD_FUTURE_STATE_ARCHITECTURE.md) | Target-state host layout, storage model, cyber VM policy, and Serenity retirement path |
| [Serenity Docker Audit](planning/SERENITY_DOCKER_AUDIT.md) | Live-audited keep/move/retire container baseline for Serenity cleanup |
| [Serenity Cleanup Wave 1](planning/SERENITY_CLEANUP_WAVE_1.md) | First safe cleanup pass: legacy Pi-hole removal, stale container pruning, and DB-app migration ordering |
| [Serenity Majority Migration Plan](planning/SERENITY_MAJORITY_MIGRATION_PLAN.md) | Kanban-ready post-D7 plan for what stays, what retires, and what moves only after PD storage ownership changes |
| [TODO](planning/TODO.md) | Active and backlog tasks | | [TODO](planning/TODO.md) | Active and backlog tasks |
## Operations
| File | Description |
|------|-------------|
| [DNS Resilience](operations/DNS_RESILIENCE.md) | Technitium backup-node topology, PD sync automation, fallback semantics, and validation SOP |
| [Backup Policy](operations/BACKUP_POLICY.md) | Backup scope, database dumps, and restore cadence |
| [PD Backup Deployment](operations/PD_BACKUP_DEPLOYMENT.md) | Live PD backup runner deployment details |
| [Secrets Management](operations/SECRETS_MANAGEMENT.md) | `.env` handling, encrypted backups, and incident guardrails |
| [PD qdrant / seerr drift cleanup incident](operations/INCIDENT_2026-05-28_PD_QDRANT_SEERR_DRIFT.md) | Backup-first reconciliation after manual qdrant recovery and media/AI compose drift cleanup |

View File

@@ -1,6 +1,6 @@
# Architecture Overview # Architecture Overview
Full homelab stack as of 2026-05-09. All 6 expansion phases are complete and deployed. Current live homelab stack plus the planned direction for the upgraded PD platform.
## Servers ## Servers
@@ -9,11 +9,23 @@ Full homelab stack as of 2026-05-09. All 6 expansion phases are complete and dep
| **PlausibleDeniability (PD)** | TrueNAS Scale 25.10.2.1 | 10.5.30.6 | Primary Docker host — all compose stacks | | **PlausibleDeniability (PD)** | TrueNAS Scale 25.10.2.1 | 10.5.30.6 | Primary Docker host — all compose stacks |
| **Serenity** | Unraid 7.2.4 | 10.5.30.5 | NAS, ARR stack, CPU reranker | | **Serenity** | Unraid 7.2.4 | 10.5.30.5 | NAS, ARR stack, CPU reranker |
| **N.O.M.A.D.** | Ubuntu 25.10 | 10.5.30.7 | Offline knowledge, game servers, and standalone local services | | **N.O.M.A.D.** | Ubuntu 25.10 | 10.5.30.7 | Offline knowledge, game servers, and standalone local services |
| **Rocinante** | (bare metal) | 10.5.1.112 | Heavy Ollama models (RTX 4090) | | **Rocinante** | (bare metal) | 10.5.30.112 | Heavy Ollama models (RTX 4090) |
## Network ## Network
- **LAN subnet:** 10.5.1.0/24 - **Management:** 10.5.0.0/24
- **Trusted:** 10.5.1.0/24
- **IoT:** 10.5.10.0/24
- **Cameras:** 10.5.20.0/24
- **Servers:** 10.5.30.0/24
- **Guest:** 10.5.90.0/24
- **Legacy quarantine:** 192.168.1.0/24 (`Old IoT`)
- **UniFi policy zones:** `Internal` = Management + Trusted + Servers, `Untrusted` = IoT + Cameras + Old IoT, `Hotspot` = Guest
- **Current custom firewall hardening:** `Allow Internal to Untrusted`, `Block Untrusted to Gateway Admin Surfaces`, `Allow Untrusted to DNS Resolver`, `Block Untrusted to Gateway Default Services`, `Allow Untrusted to Gateway DHCP`, `Allow Untrusted to Gateway mDNS`, plus explicit public-DNS allows used for fallback
- **Current DHCP DNS policy:** `Management`, `Trusted`, `Servers`, `IoT`, `Camera`, and `Old IoT` advertise the Technitium trio `10.5.30.8`, `10.5.30.9`, and `10.5.30.10`, followed by external fallback `9.9.9.9`; `Guest` advertises `9.9.9.9` and `1.1.1.1`
- **Net effect:** every non-guest lane now has three independent internal Technitium resolvers for both public DNS and the private `home.paccoco.com` zone, plus external fallback for general internet name resolution if all homelab resolvers are down; untrusted lanes still retain DNS, DHCP, and mDNS but no longer have general gateway access or UDM Pro admin-surface access
- **Current sync model:** PD (`10.5.30.8`) is the authoritative Technitium source, and root cron on PD runs `/mnt/docker-ssd/docker/compose/technitium-pilot/bin/sync_backup_nodes.sh` every 15 minutes to rsync the live config to N.O.M.A.D. (`10.5.30.9`) and Serenity (`10.5.30.10`), restart those backup stacks, and verify both private and public DNS answers
- **Current DNS caveats:** `9.9.9.9` preserves public DNS continuity but is not an authoritative substitute for the internal `home.paccoco.com` zone; same-host macvlan tests are also misleading, so resolver verification must come from an off-host client or peer host
- **Tailscale:** Serenity reachable at 100.94.87.79 - **Tailscale:** Serenity reachable at 100.94.87.79
- **NFS:** Serenity exports `/mnt/user/data` and `/mnt/user/immich` → mounted on PD at `/mnt/unraid/` via post-init script - **NFS:** Serenity exports `/mnt/user/data` and `/mnt/user/immich` → mounted on PD at `/mnt/unraid/` via post-init script
- **Docker networking:** Cross-stack communication via named external networks (`ai-services`, `ix-databases_shared-databases`) - **Docker networking:** Cross-stack communication via named external networks (`ai-services`, `ix-databases_shared-databases`)
@@ -28,7 +40,8 @@ Full homelab stack as of 2026-05-09. All 6 expansion phases are complete and dep
- **Dockhand** (3230) — Docker management UI - **Dockhand** (3230) — Docker management UI
### AI Layer ### AI Layer
- **LiteLLM** (4000) — unified LLM proxy; routes to Ollama on PD (light) and Rocinante (heavy) - **LiteLLM** (4000) — unified LLM proxy; PD remains the shared light tier, while Rocinante is an opportunistic heavy tier when the PC is online
- **Honcho** — memory/deriver stack on N.O.M.A.D. using local Ollama first so PD outages do not cascade into MeshMonitor/database incidents
- **OpenWebUI** (8282) — chat UI with RAG, STT, and web search - **OpenWebUI** (8282) — chat UI with RAG, STT, and web search
- **Qdrant** (6333/6334) — vector database for RAG - **Qdrant** (6333/6334) — vector database for RAG
- **Whisper** (8786 / 8787) — CPU speech-to-text on N.O.M.A.D. plus CUDA speech-to-text on Rocinante - **Whisper** (8786 / 8787) — CPU speech-to-text on N.O.M.A.D. plus CUDA speech-to-text on Rocinante
@@ -62,3 +75,12 @@ Full homelab stack as of 2026-05-09. All 6 expansion phases are complete and dep
4. **NFS for Immich** — photo library lives on Serenity's large array; PD mounts on demand (not at boot) 4. **NFS for Immich** — photo library lives on Serenity's large array; PD mounts on demand (not at boot)
5. **TrueNAS manages Docker** — never use `systemctl restart docker` on PD; use `docker compose` or `docker restart` for individual containers 5. **TrueNAS manages Docker** — never use `systemctl restart docker` on PD; use `docker compose` or `docker restart` for individual containers
6. **Credential hygiene** — Gitea pushes from PD/NOMAD use internal SSH (`ssh://git@10.5.30.6:2222/<owner>/<repo>.git`); GitHub remote uses SSH key (`/root/.ssh/id_gitea`) 6. **Credential hygiene** — Gitea pushes from PD/NOMAD use internal SSH (`ssh://git@10.5.30.6:2222/<owner>/<repo>.git`); GitHub remote uses SSH key (`/root/.ssh/id_gitea`)
## Planned Direction — upgraded PD + Serenity retirement
- **PD stays on bare-metal TrueNAS Scale** and becomes the long-term production center of gravity for storage, Docker, shared databases, media, identity, monitoring, and the primary DNS source node.
- **PD may also host cybersecurity VMs**, but only inside a dedicated lab VLAN / policy lane with default-deny access into production.
- **N.O.M.A.D. remains the trusted secondary lane** for offline knowledge, game hosting, and the backup Technitium resolver.
- **Rocinante becomes optional** if PD receives the 4090; otherwise it stays the heavy inference specialist.
- **Serenity is transitional**: as long as it owns the storage, qBittorrent/ARR locality can remain there; once PD directly owns the storage, that path-locality argument flips and the remaining torrent/media-ingest stack should move to PD before Serenity is retired.
- See [../planning/PD_FUTURE_STATE_ARCHITECTURE.md](../planning/PD_FUTURE_STATE_ARCHITECTURE.md) for the full target-state plan and [pd-future-state-topology.html](pd-future-state-topology.html) for the visual operator map.

View File

@@ -47,6 +47,18 @@
### NFS Mounts ### NFS Mounts
- Serenity data array → `/mnt/unraid/data` - Serenity data array → `/mnt/unraid/data`
### Planned PD upgrade target
- **Platform:** HL15 Beast ASRock build (`ASRock ROMED8-2T`)
- **Recommended CPU floor:** EPYC 7282
- **Preferred long-term CPU:** EPYC 7452 if budget allows
- **RAM floor:** 64GB
- **Preferred RAM if hosting production plus multiple cyber VMs:** 128GB
- **PSU guidance:** keep the RM1000x unless a 4090-class GPU is likely; move to HX1500i when the 4090 plan becomes real
- **Planned storage growth:**
- adopt Serenity's storage role into PD
- add a second HDD-backed ZFS pool using 5-6 new 20TB+ drives
- add SSD/NVMe tiers for apps, databases, VM disks, scratch, and model cache
--- ---
## N.O.M.A.D. (Offline Knowledge + Game Servers) ## N.O.M.A.D. (Offline Knowledge + Game Servers)

View File

@@ -1,7 +1,7 @@
# Homelab Networking Model # Homelab Networking Model
## Physical Network ## Physical Network
- **Gear:** Unifi throughout - **Gear:** UniFi throughout
- **Gateway / controller:** UDM Pro (`10.5.0.1`, WAN `69.166.182.157`) - **Gateway / controller:** UDM Pro (`10.5.0.1`, WAN `69.166.182.157`)
- **Core / access switching:** - **Core / access switching:**
- USW-24-PoE (`10.5.0.10`) - USW-24-PoE (`10.5.0.10`)
@@ -9,12 +9,34 @@
- **Wi-Fi access points:** - **Wi-Fi access points:**
- U7 Pro (`10.5.0.21`) — active - U7 Pro (`10.5.0.21`) — active
- U6 LR (`10.5.0.20`) — currently disconnected in UniFi as of 2026-05-21 - U6 LR (`10.5.0.20`) — currently disconnected in UniFi as of 2026-05-21
- **Main LAN:** 10.5.1.x (Trusted VLAN) - **Management subnet:** `10.5.0.0/24`
- **Management VLAN:** 10.5.0.x - **Trusted subnet:** `10.5.1.0/24` (VLAN 51)
- **Other VLANs:** Cameras, Guest, IoT / Old IoT - **IoT subnet:** `10.5.10.0/24` (VLAN 510)
- **Camera subnet:** `10.5.20.0/24` (VLAN 520)
- **Servers subnet:** `10.5.30.0/24` (VLAN 30)
- **Guest subnet:** `10.5.90.0/24` (VLAN 590)
- **Legacy quarantine:** `192.168.1.0/24` (`Old IoT`, VLAN 2)
- **UniFi policy zones:**
- `Internal` = Management + Trusted + Servers
- `Untrusted` = IoT + Camera + Old IoT
- `Hotspot` = Guest
- **Current custom policy additions on top of UniFi defaults:**
- `Allow Internal to Untrusted`
- `Block Untrusted to Gateway Admin Surfaces`
- `Allow Untrusted to DNS Resolver`
- `Block Untrusted to Gateway Default Services`
- `Allow Untrusted to Gateway DHCP`
- `Allow Untrusted to Gateway mDNS`
- explicit `Allow Public DNS` fallbacks
- **Current DHCP DNS policy:**
- `Management`, `Trusted`, `Servers`, `IoT`, `Camera`, and `Old IoT` advertise `10.5.30.8`, `10.5.30.9`, `10.5.30.10`, and `9.9.9.9` via DHCP
- `Guest` advertises `9.9.9.9` and `1.1.1.1`
- **Current hardening result:** every non-guest lane now has three internal Technitium resolvers for both public DNS and the private `home.paccoco.com` zone, plus an external fallback for general internet name resolution; IoT/Camera/Old IoT keep DNS, DHCP, and mDNS, but general `Untrusted -> Gateway` access and UDM Pro admin-surface access are blocked
- **Current sync model:** PD (`10.5.30.8`) is the Technitium source of truth, and root cron on PD runs `/mnt/docker-ssd/docker/compose/technitium-pilot/bin/sync_backup_nodes.sh` every 15 minutes to push the live config to N.O.M.A.D. (`10.5.30.9`) and Serenity (`10.5.30.10`), restart those backup nodes, and verify both `dns.home.paccoco.com` and a public recursive lookup
- **Current DNS caveats:** `9.9.9.9` is only a public-DNS fallback and does not preserve authoritative internal-zone behavior by itself; same-host checks against macvlan IPs are not reliable, so health verification must come from another host or client on the LAN/VLAN
- **Serenity IP:** 10.5.30.5 - **Serenity IP:** 10.5.30.5
- **N.O.M.A.D. IP:** 10.5.30.7 - **N.O.M.A.D. IP:** 10.5.30.7
- **PlausibleDeniability:** 10.5.30.6 (static on LAN) - **PlausibleDeniability:** 10.5.30.6 (Servers VLAN)
## Remote Access ## Remote Access
- **Pangolin VPS + Newt:** Services exposed as subdomains via reverse proxy. Newt agents run on PD and N.O.M.A.D. - **Pangolin VPS + Newt:** Services exposed as subdomains via reverse proxy. Newt agents run on PD and N.O.M.A.D.
@@ -53,6 +75,14 @@ networks:
external: true external: true
``` ```
### Internal Ingress Pattern
- Pangolin remains the public edge on the VPS.
- PD now runs an internal-only Traefik container on the `pangolin` network.
- Secure routed apps should move toward: `Pangolin -> Traefik -> app`.
- Traefik does not mount `docker.sock`; routes are explicit file-provider config.
- Authelia is the policy decision point for per-domain and per-group access.
- Internal services can stay HTTP-only behind Traefik unless a backend specifically needs HTTPS.
### Internal Service Model ### Internal Service Model
Internal services run HTTP only behind the reverse proxy. Compose service names used for internal DNS between services in the same stack/network. Internal services run HTTP only behind the reverse proxy. Compose service names used for internal DNS between services in the same stack/network.

View File

@@ -2,7 +2,7 @@
All homelab services, their hosts, ports, and current status. All homelab services, their hosts, ports, and current status.
*Last updated: 2026-05-21* *Last updated: 2026-06-13*
## Shared Databases (PD) ## Shared Databases (PD)
@@ -19,12 +19,16 @@ All homelab services, their hosts, ports, and current status.
| Homepage | 3300 | http://pd:3300 | ✅ Active | | Homepage | 3300 | http://pd:3300 | ✅ Active |
| Dockhand | 3230 | http://pd:3230 | ✅ Active | | Dockhand | 3230 | http://pd:3230 | ✅ Active |
| Uptime Kuma | 3001 | http://pd:3001 | ✅ Active | | Uptime Kuma | 3001 | http://pd:3001 | ✅ Active |
| Traefik | internal only | Pangolin-targeted internal ingress on `pangolin` network | ✅ Active |
| Gotify | 8443 | https://gotify.paccoco.com | ✅ Active | | Gotify | 8443 | https://gotify.paccoco.com | ✅ Active |
| Gitea | 3002 / 2222 | http://pd:3002 / http://10.5.30.6:3002 (SSH: `ssh://git@10.5.30.6:2222/<owner>/<repo>.git`) | ✅ Active | | Gitea | 3002 / 2222 | http://pd:3002 / http://10.5.30.6:3002 (SSH: `ssh://git@10.5.30.6:2222/<owner>/<repo>.git`) | ✅ Active |
| Shlink | 8087 | http://pd:8087 | ✅ Active | | Shlink | 8087 | http://pd:8087 | ✅ Active |
| Shlink Web Client | 8088 | http://pd:8088 | ✅ Active | | Shlink Web Client | 8088 | http://pd:8088 | ✅ Active |
| RackPeek | 8283 | http://pd:8283 | ✅ Active | | RackPeek | 8283 | http://pd:8283 | ✅ Active |
| Authelia | 9091 | http://pd:9091 / https://auth.paccoco.com | ✅ Active internally on PD; public Pangolin route still pending | | Authelia | 9091 | http://pd:9091 | ✅ Active internally on PD as legacy migration-only auth plumbing; public `auth.paccoco.com` now redirects to Authentik |
| Authentik | 9000 | http://pd:9000 / https://authentik.paccoco.com | ✅ Active; public identity front door for OIDC/SAML and forward-auth flows |
| Headscale | 8084 | http://pd:8084 | ✅ Active; control-plane container is running and host port is published |
| Headplane | 3005 | http://pd:3005 | ✅ Active; admin UI container is running and host port is published |
## AI Stack ## AI Stack
@@ -34,14 +38,21 @@ All homelab services, their hosts, ports, and current status.
| OpenWebUI | PD | 8282 | ✅ Active | | OpenWebUI | PD | 8282 | ✅ Active |
| Qdrant | PD | 6333 (HTTP) / 6334 (gRPC) | ✅ Active | | Qdrant | PD | 6333 (HTTP) / 6334 (gRPC) | ✅ Active |
| Whisper (faster-whisper, CPU) | N.O.M.A.D. (10.5.30.7) | 8786 | ✅ Active | | Whisper (faster-whisper, CPU) | N.O.M.A.D. (10.5.30.7) | 8786 | ✅ Active |
| Whisper (faster-whisper, CUDA large-v3) | Rocinante (10.5.1.112) | 8787 | ⚠️ Unreachable from N.O.M.A.D. during 2026-05-21 Doris validation | | Whisper (faster-whisper, CUDA large-v3) | Rocinante (10.5.30.112) | 8787 | ⚠️ Re-check from N.O.M.A.D. if Whisper-specific traffic is needed; Ollama-only recovery was validated 2026-05-27 |
| SearXNG | PD | 8888 | ✅ Active | | SearXNG | PD | 8888 | ✅ Active |
| Ollama — light tier | PD (10.5.30.6) | 11434 | ✅ Active | | Ollama — light tier | PD (10.5.30.6) | 11434 | ✅ Active; keep routine Honcho load off this host to protect MeshMonitor/shared Postgres |
| Ollama — heavy tier | Rocinante (10.5.1.112) | 11434 | ⚠️ Unreachable from N.O.M.A.D. during 2026-05-21 Doris validation | | Ollama — heavy tier | Rocinante (10.5.30.112) | 11434 | ⚠️ Personal PC / opportunistic only; do not depend on it for routine background routing |
| Reranker (TEI) | Serenity (10.5.30.5) | 9787 | ✅ Active | | Reranker (TEI) | Serenity (10.5.30.5) | 9787 | ✅ Active |
OpenClaw is no longer treated as an active PD service in the operator inventory. Only helper-artifact references remain under the school-intake planning docs. OpenClaw is no longer treated as an active PD service in the operator inventory. Only helper-artifact references remain under the school-intake planning docs.
## DNS / network control plane
| Service | Host | Port | Status |
|---------|------|------|--------|
| Technitium primary source node | PD (`10.5.30.8` via `technitium-pilot` macvlan stack) | 53 / 5380 | ✅ Active source of truth; PD root cron runs `technitium-pilot/bin/sync_backup_nodes.sh` every 15 minutes, and recent logs show successful sync + verification of Nomad (`10.5.30.9`) and Serenity (`10.5.30.10`) |
| Legacy Pi-hole runtime | PD + N.O.M.A.D. | former legacy resolver path (`10.5.30.53`, Pi-hole/Unbound/Keepalived) | Historical only. PD's old Pi-hole stack (`nebula-sync`, `pihole-primary`, `unbound-pihole-primary`, `keepalived-pihole-primary`) and NOMAD's leftover stack (`pihole-nomad` + `unbound-pihole-nomad` + `keepalived-pihole-nomad`) were both retired on 2026-05-27. The old VIP `10.5.30.53` no longer answers; Technitium (`10.5.30.8/.9/.10`) is the live DNS path. |
## Media (PD) ## Media (PD)
| Service | Port | Status | | Service | Port | Status |
@@ -60,10 +71,12 @@ OpenClaw is no longer treated as an active PD service in the operator inventory.
| Paperless-NGX | 8083 | ✅ Active; DOCX/Office ingestion via internal Tika + Gotenberg sidecars | | Paperless-NGX | 8083 | ✅ Active; DOCX/Office ingestion via internal Tika + Gotenberg sidecars |
| Karakeep | 3100 | ✅ Active | | Karakeep | 3100 | ✅ Active |
| n8n | 5678 | ✅ Active | | n8n | 5678 | ✅ Active |
| Doris Dashboard | 8787 | ✅ Active on N.O.M.A.D. behind Pangolin -> Traefik -> Authentik at `https://doris.paccoco.com` |
| KitchenOwl | 8086 | ✅ Active | | KitchenOwl | 8086 | ✅ Active |
| DoneTick | 2021 | ✅ Active | | DoneTick | 2021 | ✅ Active |
| Dakboard Bridge | 5087 | ✅ Active | | Dakboard Bridge | 5087 | ✅ Active |
| Qui | 7476 | ✅ Active | | Qui | 7476 | ✅ Active |
| Dispatcharr | 9191 | ✅ Active |
## Smart Home (PD) ## Smart Home (PD)
@@ -87,7 +100,13 @@ OpenClaw is no longer treated as an active PD service in the operator inventory.
| Service | Host | Port | Status | | Service | Host | Port | Status |
|---------|------|------|--------| |---------|------|------|--------|
| MeshCore to MQTT relay | N.O.M.A.D. (10.5.30.7) | outbound MQTT only | ✅ Running as Docker container `mctomqtt` (`mctomqtt:latest`); config bind-mounted from `/etc/mctomqtt` | | MeshCore companion observer | N.O.M.A.D. (10.5.30.7) | outbound MQTT only | ✅ Running as systemd service `meshcore-capture`; install root `/home/fizzlepoof/.meshcore-packet-capture`; direct serial connection to the Heltec companion node |
## Local file intake
| Service | Host | Port | Status |
|---------|------|------|--------|
| LocalSend Trusted Inbox | N.O.M.A.D. (`10.5.1.16` on Trusted VLAN 51) | 53317/tcp+udp | ✅ Repo-tracked Docker stack at `/opt/localsend-nomad`; advertises `Doris Trusted Inbox`; writes directly into `/home/fizzlepoof/private/inbox-secrets`; Minerva auto-sort watches that inbox via `minerva-localsend-autosort.path`; same-host checks from N.O.M.A.D. are unreliable because of macvlan isolation |
## Mapping / Geo (PD) ## Mapping / Geo (PD)

View File

@@ -0,0 +1,358 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Paccoco VLAN Policy Lanes — 2026-05-23</title>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #020617;
--panel: rgba(15, 23, 42, 0.84);
--panel-soft: rgba(15, 23, 42, 0.72);
--border: #1e293b;
--text: #e2e8f0;
--muted: #94a3b8;
--cyan: #22d3ee;
--emerald: #34d399;
--amber: #fbbf24;
--rose: #fb7185;
--violet: #a78bfa;
--slate: #94a3b8;
}
body {
font-family: 'JetBrains Mono', monospace;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 24px;
background-image:
linear-gradient(rgba(30,41,59,0.45) 1px, transparent 1px),
linear-gradient(90deg, rgba(30,41,59,0.45) 1px, transparent 1px);
background-size: 40px 40px;
}
.container { max-width: 1560px; margin: 0 auto; }
.header {
margin-bottom: 18px;
padding: 18px 20px;
border: 1px solid var(--border);
border-radius: 18px;
background: linear-gradient(180deg, rgba(15,23,42,0.92), rgba(15,23,42,0.70));
box-shadow: 0 24px 60px rgba(0,0,0,0.28);
}
.header-top {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
flex-wrap: wrap;
}
.pulse-dot {
width: 12px;
height: 12px;
border-radius: 999px;
background: var(--emerald);
box-shadow: 0 0 18px rgba(52, 211, 153, 0.5);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(0.9); opacity: 0.58; }
}
h1 {
font-size: 28px;
color: #f8fafc;
line-height: 1.15;
}
.subtitle {
color: var(--muted);
font-size: 13px;
line-height: 1.6;
max-width: 1200px;
}
.badge-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 12px;
}
.badge {
border: 1px solid var(--border);
border-radius: 999px;
padding: 6px 10px;
font-size: 11px;
color: var(--muted);
background: rgba(2,6,23,0.7);
}
.shell {
background: var(--panel-soft);
border: 1px solid var(--border);
border-radius: 20px;
padding: 18px;
overflow-x: auto;
box-shadow: 0 24px 60px rgba(0,0,0,0.28);
}
svg {
width: 100%;
min-width: 1460px;
display: block;
}
.cards {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-top: 18px;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 16px;
padding: 16px 18px;
}
.card h3 {
font-size: 13px;
color: #f8fafc;
margin-bottom: 10px;
}
.card ul {
list-style: none;
color: var(--muted);
font-size: 12px;
line-height: 1.6;
}
.card li { margin-bottom: 6px; }
.tip, .footer {
text-align: center;
color: #64748b;
font-size: 11px;
margin-top: 12px;
}
@media (max-width: 1100px) {
.cards { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="header-top">
<div class="pulse-dot"></div>
<h1>Paccoco VLAN Policy Lanes</h1>
</div>
<div class="subtitle">
Simplified operator view focused on VLAN identity and firewall intent as of 2026-05-23. This intentionally strips most host-by-host detail so the important story is obvious: which lanes are internal, which are untrusted, where DNS now lands, and which paths are allowed, blocked, or preserved only as narrow exceptions.
</div>
<div class="badge-row">
<div class="badge">Internal zone: Management + Trusted + Servers</div>
<div class="badge">Untrusted zone: IoT + Camera + Old IoT</div>
<div class="badge">Hotspot: Guest internet-only</div>
<div class="badge">Restricted-lane DNS target: 10.5.30.53</div>
</div>
</div>
<div class="shell">
<svg viewBox="0 0 1500 980" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="VLAN policy lanes diagram">
<defs>
<marker id="arrow-cyan" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#22d3ee" />
</marker>
<marker id="arrow-emerald" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#34d399" />
</marker>
<marker id="arrow-amber" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#fbbf24" />
</marker>
<marker id="arrow-rose" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#fb7185" />
</marker>
</defs>
<rect width="1500" height="980" fill="#020617" rx="18" />
<rect x="30" y="24" width="1440" height="120" rx="18" fill="rgba(8,51,68,0.08)" stroke="#22d3ee" stroke-width="1.2" stroke-dasharray="8,4" />
<text x="54" y="50" fill="#22d3ee" font-size="12" font-weight="700">Control plane and zone definitions</text>
<rect x="54" y="68" width="280" height="52" rx="10" fill="#0f172a" />
<rect x="54" y="68" width="280" height="52" rx="10" fill="rgba(136,19,55,0.38)" stroke="#fb7185" stroke-width="1.4" />
<text x="194" y="89" fill="#ffffff" font-size="13" font-weight="700" text-anchor="middle">Gateway / UDM Pro</text>
<text x="194" y="108" fill="#94a3b8" font-size="10" text-anchor="middle">Mgmt IP 10.5.0.1 • control surface lives only in Management</text>
<rect x="374" y="68" width="320" height="52" rx="10" fill="#0f172a" />
<rect x="374" y="68" width="320" height="52" rx="10" fill="rgba(6,78,59,0.38)" stroke="#34d399" stroke-width="1.4" />
<text x="534" y="89" fill="#ffffff" font-size="13" font-weight="700" text-anchor="middle">Internal Zone</text>
<text x="534" y="108" fill="#94a3b8" font-size="10" text-anchor="middle">Management + Trusted + Servers can initiate where needed</text>
<rect x="734" y="68" width="320" height="52" rx="10" fill="#0f172a" />
<rect x="734" y="68" width="320" height="52" rx="10" fill="rgba(120,53,15,0.28)" stroke="#fbbf24" stroke-width="1.4" />
<text x="894" y="89" fill="#ffffff" font-size="13" font-weight="700" text-anchor="middle">Untrusted Zone</text>
<text x="894" y="108" fill="#94a3b8" font-size="10" text-anchor="middle">IoT + Camera + Old IoT get DNS help but no broad gateway reach</text>
<rect x="1094" y="68" width="352" height="52" rx="10" fill="#0f172a" />
<rect x="1094" y="68" width="352" height="52" rx="10" fill="rgba(76,29,149,0.36)" stroke="#a78bfa" stroke-width="1.4" />
<text x="1270" y="89" fill="#ffffff" font-size="13" font-weight="700" text-anchor="middle">Shared DNS Anchor</text>
<text x="1270" y="108" fill="#94a3b8" font-size="10" text-anchor="middle">Pi-hole HA VIP 10.5.30.53 advertised to restricted lanes by DHCP</text>
<rect x="30" y="168" width="670" height="650" rx="18" fill="rgba(6,78,59,0.10)" stroke="#34d399" stroke-width="1.2" stroke-dasharray="8,4" />
<text x="54" y="194" fill="#34d399" font-size="12" font-weight="700">Internal lanes</text>
<rect x="730" y="168" width="740" height="650" rx="18" fill="rgba(120,53,15,0.08)" stroke="#fbbf24" stroke-width="1.2" stroke-dasharray="8,4" />
<text x="754" y="194" fill="#fbbf24" font-size="12" font-weight="700">Restricted / edge lanes</text>
<line x1="700" y1="492" x2="730" y2="492" stroke="#34d399" stroke-width="3" marker-end="url(#arrow-emerald)" />
<text x="715" y="480" fill="#34d399" font-size="9" text-anchor="middle">policy edge</text>
<!-- Internal column -->
<rect x="60" y="224" width="190" height="235" rx="16" fill="rgba(136,19,55,0.16)" stroke="#fb7185" stroke-width="1.4" />
<text x="155" y="254" fill="#ffffff" font-size="15" font-weight="700" text-anchor="middle">Management</text>
<text x="155" y="274" fill="#94a3b8" font-size="10" text-anchor="middle">10.5.0.0/24</text>
<text x="155" y="292" fill="#94a3b8" font-size="10" text-anchor="middle">control plane</text>
<text x="155" y="334" fill="#fb7185" font-size="11" font-weight="700" text-anchor="middle">Lives here</text>
<text x="155" y="354" fill="#94a3b8" font-size="9" text-anchor="middle">UDM Pro 10.5.0.1</text>
<text x="155" y="370" fill="#94a3b8" font-size="9" text-anchor="middle">switches, AP management</text>
<text x="155" y="408" fill="#34d399" font-size="10" text-anchor="middle">Operator-only admin surface</text>
<rect x="275" y="224" width="200" height="235" rx="16" fill="rgba(8,51,68,0.16)" stroke="#22d3ee" stroke-width="1.4" />
<text x="375" y="254" fill="#ffffff" font-size="15" font-weight="700" text-anchor="middle">Trusted</text>
<text x="375" y="274" fill="#94a3b8" font-size="10" text-anchor="middle">10.5.1.0/24 • VLAN 51</text>
<text x="375" y="292" fill="#94a3b8" font-size="10" text-anchor="middle">people + daily drivers</text>
<text x="375" y="334" fill="#ffffff" font-size="11" font-weight="700" text-anchor="middle">Purpose</text>
<text x="375" y="354" fill="#94a3b8" font-size="9" text-anchor="middle">phones, laptops, tablets</text>
<text x="375" y="370" fill="#94a3b8" font-size="9" text-anchor="middle">normal admin origin points</text>
<text x="375" y="408" fill="#34d399" font-size="10" text-anchor="middle">Can initiate toward Servers and Untrusted</text>
<rect x="500" y="224" width="170" height="235" rx="16" fill="rgba(6,78,59,0.16)" stroke="#34d399" stroke-width="1.4" />
<text x="585" y="254" fill="#ffffff" font-size="15" font-weight="700" text-anchor="middle">Servers</text>
<text x="585" y="274" fill="#94a3b8" font-size="10" text-anchor="middle">10.5.30.0/24 • VLAN 30</text>
<text x="585" y="292" fill="#94a3b8" font-size="10" text-anchor="middle">core services</text>
<text x="585" y="334" fill="#ffffff" font-size="11" font-weight="700" text-anchor="middle">Anchor services</text>
<text x="585" y="354" fill="#94a3b8" font-size="9" text-anchor="middle">PD .6 • Serenity .5</text>
<text x="585" y="370" fill="#94a3b8" font-size="9" text-anchor="middle">Nomad .7 • Rocinante .112</text>
<text x="585" y="386" fill="#a78bfa" font-size="9" text-anchor="middle">Pi-hole VIP 10.5.30.53</text>
<text x="585" y="408" fill="#34d399" font-size="10" text-anchor="middle">Service destination for the rest of the network</text>
<!-- Internal policy notes -->
<rect x="60" y="500" width="610" height="276" rx="16" fill="rgba(15,23,42,0.84)" stroke="#1e293b" stroke-width="1.2" />
<text x="86" y="530" fill="#f8fafc" font-size="13" font-weight="700">Internal policy summary</text>
<line x1="210" y1="610" x2="500" y2="610" stroke="#34d399" stroke-width="2.4" marker-end="url(#arrow-emerald)" />
<text x="355" y="596" fill="#34d399" font-size="10" text-anchor="middle">Trusted/admin workflows can reach Servers</text>
<line x1="400" y1="646" x2="740" y2="646" stroke="#34d399" stroke-width="2.4" marker-end="url(#arrow-emerald)" />
<text x="566" y="632" fill="#34d399" font-size="10" text-anchor="middle">Internal can initiate into restricted lanes when needed</text>
<line x1="585" y1="680" x2="1130" y2="680" stroke="#a78bfa" stroke-width="2.2" marker-end="url(#arrow-emerald)" />
<text x="855" y="666" fill="#a78bfa" font-size="10" text-anchor="middle">Servers host shared DNS target used by restricted lanes</text>
<text x="86" y="718" fill="#94a3b8" font-size="10">• Management remains the only rightful home of gateway administration.</text>
<text x="86" y="740" fill="#94a3b8" font-size="10">• Trusted is where John/admin clients live and where most deliberate control starts.</text>
<text x="86" y="762" fill="#94a3b8" font-size="10">• Servers is the service center, not a client junk drawer.</text>
<!-- Restricted columns -->
<rect x="760" y="224" width="190" height="250" rx="16" fill="rgba(120,53,15,0.16)" stroke="#fbbf24" stroke-width="1.4" />
<text x="855" y="254" fill="#ffffff" font-size="15" font-weight="700" text-anchor="middle">IoT</text>
<text x="855" y="274" fill="#94a3b8" font-size="10" text-anchor="middle">10.5.10.0/24 • VLAN 510</text>
<text x="855" y="292" fill="#94a3b8" font-size="10" text-anchor="middle">appliances + smart-home gear</text>
<text x="855" y="334" fill="#ffffff" font-size="11" font-weight="700" text-anchor="middle">Kept</text>
<text x="855" y="354" fill="#a78bfa" font-size="9" text-anchor="middle">DNS -> 10.5.30.53</text>
<text x="855" y="370" fill="#94a3b8" font-size="9" text-anchor="middle">DHCP preserved</text>
<text x="855" y="386" fill="#94a3b8" font-size="9" text-anchor="middle">mDNS preserved where needed</text>
<text x="855" y="426" fill="#fb7185" font-size="10" text-anchor="middle">No broad gateway reach</text>
<rect x="980" y="224" width="190" height="250" rx="16" fill="rgba(120,53,15,0.16)" stroke="#fbbf24" stroke-width="1.4" />
<text x="1075" y="254" fill="#ffffff" font-size="15" font-weight="700" text-anchor="middle">Camera</text>
<text x="1075" y="274" fill="#94a3b8" font-size="10" text-anchor="middle">10.5.20.0/24 • VLAN 520</text>
<text x="1075" y="292" fill="#94a3b8" font-size="10" text-anchor="middle">Protect chimes + camera-adjacent gear</text>
<text x="1075" y="334" fill="#ffffff" font-size="11" font-weight="700" text-anchor="middle">Kept</text>
<text x="1075" y="354" fill="#a78bfa" font-size="9" text-anchor="middle">DNS -> 10.5.30.53</text>
<text x="1075" y="370" fill="#94a3b8" font-size="9" text-anchor="middle">DHCP preserved</text>
<text x="1075" y="386" fill="#94a3b8" font-size="9" text-anchor="middle">same shield posture as IoT</text>
<text x="1075" y="426" fill="#fb7185" font-size="10" text-anchor="middle">No broad gateway reach</text>
<rect x="1200" y="224" width="230" height="250" rx="16" fill="rgba(136,19,55,0.16)" stroke="#fb7185" stroke-width="1.4" />
<text x="1315" y="254" fill="#ffffff" font-size="15" font-weight="700" text-anchor="middle">Old IoT / Legacy</text>
<text x="1315" y="274" fill="#94a3b8" font-size="10" text-anchor="middle">192.168.1.0/24 • VLAN 2</text>
<text x="1315" y="292" fill="#94a3b8" font-size="10" text-anchor="middle">shrinking-only containment lane</text>
<text x="1315" y="334" fill="#ffffff" font-size="11" font-weight="700" text-anchor="middle">Still tolerated</text>
<text x="1315" y="354" fill="#a78bfa" font-size="9" text-anchor="middle">DNS -> 10.5.30.53</text>
<text x="1315" y="370" fill="#94a3b8" font-size="9" text-anchor="middle">same shield posture as other untrusted lanes</text>
<text x="1315" y="386" fill="#fbbf24" font-size="9" text-anchor="middle">no new joins if avoidable</text>
<text x="1315" y="426" fill="#fb7185" font-size="10" text-anchor="middle">Exception-only future</text>
<rect x="870" y="522" width="450" height="100" rx="16" fill="rgba(30,41,59,0.26)" stroke="#94a3b8" stroke-width="1.3" />
<text x="1095" y="552" fill="#ffffff" font-size="15" font-weight="700" text-anchor="middle">Guest / Hotspot</text>
<text x="1095" y="572" fill="#94a3b8" font-size="10" text-anchor="middle">10.5.90.0/24 • VLAN 590</text>
<text x="1095" y="592" fill="#94a3b8" font-size="10" text-anchor="middle">internet-only lane • kept separate from the internal story</text>
<!-- Policy flows -->
<rect x="760" y="656" width="670" height="120" rx="16" fill="rgba(15,23,42,0.84)" stroke="#1e293b" stroke-width="1.2" />
<text x="786" y="686" fill="#f8fafc" font-size="13" font-weight="700">Firewall intent</text>
<line x1="815" y1="720" x2="1035" y2="720" stroke="#a78bfa" stroke-width="2.2" marker-end="url(#arrow-emerald)" />
<text x="925" y="708" fill="#a78bfa" font-size="10" text-anchor="middle">Restricted lanes get DNS service path</text>
<line x1="1060" y1="742" x2="240" y2="120" stroke="#fb7185" stroke-width="2" stroke-dasharray="6,5" marker-end="url(#arrow-rose)" />
<text x="802" y="550" fill="#fb7185" font-size="10" text-anchor="middle">Blocked: Untrusted -> Gateway admin / broad gateway access</text>
<line x1="1310" y1="470" x2="1180" y2="620" stroke="#fbbf24" stroke-width="2" stroke-dasharray="4,4" marker-end="url(#arrow-amber)" />
<text x="1250" y="560" fill="#fbbf24" font-size="10" text-anchor="middle">Only proven exceptions survive</text>
<!-- Legend -->
<rect x="30" y="850" width="1440" height="100" rx="18" fill="rgba(15,23,42,0.88)" stroke="#1e293b" stroke-width="1.2" />
<text x="56" y="880" fill="#ffffff" font-size="12" font-weight="700">Legend</text>
<rect x="56" y="898" width="16" height="10" rx="2" fill="rgba(8,51,68,0.40)" stroke="#22d3ee" stroke-width="1" />
<text x="82" y="907" fill="#94a3b8" font-size="9">trusted / operator lane</text>
<rect x="242" y="898" width="16" height="10" rx="2" fill="rgba(6,78,59,0.40)" stroke="#34d399" stroke-width="1" />
<text x="268" y="907" fill="#94a3b8" font-size="9">servers / service core</text>
<rect x="432" y="898" width="16" height="10" rx="2" fill="rgba(120,53,15,0.30)" stroke="#fbbf24" stroke-width="1" />
<text x="458" y="907" fill="#94a3b8" font-size="9">restricted edge lane</text>
<rect x="632" y="898" width="16" height="10" rx="2" fill="rgba(136,19,55,0.40)" stroke="#fb7185" stroke-width="1" />
<text x="658" y="907" fill="#94a3b8" font-size="9">management / sensitive control surface</text>
<line x1="930" y1="903" x2="972" y2="903" stroke="#34d399" stroke-width="2.2" marker-end="url(#arrow-emerald)" />
<text x="986" y="907" fill="#94a3b8" font-size="9">allowed path</text>
<line x1="1102" y1="903" x2="1144" y2="903" stroke="#fb7185" stroke-width="2.2" stroke-dasharray="6,5" marker-end="url(#arrow-rose)" />
<text x="1158" y="907" fill="#94a3b8" font-size="9">blocked path</text>
<line x1="1274" y1="903" x2="1316" y2="903" stroke="#fbbf24" stroke-width="2.2" stroke-dasharray="4,4" marker-end="url(#arrow-amber)" />
<text x="1330" y="907" fill="#94a3b8" font-size="9">narrow preserved exception</text>
</svg>
</div>
<div class="cards">
<div class="card">
<h3>Read this diagram as policy, not inventory</h3>
<ul>
<li>• Left side is where control and deliberate administration belong.</li>
<li>• Right side is where devices live when they need containment more than trust.</li>
<li>• The important service export from Servers is shared DNS at 10.5.30.53.</li>
</ul>
</div>
<div class="card">
<h3>The two rules that matter most</h3>
<ul>
<li>• Internal can initiate outward when needed.</li>
<li>• Untrusted does not get broad gateway access back in, especially not UDM admin TCP.</li>
<li>• Any exception has to earn its keep with a real failing device.</li>
</ul>
</div>
<div class="card">
<h3>What still deserves live validation</h3>
<ul>
<li>• Renew a DHCP lease on one client from each restricted lane.</li>
<li>• Confirm that client resolves through 10.5.30.53.</li>
<li>• Confirm gateway UI/admin ports still fail from those lanes.</li>
</ul>
</div>
</div>
<div class="tip">Open locally with: xdg-open /home/fizzlepoof/repos/truenas-stacks/docs/architecture/network-policy-lanes-2026-05-23.html</div>
<div class="footer">Focused simplification derived from the broader network topology document created on 2026-05-23.</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,424 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Paccoco Homelab Network Topology — 2026-05-23</title>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'JetBrains Mono', monospace;
background: #020617;
color: #e2e8f0;
min-height: 100vh;
padding: 24px;
}
.container {
max-width: 1480px;
margin: 0 auto;
}
.header {
margin-bottom: 20px;
}
.header-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.pulse-dot {
width: 12px;
height: 12px;
border-radius: 999px;
background: #22d3ee;
animation: pulse 2s infinite;
box-shadow: 0 0 18px rgba(34, 211, 238, 0.55);
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.55; transform: scale(0.92); }
}
h1 {
font-size: 26px;
font-weight: 700;
color: #f8fafc;
}
.subtitle {
color: #94a3b8;
font-size: 13px;
margin-left: 24px;
line-height: 1.5;
}
.diagram-shell {
background: rgba(15, 23, 42, 0.72);
border: 1px solid #1e293b;
border-radius: 18px;
padding: 18px;
overflow-x: auto;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.28);
}
svg {
width: 100%;
min-width: 1380px;
display: block;
}
.cards {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-top: 18px;
}
.card {
background: rgba(15, 23, 42, 0.72);
border: 1px solid #1e293b;
border-radius: 14px;
padding: 16px 18px;
}
.card-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.card-dot {
width: 8px;
height: 8px;
border-radius: 999px;
}
.cyan { background: #22d3ee; }
.emerald { background: #34d399; }
.rose { background: #fb7185; }
.amber { background: #fbbf24; }
.violet { background: #a78bfa; }
.slate { background: #94a3b8; }
.card h3 {
font-size: 13px;
color: #f8fafc;
}
.card ul {
list-style: none;
color: #94a3b8;
font-size: 12px;
line-height: 1.55;
}
.card li { margin-bottom: 6px; }
.footer {
text-align: center;
color: #64748b;
font-size: 11px;
margin-top: 14px;
}
.tip {
margin-top: 10px;
color: #94a3b8;
font-size: 12px;
}
@media (max-width: 1100px) {
.cards { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="header-row">
<div class="pulse-dot"></div>
<h1>Paccoco Homelab Network Topology</h1>
</div>
<div class="subtitle">
Current-state operator map as of 2026-05-23. Reflects live UniFi segmentation, the new Servers VLAN,
restricted-lane DNS cutover to 10.5.30.53, and the broader Untrusted → Gateway shield with DHCP/mDNS preservation.
</div>
</div>
<div class="diagram-shell">
<svg viewBox="0 0 1500 1080" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Homelab network topology diagram">
<defs>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="#1e293b" stroke-width="0.6" />
</pattern>
<marker id="arrow-cyan" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#22d3ee" />
</marker>
<marker id="arrow-emerald" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#34d399" />
</marker>
<marker id="arrow-amber" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#fbbf24" />
</marker>
<marker id="arrow-rose" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#fb7185" />
</marker>
</defs>
<rect width="1500" height="1080" fill="#020617" />
<rect width="1500" height="1080" fill="url(#grid)" />
<!-- Top boundaries -->
<rect x="30" y="24" width="1440" height="130" rx="16" fill="rgba(120,53,15,0.10)" stroke="#fbbf24" stroke-width="1.2" stroke-dasharray="8,4" />
<text x="50" y="48" fill="#fbbf24" font-size="12" font-weight="700">External access + WAN edge</text>
<rect x="30" y="176" width="1440" height="214" rx="16" fill="rgba(8,51,68,0.08)" stroke="#22d3ee" stroke-width="1.2" stroke-dasharray="8,4" />
<text x="50" y="200" fill="#22d3ee" font-size="12" font-weight="700">UniFi control plane + switching fabric</text>
<rect x="30" y="420" width="1440" height="520" rx="16" fill="rgba(30,41,59,0.14)" stroke="#64748b" stroke-width="1.2" stroke-dasharray="8,4" />
<text x="50" y="444" fill="#cbd5e1" font-size="12" font-weight="700">Segmentation lanes</text>
<!-- External row -->
<rect x="70" y="62" width="190" height="62" rx="8" fill="#0f172a" />
<rect x="70" y="62" width="190" height="62" rx="8" fill="rgba(30,41,59,0.55)" stroke="#94a3b8" stroke-width="1.5" />
<text x="165" y="87" fill="#ffffff" font-size="13" font-weight="700" text-anchor="middle">Internet / Clients</text>
<text x="165" y="106" fill="#94a3b8" font-size="10" text-anchor="middle">trusted users • phones • browsers</text>
<rect x="350" y="62" width="220" height="62" rx="8" fill="#0f172a" />
<rect x="350" y="62" width="220" height="62" rx="8" fill="rgba(120,53,15,0.30)" stroke="#fbbf24" stroke-width="1.5" />
<text x="460" y="87" fill="#ffffff" font-size="13" font-weight="700" text-anchor="middle">Pangolin VPS + Newt</text>
<text x="460" y="106" fill="#94a3b8" font-size="10" text-anchor="middle">reverse proxy for public subdomains</text>
<rect x="660" y="62" width="180" height="62" rx="8" fill="#0f172a" />
<rect x="660" y="62" width="180" height="62" rx="8" fill="rgba(136,19,55,0.40)" stroke="#fb7185" stroke-width="1.5" />
<text x="750" y="87" fill="#ffffff" font-size="13" font-weight="700" text-anchor="middle">Tailscale</text>
<text x="750" y="106" fill="#94a3b8" font-size="10" text-anchor="middle">Serenity: 100.94.87.79</text>
<rect x="950" y="62" width="250" height="62" rx="8" fill="#0f172a" />
<rect x="950" y="62" width="250" height="62" rx="8" fill="rgba(30,41,59,0.55)" stroke="#94a3b8" stroke-width="1.5" />
<text x="1075" y="87" fill="#ffffff" font-size="13" font-weight="700" text-anchor="middle">Cloudflare DNS</text>
<text x="1075" y="106" fill="#94a3b8" font-size="10" text-anchor="middle">paccoco.com records point to Pangolin VPS</text>
<!-- Core row -->
<rect x="100" y="242" width="220" height="92" rx="10" fill="#0f172a" />
<rect x="100" y="242" width="220" height="92" rx="10" fill="rgba(136,19,55,0.40)" stroke="#fb7185" stroke-width="1.6" />
<text x="210" y="268" fill="#ffffff" font-size="14" font-weight="700" text-anchor="middle">UDM Pro</text>
<text x="210" y="288" fill="#94a3b8" font-size="10" text-anchor="middle">gateway + UniFi controller</text>
<text x="210" y="305" fill="#94a3b8" font-size="10" text-anchor="middle">Mgmt IP: 10.5.0.1</text>
<text x="210" y="322" fill="#fb7185" font-size="9" text-anchor="middle">Admin TCP blocked from Untrusted</text>
<rect x="410" y="230" width="250" height="116" rx="10" fill="#0f172a" />
<rect x="410" y="230" width="250" height="116" rx="10" fill="rgba(120,53,15,0.28)" stroke="#fbbf24" stroke-width="1.6" />
<text x="535" y="256" fill="#ffffff" font-size="14" font-weight="700" text-anchor="middle">Switch Fabric</text>
<text x="535" y="278" fill="#94a3b8" font-size="10" text-anchor="middle">USW-24-PoE — 10.5.0.10</text>
<text x="535" y="296" fill="#94a3b8" font-size="10" text-anchor="middle">USW Pro HD 24 — 10.5.0.135</text>
<text x="535" y="314" fill="#fbbf24" font-size="9" text-anchor="middle">10G links for PD + Serenity • 2.5G lanes for Nomad/FlyingDutchman/Rocinante</text>
<rect x="760" y="230" width="250" height="116" rx="10" fill="#0f172a" />
<rect x="760" y="230" width="250" height="116" rx="10" fill="rgba(8,51,68,0.42)" stroke="#22d3ee" stroke-width="1.6" />
<text x="885" y="256" fill="#ffffff" font-size="14" font-weight="700" text-anchor="middle">Wi-Fi Access Points</text>
<text x="885" y="278" fill="#94a3b8" font-size="10" text-anchor="middle">U7 Pro — 10.5.0.21 — active</text>
<text x="885" y="296" fill="#94a3b8" font-size="10" text-anchor="middle">U6 LR — 10.5.0.20</text>
<text x="885" y="314" fill="#fb7185" font-size="9" text-anchor="middle">currently disconnected in UniFi</text>
<rect x="1110" y="230" width="280" height="116" rx="10" fill="#0f172a" />
<rect x="1110" y="230" width="280" height="116" rx="10" fill="rgba(6,78,59,0.40)" stroke="#34d399" stroke-width="1.6" />
<text x="1250" y="256" fill="#ffffff" font-size="14" font-weight="700" text-anchor="middle">Policy Zones</text>
<text x="1250" y="278" fill="#94a3b8" font-size="10" text-anchor="middle">Internal = Mgmt + Trusted + Servers</text>
<text x="1250" y="296" fill="#94a3b8" font-size="10" text-anchor="middle">Untrusted = IoT + Camera + Old IoT</text>
<text x="1250" y="314" fill="#94a3b8" font-size="10" text-anchor="middle">Hotspot = Guest</text>
<!-- Arrows top/core -->
<line x1="260" y1="93" x2="348" y2="93" stroke="#94a3b8" stroke-width="1.5" marker-end="url(#arrow-amber)" />
<line x1="570" y1="93" x2="658" y2="93" stroke="#94a3b8" stroke-width="1.5" marker-end="url(#arrow-rose)" />
<line x1="838" y1="93" x2="948" y2="93" stroke="#94a3b8" stroke-width="1.5" marker-end="url(#arrow-amber)" />
<line x1="210" y1="124" x2="210" y2="240" stroke="#22d3ee" stroke-width="1.8" marker-end="url(#arrow-cyan)" />
<line x1="535" y1="346" x2="535" y2="438" stroke="#22d3ee" stroke-width="1.8" marker-end="url(#arrow-cyan)" />
<line x1="885" y1="346" x2="885" y2="438" stroke="#22d3ee" stroke-width="1.8" marker-end="url(#arrow-cyan)" />
<line x1="210" y1="334" x2="410" y2="288" stroke="#22d3ee" stroke-width="1.6" marker-end="url(#arrow-cyan)" />
<line x1="660" y1="288" x2="760" y2="288" stroke="#22d3ee" stroke-width="1.6" marker-end="url(#arrow-cyan)" />
<line x1="1010" y1="288" x2="1110" y2="288" stroke="#22d3ee" stroke-width="1.6" marker-end="url(#arrow-cyan)" />
<!-- VLAN columns -->
<!-- Management -->
<rect x="56" y="482" width="250" height="390" rx="14" fill="rgba(136,19,55,0.16)" stroke="#fb7185" stroke-width="1.3" />
<text x="181" y="512" fill="#ffffff" font-size="15" font-weight="700" text-anchor="middle">Management</text>
<text x="181" y="531" fill="#94a3b8" font-size="10" text-anchor="middle">10.5.0.0/24</text>
<text x="181" y="548" fill="#94a3b8" font-size="10" text-anchor="middle">UDM Pro • switches • AP management</text>
<rect x="82" y="578" width="198" height="78" rx="8" fill="#0f172a" />
<rect x="82" y="578" width="198" height="78" rx="8" fill="rgba(136,19,55,0.40)" stroke="#fb7185" stroke-width="1.4" />
<text x="181" y="604" fill="#ffffff" font-size="12" font-weight="700" text-anchor="middle">Core Infra</text>
<text x="181" y="623" fill="#94a3b8" font-size="9" text-anchor="middle">UDM Pro 10.5.0.1</text>
<text x="181" y="639" fill="#94a3b8" font-size="9" text-anchor="middle">USW-24-PoE 10.5.0.10 • USW Pro HD 10.5.0.135</text>
<rect x="82" y="690" width="198" height="82" rx="8" fill="#0f172a" />
<rect x="82" y="690" width="198" height="82" rx="8" fill="rgba(8,51,68,0.40)" stroke="#22d3ee" stroke-width="1.4" />
<text x="181" y="716" fill="#ffffff" font-size="12" font-weight="700" text-anchor="middle">AP Control</text>
<text x="181" y="735" fill="#94a3b8" font-size="9" text-anchor="middle">U7 Pro active</text>
<text x="181" y="751" fill="#94a3b8" font-size="9" text-anchor="middle">U6 LR disconnected but still modeled</text>
<text x="181" y="812" fill="#fb7185" font-size="10" text-anchor="middle">Management stays operator-only</text>
<!-- Trusted -->
<rect x="336" y="482" width="250" height="390" rx="14" fill="rgba(8,51,68,0.16)" stroke="#22d3ee" stroke-width="1.3" />
<text x="461" y="512" fill="#ffffff" font-size="15" font-weight="700" text-anchor="middle">Trusted</text>
<text x="461" y="531" fill="#94a3b8" font-size="10" text-anchor="middle">10.5.1.0/24 • VLAN 51</text>
<text x="461" y="548" fill="#94a3b8" font-size="10" text-anchor="middle">laptops • phones • tablets • daily-driver clients</text>
<rect x="362" y="578" width="198" height="82" rx="8" fill="#0f172a" />
<rect x="362" y="578" width="198" height="82" rx="8" fill="rgba(8,51,68,0.40)" stroke="#22d3ee" stroke-width="1.4" />
<text x="461" y="604" fill="#ffffff" font-size="12" font-weight="700" text-anchor="middle">User Devices</text>
<text x="461" y="623" fill="#94a3b8" font-size="9" text-anchor="middle">operator/admin endpoints live here</text>
<text x="461" y="639" fill="#94a3b8" font-size="9" text-anchor="middle">can initiate into servers + untrusted lanes</text>
<rect x="362" y="690" width="198" height="82" rx="8" fill="#0f172a" />
<rect x="362" y="690" width="198" height="82" rx="8" fill="rgba(30,41,59,0.55)" stroke="#94a3b8" stroke-width="1.4" />
<text x="461" y="716" fill="#ffffff" font-size="12" font-weight="700" text-anchor="middle">Wi-Fi SSID</text>
<text x="461" y="735" fill="#94a3b8" font-size="9" text-anchor="middle">primary trusted network</text>
<text x="461" y="751" fill="#94a3b8" font-size="9" text-anchor="middle">legacy compatibility cleanup still ongoing</text>
<text x="461" y="812" fill="#34d399" font-size="10" text-anchor="middle">Allowed to initiate toward Untrusted</text>
<!-- Servers -->
<rect x="616" y="482" width="290" height="390" rx="14" fill="rgba(6,78,59,0.16)" stroke="#34d399" stroke-width="1.3" />
<text x="761" y="512" fill="#ffffff" font-size="15" font-weight="700" text-anchor="middle">Servers</text>
<text x="761" y="531" fill="#94a3b8" font-size="10" text-anchor="middle">10.5.30.0/24 • VLAN 30</text>
<text x="761" y="548" fill="#94a3b8" font-size="10" text-anchor="middle">core services + app stacks + AI + storage-adjacent hosts</text>
<rect x="642" y="572" width="238" height="112" rx="8" fill="#0f172a" />
<rect x="642" y="572" width="238" height="112" rx="8" fill="rgba(6,78,59,0.40)" stroke="#34d399" stroke-width="1.4" />
<text x="761" y="598" fill="#ffffff" font-size="12" font-weight="700" text-anchor="middle">Core Hosts</text>
<text x="761" y="617" fill="#94a3b8" font-size="9" text-anchor="middle">PlausibleDeniability — 10.5.30.6</text>
<text x="761" y="633" fill="#94a3b8" font-size="9" text-anchor="middle">Serenity — 10.5.30.5</text>
<text x="761" y="649" fill="#94a3b8" font-size="9" text-anchor="middle">N.O.M.A.D. — 10.5.30.7</text>
<text x="761" y="665" fill="#94a3b8" font-size="9" text-anchor="middle">Rocinante — 10.5.30.112</text>
<rect x="642" y="706" width="238" height="98" rx="8" fill="#0f172a" />
<rect x="642" y="706" width="238" height="98" rx="8" fill="rgba(76,29,149,0.40)" stroke="#a78bfa" stroke-width="1.4" />
<text x="761" y="732" fill="#ffffff" font-size="12" font-weight="700" text-anchor="middle">Shared Services</text>
<text x="761" y="751" fill="#94a3b8" font-size="9" text-anchor="middle">Pi-hole HA VIP / DNS: 10.5.30.53</text>
<text x="761" y="767" fill="#94a3b8" font-size="9" text-anchor="middle">Gitea • Home Assistant • n8n • OpenWebUI • Qdrant</text>
<text x="761" y="783" fill="#94a3b8" font-size="9" text-anchor="middle">LiteLLM • monitoring • media • productivity stacks</text>
<text x="761" y="832" fill="#34d399" font-size="10" text-anchor="middle">Internal services reachable by trusted/admin workflows</text>
<!-- IoT -->
<rect x="936" y="482" width="250" height="390" rx="14" fill="rgba(120,53,15,0.16)" stroke="#fbbf24" stroke-width="1.3" />
<text x="1061" y="512" fill="#ffffff" font-size="15" font-weight="700" text-anchor="middle">IoT</text>
<text x="1061" y="531" fill="#94a3b8" font-size="10" text-anchor="middle">10.5.10.0/24 • VLAN 510</text>
<text x="1061" y="548" fill="#94a3b8" font-size="10" text-anchor="middle">ecobees • MyQ • fridge • smart appliances</text>
<rect x="962" y="584" width="198" height="84" rx="8" fill="#0f172a" />
<rect x="962" y="584" width="198" height="84" rx="8" fill="rgba(120,53,15,0.30)" stroke="#fbbf24" stroke-width="1.4" />
<text x="1061" y="610" fill="#ffffff" font-size="12" font-weight="700" text-anchor="middle">DHCP DNS</text>
<text x="1061" y="629" fill="#94a3b8" font-size="9" text-anchor="middle">hands out 10.5.30.53</text>
<text x="1061" y="645" fill="#94a3b8" font-size="9" text-anchor="middle">no longer depends on gateway-default DNS</text>
<rect x="962" y="696" width="198" height="92" rx="8" fill="#0f172a" />
<rect x="962" y="696" width="198" height="92" rx="8" fill="rgba(136,19,55,0.40)" stroke="#fb7185" stroke-width="1.4" />
<text x="1061" y="722" fill="#ffffff" font-size="12" font-weight="700" text-anchor="middle">Gateway Access</text>
<text x="1061" y="741" fill="#94a3b8" font-size="9" text-anchor="middle">general gateway access blocked</text>
<text x="1061" y="757" fill="#94a3b8" font-size="9" text-anchor="middle">DHCP + mDNS preserved</text>
<text x="1061" y="773" fill="#94a3b8" font-size="9" text-anchor="middle">UDM admin TCP explicitly blocked</text>
<!-- Camera -->
<rect x="1216" y="482" width="250" height="180" rx="14" fill="rgba(120,53,15,0.16)" stroke="#fbbf24" stroke-width="1.3" />
<text x="1341" y="512" fill="#ffffff" font-size="15" font-weight="700" text-anchor="middle">Camera</text>
<text x="1341" y="531" fill="#94a3b8" font-size="10" text-anchor="middle">10.5.20.0/24 • VLAN 520</text>
<text x="1341" y="548" fill="#94a3b8" font-size="10" text-anchor="middle">Protect chimes + camera-adjacent gear</text>
<text x="1341" y="582" fill="#94a3b8" font-size="9" text-anchor="middle">Same shield posture as IoT</text>
<text x="1341" y="599" fill="#94a3b8" font-size="9" text-anchor="middle">DNS -> 10.5.30.53 • DHCP/mDNS preserved</text>
<text x="1341" y="616" fill="#94a3b8" font-size="9" text-anchor="middle">general gateway access blocked</text>
<!-- Guest -->
<rect x="1216" y="686" width="250" height="86" rx="14" fill="rgba(30,41,59,0.26)" stroke="#94a3b8" stroke-width="1.3" />
<text x="1341" y="715" fill="#ffffff" font-size="15" font-weight="700" text-anchor="middle">Guest / Hotspot</text>
<text x="1341" y="734" fill="#94a3b8" font-size="10" text-anchor="middle">10.5.90.0/24 • VLAN 590 • internet-only lane</text>
<!-- Old IoT -->
<rect x="1216" y="794" width="250" height="96" rx="14" fill="rgba(136,19,55,0.18)" stroke="#fb7185" stroke-width="1.3" />
<text x="1341" y="823" fill="#ffffff" font-size="15" font-weight="700" text-anchor="middle">Old IoT / Legacy Quarantine</text>
<text x="1341" y="842" fill="#94a3b8" font-size="10" text-anchor="middle">192.168.1.0/24 • VLAN 2</text>
<text x="1341" y="859" fill="#94a3b8" font-size="9" text-anchor="middle">shrinking-only containment lane</text>
<text x="1341" y="875" fill="#94a3b8" font-size="9" text-anchor="middle">same DNS/gateway shield pattern as IoT/Camera</text>
<!-- Inter-lane arrows / policy cues -->
<line x1="510" y1="636" x2="935" y2="636" stroke="#34d399" stroke-width="1.8" marker-end="url(#arrow-emerald)" />
<text x="726" y="626" fill="#34d399" font-size="9" text-anchor="middle">Allow Internal → Untrusted</text>
<line x1="1061" y1="584" x2="761" y2="756" stroke="#a78bfa" stroke-width="1.8" marker-end="url(#arrow-emerald)" />
<text x="930" y="699" fill="#a78bfa" font-size="9" text-anchor="middle">DNS only → 10.5.30.53</text>
<line x1="1061" y1="696" x2="210" y2="334" stroke="#fb7185" stroke-width="1.6" stroke-dasharray="6,5" marker-end="url(#arrow-rose)" />
<text x="705" y="520" fill="#fb7185" font-size="9" text-anchor="middle">Blocked: general Untrusted → Gateway</text>
<line x1="1341" y1="572" x2="210" y2="306" stroke="#fb7185" stroke-width="1.4" stroke-dasharray="5,5" marker-end="url(#arrow-rose)" />
<text x="770" y="394" fill="#fb7185" font-size="9" text-anchor="middle">Blocked: UDM admin TCP from Camera / IoT / Old IoT</text>
<line x1="1061" y1="788" x2="761" y2="786" stroke="#fbbf24" stroke-width="1.5" stroke-dasharray="4,4" marker-end="url(#arrow-amber)" />
<text x="908" y="779" fill="#fbbf24" font-size="9" text-anchor="middle">Only proven exceptions stay</text>
<!-- Legend -->
<rect x="52" y="958" width="1396" height="90" rx="12" fill="rgba(15,23,42,0.85)" stroke="#1e293b" stroke-width="1.2" />
<text x="78" y="986" fill="#ffffff" font-size="12" font-weight="700">Legend</text>
<rect x="78" y="1000" width="16" height="10" rx="2" fill="rgba(8,51,68,0.40)" stroke="#22d3ee" stroke-width="1" />
<text x="103" y="1009" fill="#94a3b8" font-size="9">Trusted / operator lanes</text>
<rect x="270" y="1000" width="16" height="10" rx="2" fill="rgba(6,78,59,0.40)" stroke="#34d399" stroke-width="1" />
<text x="295" y="1009" fill="#94a3b8" font-size="9">Servers / core services</text>
<rect x="468" y="1000" width="16" height="10" rx="2" fill="rgba(120,53,15,0.30)" stroke="#fbbf24" stroke-width="1" />
<text x="493" y="1009" fill="#94a3b8" font-size="9">IoT / camera lanes</text>
<rect x="640" y="1000" width="16" height="10" rx="2" fill="rgba(136,19,55,0.40)" stroke="#fb7185" stroke-width="1" />
<text x="665" y="1009" fill="#94a3b8" font-size="9">Management / blocked security surfaces</text>
<line x1="910" y1="1005" x2="948" y2="1005" stroke="#34d399" stroke-width="1.8" marker-end="url(#arrow-emerald)" />
<text x="960" y="1009" fill="#94a3b8" font-size="9">allowed path</text>
<line x1="1088" y1="1005" x2="1126" y2="1005" stroke="#fb7185" stroke-width="1.8" stroke-dasharray="5,5" marker-end="url(#arrow-rose)" />
<text x="1138" y="1009" fill="#94a3b8" font-size="9">blocked path</text>
<line x1="1262" y1="1005" x2="1300" y2="1005" stroke="#fbbf24" stroke-width="1.6" stroke-dasharray="4,4" marker-end="url(#arrow-amber)" />
<text x="1312" y="1009" fill="#94a3b8" font-size="9">narrow preserved exception</text>
</svg>
</div>
<div class="cards">
<div class="card">
<div class="card-header">
<div class="card-dot emerald"></div>
<h3>Core hosts + routing facts</h3>
</div>
<ul>
<li>• PD 10.5.30.6 is the main Docker/control host.</li>
<li>• Serenity 10.5.30.5 handles NAS + ARR + Tailscale reachability.</li>
<li>• N.O.M.A.D. 10.5.30.7 runs local services and game/server workloads.</li>
<li>• Rocinante 10.5.30.112 carries heavy Ollama inference.</li>
<li>• UDM Pro stays at 10.5.0.1 on the management subnet.</li>
</ul>
</div>
<div class="card">
<div class="card-header">
<div class="card-dot amber"></div>
<h3>What changed recently</h3>
</div>
<ul>
<li>• Servers VLAN is live at 10.5.30.0/24 (VLAN 30).</li>
<li>• IoT, Camera, and Old IoT now DHCP-advertise 10.5.30.53 for DNS.</li>
<li>• Broader Untrusted → Gateway block is live.</li>
<li>• DHCP and mDNS are still preserved for restricted lanes.</li>
<li>• UDM Pro admin TCP ports are explicitly blocked from Untrusted.</li>
</ul>
</div>
<div class="card">
<div class="card-header">
<div class="card-dot rose"></div>
<h3>Still worth validating on real clients</h3>
</div>
<ul>
<li>• Renew one DHCP lease per restricted lane.</li>
<li>• Confirm DNS resolution works through 10.5.30.53.</li>
<li>• Confirm gateway admin/UI access fails from those lanes.</li>
<li>• Keep Google/cast validation as a separate laptop-present test wave.</li>
<li>• Treat any new exception as earned only by a real failing device.</li>
</ul>
</div>
</div>
<div class="tip">Open locally with: xdg-open /home/fizzlepoof/repos/truenas-stacks/docs/architecture/network-topology-2026-05-23.html</div>
<div class="footer">Generated from current repo docs + live UniFi changes documented on 2026-05-23.</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,210 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PD Future-State Topology</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #020617;
--panel: #0f172a;
--panel-2: #111827;
--text: #e2e8f0;
--muted: #94a3b8;
--cyan: #22d3ee;
--emerald: #34d399;
--violet: #a78bfa;
--amber: #fbbf24;
--rose: #fb7185;
--slate: #94a3b8;
--line: #1e293b;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: 'JetBrains Mono', monospace;
color: var(--text);
background:
linear-gradient(var(--line) 1px, transparent 1px),
linear-gradient(90deg, var(--line) 1px, transparent 1px),
var(--bg);
background-size: 40px 40px;
}
.wrap { max-width: 1600px; margin: 0 auto; padding: 24px; }
.header, .card, .diagram-shell {
background: rgba(15, 23, 42, 0.88);
border: 1px solid #1f2937;
border-radius: 16px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
}
.header { padding: 20px 24px; margin-bottom: 20px; }
h1 { margin: 0 0 8px; font-size: 28px; }
.subtitle { color: var(--muted); line-height: 1.6; }
.diagram-shell { padding: 16px; overflow-x: auto; }
svg { width: 100%; min-width: 1450px; height: auto; display: block; }
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-top: 20px;
}
.card { padding: 16px 18px; }
.card h3 { margin: 0 0 12px; font-size: 16px; }
.card ul { margin: 0; padding-left: 18px; color: var(--muted); line-height: 1.7; }
.footer { margin-top: 16px; color: var(--muted); font-size: 12px; }
@media (max-width: 1100px) { .grid { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="wrap">
<div class="header">
<h1>PD Future-State Topology</h1>
<div class="subtitle">
Target-state operator map for the post-upgrade homelab: PD becomes the primary production platform, NOMAD remains the trusted secondary lane, Rocinante is optional AI specialization, and Serenity is drained then retired after storage adoption.
</div>
</div>
<div class="diagram-shell">
<svg viewBox="0 0 1500 940" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="PD future-state topology diagram">
<defs>
<marker id="arrow-cyan" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L0,6 L9,3 z" fill="#22d3ee" />
</marker>
<marker id="arrow-emerald" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L0,6 L9,3 z" fill="#34d399" />
</marker>
<marker id="arrow-rose" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L0,6 L9,3 z" fill="#fb7185" />
</marker>
<marker id="arrow-amber" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L0,6 L9,3 z" fill="#fbbf24" />
</marker>
</defs>
<rect x="20" y="20" width="1460" height="900" rx="18" fill="none" stroke="#334155" stroke-width="1.5" />
<path d="M745 115 L745 170" stroke="#22d3ee" stroke-width="3" fill="none" marker-end="url(#arrow-cyan)"/>
<path d="M540 405 L400 405" stroke="#34d399" stroke-width="3" fill="none" marker-end="url(#arrow-emerald)"/>
<path d="M960 405 L1110 405" stroke="#a78bfa" stroke-width="3" fill="none" marker-end="url(#arrow-cyan)"/>
<path d="M745 640 L745 760" stroke="#fbbf24" stroke-width="3" fill="none" marker-end="url(#arrow-amber)"/>
<path d="M1240 535 C1320 560, 1360 620, 1360 710" stroke="#fb7185" stroke-width="3" fill="none" stroke-dasharray="8 6" marker-end="url(#arrow-rose)"/>
<path d="M250 520 C180 570, 160 640, 180 725" stroke="#34d399" stroke-width="3" fill="none" marker-end="url(#arrow-emerald)"/>
<path d="M1180 220 C1290 240, 1360 300, 1380 390" stroke="#fbbf24" stroke-width="2.5" fill="none" stroke-dasharray="7 5" marker-end="url(#arrow-amber)"/>
<rect x="600" y="50" width="290" height="65" rx="10" fill="#0f172a" />
<rect x="600" y="50" width="290" height="65" rx="10" fill="rgba(30,41,59,0.55)" stroke="#94a3b8" stroke-width="1.5" />
<text x="745" y="77" fill="#e2e8f0" text-anchor="middle" font-size="15" font-weight="700">Internet / Remote Edge</text>
<text x="745" y="97" fill="#94a3b8" text-anchor="middle" font-size="11">Pangolin VPS, public DNS, external access</text>
<rect x="510" y="170" width="470" height="470" rx="18" fill="none" stroke="#fbbf24" stroke-width="1.5" stroke-dasharray="10 6" />
<text x="530" y="195" fill="#fbbf24" font-size="12">Production / Servers lane</text>
<rect x="60" y="300" width="340" height="270" rx="14" fill="#0f172a" />
<rect x="60" y="300" width="340" height="270" rx="14" fill="rgba(6,78,59,0.35)" stroke="#34d399" stroke-width="1.6" />
<text x="85" y="330" fill="#e2e8f0" font-size="18" font-weight="700">N.O.M.A.D.</text>
<text x="85" y="352" fill="#34d399" font-size="12">Trusted secondary / resilience lane</text>
<text x="85" y="385" fill="#cbd5e1" font-size="12">• Project NOMAD offline stack</text>
<text x="85" y="408" fill="#cbd5e1" font-size="12">• Pelican / Wings / game servers</text>
<text x="85" y="431" fill="#cbd5e1" font-size="12">• Backup Technitium resolver</text>
<text x="85" y="454" fill="#cbd5e1" font-size="12">• Small bounded utility workloads</text>
<text x="85" y="500" fill="#94a3b8" font-size="11">Second failure domain. Do not turn into a second PD.</text>
<rect x="540" y="220" width="420" height="360" rx="16" fill="#0f172a" />
<rect x="540" y="220" width="420" height="360" rx="16" fill="rgba(6,78,59,0.42)" stroke="#34d399" stroke-width="1.8" />
<text x="570" y="253" fill="#e2e8f0" font-size="24" font-weight="700">PD (upgraded HL15 Beast)</text>
<text x="570" y="277" fill="#34d399" font-size="13">Primary production core on bare-metal TrueNAS Scale</text>
<rect x="570" y="305" width="180" height="110" rx="10" fill="rgba(76,29,149,0.36)" stroke="#a78bfa" stroke-width="1.4" />
<text x="590" y="330" fill="#e2e8f0" font-size="14" font-weight="700">Storage</text>
<text x="590" y="354" fill="#cbd5e1" font-size="11">• imported Serenity capacity</text>
<text x="590" y="374" fill="#cbd5e1" font-size="11">• new 20TB+ HDD pool</text>
<text x="590" y="394" fill="#cbd5e1" font-size="11">• SSD/NVMe app + VM tier</text>
<rect x="770" y="305" width="160" height="110" rx="10" fill="rgba(8,51,68,0.36)" stroke="#22d3ee" stroke-width="1.4" />
<text x="790" y="330" fill="#e2e8f0" font-size="14" font-weight="700">Apps + DBs</text>
<text x="790" y="354" fill="#cbd5e1" font-size="11">• Docker / compose</text>
<text x="790" y="374" fill="#cbd5e1" font-size="11">• shared Postgres/MariaDB/Redis</text>
<text x="790" y="394" fill="#cbd5e1" font-size="11">• identity / monitoring / media</text>
<rect x="570" y="440" width="180" height="110" rx="10" fill="rgba(120,53,15,0.32)" stroke="#fbbf24" stroke-width="1.4" />
<text x="590" y="465" fill="#e2e8f0" font-size="14" font-weight="700">Media + ARR</text>
<text x="590" y="489" fill="#cbd5e1" font-size="11">• qbit/ARR move here after cutover</text>
<text x="590" y="509" fill="#cbd5e1" font-size="11">• path locality follows the disks</text>
<text x="590" y="529" fill="#cbd5e1" font-size="11">• removes Serenity dependency</text>
<rect x="770" y="440" width="160" height="110" rx="10" fill="rgba(136,19,55,0.30)" stroke="#fb7185" stroke-width="1.4" />
<text x="790" y="465" fill="#e2e8f0" font-size="14" font-weight="700">Cyber VM lane</text>
<text x="790" y="489" fill="#cbd5e1" font-size="11">• AD / Windows / Linux labs</text>
<text x="790" y="509" fill="#cbd5e1" font-size="11">• segmented VLAN</text>
<text x="790" y="529" fill="#cbd5e1" font-size="11">• default-deny to production</text>
<rect x="1110" y="300" width="310" height="250" rx="14" fill="#0f172a" />
<rect x="1110" y="300" width="310" height="250" rx="14" fill="rgba(76,29,149,0.36)" stroke="#a78bfa" stroke-width="1.6" />
<text x="1135" y="330" fill="#e2e8f0" font-size="18" font-weight="700">Rocinante</text>
<text x="1135" y="352" fill="#a78bfa" font-size="12">Optional AI specialist</text>
<text x="1135" y="385" fill="#cbd5e1" font-size="12">• Keep if PD does not get the 4090</text>
<text x="1135" y="408" fill="#cbd5e1" font-size="12">• Otherwise overflow / experimental AI</text>
<text x="1135" y="431" fill="#cbd5e1" font-size="12">• Must justify its ongoing power + sprawl</text>
<text x="1135" y="477" fill="#94a3b8" font-size="11">Heavy inference can stay separate if desired, but production does not depend on that.</text>
<rect x="545" y="690" width="400" height="180" rx="16" fill="#0f172a" />
<rect x="545" y="690" width="400" height="180" rx="16" fill="rgba(120,53,15,0.30)" stroke="#fbbf24" stroke-width="1.6" stroke-dasharray="10 6" />
<text x="570" y="720" fill="#e2e8f0" font-size="18" font-weight="700">Serenity</text>
<text x="570" y="742" fill="#fbbf24" font-size="12">Transition-only platform</text>
<text x="570" y="775" fill="#cbd5e1" font-size="12">Now: storage owner, torrent locality, reranker, backup DNS node</text>
<text x="570" y="798" fill="#cbd5e1" font-size="12">Then: drain services, adopt storage into PD, remove dependency</text>
<text x="570" y="821" fill="#fb7185" font-size="12">End-state: retire to reduce power draw and operational sprawl</text>
<rect x="1140" y="650" width="280" height="200" rx="14" fill="#0f172a" />
<rect x="1140" y="650" width="280" height="200" rx="14" fill="rgba(136,19,55,0.28)" stroke="#fb7185" stroke-width="1.6" />
<text x="1165" y="680" fill="#e2e8f0" font-size="18" font-weight="700">Lab VLAN policy</text>
<text x="1165" y="712" fill="#cbd5e1" font-size="12">• Lab -> production: deny by default</text>
<text x="1165" y="735" fill="#cbd5e1" font-size="12">• Trusted admin -> lab: explicit allow</text>
<text x="1165" y="758" fill="#cbd5e1" font-size="12">• Lab -> DNS/NTP/internet: allow</text>
<text x="1165" y="781" fill="#cbd5e1" font-size="12">• Resource-cap VMs to protect production</text>
<text x="1165" y="804" fill="#cbd5e1" font-size="12">• Malware / truly sketchy work should still move elsewhere</text>
<rect x="80" y="660" width="300" height="170" rx="14" fill="#0f172a" />
<rect x="80" y="660" width="300" height="170" rx="14" fill="rgba(30,41,59,0.52)" stroke="#94a3b8" stroke-width="1.4" />
<text x="105" y="690" fill="#e2e8f0" font-size="18" font-weight="700">Operating principles</text>
<text x="105" y="722" fill="#cbd5e1" font-size="12">1. PD is the production center of gravity</text>
<text x="105" y="745" fill="#cbd5e1" font-size="12">2. NOMAD stays the trusted secondary lane</text>
<text x="105" y="768" fill="#cbd5e1" font-size="12">3. Cyber labs can share hardware, not trust</text>
<text x="105" y="791" fill="#cbd5e1" font-size="12">4. Design around Serenity retirement</text>
</svg>
</div>
<div class="grid">
<div class="card">
<h3>What belongs on PD</h3>
<ul>
<li>Primary ZFS storage, production Docker, databases, media, identity, monitoring, and DNS source-of-truth</li>
<li>qBittorrent + ARR stack after the storage move, because path locality should follow the disks</li>
<li>AI primary too if the 4090 lands there</li>
</ul>
</div>
<div class="card">
<h3>What remains off PD</h3>
<ul>
<li>NOMAD keeps offline knowledge, game hosting, and backup DNS</li>
<li>Rocinante stays only if heavy inference separation is still useful</li>
<li>High-risk or sketchy cyber work should eventually move to a dedicated lab node</li>
</ul>
</div>
<div class="card">
<h3>What this means for Serenity</h3>
<ul>
<li>Remove obvious service sprawl early</li>
<li>Preserve storage/torrent locality only until PD owns the storage directly</li>
<li>Retire Serenity once its storage and remaining roles are drained</li>
</ul>
</div>
</div>
<div class="footer">Generated as a repo-owned planning artifact for the PD rebuild, segmentation plan, and Serenity retirement path.</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,129 @@
# DNS Resilience
Current homelab DNS resilience model after the single-node outage lesson.
## Problem Statement
When Technitium on PD died, clients that depended on that single internal resolver lost DNS. Public internet connectivity did not matter because name resolution was gone first.
The fix was to stop treating DNS as a single-host service and advertise multiple internal resolvers across the important LANs.
## Current Resolver Topology
| Resolver | Host | Service role | Resolver IP |
|----------|------|--------------|-------------|
| PD Technitium | PlausibleDeniability | primary live source of truth | `10.5.30.8` |
| Nomad Technitium | N.O.M.A.D. | backup internal resolver | `10.5.30.9` |
| Serenity Technitium | Serenity | backup internal resolver | `10.5.30.10` |
| Quad9 | external | public-DNS fallback only | `9.9.9.9` |
All three Technitium nodes serve:
- recursive public DNS
- the private authoritative zone `home.paccoco.com`
## DHCP Advertisement Policy
UniFi DHCP currently advertises the Technitium trio plus an external fallback on:
- `Management`
- `Trusted`
- `Servers`
- `IoT`
- `Camera`
- `Old IoT`
Advertised order:
1. `10.5.30.8`
2. `10.5.30.9`
3. `10.5.30.10`
4. `9.9.9.9`
`Guest` remains public-only:
- `9.9.9.9`
- `1.1.1.1`
## What This Fixes
- A single Technitium host failure no longer wipes DNS for Trusted or Servers clients.
- Internal-zone continuity now survives the loss of any one Technitium node, as long as at least one of the remaining Technitium nodes is healthy.
- Public DNS continuity still exists even if the whole internal Technitium trio is down, because clients also have `9.9.9.9`.
## Important Limits
### External fallback is not full internal-DNS continuity
`9.9.9.9` is only a public fallback.
It can preserve general internet resolution if all internal resolvers die, but it does not replace the authoritative internal `home.paccoco.com` zone behavior.
Operationally: if all three Technitium nodes are down, clients may still resolve public domains, but private homelab names may be missing or semantically wrong.
### Macvlan health checks are deceptive
These Technitium nodes use dedicated macvlan IPs.
That means a host often cannot reliably query its own resolver IP:
- PD may fail to query `10.5.30.8` from the PD host itself
- NOMAD may fail to query `10.5.30.9` from NOMAD itself
- Serenity may fail to query `10.5.30.10` from Serenity itself
Do not treat same-host DNS failures as proof that the resolver is down.
Use an off-host probe from another LAN host or client.
## Current Sync Model
PD is the authoritative Technitium source of truth.
Live sync runner:
- path: `/mnt/docker-ssd/docker/compose/technitium-pilot/bin/sync_backup_nodes.sh`
Live schedule:
- root cron on PD every 15 minutes
Log path:
- `/mnt/tank/docker/appdata/technitium-pilot/logs/sync-backup-nodes.log`
The runner does all of the following:
1. rsync the live PD Technitium config to NOMAD and Serenity
2. exclude rebuildable cache/stats/blocklist artifacts
3. restart the backup stacks
4. verify a private authoritative answer for `dns.home.paccoco.com`
5. verify a public recursive answer for `cloudflare.com`
6. stamp each backup with `last_pd_sync_utc.txt`
## Validation SOP
When verifying DNS resilience, check from the correct network context.
Minimum proof:
1. confirm UniFi DHCP advertises `10.5.30.8`, `10.5.30.9`, `10.5.30.10`, and `9.9.9.9` on the intended VLANs
2. from an off-host client or peer host, query each internal resolver directly for:
- `dns.home.paccoco.com A`
- `cloudflare.com A`
3. confirm the PD sync cron exists
4. inspect the latest sync log for successful Nomad and Serenity verification
Recommended direct checks:
- `dig @10.5.30.8 dns.home.paccoco.com A`
- `dig @10.5.30.9 dns.home.paccoco.com A`
- `dig @10.5.30.10 dns.home.paccoco.com A`
- `dig @10.5.30.8 cloudflare.com A`
- `dig @10.5.30.9 cloudflare.com A`
- `dig @10.5.30.10 cloudflare.com A`
## Operator Guidance
- If internal-zone records change on PD, the backups should pick them up automatically within 15 minutes.
- If the sync log shows failures, treat that as an internal-zone resilience regression even if public DNS still works.
- If testing from the host that owns the macvlan IP fails, repeat the test from another host before escalating.
- Keep at least one external fallback in DHCP for non-guest lanes.
## Source Paths
- Repo stack docs:
- `technitium-pilot/README.md`
- `technitium-nomad/README.md`
- `technitium-serenity/README.md`
- Architecture docs:
- `docs/architecture/ARCHITECTURE_OVERVIEW.md`
- `docs/architecture/NETWORKING_MODEL.md`

View File

@@ -1,8 +1,9 @@
# Identity and SSO # Identity and SSO
Authelia is the staged identity provider for the homelab. Authentik is the public identity provider for the homelab.
Authelia remains available internally on PD as legacy migration-only plumbing while older routes are retired.
## Why Authelia ## Why Authentik
- centralizes auth for the growing pile of web apps - centralizes auth for the growing pile of web apps
- works with shared Postgres/Redis already in the lab - works with shared Postgres/Redis already in the lab
@@ -13,8 +14,29 @@ Authelia is the staged identity provider for the homelab.
- stack path: identity/ - stack path: identity/
- host target: PlausibleDeniability - host target: PlausibleDeniability
- public entrypoint: auth.paccoco.com - public entrypoint: `https://authentik.paccoco.com` (`https://auth.paccoco.com` is now just a compatibility redirect)
- status: repo-staged, not assumed live until deployed on PD - status: Authentik is the public login front door; Traefik now protects `doris.paccoco.com` through the embedded outpost and Authelia is no longer intended for direct public use
## Access-control direction
Traefik is the internal router. Authentik is now the preferred public auth layer via native OIDC or embedded outpost forward-auth; Authelia is retained only for legacy/internal migration cases still being retired.
Current intended protected domains:
- `doris.paccoco.com`
- `gitea.paccoco.com`
- `grafana.paccoco.com`
- `traefik.paccoco.com`
App-native SSO exception:
- `dns.paccoco.com` now uses Technitium's own OIDC flow with Authentik instead of Traefik forward-auth, so the app owns `/sso/login` and `/sso/callback` directly.
Current baseline group policy:
- `admins` -> full access to the domains above
Planned expansion pattern:
- create additional Authelia groups per audience or app class
- grant access in `access_control.rules` by domain + `group:<name>` subject
- keep Pangolin SSO disabled for routes that Traefik/Authelia protect, to avoid double auth
## Initial bootstrap flow ## Initial bootstrap flow

View File

@@ -0,0 +1,76 @@
# Incident: UniFi Baseline Secret Leak (2026-05-22)
## Summary
A raw UniFi baseline export was committed to the main `truenas-stacks` repo during the May 22, 2026 network-cutover documentation pass. GitGuardian later flagged three secret leaks in the latest commits.
The leaked material came from a controller export artifact, not from a normal `.env` file.
## What leaked
The committed file contained three WireGuard secrets in `home/doris-dashboard/docs/baselines/unifi-object-baseline-2026-05-22-024653.json`:
1. an embedded `wireguard_client_configuration_file` containing a `PrivateKey = ...`
2. one `x_wireguard_private_key`
3. another `x_wireguard_private_key`
## How it happened
The failure mode was:
1. a read-only UniFi object baseline was captured to support cautious cutover work
2. the export was saved as a raw JSON artifact under `home/doris-dashboard/docs/baselines/`
3. that raw export was treated like a harmless recon artifact and committed alongside other network redesign docs
4. the export included VPN/WireGuard fields that are safe for controller runtime but unsafe for a git-tracked documentation repo
5. GitGuardian detected the leaked keys after push
## Root cause
This was a process failure, not a git-crypt failure.
The repo already had rules for `.env` secrets, but not a strong enough rule for **controller exports / diagnostic baselines / machine snapshots** that may embed secrets even when they are not named `.env`.
In short:
- we protected the usual secret files
- we did **not** treat raw infrastructure exports as secret-bearing by default
## Remediation completed
The following remediation was performed:
- the leaked values were removed from the working tree
- git history was rewritten to purge the exact leaked values
- cleaned history was force-pushed to both `origin` and `github`
- raw JSON baseline exports under `home/doris-dashboard/docs/baselines/` were moved out of the main repo policy
- the repo now documents that only sanitized summaries or explicitly redacted artifacts belong in the main repo
## New permanent rules
1. **Raw controller/API exports do not belong in the main repo.**
- Examples: UniFi `networkconf`, `portconf`, full appliance JSON dumps, diagnostic snapshots, vendor backups, tunnel/client config exports.
2. **Assume machine exports are secret-bearing until proven otherwise.**
- If it came from a controller or appliance, inspect it like a secret file.
3. **Only commit one of these:**
- a markdown summary
- a purpose-built redacted JSON artifact
- a generated diff with secret-bearing keys removed
4. **Before committing infra artifacts, scan them explicitly.**
- Look for terms like `private_key`, `wireguard`, `token`, `secret`, `password`, `Authorization`, `cookie`, `client_secret`.
5. **If raw export retention is required, store it outside the main repo.**
- Prefer the encrypted secrets repo or another non-public operator-only location.
6. **If a secret-bearing export is committed, treat it as an incident.**
- redact/remove from current tree
- rewrite history
- force-push
- rotate live credentials/keys as appropriate
## Operator follow-up still recommended
History cleanup fixed the repo exposure. If any leaked WireGuard keys were active in production at the time of exposure, rotate them deliberately and update affected clients.
## Prevent-recurrence checklist
- [ ] raw exports ignored by default in the repo path where they are commonly captured
- [ ] incident documented in `SECRETS_MANAGEMENT.md`
- [ ] dashboard baseline directory has a README explaining what may and may not be committed
- [ ] future cutover notes point to sanitized summaries rather than raw exports

View File

@@ -0,0 +1,126 @@
# Incident: PD qdrant / seerr compose drift cleanup (2026-05-28)
## Summary
An earlier recovery on PlausibleDeniability left the runtime state healthier than the operator metadata around it.
Public and LAN access were restored, but `qdrant` had been manually recreated from `docker inspect` data because its prior compose-path labeling was stale or absent. That fixed service availability, but it also created the risk that live state, repo state, and future operator assumptions could drift apart.
On 2026-05-28, the stack was squared away by taking fresh backups, re-validating the live compose roots, re-syncing repo-managed compose content into the live directories, pruning a stale backup artifact from the live media compose directory, and re-verifying health.
## What broke
The incident had two layers:
1. **Service health / accessibility breakage**
- `qdrant` and related AI/media surface area needed recovery work earlier in the day.
- `seerr` public/local availability also had to be checked as part of the same cleanup pass.
2. **Post-recovery metadata drift risk**
- `qdrant` no longer had a trustworthy compose lifecycle trail from the original broken state.
- Manual recreation from `docker inspect` restored the container, but that is not the same as a clean repo-driven compose reconciliation.
- A stale file (`docker-compose.yaml.pre-d3b-20260526-003732`) was still sitting in the live media compose directory, which was not dangerous by itself but increased operator ambiguity.
## What was manually recreated
During the earlier recovery, `qdrant` was recreated manually from live container metadata when the prior compose-path labels were stale or missing.
By the end of the cleanup, the running `qdrant` container correctly reported:
- working dir: `/mnt/docker-ssd/docker/compose/ai`
- compose file: `/mnt/docker-ssd/docker/compose/ai/docker-compose.yaml`
- service: `qdrant`
`seerr` correctly reported:
- working dir: `/mnt/docker-ssd/docker/compose/media`
- compose file: `/mnt/docker-ssd/docker/compose/media/docker-compose.yaml`
- service: `seerr`
## Backups taken before cleanup
Fresh rollback-friendly backups were created on PD at:
- directory: `/mnt/docker-ssd/docker/backups/post-incident-sync-20260528-192945`
- tarball: `/mnt/docker-ssd/docker/backups/post-incident-sync-20260528-192945.tgz`
Backup contents include:
- live `ai` compose tree
- live `media` compose tree
- `qdrant` appdata
- `docker inspect` output for `qdrant`, `openwebui`, and `seerr`
- rendered `docker compose config` output for both stacks
## Cleanup actions performed
1. verified the live container labels for `qdrant`, `openwebui`, and `seerr`
2. compared live compose files against the repo-managed copies under `truenas-stacks`
3. confirmed the active tracked compose content already matched repo
4. re-synced repo-managed `ai/` and `media/` compose trees into the live PD compose directories
5. pruned the stale live file `docker-compose.yaml.pre-d3b-20260526-003732` from the media compose directory **after** backup
6. re-ran `docker compose config` validation in both live directories
7. re-checked HTTP health for `qdrant`, `openwebui`, and `seerr`
## What was verified at the end
### Live compose / label alignment
The live runtime and repo are back in agreement for the affected services:
- `qdrant` labels point to `/mnt/docker-ssd/docker/compose/ai/docker-compose.yaml`
- `seerr` labels point to `/mnt/docker-ssd/docker/compose/media/docker-compose.yaml`
- repo-managed tracked compose content matches the live active files
### Health
The following checks passed after cleanup:
- `http://10.5.30.6:6333/healthz``200`
- `http://10.5.30.6:8282/``200`
- `http://10.5.30.6:5055/``200`
### Compose validation
These both rendered successfully:
- `/mnt/docker-ssd/docker/compose/ai/docker-compose.yaml`
- `/mnt/docker-ssd/docker/compose/media/docker-compose.yaml`
## Important non-actions
- The Docker daemon was **not** restarted.
- The stacks were **not** redeployed during this cleanup pass.
- No repo-unrelated working tree changes were touched.
That was intentional: the active tracked compose files already matched repo, and the services were healthy, so a no-redeploy reconciliation was the safer path.
## Why this matters
The main value of this cleanup was not changing application behavior. It was restoring operator confidence that:
- the live compose roots are the authoritative ones
- the running containers point back to those roots
- repo state and live state are aligned again
- future maintenance can start from the compose directories instead of from ad hoc inspect output
## Next time: safer recovery procedure
If a PD stack service must be rebuilt under pressure:
1. **Identify the authoritative compose directory first.**
- Prefer `/mnt/docker-ssd/docker/compose/<stack>/docker-compose.yaml` over ad hoc recreation.
2. **Take a backup before cleanup, even if the service is already back up.**
- Capture compose trees, relevant appdata, and `docker inspect` output.
3. **If manual recreation is unavoidable, treat it as temporary stabilization only.**
- Reconcile labels, compose roots, and repo state immediately afterward.
4. **Compare repo ↔ live compose content before redeploying.**
- If tracked content already matches and health is good, avoid unnecessary churn.
5. **Prune stale backup/override files from live compose dirs only after backup.**
- Leaving old compose fragments nearby makes future incident work harder.
6. **Verify both health and metadata.**
- A container being up is not enough; confirm compose labels, working dir, and health endpoints.
## Follow-up status
No immediate further action was required after the cleanup pass. The remaining lesson is procedural: manual container recreation is acceptable as a short-term rescue, but it should always be followed by a backup-first repo/live reconciliation so drift does not become the new baseline.

View File

@@ -35,7 +35,7 @@ PD_DATABASES_ROOT=/mnt/docker-ssd/docker/databases
PD_TANK_DOCKER_ROOT=/mnt/tank/docker PD_TANK_DOCKER_ROOT=/mnt/tank/docker
PD_DB_DUMP_ROOT=/mnt/tank/docker/backups/db-dumps PD_DB_DUMP_ROOT=/mnt/tank/docker/backups/db-dumps
POSTGRES_CONTAINER=shared-postgres POSTGRES_CONTAINER=shared-postgres
POSTGRES_DB_LIST="n8n paperless" POSTGRES_DB_LIST="gitea n8n paperless"
BACKUP_SSH_KEY=/home/truenas_admin/.ssh/serenity_backup_ed25519 BACKUP_SSH_KEY=/home/truenas_admin/.ssh/serenity_backup_ed25519
DOCKER_BIN=/usr/bin/docker DOCKER_BIN=/usr/bin/docker
SUDO_BIN=/usr/bin/sudo SUDO_BIN=/usr/bin/sudo
@@ -54,7 +54,7 @@ mkdir -p /mnt/tank/docker/backups/db-dumps
Confirm: Confirm:
- fresh `*.sql.gz` files appear in `/mnt/tank/docker/backups/db-dumps` - fresh `gitea_*.sql.gz`, `n8n_*.sql.gz`, and `paperless_*.sql.gz` files appear in `/mnt/tank/docker/backups/db-dumps`
- appdata/database/tank-docker content lands under the chosen Serenity backup root - appdata/database/tank-docker content lands under the chosen Serenity backup root
- no sudo prompt appeared during `docker exec` - no sudo prompt appeared during `docker exec`
@@ -98,6 +98,8 @@ What it proves:
- expected files still exist inside the restored sample - expected files still exist inside the restored sample
- the latest run state can be exported to Prometheus via node-exporter's textfile collector - the latest run state can be exported to Prometheus via node-exporter's textfile collector
Current expectation: `POSTGRES_DB_LIST` includes `gitea` so the backup set contains a logical dump for the Gitea database in addition to the raw synced Postgres data directory.
## Important limitations ## Important limitations
- Do **not** use `systemctl restart docker` on PD. - Do **not** use `systemctl restart docker` on PD.
@@ -105,3 +107,4 @@ What it proves:
- `rsync --delete` is intentional; use a dedicated destination root, not a shared miscellaneous folder. - `rsync --delete` is intentional; use a dedicated destination root, not a shared miscellaneous folder.
- This scaffolding does not yet back up `.env` files here because those are already handled by the separate encrypted/private-repo process. - This scaffolding does not yet back up `.env` files here because those are already handled by the separate encrypted/private-repo process.
- If you insist on a non-root cron user on PD, you will need passwordless Docker access (`sudo -n /usr/bin/docker ...`) and write access to the chosen dump directory. - If you insist on a non-root cron user on PD, you will need passwordless Docker access (`sudo -n /usr/bin/docker ...`) and write access to the chosen dump directory.
- For Gitea, do not treat the live-synced Postgres datadir as the main restore proof; keep and verify the `gitea` logical dump as part of the normal restore-verification cycle.

View File

@@ -4,10 +4,10 @@
Secrets are managed in two layers: Secrets are managed in two layers:
1. **Live secrets**`.env` files on PD at `/mnt/docker-ssd/docker/compose/<stack>/.env`, gitignored and never committed to the main repo 1. **Live secrets**`.env` files in the active deployment trees on PD, N.O.M.A.D., and Serenity; gitignored and never committed to the main repo
2. **Encrypted backup**all `.env` files are synced to a private git-crypt encrypted Gitea repo at `/mnt/docker-ssd/docker/secrets/` 2. **Encrypted backup**those live `.env` files are backed up into the private git-crypt encrypted Gitea repo at `/mnt/docker-ssd/docker/secrets/` on PD
The sync is a one-command operation and is safe to re-run at any time. PD is the secrets-backup hub. PD-origin `.env` files sync locally into the encrypted repo, and standalone `.env` files from N.O.M.A.D. or Serenity must also be copied into that same encrypted repo so the backup set is not PD-only.
--- ---
@@ -37,7 +37,9 @@ openssl rand -base64 32
## Live .env File Locations ## Live .env File Locations
All `.env` files live alongside their `docker-compose.yaml`: ### PD compose tree
PD stack `.env` files live alongside their `docker-compose.yaml`:
``` ```
/mnt/docker-ssd/docker/compose/ /mnt/docker-ssd/docker/compose/
@@ -56,6 +58,31 @@ All `.env` files live alongside their `docker-compose.yaml`:
photos/.env photos/.env
``` ```
### N.O.M.A.D. standalone service roots
N.O.M.A.D. keeps standalone service deployments under `/opt/<service>`. Any service-local `.env` there is also part of the secrets backup scope.
Backed-up examples currently include:
```
/opt/doris-kitchen/.env
/opt/doris-schoolhouse/.env
/opt/hawser-nomad/.env
/opt/honcho/.env
/opt/pihole-nomad/.env
/opt/technitium-nomad/.env
```
### Serenity appdata roots
Serenity service-local `.env` files under `/mnt/user/appdata/<service>/.env` are also in scope for encrypted backup.
Current verified example:
```
/mnt/user/appdata/technitium-serenity/.env
```
--- ---
## Secrets Repo (git-crypt) ## Secrets Repo (git-crypt)
@@ -72,7 +99,7 @@ All `.env` files live alongside their `docker-compose.yaml`:
| Directory | Contents | | Directory | Contents |
|-----------|----------| |-----------|----------|
| `env/` | `.env` files for each stack, named `<stack>.env` | | `env/` | `.env` files for PD stacks and standalone service roots, named with stable service-oriented filenames such as `<stack>.env`, `<service>-nomad.env`, or `<service>-serenity.env` |
| `keys/` | API keys and tokens | | `keys/` | API keys and tokens |
| `certs/` | TLS certificates | | `certs/` | TLS certificates |
| `tokens/` | Service tokens (Paperless, LiteLLM, etc.) | | `tokens/` | Service tokens (Paperless, LiteLLM, etc.) |
@@ -84,9 +111,12 @@ All files except `README.md` and `.gitattributes` are encrypted by git-crypt on
## Syncing .env Files to the Secrets Repo ## Syncing .env Files to the Secrets Repo
Use the sync script — no root required, safe to re-run: ### PD-native compose env sync
Use the sync script from PD after any live PD `.env` change. Run it in a root-capable context and make sure `git-crypt` is on `PATH`:
```bash ```bash
export PATH="/mnt/docker-ssd/bin:/root/bin:$PATH"
bash /mnt/docker-ssd/docker/compose/scripts/sync-envs-to-secrets.sh bash /mnt/docker-ssd/docker/compose/scripts/sync-envs-to-secrets.sh
``` ```
@@ -96,10 +126,35 @@ What it does:
- Skips unchanged files - Skips unchanged files
- Commits and pushes to Gitea automatically - Commits and pushes to Gitea automatically
Run this any time you create or update a stack's `.env` file. Run this any time you create or update a PD stack `.env` file.
**Operational rule:** after any live `.env` change on PD, Doris or the operator must run the sync script and confirm whether it committed/pushed changes or reported the secrets repo already up to date. Do not treat live `.env` edits as complete until this sync step is checked. **Operational rule:** after any live `.env` change on PD, Doris or the operator must run the sync script and confirm whether it committed/pushed changes or reported the secrets repo already up to date. Do not treat live `.env` edits as complete until this sync step is checked.
### N.O.M.A.D. and Serenity standalone env backup rule
Not every important `.env` lives under PD's compose tree.
For N.O.M.A.D. `/opt/<service>/.env` files and Serenity `/mnt/user/appdata/<service>/.env` files:
1. pull or copy the live file contents to PD through an approved host path
2. store them in `/mnt/docker-ssd/docker/secrets/env/` using a stable name like `<service>-nomad.env` or `<service>-serenity.env`
3. `git add`, `git commit`, and `git push` from the encrypted secrets repo on PD
4. verify the backed-up file hash matches the live source before declaring the backup complete
Current naming examples:
```
env/doris-kitchen.env
env/doris-schoolhouse.env
env/hawser-nomad.env
env/honcho.env
env/pihole-nomad.env
env/technitium-nomad.env
env/technitium-serenity.env
```
Operationally, treat these non-PD env files as first-class backup scope. The homelab secrets backup is not complete if only PD compose env files are protected.
--- ---
## git-crypt on TrueNAS ## git-crypt on TrueNAS
@@ -206,6 +261,81 @@ On a fresh PD after reinstalling TrueNAS:
--- ---
## Controller Export / Baseline Artifact Rules
Raw infrastructure exports must be treated as potentially secret-bearing even when they are not `.env` files.
Examples:
- UniFi `networkconf` / `portconf` dumps
- firewall/router/controller JSON exports
- VPN client or server config exports
- diagnostic snapshots taken from appliances or operator APIs
### Permanent rules
- Do **not** commit raw controller or appliance exports to the main repo.
- Commit only sanitized markdown summaries or explicitly redacted artifacts.
- If raw retention is needed, store the export in the encrypted secrets repo or another operator-only location outside the main repo.
- Before committing infra artifacts, scan for obvious secret-bearing keys such as:
- `private_key`
- `wireguard`
- `token`
- `secret`
- `password`
- `Authorization`
- `cookie`
- `client_secret`
### Incident reference
A real leak occurred on 2026-05-22 when a raw UniFi baseline export under `home/doris-dashboard/docs/baselines/` was committed with embedded WireGuard secrets.
See:
- `docs/operations/INCIDENT_2026-05-22_UNIFI_BASELINE_SECRET_LEAK.md`
- `home/doris-dashboard/docs/baselines/README.md`
### Git hook + CI guardrails for infra artifacts
The repo now includes layered guardrails aimed specifically at catching secret-bearing controller/export artifacts before they leave a workstation.
Files:
- `scripts/scan-secret-bearing-artifacts.sh`
- `.githooks/pre-commit`
- `.githooks/pre-push`
- `.github/workflows/secret-guardrails.yml`
Enable the hooks once per clone:
```bash
git config core.hooksPath .githooks
chmod +x .githooks/pre-commit .githooks/pre-push scripts/scan-secret-bearing-artifacts.sh
```
Manual runs:
```bash
scripts/scan-secret-bearing-artifacts.sh --staged
scripts/scan-secret-bearing-artifacts.sh --tracked
scripts/scan-secret-bearing-artifacts.sh --git-range origin/main..HEAD
```
What each layer does:
- `pre-commit`: scans staged files before a local commit is created
- `pre-push`: scans the commits about to be pushed, including unpublished branch history
- GitHub Actions: runs the narrow artifact scan across tracked files and also runs Gitleaks on pushes and pull requests
What it is for:
- raw baselines
- exports
- snapshots
- dumps
- similar machine-generated infra artifacts
What it is not:
- a full replacement for operator judgment about what belongs in the repo
- proof that a raw export is safe just because the scanner stayed quiet
- a reason to skip rotating live secrets if one ever does leak
## Security Reminders ## Security Reminders
- **Key backup**: `C:\Users\Fizzlepoof\Downloads\.git-crypt-secrets.key` — also store in password manager - **Key backup**: `C:\Users\Fizzlepoof\Downloads\.git-crypt-secrets.key` — also store in password manager

View File

@@ -6,7 +6,7 @@ PD live compose root: `/mnt/docker-ssd/docker/compose`
NOMAD exception: standalone live deployments belong under `/opt/<service>` rather than a shared `/mnt/docker-ssd/docker/compose` tree. NOMAD exception: standalone live deployments belong under `/opt/<service>` rather than a shared `/mnt/docker-ssd/docker/compose` tree.
Remotes: Remotes:
- GitHub: `git@github.com:Paccoco/truenas-stacks.git` - GitHub: `git@github.com:fizzlepoof/truenas-stacks.git`
- Gitea web: `http://10.5.30.6:3002/fizzlepoof/truenas-stacks` - Gitea web: `http://10.5.30.6:3002/fizzlepoof/truenas-stacks`
- Gitea SSH: `ssh://git@10.5.30.6:2222/fizzlepoof/truenas-stacks.git` - Gitea SSH: `ssh://git@10.5.30.6:2222/fizzlepoof/truenas-stacks.git`

View File

@@ -1,9 +1,9 @@
# Pi-hole Deployment Plan # Pi-hole Deployment Plan
**Status:** Historical Trusted-VLAN design. PD primary is still valid, but after NOMAD moved to Servers (`10.5.30.7`) its participation in the old Trusted-side VIP `10.5.30.53` was disabled to restore correct routing. Do not reuse this document for NOMAD as-is until a same-subnet HA redesign exists. **Status:** Historical Pi-hole design. It is no longer the active DNS source of truth. Both the PD and NOMAD Pi-hole runtimes were retired on 2026-05-27 after cutover to the Technitium trio, and the old VIP `10.5.30.53` no longer answers. Treat this document as history/reference, not the current intended DNS architecture.
**Architecture:** 3-node HA cluster with Keepalived VIP, Unbound recursive DNS, Nebula Sync **Architecture:** 3-node HA cluster with Keepalived VIP, Unbound recursive DNS, Nebula Sync
> **Warning:** The examples below describe the original Trusted-side plan where NOMAD was `10.5.30.7` and shared VIP `10.5.30.53/24`. They are retained as historical deployment notes, not the current NOMAD runtime truth. > **Warning:** The examples below describe the original HA Pi-hole plan. The entire Pi-hole path has now been retired. Current live DNS is Technitium on `10.5.30.8`, `10.5.30.9`, and `10.5.30.10`; the old Pi-hole VIP `10.5.30.53` is dead. Do not treat anything below as the desired current-state DNS design.
--- ---
@@ -520,7 +520,7 @@ Add in Pi-hole (primary) → Local DNS → DNS Records:
|--------|----| |--------|----|
| pd.lan | 10.5.30.6 | | pd.lan | 10.5.30.6 |
| nomad.lan | 10.5.30.7 | | nomad.lan | 10.5.30.7 |
| rocinante.lan | 10.5.1.112 | | rocinante.lan | 10.5.30.112 |
| rpi4.lan | 10.5.1.X | | rpi4.lan | 10.5.1.X |
Nebula Sync will propagate these to all replicas. Nebula Sync will propagate these to all replicas.

View File

@@ -0,0 +1,305 @@
# PD Future-State Architecture Plan
Status: proposed target state for the next major PD rebuild and Serenity retirement.
## Goal
Turn PD into the long-term primary homelab platform while keeping enough separation for resilience and cybersecurity lab work.
This plan assumes:
- PD is rebuilt on the HL15 Beast ASRock platform
- Serenity is temporary and will be retired after its storage is adopted into PD
- John will add a second HDD ZFS pool using 5-6 new 20TB+ drives
- A few SSDs may be added for appdata, databases, scratch, and cache tiers
- Cybersecurity VMs may live on PD, but only inside a tightly segmented lab lane
## Recommended platform model
### Keep PD on bare-metal TrueNAS Scale
Recommended role:
- primary NAS
- primary production Docker/app host
- primary shared database host
- primary media host
- primary identity/control-plane host
- primary DNS source node
- primary AI host if the 4090 lands here
- limited, disciplined VM host for cybersecurity lab work
Do not treat PD as an unrestricted cyber range.
Production remains the first-class role.
## Hardware direction
### Minimum build worth doing
- HL15 Beast ASRock build
- EPYC 7282
- 64GB RAM
- RM1000x if no 4090 is going in immediately
### Preferred long-term build
- HL15 Beast ASRock build
- EPYC 7452
- 128GB RAM preferred if PD will carry production plus multiple security VMs
- HX1500i if PD is likely to receive the 4090
### Why the base model is not recommended
The 7252 + 16GB base build is too small for the intended role once PD becomes:
- primary ZFS host
- primary app host
- primary DB host
- future ARR/qbit host after pool migration
- possible AI host
- bounded cybersecurity VM host
## Storage model
### HDD pools
PD should end up with at least two major HDD-backed data classes:
1. Imported Serenity capacity
- absorb the current Serenity storage role
- preserve large media/archive capacity
2. New HDD ZFS pool
- 5-6 x 20TB+ drives
- use for future bulk media, archives, backup targets, and growth
Exact vdev shape should be chosen when the final drive count is locked, but the high-level operating model should be:
- one bulk pool for high-capacity household data
- one or more fast SSD-backed tiers for apps, DBs, and VM-heavy workloads
### SSD / NVMe role split
Use SSD/NVMe for:
- Docker root
- shared PostgreSQL / MariaDB / Redis data
- write-heavy appdata
- VM disks for cyber lab systems
- AI model cache
- transcode/temp/scratch
Use HDD pools for:
- media libraries
- document archives
- backups
- long-term storage
- lower-IO datasets
## Host role layout
### PD = production core
PD should eventually own:
- ZFS/NAS duties
- production Docker stack
- shared databases
- media stack
- household/productivity stack
- Authentik / identity stack
- monitoring/control plane
- primary Technitium node
- ARR/qbit stack after storage cutover
- AI primary if the 4090 lands here
- controlled cyber lab VMs
### N.O.M.A.D. = secondary trusted platform
Keep NOMAD for:
- offline knowledge / Project NOMAD
- game servers
- backup Technitium resolver
- small bounded utility workloads
NOMAD should not become a second general-purpose production host.
### Rocinante = optional AI specialist
If PD gets the 4090:
- Rocinante becomes optional
- can remain an overflow / experimental inference box
- can later be repurposed or retired
If PD does not get the 4090:
- Rocinante stays the heavy inference box
- PD remains the production/NAS core
### Serenity = transition-only
Near-term:
- current storage owner
- current torrent locality host
- current reranker host
- backup Technitium node
End-state:
- storage role moved into PD
- remaining services drained or moved
- host retired
## Service placement target
### Keep on PD long-term
Infrastructure / control plane:
- Gitea
- Homepage
- Uptime Kuma
- Gotify
- RackPeek
- Traefik / Pangolin-side internal ingress
- Authentik
- shared PostgreSQL / MariaDB / Redis
- monitoring stack
Household / productivity:
- Paperless
- Immich
- Karakeep
- n8n
- KitchenOwl
- DoneTick
- Qui
Media / library:
- Plex
- Tautulli
- Audiobookshelf
- Calibre-Web
- Seerr
- RomM
- GameVault
- Shelfmark
- Notifiarr
AI / search:
- LiteLLM
- OpenWebUI
- Qdrant
- SearXNG
- Ollama light tier
- primary heavy inference too if the 4090 lands on PD
- reranker after Serenity is retired
Torrent/media ingestion after storage cutover:
- qBittorrent
- qbit_manage
- Gluetun
- unpackerr
- Sonarr / Sonarr-anime
- Radarr
- Lidarr
- Readarr / Readarr-epub
- Prowlarr
- Bazarr
- autobrr
### Keep off PD long-term
NOMAD:
- Project NOMAD stack
- Pelican / Wings / game servers
- backup Technitium resolver
Optional elsewhere:
- heavy inference on Rocinante if PD does not get the 4090
- high-risk security experiments on a future dedicated lab node if the school/lab workload outgrows PD-hosted segmented VMs
### Move off Serenity early; does not need the storage cutover
- retire Wizarr if it is still present; rebuild later only if onboarding need returns
- Notifiarr -> PD only if PD headroom makes the move worth it
- netdata -> keep only where it still adds monitoring value
These are low-risk peel-off candidates because they are not part of the storage-local torrent/media locality lane.
## Cybersecurity VM model on PD
### This is acceptable on PD
- Windows Server / AD lab VMs
- Windows client VMs
- Linux practice VMs
- Wazuh / SIEM learning stack
- Kali or similar coursework boxes
- isolated vulnerable targets for coursework
- patch/test environments
### This should remain tightly bounded
- scanning labs
- IDS/packet-capture experiments
- exploit practice
- intentionally vulnerable targets
These may run on PD only when:
- placed on a dedicated lab VLAN
- firewalled from production by default
- resource-limited
- treated as disposable
### This should ideally not live on PD
- malware detonation
- unknown or sketchy offensive tooling
- random public PoCs with unclear trust
- anything that could meaningfully threaten production storage or family services
## Network / VLAN recommendation for the lab lane
Create or preserve a dedicated lab/security lane with these rules:
- separate VLAN / subnet from production lanes
- default-deny from lab to production
- allow only explicit admin paths from trusted management devices
- allow outbound internet as needed for updates/labs
- allow tightly scoped DNS/NTP/logging paths
- keep identity trust boundaries explicit rather than automatic
Suggested policy posture:
- Lab -> PD production apps: deny by default
- Lab -> shared DNS: allow
- Lab -> internet: allow with logging
- Trusted admin devices -> lab: explicit allow
- Lab -> management interfaces: explicit deny unless specifically required
## Phased roadmap
### Phase 1: document and reduce Serenity sprawl now
- document current host roles and future-state design
- classify Serenity services into keep / move / retire
- retire or peel off only the smallest low-risk items first (`Wizarr`, maybe `Notifiarr`, and possibly `netdata`) while leaving `Hawser` + `dockersocket` in place as the Dockhand management path
### Phase 2: build upgraded PD
- install TrueNAS Scale on the new PD hardware
- stand up SSD tiers for apps/DBs/VMs
- add the new HDD pool
- validate networking, Docker, GPU, and backup posture
### Phase 3: move production services into the new PD layout
- migrate any remaining general-purpose apps off Serenity
- relocate databases/appdata onto the intended SSD tier
- validate ingress, monitoring, and DNS roles
- keep `Newt`, `reranker`, and the off-PD Technitium node as explicit special cases until their redesign work is ready
### Phase 4: move the storage-dependent torrent/media automation stack
- after the storage move design is finalized, move qbit + ARR locality to PD
- keep path locality on the box that owns the disks
- then remove the old Serenity-locality requirement
### Phase 5: move or redesign the remaining special cases
- move reranker onto PD if AI core lands there
- redesign backup DNS so Serenity is no longer required, likely by introducing a small dedicated Raspberry Pi 4B resolver lane while preserving an off-PD Technitium failure domain
- preserve at least one off-PD Technitium node outside PD even after Serenity retires
### Phase 6: retire Serenity
- verify pool adoption is complete
- verify services are drained
- confirm backups and DNS redundancy still work
- power down and retire Serenity
## Architecture principles
1. PD becomes the production center of gravity.
2. NOMAD stays a separate trusted failure domain.
3. Rocinante is optional and should justify its existence.
4. Serenity should be designed out of the future state.
5. The cybersecurity lab lane can share PD hardware, but not PD trust boundaries.
6. Production stability beats hypervisor sprawl.
## Immediate implications for current cleanup work
For the current Serenity Docker review, the guiding rule is:
- remove obvious sprawl from Serenity now
- retire dead weight and peel off only the smallest miscellaneous low-risk apps before touching the storage-locality lane
- do not over-optimize the final placement around Serenity permanence
- remember that once PD directly owns the storage, the qbit/ARR locality argument flips to PD

View File

@@ -0,0 +1,559 @@
# Serenity Cleanup Wave 1 Plan
Status: approved planning baseline for the first safe cleanup pass on Serenity.
## Goal
Reduce obvious legacy clutter on Serenity without breaking the still-needed torrent/media-locality group or the still-needed Serenity Newt path.
This wave is intentionally conservative.
It does not move qBittorrent/ARR off Serenity yet.
It does not retire Serenity yet.
It does not delete databases that still back live apps.
## Operator decisions already resolved
- Nothing should intentionally remain on Serenity after PD owns the disks locally.
- Technitium already covers the DNS role John wants.
- Serenity Pi-hole remnants should be treated as removable.
- Serenity Newt is still needed and must be preserved.
- GameVault and RomM should migrate, not be pruned.
- Final end-state remains:
- move qbit + ARR family to PD after storage cutover
- leave no intentional production app role on Serenity
- retire Serenity entirely
## Scope of cleanup wave 1
Wave 1 includes only these categories:
1. Remove legacy DNS clutter that should no longer be serving production traffic.
2. Remove obviously stale created/exited containers.
3. Document migration order for the two database-backed apps that should move later.
Wave 1 explicitly excludes:
- qbit
- GluetunVPN
- qbit_manage
- prowlarr
- sonarr
- sonarr-anime
- radarr
- lidarr
- readarr
- readarr-epub
- bazarr
- autobrr
- unpackerr
- Notifiarr
- shelfmark
- Newt
- technitium-dns-pilot
- GameVault
- romm
- reranker
## Live facts this plan is based on
From the live Serenity audit before D5/D6/D7:
- ARR/torrent locality is still tied to `/mnt/user/data`
- `GameVault` pointed at local Postgres on `10.5.30.5:5432`
- `RomM` pointed at local MariaDB on `10.5.30.5:3306`
- live container env inspection showed those explicit DB_HOST/DB_PORT bindings on 2026-05-25 before cutover
- quick live checks did not surface immediate DB dependencies for `Wizarr`, `Shelfmark`, or `Notifiarr`
- `Newt` is still needed
- legacy Pi-hole containers are still running even though Technitium is now the intended DNS path
- Serenity Newt-backed Pangolin routes still had stale health-check hostnames pointing at old `10.5.1.5` even though their target IPs had already been rewritten to `localhost`/`10.5.30.5`
- a temporary `10.5.1.5/32` alias on `br0` validated the diagnosis, but it was removed because the old IP is no longer allowed on that VLAN
- authoritative Pangolin cleanup was then completed by rewriting the audited Serenity target set to `10.5.30.5` for both routing and health checks, removing the need for any legacy-IP workaround
## Wave 1 current execution status
Live execution on 2026-05-25 established:
- `Huntarr` and `omegabrr` were low-risk stale stopped containers and were removed from Serenity
- the recent non-running `Created` containers (`calibre-web`, `SuggestArr`, `Cleanuparr`, `calibre`, `agregarr`) were metadata-verified and then removed after confirming they were still only `Created`, had `restart=no`, and were not live workloads
- `Unraid-Cloudflared-Tunnel` was audited, stopped, and removed after verification showed it was only transport-alive and no current public traffic depended on it
- the Serenity Pi-hole HA stack (`binhex-official-pihole`, `pihole-serenity`, `unbound-pihole-serenity`, `keepalived-pihole-serenity`) was then stopped and removed after live mixed-host DNS checks confirmed the Technitium path remained healthy without it
- `gamevault` and `gamevault-db` validated healthy on PD, then Serenity `postgresql15` was exported, stopped, archived for rollback, and removed during D7
- `romm` and `romm-db` validated healthy on PD, then Serenity `MariaDB-Official` was exported, stopped, archived for rollback, and removed during D7
- the stale stopped Serenity source app containers `romm` and `GameVault` were then removed as post-cutover cleanup, so rollback now depends on retained appdata/backup artifacts rather than source containers lingering in `docker ps -a`
## Wave 1-A: legacy Pi-hole removal
### Target containers
- `binhex-official-pihole`
- `pihole-serenity`
- `unbound-pihole-serenity`
- `keepalived-pihole-serenity`
### Why they are in scope
- They are legacy DNS/HA remnants.
- Current homelab docs describe the active internal DNS path as the Technitium trio.
- Operator confirmed Technitium covers the intended DNS role.
- Keeping old DNS stacks around increases confusion and future troubleshooting blast radius.
### Preconditions
Before removal, verify only these read-only checks:
1. Serenity Technitium backup node is healthy.
2. DHCP-advertised resolver set is still PD/NOMAD/Serenity Technitium, not Pi-hole.
3. No Pangolin route, bookmark, or admin workflow still intentionally points at a Pi-hole UI.
4. No host on the LAN still relies on the old Pi-hole admin port out of habit.
### Removal order
1. stop `keepalived-pihole-serenity`
2. stop `pihole-serenity`
3. stop `unbound-pihole-serenity`
4. stop `binhex-official-pihole`
5. verify Technitium-only DNS behavior still looks normal
6. remove the stopped containers
7. archive or delete their stale appdata only after a short observation window
### Verification after removal
- Serenity Technitium container remained healthy
- mixed-host DNS checks stayed good after removal:
- from NOMAD, `10.5.30.8` and `10.5.30.10` still resolved both public and homelab names
- from PD, `10.5.30.9` and `10.5.30.10` still resolved both public and homelab names
- `10.5.30.53` continued answering DNS even after Serenity Pi-hole removal, confirming it is no longer tied to the removed Serenity Pi-hole containers
- no immediate client-facing DNS regression was observed during the removal window
- no public regression was observed on sampled hostnames such as `panel.paccoco.com` and `audiobookshelf.paccoco.com`
## Wave 1-B: stale container pruning
### Created-only clutter to remove
- `calibre-web`
- `SuggestArr`
- `Cleanuparr`
- `calibre`
- `agregarr`
### Exited clutter to remove
- `Huntarr`
- `omegabrr`
### Why they are in scope
- They are not live workloads.
- They add noise to `docker ps -a` and make host intent harder to understand.
- There is no current architecture reason to preserve them as active Serenity residents.
### Safe pruning rules
Before deleting each one:
1. confirm container status is still `Created` or `Exited`
2. confirm it is not referenced by a live reverse-proxy route
3. confirm it is not the only source of some needed config/data you still care about
4. if uncertain, export one final metadata snapshot first:
- image name
- mounts
- env file path if obvious
### Practical order
1. remove `Created` containers first
2. remove long-dead exited containers second
3. leave appdata in place initially
4. only delete appdata later after a short cooling-off window
## Wave 1-C: cloudflared deadwood removal
### Target container
- `Unraid-Cloudflared-Tunnel`
### Why it is in scope
- It is already documented in repo docs as dead/stale.
- Pangolin/Newt is the active exposure pattern now.
- Live audit on 2026-05-25 showed the container is a remote-managed Cloudflare Tunnel carrying a stale legacy config, not an active Serenity service path.
### Live audit findings (2026-05-25)
- `Unraid-Cloudflared-Tunnel` is healthy at the transport layer (`/ready` reported 4 ready connections), but metrics showed `cloudflared_tunnel_total_requests 0` and `cloudflared_tunnel_request_errors 0` for the current run.
- Startup logs exposed the remote-managed ingress set as legacy hostnames pointed at old `192.168.1.x` origins:
- `wazuh.paccoco.com` -> `https://192.168.1.102`
- `remotely.paccoco.com` -> `http://192.168.1.180:5001`
- `hp.paccoco.com` -> `http://192.168.1.180:3000`
- `octoprint.paccoco.com` -> `http://192.168.1.52`
- `audiobookshelf.paccoco.com` -> `http://192.168.1.5:13378`
- `spoolman.paccoco.com` -> `http://192.168.1.202:7912`
- `node1.paccoco.com` -> `https://192.168.1.189:8080`
- `hp2.paccoco.com` -> `http://192.168.1.224:3000`
- `panel.paccoco.com` -> `https://192.168.1.189`
- `nextcloud.paccoco.com` -> `http://192.168.1.5:11000`
- The tunnel has no local config files under `/mnt/user/appdata/cloudflared`; it is driven entirely by Cloudflare-side config plus a token.
- Public checks for those hostnames currently resolve to `172.245.79.139` and return either live responses or front-door 404s without producing any new tunnel traffic, indicating the present public path is elsewhere and not traversing this Serenity container.
- Repo/docs no longer identify this tunnel as an intended live exposure path; the only repeated modern exposure requirement in Serenity docs is the local Pangolin/Newt lane.
### Preconditions
1. verify no current DNS/public route still expects this tunnel
2. verify no local notes still treat it as the active exposure path
3. verify Newt-based routes are the real live path
Status after live audit:
- (1) satisfied enough for container retirement: no observed current public traffic is reaching this tunnel
- (2) satisfied: local docs treat it as stale legacy deadwood, not the intended active path
- (3) satisfied for Serenity-hosted apps: Pangolin/Newt remains the intentionally preserved exposure path
### Recommended retirement path
1. stop the container and watch briefly for any unexpected complaint or new public breakage
2. remove the container
3. keep `/mnt/user/appdata/cloudflared` during a cooling-off window even though it appears empty/unneeded
4. later, from the Cloudflare side, delete or repoint the stale tunnel config/hostnames if they still exist there
Status:
- completed on 2026-05-25: `Unraid-Cloudflared-Tunnel` was stopped and removed
- sampled public checks (`panel.paccoco.com`, `audiobookshelf.paccoco.com`) remained healthy after removal
## Wave 1-D: DB-backed migration ordering
These apps should not be deleted in wave 1.
They need planned migration.
### Pair 1: GameVault + local Postgres
Live dependency:
- `GameVault` -> `postgresql15`
Recommended sequence:
1. create PD-side target appdata path
2. create PD-side Postgres DB/user on shared-postgres, or a deliberate dedicated PD Postgres if there is a reason not to use shared-postgres
3. export GameVault DB from Serenity
4. import into PD target database
5. migrate GameVault appdata/config
6. recreate GameVault on PD attached to the shared database network if using shared-postgres
7. verify login, library visibility, and metadata path behavior
8. only then retire Serenity `postgresql15`
Default recommendation:
- prefer PD shared-postgres unless GameVault has a proven reason to stay isolated
- note: live `psql` inspection on 2026-05-25 showed a Postgres collation-version mismatch warning on Serenity (`gamevault` created with glibc collation 2.36 while host now provides 2.41), so include a post-migration refresh/reindex plan rather than carrying that debt forward silently
### Pair 2: RomM + local MariaDB
Live dependency:
- `RomM` -> `MariaDB-Official`
Recommended sequence:
1. create PD-side target appdata path
2. create PD-side MariaDB DB/user on shared-mariadb, or a deliberate dedicated PD MariaDB only if needed
3. export RomM DB from Serenity
4. import into PD target MariaDB
5. migrate RomM appdata/config/assets/resources
6. recreate RomM on PD attached to the shared database network if using shared-mariadb
7. verify UI, library, metadata, and asset behavior
8. only then retire Serenity `MariaDB-Official`
Default recommendation:
- prefer PD shared-mariadb unless RomM proves awkward on the shared stack
## Recommended order across all wave 1 work
1. verify Technitium is the only intended active DNS path
2. remove legacy Pi-hole stack
3. remove dead Cloudflared tunnel
4. remove stale created/exited containers
5. leave GameVault/Postgres and RomM/MariaDB in place until their PD migration is prepared
6. keep qbit/ARR locality untouched until PD storage cutover is real
## Risks and guardrails
### Do not touch yet
Do not touch in this wave:
- qbit
- ARR family
- GluetunVPN
- qbit_manage
- Newt
- technitium-dns-pilot
- GameVault
- romm
- postgresql15
- MariaDB-Official
### Specific guardrails
- Do not delete any appdata directory in the same step as container removal unless the dependency is unquestionably dead.
- Do not remove `postgresql15` until GameVault is verified on PD.
- Do not remove `MariaDB-Official` until RomM is verified on PD.
- Do not move qbit/ARR until PD directly owns the relevant media/torrent paths.
- Do not break Serenity Newt while cleanup is happening.
## Suggested Kanban decomposition
### Card A1 — verify legacy Pi-hole is truly unused
Definition of done:
- current DNS path confirmed as Technitium-only
- no intentional admin dependency on Serenity Pi-hole remains
### Card A2 — remove Serenity legacy Pi-hole containers
Definition of done:
- all four legacy Pi-hole containers stopped and removed
- no DNS regression observed
### Card B1 — remove stale created containers
Definition of done:
- created-only clutter removed
- appdata retained for cooling-off period
### Card B2 — remove stale exited containers
Definition of done:
- exited clutter removed
- appdata retained for cooling-off period
### Card C1 — remove dead Unraid Cloudflared tunnel
Definition of done:
- no public path depends on it
- container removed
### Card D1 — choose PD target layout for GameVault and RomM wave
Definition of done:
- PD target appdata paths chosen for both apps
- decision recorded to use PD shared-postgres for GameVault unless blocked
- decision recorded to use PD shared-mariadb for RomM unless blocked
- required PD mount paths for libraries/assets/resources identified
Chosen target layout (2026-05-25):
- place both services in the PD `media` stack so they follow the same steady-state placement already documented for PD media/library apps
- attach both services to:
- `media-net` for local app adjacency
- `pangolin` for internal ingress / public exposure
- attach only GameVault to `ix-databases_shared-databases` because it keeps using PD `shared-postgres`
- keep RomM on `media-net` + `pangolin`; its database will be a dedicated sibling service on `media-net`, not a shared DB consumer
- preserve the current host ports on PD because live checks showed them free there:
- GameVault: `8785:8080`
- RomM: `8457:8080`
- preserve the public hostnames already in use during cutover:
- `gamevault.paccoco.com`
- `romm.paccoco.com`
- use the PD internal ingress model (`Pangolin -> Traefik -> app`) when the public route is re-homed, rather than introducing another direct-to-container edge pattern
Chosen storage layout:
- GameVault appdata on SSD because it is small and write-active:
- `/mnt/docker-ssd/docker/appdata/gamevault/media` -> `/media`
- `/mnt/docker-ssd/docker/appdata/gamevault/logs` -> `/logs`
- GameVault canonical content mounts should be re-used, not copied:
- `/mnt/unraid/data/media/Games-Apps Isos` -> `/files`
- `/mnt/unraid/data/media/Saved Games` -> `/savefiles`
- RomM split layout:
- write-active cache on SSD:
- `/mnt/docker-ssd/docker/appdata/romm/redis-data` -> `/redis-data`
- config/assets/resources on tank appdata:
- `/mnt/tank/docker/appdata/romm/config` -> `/romm/config`
- `/mnt/tank/docker/appdata/romm/assets` -> `/romm/assets`
- `/mnt/tank/docker/appdata/romm/resources` -> `/romm/resources`
- RomM canonical library mount should be re-used, not copied:
- `/mnt/unraid/data/media/RomM` -> `/romm/library`
Chosen database layout:
- GameVault -> PD `shared-postgres` on `ix-databases_shared-databases`
- target DB: `gamevault`
- target DB user: `gamevault`
- RomM -> dedicated PD `romm-db` service on `media-net`
- target image pin: `mariadb:12.2`
- target DB: `romm`
- target DB user: `romm`
- target data path: `/mnt/docker-ssd/docker/appdata/romm-db/mysql`
Routing/proxy notes to preserve during implementation:
- RomM upstream docs call out reverse-proxy sensitivity; keep websocket support intact when the route is moved behind PD Traefik
- existing Traefik file-provider patterns in `ingress/traefik/dynamic/routes.yml` should be extended rather than inventing a new routing mechanism for these two apps
### Card D2 — stage PD database targets for the wave
Definition of done:
- GameVault target DB/user created on PD shared-postgres or explicit exception documented
- RomM target DB/user created on the chosen PD MariaDB target or explicit exception documented
- connection details verified from the future PD app network context
- migration rollback notes captured before any source export
Execution notes (2026-05-25):
- staged PD targets on the live shared DB services:
- Postgres DB/user: `gamevault` / `gamevault`
- MariaDB DB/user: `romm` / `romm`
- stored live credentials in the PD media stack env file and synced that env into the encrypted secrets repo; credentials were not written into the main repo
- verified future app-network connectivity with ephemeral clients on `ix-databases_shared-databases`:
- Postgres check returned `gamevault|gamevault`
- MariaDB check returned `romm` / `romm@%`
- rollback notes:
- pre-change backup of PD media env: `/mnt/docker-ssd/docker/compose/media/.env.pre-serenity-wave2-d2-20260525-182938`
- if D3/D4 later uncover an issue, keep the staged DBs unused, restore the prior media `.env` if needed, and rotate/drop the staged users before re-attempting
- no Serenity source data was touched yet in D2; this card only prepared empty PD landing zones
- note after D3b:
- the staged shared-mariadb `romm` target is now superseded by the dedicated `romm-db` plan and should not be used for RomM cutover
### Card D3 — export and validate Serenity source databases
Definition of done:
- pre-cutover exports from Serenity are taken for both apps so restore/import flow can be tested before downtime
- `gamevault` Postgres dump exported from Serenity and integrity-checked
- `romm` MariaDB dump exported from Serenity and integrity-checked
- source app versions and DB container versions recorded alongside dumps
- Postgres collation-version warning captured as a post-import remediation item
- final cutover note recorded: these pre-cutover dumps are not the authoritative final state; a last quiesced export must be taken after the Serenity app is stopped during cutover and imported to PD before PD goes live
Execution notes (2026-05-25):
- successful pre-cutover exports were captured and staged under PD backup storage:
- latest verified dump set: `/mnt/tank/docker/backups/db-dumps/serenity-wave2/20260525-184917/`
- `20260525-184917-serenity-gamevault.pgcustom` — 175290 bytes, sha256 `1ebd0286483e7f34dddc21076a7540c966a812d8891a8ed7a24247fe972d927d`
- `20260525-184917-serenity-romm.sql` — 8526126 bytes, sha256 `c74a2d9d715f6c0982d707ba9f41174e19ba681a1debebde865e94e63271302b`
- source runtime facts recorded during export validation:
- Serenity `postgresql15`: PostgreSQL 15.18, `gamevault` has 18 public tables and ~12 MB logical size
- Serenity `MariaDB-Official`: MariaDB 12.2.2, `romm` has 20 tables and ~12.41 MB logical size
- the existing Serenity Postgres collation-version mismatch warning is still present and should be remediated after final import on PD
- GameVault restore-path validation succeeded:
- the pre-cutover Postgres dump restored cleanly into PD shared-postgres
- PD `gamevault` target currently shows the expected 18 public tables
- RomM restore-path validation exposed a blocker on the planned PD shared-mariadb target:
- importing the Serenity RomM dump into PD shared-mariadb failed at line 167 while creating `device_save_sync`
- MariaDB returned `ERROR 1005 (HY000): Can't create table romm.device_save_sync (errno: 121 "Duplicate key on write or update")`
- PD shared-mariadb is currently MariaDB 11.4.11 while the Serenity RomM source dump was produced from MariaDB 12.2.2
- treat this as a compatibility / target-selection issue, not a dump-corruption issue; the dump itself is populated and preserved in backup storage
- net result:
- D3 is complete for GameVault
- D3 export validation is complete for RomM, but the original PD shared-mariadb landing target is rejected
### Card D3b — resolve RomM PD database target compatibility
Decision:
- do not upgrade PD `shared-mariadb` just to fit RomM
- keep PD `shared-mariadb` on its current conservative shared-infra track unless a separate approved maintenance change is planned for all consumers
- provision a dedicated PD MariaDB target for RomM instead
Evidence behind the decision:
- the RomM source dump repeatedly uses reused foreign-key names such as `CONSTRAINT \`1\`` and `CONSTRAINT \`2\`` across multiple tables
- MariaDB docs state that foreign-key names must be unique per database before MariaDB 12.1, and only become reusable across tables in MariaDB 12.1+
- Serenity `MariaDB-Official` is MariaDB 12.2.2, which explains why the source schema can exist there
- PD `shared-mariadb` is MariaDB 11.4.11, where the same dump fails with `errno: 121` while creating `device_save_sync`
- the PD databases stack currently provisions MariaDB specifically for Uptime Kuma via `databases/initdb-mariadb/01-create-uptime-kuma-db.sh`; there is no evidence in repo or live stack layout that shared-mariadb was intentionally upgraded or validated as a broad multi-app MariaDB landing zone
- RomM's own setup docs commonly show a dedicated MariaDB container and even call out compatibility-conscious MariaDB version choices in example guides
Operational recommendation:
- create a dedicated PD MariaDB service for RomM, isolated from `shared-mariadb`
- pin that dedicated service to a MariaDB 12.x line compatible with the Serenity source behavior unless RomM docs or release notes later justify a different pin
- attach PD RomM only to its own DB service plus the networks it already needs for ingress/library access
- leave `shared-mariadb` untouched for now to avoid coupling the RomM migration to a shared-database major-version change and validation cycle
Definition of done for D3b:
- dedicated PD MariaDB target for RomM is chosen and documented
- target image/version pin recorded
- restore path is re-tested successfully against that dedicated target before RomM cutover proceeds
Execution notes (2026-05-26):
- restore-path proof was executed on PD using an isolated test container pinned to `mariadb:12.2`
- test target details:
- container: `romm-db-d3b-test`
- image/runtime version: `mariadb:12.2` / `12.2.2-MariaDB`
- data path: `/mnt/docker-ssd/docker/appdata/romm-db-d3b-test/mysql`
- the preserved Serenity RomM dump `/home/fizzlepoof/serenity-wave2-d3-work/20260525-184736-serenity-romm.sql` imported cleanly into the 12.2 target on PD
- post-import verification on the PD test target returned:
- `20` tables in schema `romm`
- approximately `10.42 MB` logical table+index footprint
- result:
- the compatibility blocker is resolved for a dedicated MariaDB 12.2 target
- RomM should proceed against dedicated `romm-db`, not `shared-mariadb`
### Card D4 — sync appdata for GameVault and RomM to PD staging paths
Definition of done:
- GameVault config/media/log paths copied to PD staging
- RomM config/assets/resources paths copied to PD staging
- large library mounts intentionally re-used from canonical storage instead of blindly duplicating data
- ownership/permissions on PD staging paths verified
Note:
- do not advance RomM cutover assumptions until the MariaDB target mismatch found in D3 is resolved; RomM may need a different PD DB target than the original shared-mariadb assumption
### Card D5 — cut over GameVault to PD
Definition of done:
- Serenity GameVault stopped only for the final cutover window
- after GameVault is stopped on Serenity, a final quiesced `gamevault` Postgres export is taken and imported to the PD target DB so PD does not come up on stale data
- PD GameVault starts against the PD target Postgres DB
- login, library visibility, metadata behavior, and save/upload paths verified
- public/LAN route updated if needed and verified
- Serenity `postgresql15` kept in place but clearly marked retirement-ready if cutover succeeds
### Card D6 — cut over RomM to PD
Definition of done:
- Serenity RomM stopped only for the final cutover window
- after RomM is stopped on Serenity, a final quiesced `romm` MariaDB export is taken and imported to the PD target DB so PD does not come up on stale data
- PD RomM starts against the PD target MariaDB DB
- UI, library visibility, assets/resources, background jobs, and metadata behavior verified
- public/LAN route updated if needed and verified
- Serenity `MariaDB-Official` kept in place but clearly marked retirement-ready if cutover succeeds
### Card D7 — retire Serenity DB remnants after cooldown
Definition of done:
- GameVault and RomM remain healthy on PD through an observation window
- Serenity `postgresql15` and `MariaDB-Official` are stopped and removed only after successful PD validation
- Serenity-side DB appdata is retained for cooldown/rollback, not deleted immediately
- docs and host inventory updated to show Serenity no longer carries those DB pairs
Execution notes (2026-05-25):
- PD validation passed before source retirement:
- `gamevault`, `gamevault-db`, `romm`, and `romm-db` all reported healthy
- HTTP checks returned `200` from PD for `http://127.0.0.1:8785/` and `http://127.0.0.1:8457/`
- PD DB quick checks returned 18 public tables for `gamevault` and 20 tables for `romm`
- Serenity rollback retention created at `/mnt/user/backups/doris/serenity-d7-db-retire-20260525-213531`
- `romm.sql`
- `gamevault.sql`
- `mariadb-official-appdata.tgz`
- `postgresql15-appdata.tgz`
- Serenity source DB appdata paths were intentionally left in place for cooldown:
- `/mnt/user/appdata/mariadb-official`
- `/mnt/cache/appdata/postgresql15`
- After D7, `docker ps -a` on Serenity no longer listed `MariaDB-Official` or `postgresql15`
## Open item that still needs verification
- `reranker` mounts `/mnt/user/appdate/reranker`
- verify whether `appdate` is intentional before any future reranker move or cleanup
## Expected result after wave 1
After wave 1, Serenity should still be alive for the workloads that currently justify it, but with much less misleading baggage:
- torrent/media-locality group still intact
- Newt still intact
- Technitium backup node still intact
- GameVault and RomM still live until their migration is prepared
- legacy Pi-hole gone
- dead Cloudflared gone
- stale created/exited clutter gone
That leaves a cleaner host and a safer runway for the later PD storage cutover and full Serenity retirement.
## Wave 1 verification results (2026-05-25)
Verified during this planning pass:
- Serenity still has these live containers relevant to wave 1:
- `technitium-dns-pilot`
- `binhex-official-pihole`
- `pihole-serenity`
- `unbound-pihole-serenity`
- `keepalived-pihole-serenity`
- `Newt`
- `Unraid-Cloudflared-Tunnel`
- PD still runs the primary Technitium stack plus its own Pi-hole and Newt lane.
- From NOMAD, `/etc/resolv.conf` currently lists `10.5.30.8`, `10.5.30.9`, and `10.5.30.10` ahead of external fallback `9.9.9.9`.
- From NOMAD, `dig` to `10.5.30.8` and `10.5.30.10` succeeded for public DNS resolution; same-host checks to `10.5.30.9` were unreliable, matching the existing macvlan caveat in the docs.
- `Unraid-Cloudflared-Tunnel` is still running, but repo docs already classify it as dead/stale and its container has `Restart=no`.
- Serenity `Newt` must not be treated as deadwood: operator confirmed Pangolin tunnels Serenity resources through Serenity's local Newt instead of routing from PD or NOMAD to Serenity resources over the Serenity LAN IP.
- Live Serenity `Newt` logs still show repeated Pangolin health checks against stale `10.5.1.5` targets (`7474`, `8785`, `8787`, `8788`, `8990`, `8457`, `5690`, `5454`).
Operational implication:
- removing Cloudflared remains low-risk after one final dependency check
- removing the legacy Pi-hole stack remains appropriate
- removing Serenity `Newt` is not appropriate during wave 1
- Pangolin target drift for Serenity-hosted resources should be repaired later by rehoming those resources to the correct Serenity site/local-path model instead of stale literal pre-migration IPs

View File

@@ -0,0 +1,336 @@
# Serenity Docker Audit
Status: live-audited baseline for cleanup and migration planning, refreshed after the GameVault/RomM cutover cleanup and the broader keep/move/retire placement review.
Last live verification: 2026-05-26
## Access path used
Live audit now succeeds directly from NOMAD using the configured host alias:
- `ssh serenity`
Older discovery in this document used the PD pivot path before direct local SSH was wired up.
## What is currently running on Serenity
### Keep for now until PD storage ownership changes
These are the containers whose current placement still makes operational sense because Serenity owns the active media/torrent locality today.
- `qbit`
- `GluetunVPN`
- `qbit_manage`
- `prowlarr`
- `sonarr`
- `sonarr-anime`
- `radarr`
- `lidarr`
- `readarr`
- `readarr-epub`
- `bazarr`
- `autobrr`
- `unpackerr`
- `shelfmark`
Common pattern observed from live mounts:
- these stacks are bound heavily to `/mnt/user/data`
- torrent state and backup artifacts live under Serenity-owned paths like `/mnt/user/data/torrents`, `/mnt/user/data/BT_backup`, and appdata under `/mnt/user/appdata/*`
- current path locality still argues for keeping them on Serenity until PD directly owns the disks and final media paths
- `shelfmark` is not a generic "random app" here; its live mounts also tie it to Serenity-owned `/mnt/user/data`, audiobook ingest paths, and torrent/library paths
### Keep temporarily, but plan to move or collapse later
These are live today, but they are not good long-term reasons to keep Serenity alive after PD is rebuilt.
- `reranker`
- currently on Serenity because CPU-only TEI was moved off PD
- planned long-term home: PD if PD remains the AI control-plane/core host
- live runtime uses `/mnt/user/appdate/reranker`, not `/mnt/user/appdata/reranker`; treat that as a migration hazard that must be handled deliberately
- `technitium-dns-pilot`
- current backup Technitium node on Serenity (`10.5.30.10`)
- long-term: keep at least one off-PD backup resolver, but that does not have to remain on Serenity forever
- `Newt`
- live on Serenity now
- operator confirmed it is still needed, so it should be preserved during cleanup and only rehomed deliberately later
- `Hawser`
- operator confirmed Dockhand depends on it for remote management of Serenity's remaining Docker stacks
- keep on Serenity with `dockersocket` until that management pattern is intentionally replaced
- `dockersocket`
- Hawser helper sidecar required by the current Dockhand remote-management path
- `netdata`
- useful while Serenity remains live, not a reason to keep Serenity permanently
- `Notifiarr`
- low-risk utility app that can move later if PD has spare headroom
- not urgent while PD remains RAM-constrained
- `Wizarr`
- operator confirmed it is unused and should be retired rather than migrated
### Placement verdict by service group
#### Keep on Serenity until the storage-locality cutover is designed
- torrent / download lane:
- `qbit`
- `GluetunVPN`
- `qbit_manage`
- `autobrr`
- `unpackerr`
- media automation lane:
- `prowlarr`
- `sonarr`
- `sonarr-anime`
- `radarr`
- `lidarr`
- `readarr`
- `readarr-epub`
- `bazarr`
- locality-adjacent helpers:
- `Notifiarr`
- `shelfmark`
Reason:
- all of these either mount Serenity-owned `/mnt/user/data` directly or depend on the downloader/media-path locality that still lives on Serenity today
- moving them before PD owns the storage locally would just trade one cleanup project for a fragile NFS-path rewrite project
#### Optional early cleanup / peel-off work
- retire `Wizarr`
- move `Notifiarr` only if PD headroom clearly allows it
- review whether `netdata` still adds enough local-monitoring value to keep
Reason:
- these are the few remaining low-risk app-level changes that do not depend on the qbit/ARR storage cutover
- PD is constrained right now, so only very small wins should happen before the larger storage redesign
#### Keep as explicit temporary exceptions, then redesign last
- `reranker`
- `technitium-dns-pilot`
- `Newt`
Reason:
- these are the remaining special cases that still justify Serenity for non-media reasons
- each one has a real redesign question attached:
- reranker: whether PD should re-absorb AI helper services later
- Technitium backup node: which off-PD host should own the durable backup resolver role
- Newt: when Serenity-hosted Pangolin resources are deliberately rehomed so the local tunnel is no longer required
### Retire candidates
These should be treated as cleanup targets unless a specific live dependency is rediscovered.
- `Unraid-Cloudflared-Tunnel`
- live audit on 2026-05-25 showed a remote-managed Cloudflare Tunnel with stale legacy `192.168.1.x` origins and zero observed proxied requests on the current run
- stopped and removed on 2026-05-25 after post-stop checks showed sampled public hostnames remained healthy without it
- `binhex-official-pihole`
- `pihole-serenity`
- `unbound-pihole-serenity`
- `keepalived-pihole-serenity`
- these were legacy DNS/HA remnants once Technitium became the intended resolver strategy
- mixed-host DNS verification passed during retirement on 2026-05-25, and the Serenity Pi-hole HA stack was then stopped and removed without immediate DNS regression
- the old local DB pair (`postgresql15` and `MariaDB-Official`) was retired on 2026-05-25 after PD validation and rollback bundle creation
- the stale stopped source app containers (`romm` and `GameVault`) were removed on 2026-05-26
- retained rollback paths still exist on Serenity:
- `/mnt/user/backups/doris/serenity-d7-db-retire-20260525-213531`
- `/mnt/user/appdata/romm`
- `/mnt/user/appdata/gamevault`
- `/mnt/user/appdata/mariadb-official`
- `/mnt/cache/appdata/postgresql15`
## Not-running / stale containers seen in `docker ps -a`
These did not appear live and should be reviewed for deletion after confirming their data is not needed.
Created only (retired on 2026-05-25 after metadata verification):
- `calibre-web`
- `SuggestArr`
- `Cleanuparr`
- `calibre`
- `agregarr`
Exited:
- none remaining from the previously audited stale set; `Huntarr`, `omegabrr`, `romm`, and `GameVault` have now been removed
Current result:
- no stale created/exited containers remain in `docker ps -a`
## Live project roots observed
Compose Manager project roots found on Serenity:
- `/boot/config/plugins/compose.manager/projects/pihole-ha`
- `/boot/config/plugins/compose.manager/projects/re-ranker`
Direct compose file under appdata:
- `/mnt/user/appdata/technitium-serenity/docker-compose.yaml`
Operational implication:
- much of Serenity appears to be managed through Unraid Docker templates or ad-hoc container definitions, not a clean compose-per-stack layout
- cleanup work should expect drift between repo planning docs, backup stack snapshots, and the actual Unraid runtime inventory
## Important live mount observations
Examples from the live container inspection:
- `qbit` binds `/mnt/user/data -> /data`
- `qbit_manage` binds `/mnt/user/data`, `/mnt/user/data/BT_backup`, and `/mnt/user/appdata/qbit_manage`
- `shelfmark` binds `/mnt/user/data`, `/mnt/user/data/media/books/Audiobooks`, `/mnt/user/data/media/books/ingest`, and `/mnt/user/data/torrents`
- most ARR-family services bind `/mnt/user/data`
- `reranker` binds `/mnt/user/appdate/reranker -> /data`
- `Wizarr` only binds its own appdata/database paths and has no meaningful storage-locality reason to stay on Serenity
- `Hawser` + `dockersocket` are local helper tooling, not media-path owners
Notable typo/risk:
- `reranker` is mounted from `/mnt/user/appdate/reranker` (note `appdate`, not `appdata`)
- `/mnt/user/appdate/reranker` exists live; `/mnt/user/appdata/reranker` does not currently exist
- treat that as an intentional-on-disk reality for now, but fix the naming deliberately during a future migration rather than by surprise during unrelated cleanup
## Recommended next work order
### Phase 1: completed cleanup baseline
- dead Cloudflared / Pi-hole / stale-container cleanup is done
- the old local DB pair and source GameVault/RomM containers are already retired
- current live work should start from the narrower remaining set, not re-open that cleanup unless new evidence appears
### Phase 2: peel off the low-risk non-locality apps
- retire `Wizarr`
- optionally move `Notifiarr` if PD headroom clearly permits it
- decide whether `netdata` still provides unique value once the broader monitoring stack is considered
### Phase 3: preserve the only things that currently justify Serenity
Until PD owns the storage locally, keep the torrent/media-ingest locality group together:
- qBittorrent/VPN path
- ARR family
- qbit_manage
- autobrr
- unpackerr
- related helpers like Notifiarr and Shelfmark
### Phase 4: cut over after PD storage migration
Once PD directly owns the relevant media/torrent datasets:
- move qbit + ARR locality to PD
- keep path locality on the box that owns the disks
- move or retire the locality-adjacent helpers that were intentionally left with that lane
### Phase 5: redesign the special cases last
- move reranker to PD if desired, while normalizing the `appdate`/`appdata` path naming intentionally
- keep at least one off-PD Technitium node somewhere, preferably NOMAD if Serenity is retiring
- re-home or retire Serenity-local `Newt` only after Pangolin dependencies are deliberately redesigned
## Kanban-ready workstreams
### Epic A — Serenity live inventory normalization
- capture `docker ps`, mounts, ports, and remaining host-role assumptions
- keep the repo docs aligned with the actual Unraid runtime inventory
- treat ad-hoc Unraid container definitions as a drift risk until they are intentionally replaced
### Epic B — low-risk miscellaneous app peel-off
- retire `Wizarr`
- decide whether `Notifiarr` is worth an early move given PD RAM pressure
- decide whether `netdata` still earns its keep alongside the broader monitoring stack
### Epic C — torrent/media-locality preservation until PD cutover
- keep qbit/ARR stack stable on Serenity for now
- document exact media/torrent paths that must exist on PD before migration
- prevent premature moves that would recreate NFS-path weirdness
### Epic D — special-case redesign
- normalize the reranker path oddity during a planned move rather than an incidental cleanup
- decide where the off-PD backup Technitium role should live after Serenity
- re-home Pangolin/Newt dependencies last, not during the media cutover
### Epic E — final Serenity retirement
- move remaining wanted apps to PD or NOMAD
- preserve only the intended backup-DNS failure-domain role off PD
- decommission Serenity once no production path depends on it
## Resolved operator decisions
Resolved on 2026-05-25:
1. No app should deliberately remain on Serenity once PD owns the disks locally.
2. Technitium covers the desired DNS role; the legacy Serenity Pi-hole stack should be treated as removable.
3. `Newt` on Serenity is still needed and should not be treated as cleanup.
4. `GameVault` and `RomM` should migrate rather than be pruned.
5. End-state remains:
- move qbit + ARR family to PD after storage cutover
- leave no intentional production app role on Serenity
- retire Serenity entirely
6. `Wizarr` is explicit retire-now dead weight; `Notifiarr` is the main optional tiny move candidate; `Hawser`/`dockersocket` remain intentional keepers because Dockhand depends on them.
## Remaining verification questions
- No additional live dependency beyond the retired `GameVault`/`RomM` pair was surfaced during the quick DB audit; if that changes later, treat it as a rediscovery against rollback artifacts rather than a live-stack blocker.
- Decide when the retained DB/appdata rollback artifacts are old enough to archive more aggressively or finally delete.
- Normalize the `reranker` path naming during the eventual move: preserve the live `/mnt/user/appdate/reranker` data now, but decide whether the destination should standardize back to `appdata`.
- Serenity's Pangolin/Newt health-check drift is fixed now; any future Newt rehome is an architecture task, not a stale-health-check incident response.
## Pangolin / Newt live remediation status
Live verification on 2026-05-25 showed Serenity Newt still probing stale pre-renumbering health-check URLs like `http://10.5.1.5:8787/` even though the Pangolin target objects already showed `ip=localhost` or `10.5.30.5` for those resources.
Affected target IDs observed live:
- `15` (`autobrr`)
- `20` (`notifiarr`)
- `25` (`readarr`)
- `29` (`wizarr`)
- `56` (`readarr-epub`)
- `57` (`sonarr-anime`)
- `58` (`romm`)
- `69` (`gamevault`)
Diagnostic probe performed live on Serenity:
- temporarily added `10.5.1.5/32` to `br0`
- this immediately restored health for several targets, proving the stale `hcHostname` diagnosis
However, the old `10.5.1.5` address is no longer allowed on that VLAN, so the alias was removed again.
Verification after removal:
- `ip addr del 10.5.1.5/32 dev br0`
- Newt immediately resumed failures such as:
- target `56` -> `http://10.5.1.5:8788/`
- target `57` -> `http://10.5.1.5:8990/`
- target `15` -> `http://10.5.1.5:7474/`
- target `25` -> `http://10.5.1.5:8787/`
Interpretation:
- the alias was useful as a proof-of-cause test only
- it is not an acceptable steady-state fix here
- the real remaining task is to authoritatively rewrite the stale Pangolin `hcHostname` values away from `10.5.1.5`
## Pangolin / Newt authoritative fix completed
Follow-up live mutation on 2026-05-25 rewrote the remaining Serenity site targets that were still drifting on stale `10.5.1.5` health checks:
- target `25` (`readarr`) -> `ip=10.5.30.5`, `hcHostname=10.5.30.5`, `hcHealth=healthy`
- target `56` (`readarr-epub`) -> `ip=10.5.30.5`, `hcHostname=10.5.30.5`, `hcHealth=healthy`
- target `57` (`sonarr-anime`) -> `ip=10.5.30.5`, `hcHostname=10.5.30.5`, `hcHealth=healthy`
Post-fix verification:
- `docker exec Newt wget http://10.5.30.5:8787/`, `:8788/`, and `:8990/` all succeeded from inside Serenity's `Newt` container
- public probes for `readarr.paccoco.com`, `readarr-epub.paccoco.com`, and `sonarr-anime.paccoco.com` all returned the expected Pangolin-auth redirect flow
- live Pangolin API inventory for site `serenity` no longer contains any target with `ip=10.5.1.5` or `hcHostname=10.5.1.5`
Current steady state for the audited Serenity-hosted Pangolin targets (`15`, `20`, `25`, `29`, `56`, `57`, `58`, `69`):
- all now show `ip=10.5.30.5`
- all now show `hcHostname=10.5.30.5`
- all now report `hcHealth=healthy`
Rollback / evidence artifacts:
- pre-change backup for targets `56` and `57`: `/home/fizzlepoof/pangolin-target-backup-serenity-hc-authoritative-20260525T204548Z.json`
- post-fix snapshot for audited targets: `/home/fizzlepoof/pangolin-target-snapshot-serenity-post-hc-fix-20260525T204741Z.json`
## Database dependency findings
Live inspection before the GameVault/RomM retirement pointed to:
- `GameVault` -> local `postgresql15`
- `DB_HOST=10.5.30.5`
- `DB_PORT=5432`
- `RomM` -> local `MariaDB-Official`
- `DB_HOST=10.5.30.5`
- `DB_PORT=3306`
No immediate database dependency was surfaced from the quick live environment check for:
- `Wizarr`
- `Shelfmark`
- `Notifiarr`
Operational implication:
- `postgresql15` should currently be treated as a `GameVault` dependency until proven otherwise.
- `MariaDB-Official` should currently be treated as a `RomM` dependency until proven otherwise.
- those databases can likely retire once their dependent apps are migrated to PD and verified there.
- the legacy Pi-hole containers can be scheduled for removal at the next cleanup window.

View File

@@ -0,0 +1,368 @@
# Serenity Majority Migration Plan
Status: approved planning baseline for the post-D7 Serenity drain strategy.
Last updated: 2026-05-26
## Execution update — 2026-05-26
Completed live:
- `Wizarr` retired from Serenity runtime
- stale Pangolin frontend for `wizarr.paccoco.com` disabled; public hostname now returns `404` instead of fronting a dead service
- `Hawser` + `dockersocket` verified live and healthy on Serenity
- `Notifiarr` re-evaluated against live PD headroom and intentionally left on Serenity for now
- `netdata` re-evaluated and kept on Serenity for now as host-local monitoring, not as an early migration target
- closeout check: no remaining open Serenity-specific Kanban cards exist from the completed cleanup/planning wave; only the documented planned/blocked future lanes remain
Current blockers verified live:
- PD is still RAM-constrained (`31 GiB total`, `27 GiB used`, `2.3 GiB free` at check time)
- PD still consumes Serenity storage over NFS (`/mnt/unraid/data` and `/mnt/unraid/immich` mounted from `10.5.30.5`)
- backup-DNS replacement is still only a target direction in docs; no Pi 4B resolver lane is live yet
- Serenity-local `Newt` is still part of the current Pangolin path for Serenity resources, so the special-case access lane is not retired yet
Interpretation:
- The no-regret cleanup phase is complete.
- The majority migration phase remains blocked on future infrastructure work, not on missed low-risk cleanup.
## Goal
Move the majority of remaining Docker workload off Serenity without recreating NFS/path-locality pain, while preserving the few roles that still legitimately need to live there until later infrastructure work is complete.
This plan assumes:
- qBittorrent and the torrent/media-locality lane stay on Serenity for now
- PD is resource-constrained for a while, especially on RAM
- `Wizarr` should be retired, not migrated
- `Hawser` + `dockersocket` stay on Serenity because Dockhand uses them for remote management of Serenity's remaining Docker stacks
- `Shelfmark` stays with the media-locality lane for now
- Serenity's backup-DNS replacement should likely land on a small dedicated Raspberry Pi 4B rather than collapsing onto PD
## Executive summary
It is feasible to move the majority of Serenity's Docker workload off the box, but not as a single near-term wave.
Right now the correct strategy is:
1. retire obvious dead weight
2. preserve the torrent/media-locality lane on Serenity
3. keep Hawser/dockersocket, Newt, reranker, and backup-DNS as explicit temporary exceptions
4. only move tiny low-risk apps early if PD headroom makes the move worth it
5. do the real majority migration only after PD directly owns the relevant storage and path locality
## Current placement decision matrix
### Keep on Serenity now
#### Media-locality lane
- `GluetunVPN`
- `qbit`
- `qbit_manage`
- `prowlarr`
- `sonarr`
- `sonarr-anime`
- `radarr`
- `lidarr`
- `readarr`
- `readarr-epub`
- `bazarr`
- `autobrr`
- `unpackerr`
- `shelfmark`
Reason:
- these either mount Serenity-owned `/mnt/user/data` directly or depend on workflows whose correctness currently assumes Serenity-local path ownership
- splitting them early would turn this into an NFS-path rewrite project instead of a cleanup project
#### Temporary intentional exceptions
- `Hawser`
- `dockersocket`
- `Newt`
- `technitium-dns-pilot`
- `reranker`
- `netdata`
Reason:
- `Hawser` + `dockersocket` remain part of the current Dockhand remote-management path
- `Newt` still matters because Pangolin tunnels Serenity resources through Serenity-local Newt
- `technitium-dns-pilot` preserves an off-PD DNS failure domain until replacement exists
- `reranker` is not urgent to move while PD remains constrained
- `netdata` is host-local monitoring; evaluate whether to simplify it later, not as a forced migration target now
### Retire
- `Wizarr`
Reason:
- operator confirmed it is unused and disposable
- if onboarding needs return later, it can be rebuilt cleanly instead of migrated
### Optional tiny move candidate
- `Notifiarr`
Reason:
- low storage-locality coupling
- but not worth forcing while PD remains constrained unless there is a concrete benefit
## What should not be added to Serenity
Default rule: add nothing new.
Serenity is in a shrinking transitional role. New durable app responsibilities should go elsewhere unless they are explicitly temporary, host-local, and part of a migration or retirement aid.
## Suggested long-term end state
### PD long-term
After storage cutover and capacity improvement, PD should absorb:
- qBittorrent + Gluetun + qbit_manage
- ARR family
- `autobrr`
- `unpackerr`
- `shelfmark`
- optional `Notifiarr`
- `reranker` if PD remains the AI/control-plane center
### Off-PD DNS resilience
When Serenity retires, preserve at least one non-PD Technitium lane. Likely target:
- dedicated Raspberry Pi 4B backup resolver
### Serenity end state
- no intentional durable production app role
- no unique production dependency path left behind
- host eligible for retirement after cooldown and verification
## Execution-style Kanban board
Use this as the working card map.
### Epic A — Immediate cleanup and intention-locking
#### A1. Retire Wizarr
Status: completed 2026-05-26
Depends on: none
Acceptance criteria:
- `Wizarr` is removed from live Serenity runtime if still present
- no reverse-proxy or bookmark expectation still points at it as a live service
- docs no longer describe it as a migration target
- rollback expectation is explicitly "rebuild if needed later," not "preserve migrated state"
#### A2. Document Hawser + dockersocket as protected keepers
Status: completed 2026-05-26
Depends on: none
Acceptance criteria:
- docs explicitly state Dockhand depends on `Hawser` + `dockersocket`
- future cleanup cards do not treat them as accidental leftovers
- any later rehome/removal must be paired with a replacement Dockhand management path
#### A3. Decide whether netdata remains worth keeping
Status: completed 2026-05-26
Depends on: none
Acceptance criteria:
- decision recorded as one of:
- keep as host-local monitoring until retirement
- simplify/replace with a lighter local signal
- remove after confirming broader monitoring already covers the operator need
- if changed, verification includes equivalent host visibility from the replacement path
### Epic B — Preserve Serenity's temporary intentional roles
#### B1. Keep the media-locality lane stable on Serenity
Status: locked
Depends on: none
Acceptance criteria:
- no partial migration of qbit/ARR lane is attempted before storage design is ready
- docs keep the lane grouped as an intentional temporary unit
- future cards treat locality breakage as a rollback trigger, not a minor warning
#### B2. Keep Hawser + dockersocket in place for Dockhand
Status: locked
Depends on: none
Acceptance criteria:
- `Hawser` and `dockersocket` remain healthy on Serenity
- Dockhand remote management path still works for the remaining Serenity stacks
- no proposal to move them is executed without a replacement management path
#### B3. Keep Serenity-local Newt until Pangolin redesign exists
Status: locked
Depends on: none
Acceptance criteria:
- Pangolin-routed Serenity resources continue working
- no retirement of Serenity-local Newt is attempted early
- redesign work is handled as its own lane, not hidden inside app migration cards
#### B4. Keep backup Technitium role on Serenity until replacement exists
Status: locked
Depends on: none
Acceptance criteria:
- off-PD DNS failure domain remains intact
- no cutover collapses this role onto PD alone
- replacement host is defined and verified before Serenity DNS role is removed
#### B5. Keep reranker on Serenity until PD capacity or architecture changes
Status: locked
Depends on: none
Acceptance criteria:
- reranker remains stable where it is
- no move is attempted just for neatness
- any future move explicitly handles the live `/mnt/user/appdate/reranker` path oddity
### Epic C — Optional tiny near-term moves
#### C1. Re-evaluate Notifiarr against live PD headroom
Status: completed 2026-05-26
Depends on: none
Acceptance criteria:
- current PD RAM/storage headroom is checked at execution time
- clear recommendation recorded: move now, defer, or drop the idea
- if moved, verification proves app health plus any expected integrations still work
### Epic D — Replace Serenity special-case roles
#### D1. Design the post-Serenity backup DNS lane
Status: planned
Depends on: none
Target direction:
- dedicated Raspberry Pi 4B backup resolver
Acceptance criteria:
- target host chosen and documented
- role is explicitly outside PD's failure domain
- expected sync, secrets, and verification model are documented before cutover
#### D2. Build and validate the Pi 4B backup resolver
Status: blocked
Depends on: D1
Acceptance criteria:
- Pi 4B resolver is online and documented
- sync path is defined
- LAN clients can resolve through it as expected
- it is clearly not dependent on PD for local authoritative continuity
#### D3. Redesign Pangolin/Newt dependency
Status: planned
Depends on: none
Acceptance criteria:
- Serenity-hosted access path no longer requires Serenity-local Newt
- replacement routing model is documented
- cutover plan includes rollback and validation steps
#### D4. Re-evaluate reranker final home
Status: planned
Depends on: none
Acceptance criteria:
- host choice made based on live resource reality and AI architecture, not symmetry
- migration plan explicitly preserves existing data path and naming oddity handling
### Epic E — Majority migration after storage ownership changes
#### E1. Define PD storage cutover model
Status: blocked
Depends on: none
Acceptance criteria:
- exact target datasets/paths on PD are documented
- ownership, mount semantics, and performance assumptions are explicit
- migration no longer depends on NFS-style cross-host path fakery
#### E2. Validate media/torrent path behavior on the target model
Status: blocked
Depends on: E1
Acceptance criteria:
- download path behavior validated
- import path behavior validated
- hardlink or equivalent behavior validated
- post-processing behavior validated
- ARR/qbit path mapping is internally consistent
#### E3. Move qbit + Gluetun + qbit_manage
Status: blocked
Depends on: E2
Acceptance criteria:
- downloader lane works on the target without Serenity path dependence
- VPN and management behavior are verified
- rollback window is defined before source teardown
#### E4. Move ARR + helper lane
Status: blocked
Depends on: E3
Includes:
- `prowlarr`
- `sonarr`
- `sonarr-anime`
- `radarr`
- `lidarr`
- `readarr`
- `readarr-epub`
- `bazarr`
- `autobrr`
- `unpackerr`
- `shelfmark`
- optional `Notifiarr`
Acceptance criteria:
- libraries remain visible
- imports and automation continue working
- no stale Serenity-local path assumptions remain in configs
#### E5. Post-cutover soak and verification
Status: blocked
Depends on: E4
Acceptance criteria:
- successful download/import verification exists
- operator-facing health paths are normal
- rollback confidence window passes without hidden path regressions
### Epic F — Final Serenity retirement
#### F1. Remove remaining special roles from Serenity
Status: blocked
Depends on: D2, D3, D4, E5
Acceptance criteria:
- no production path still depends on Serenity DNS role, Serenity-local Newt, or Serenity reranker placement
- any retained backups are documented and off the critical path
#### F2. Retire Serenity
Status: blocked
Depends on: F1
Acceptance criteria:
- no intentional production app remains
- no unique resolver or tunnel role remains
- docs describe Serenity as retired rather than transitional
## Suggested execution order
If only a few cards should move soon, use this order:
1. A1 — Retire Wizarr
2. A2 — Lock Hawser/dockersocket as intentional keepers
3. A3 — Decide netdata end-state
4. C1 — Re-evaluate Notifiarr only if there is real benefit
5. D1/D2 — Create the replacement backup-DNS lane on Pi 4B
6. D3/D4 — redesign special cases
7. E1-E5 — execute the true majority migration only after PD storage ownership changes
8. F1/F2 — retire Serenity
## Recommendation
Do not force a near-term "move the majority now" project.
The correct near-term board is:
- retire dead weight
- keep the locality lane intact
- keep Dockhand's Hawser path intact
- build the future DNS replacement lane
- wait for the storage and capacity conditions that make the real majority move sane

View File

@@ -5,14 +5,17 @@
- [x] Regenerate N.O.M.A.D. Newt secret (was exposed in chat) — completed 2026-05-15 - [x] Regenerate N.O.M.A.D. Newt secret (was exposed in chat) — completed 2026-05-15
## Infrastructure ## Infrastructure
- [x] Deploy Pi-hole HA on PD + N.O.M.A.D. with VIP `10.5.30.53` — completed 2026-05-15; historical Trusted-VLAN design, later superseded after NOMAD moved to Servers (`10.5.30.7`) and its Trusted-side Keepalived role was disabled pending a same-subnet HA redesign; see docs/planning/DEPLOY_PIHOLE.md - [x] Deploy Pi-hole HA on PD + N.O.M.A.D. with VIP `10.5.30.53` — completed 2026-05-15; historical design later superseded by the Technitium trio. Both the PD runtime at `/mnt/docker-ssd/docker/compose/pihole` and the NOMAD runtime at `/opt/pihole-nomad` were retired on 2026-05-27; see docs/planning/DEPLOY_PIHOLE.md
- PD primary live at `/mnt/docker-ssd/docker/compose/pihole` - PD primary retired 2026-05-27 after local resolver cutover to Technitium (`10.5.30.8/.9/.10` + `9.9.9.9`)
- NOMAD backup live at `/opt/pihole-nomad` - NOMAD legacy survivor retired 2026-05-27; old VIP `10.5.30.53` no longer answers
- RPi4 remains optional future BACKUP2 work if you want a third node later - RPi4 remains optional future BACKUP2 work if you want a third node later
- [x] Deploy Kima-Hub — completed 2026-05-06; docs/planning/DEPLOY_KIMA_HUB.md now serves as deployment/repair reference - [x] Deploy Kima-Hub — completed 2026-05-06; docs/planning/DEPLOY_KIMA_HUB.md now serves as deployment/repair reference
- [ ] Set up off-site backup for vital data (appdata, databases, config repo, photos) - [ ] Set up off-site backup for vital data (appdata, databases, config repo, photos)
- [ ] Remove dead Unraid-Cloudflared-Tunnel container from Serenity - [x] Remove dead Unraid-Cloudflared-Tunnel container from Serenity — completed 2026-05-25
- [ ] Serenity malcolm pool capacity planning (heavily utilized) - [ ] Serenity malcolm pool capacity planning (heavily utilized)
- [ ] Design post-Serenity backup DNS lane (target: Pi 4B backup resolver outside PD's failure domain)
- [ ] Redesign Pangolin/Newt so Serenity resources no longer require Serenity-local Newt
- [ ] Define PD storage cutover model for qBittorrent/ARR locality migration off Serenity
- [x] Add Pangolin resource for `openwebui.paccoco.com` → OpenWebUI (port 8282) — completed 2026-05-15 - [x] Add Pangolin resource for `openwebui.paccoco.com` → OpenWebUI (port 8282) — completed 2026-05-15
- [x] Add Cloudflare DNS record for `openwebui.paccoco.com` — completed 2026-05-15 - [x] Add Cloudflare DNS record for `openwebui.paccoco.com` — completed 2026-05-15
- [ ] Install fresh editor on PD - [ ] Install fresh editor on PD

View File

@@ -22,7 +22,7 @@ Tech stack: FastAPI, Jinja2, JSON state store for V1 bootstrap, Docker Compose o
- Progress photos: out for V1. - Progress photos: out for V1.
- Muscle-size tracking: not a priority. - Muscle-size tracking: not a priority.
- Current height assumption for seed data: 68.5 inches unless John corrects it. - Current height assumption for seed data: 68.5 inches unless John corrects it.
- Exercise catalog: John will send the real exercise list later; V1 should support templates/routines once that lands. - Exercise catalog: John's current five-day split has now been imported as the default exercise/routine library with a default prescription of 3 sets x 10 reps; working weights are still pending.
## Current scaffold already started ## Current scaffold already started
@@ -45,9 +45,12 @@ Tech stack: FastAPI, Jinja2, JSON state store for V1 bootstrap, Docker Compose o
- `GET /api/history/{user_id}/workouts` - `GET /api/history/{user_id}/workouts`
- `GET /api/progression/{user_id}/exercises` - `GET /api/progression/{user_id}/exercises`
- Homepage now renders current metrics, recent workouts, exercise bests, and placeholder template/routine scaffolding. - Homepage now renders current metrics, recent workouts, exercise bests, and placeholder template/routine scaffolding.
- Homepage now renders John's imported five-day split as the default routine board.
- Homepage now includes mobile-friendly HTML forms for quick weight entry, body measurements, exercise templates, routine drafts, and quick workout logging. - Homepage now includes mobile-friendly HTML forms for quick weight entry, body measurements, exercise templates, routine drafts, and quick workout logging.
- Routine log sheets now support dropdown exercise selection with set-by-set rep and weight entry for seeded program days.
- Live workout pages now let John start a session and save one exercise at a time mid-workout before finishing the routine into history.
- Homepage now includes John-focused recent history snapshots so the app is useful before charts exist. - Homepage now includes John-focused recent history snapshots so the app is useful before charts exist.
- Tests currently cover health, seeded users, BMI calculation, imperial measurement logging, exercise template creation, routine creation, history ordering, structured workout logging, progression summaries, homepage render, and form-post redirects. - Tests currently cover health, seeded users, BMI calculation, imperial measurement logging, exercise template creation, routine creation, history ordering, structured workout logging, live workout session flows, progression summaries, homepage render, routine log flows, and form-post redirects.
## Next build slices ## Next build slices
@@ -86,7 +89,7 @@ Tech stack: FastAPI, Jinja2, JSON state store for V1 bootstrap, Docker Compose o
## Biggest open product questions still waiting on John ## Biggest open product questions still waiting on John
1. Exact exercise/routine list. 1. Working weights for the imported split.
2. Preferred auth mode for V1. 2. Preferred auth mode for V1.
3. Which extra non-photo metrics matter beyond weight, BMI, body fat, waist, hips, chest, and neck. 3. Which extra non-photo metrics matter beyond weight, BMI, body fat, waist, hips, chest, and neck.
4. Whether 68.5 inches should be treated as the current real height, or whether he wants 69 inches used instead. 4. Whether 68.5 inches should be treated as the current real height, or whether he wants 69 inches used instead.

View File

@@ -1,9 +1,14 @@
# Meshtastic Stack # Meshtastic Stack
## Overview ## Overview
Self-hosted Meshtastic monitoring and management stack running on PlausibleDeniability. Self-hosted MeshMonitor stack running on PlausibleDeniability, plus the current operator note for the separate NOMAD-side MeshCore companion observer.
Related NOMAD-side bridge note: a separate MeshCore-to-MQTT relay is running on N.O.M.A.D. as Docker container `mctomqtt` (`mctomqtt:latest`), with config bind-mounted from `/etc/mctomqtt`. The package also includes a systemd unit under `/opt/mctomqtt`, but that is not the active runtime on this host. Related NOMAD-side bridge note: the legacy `mctomqtt` Docker relay was retired on 2026-06-13. N.O.M.A.D. now uses the LetsMesh companion observer flow via `meshcore-capture.service`, running from `/home/fizzlepoof/.meshcore-packet-capture` and connecting directly to the Heltec over `/dev/serial/by-id/...`.
Important split:
- **PD** hosts the MeshMonitor web/UI stack.
- **NOMAD** owns the USB-attached Heltec companion observer via `meshcore-capture.service`.
- Do not assume MeshMonitor and the companion observer are the same runtime path.
## Stack Location ## Stack Location
`/mnt/docker-ssd/docker/compose/meshtastic/` `/mnt/docker-ssd/docker/compose/meshtastic/`
@@ -12,29 +17,43 @@ Related NOMAD-side bridge note: a separate MeshCore-to-MQTT relay is running on
| Container | Image | Port | Purpose | | Container | Image | Port | Purpose |
|-----------|-------|------|---------| |-----------|-------|------|---------|
| meshmonitor | ghcr.io/yeraze/meshmonitor:4.2.0 | 8081→3001 | Main UI + backend | | meshmonitor | ghcr.io/yeraze/meshmonitor:latest | 8081→3001, 4404→4404, 1883→1883 | Main UI + backend; live compose publishes the virtual-node port and embedded MQTT broker directly from this container, and now maps `/dev/ttyACM0` into the container for native MeshCore USB sources |
| meshmonitor (virtual node) | — | 4404 | TCP virtual node server | | meshmonitor (virtual node) | — | 4404 | TCP virtual node server exposed directly by the meshmonitor container |
| meshmonitor-tileserver | maptiler/tileserver-gl-light | 8082→8080 | Offline map tiles | | meshmonitor (embedded MQTT broker) | — | 1883 | Embedded Aedes-based MQTT broker exposed directly by the meshmonitor container |
| meshmonitor-mqtt-proxy | ghcr.io/ln4cy/mqtt-proxy:master | — | MQTT→TCP bridge | | meshmonitor-tileserver | maptiler/tileserver-gl-light:latest | 8082→8080 | Offline map tiles |
| meshmonitor-upgrader | docker:latest | — | Auto-upgrade watchdog | | meshmonitor-upgrader | docker:latest | — | Auto-upgrade watchdog |
Live runtime verified on 2026-07-03:
- `docker compose ps` on PD showed `meshmonitor`, `meshmonitor-tileserver`, and `meshmonitor-upgrader` running.
- Repo compose and live runtime now agree that there is no separate `meshmonitor-mqtt-proxy` sidecar in the active stack.
- The embedded MQTT broker source `Meshcore Peachtree Observer` is configured in MeshMonitor and logs `MQTT broker listening on 0.0.0.0:1883`.
- A native MeshCore USB source `PT Meshcore` is now auto-connected in MeshMonitor via `/dev/ttyACM0` after the compose file was updated to map the device into the container.
- NOMAD `meshcore-capture.service` was later returned to its three upstream brokers after the embedded-broker test path proved unnecessary for real MeshCore ingestion.
## Architecture ## Architecture
``` ```
Meshtastic Node (10.5.1.172) Trusted-LAN mesh node (10.5.1.120)
MQTT direct node connection configured by MESHTASTIC_NODE_IP
meshmonitor-mqtt-proxy meshmonitor backend on PD
↓ TCP (port 4404)
meshmonitor virtual node server
meshmonitor backend
PostgreSQL (shared-postgres, db: meshmonitor) PostgreSQL (shared-postgres, db: meshmonitor)
Separate path:
NOMAD Heltec companion observer (`meshcore-capture.service`)
↓ upstream MQTT brokers (LetsMesh US / EU + NashMe)
PD native MeshCore USB source (`PT Meshcore`)
↓ `/dev/ttyACM0` mapped into the meshmonitor container
↓ meshcore.js native companion backend
``` ```
Note: the embedded MeshMonitor broker now accepts the NOMAD connection on `10.5.30.6:1883`, but current MeshMonitor logs still show protobuf decode warnings for MeshCore packet payloads. Treat this as confirmed broker fanout connectivity, not yet confirmed first-class MeshCore ingestion.
## Node Connection ## Node Connection
- Node IP: `10.5.1.172` - PD MeshMonitor compose is currently configured with `MESHTASTIC_NODE_IP=10.5.1.120`
- Connection type: MQTT via mqtt-proxy (not direct TCP) - MeshMonitor itself publishes port `4404` for the virtual-node path and `1883` for the embedded MQTT broker; there is no separate `meshmonitor-mqtt-proxy` service in the active stack
- mqtt-proxy connects to meshmonitor's virtual node server on port 4404 - For native MeshCore USB sources on PD, the live compose now maps `/dev/ttyACM0` directly into the `meshmonitor` container
- NOMAD's companion observer is a different ingest path and should not be documented as a MeshMonitor sidecar
## Storage ## Storage
- Appdata: `/mnt/tank/docker/appdata/meshmonitor/data``/data` - Appdata: `/mnt/tank/docker/appdata/meshmonitor/data``/data`
@@ -60,7 +79,7 @@ mkdir -p /mnt/docker-ssd/docker/compose/meshtastic/scripts
## Known Warnings ## Known Warnings
- Node `!dd972536` has a low-entropy public key — this is a device configuration issue, not a meshmonitor bug - Node `!dd972536` has a low-entropy public key — this is a device configuration issue, not a meshmonitor bug
- Frequent connect/disconnect cycles in logs are normal when the MQTT proxy reconnects - MeshMonitor logs can still show noisy reconnect/admin-session churn during node or radio instability; do not confuse that with the retired NOMAD `mctomqtt` path
## Tiles ## Tiles
Map tiles stored in `./tiles/`. Currently includes `zurich_switzerland.mbtiles` (34MB). Map tiles stored in `./tiles/`. Currently includes `zurich_switzerland.mbtiles` (34MB).
@@ -68,5 +87,4 @@ Tiles directory is gitignored. To add new tiles, download `.mbtiles` files and p
## TODO ## TODO
- [ ] Document all running meshtastic-map container config - [ ] Document all running meshtastic-map container config
- [ ] Document meshtastic-map-mosquitto and meshtastic-mqtt containers
- [ ] Add healthcheck to meshmonitor container - [ ] Add healthcheck to meshmonitor container

View File

@@ -12,6 +12,11 @@ Offline survival/knowledge server and game server host.
- **Motherboard:** ASRock H97M Pro4 - **Motherboard:** ASRock H97M Pro4
- **UPS:** None - **UPS:** None
## Planned long-term role
- Keep NOMAD intentionally separate from PD's production stack.
- Continue to host Project NOMAD, game services, and the backup Technitium resolver.
- Accept only small bounded helper workloads; do not let it drift into being a second general-purpose production host.
## Storage ## Storage
| Device | Size | Mount | Purpose | | Device | Size | Mount | Purpose |
|--------|------|-------|---------| |--------|------|-------|---------|
@@ -199,43 +204,49 @@ These are expected to be managed by Wings/Pelican rather than a local compose fi
- Topic thumbs write back to `doris-digest/data/feedback.json`. - Topic thumbs write back to `doris-digest/data/feedback.json`.
- Paperless acknowledge/dismiss state writes to `/opt/doris-dashboard/data/paperless_review_state.json` on the live host. - Paperless acknowledge/dismiss state writes to `/opt/doris-dashboard/data/paperless_review_state.json` on the live host.
### MeshCore to MQTT relay ### Technitium Backup Resolver
- Install date: 2026-05-18 - Live stack path: `/opt/technitium-nomad`
- Install root: `/opt/mctomqtt` - Live secrets file: `/opt/technitium-nomad/.env`
- Config root: `/etc/mctomqtt` - Resolver bind IP: `10.5.30.9`
- Service account: `mctomqtt:mctomqtt` - Role: active DHCP-advertised backup Technitium resolver for the homelab
- Installer metadata: `.version_info` reports `installer_version: 1.3.0.0-preview`, repo `Cisien/meshcoretomqtt`, branch `main`, commit `10f7c0e` - Config is refreshed from PD's live Technitium directory by the PD-side sync runner every 15 minutes
- Detected install type marker: `/opt/mctomqtt/.install_type` contains `docker` - The live `.env` is also backed up into the encrypted PD secrets repo as `env/technitium-nomad.env`
- Runtime model: - Same-host checks from NOMAD to `10.5.30.9` are unreliable because the resolver sits behind macvlan networking; verify from another LAN peer instead
- Active runtime is a standalone Docker container named `mctomqtt`
- Image: `mctomqtt:latest` ### LocalSend Trusted inbox
- Command: `python3 /opt/mctomqtt/mctomqtt.py` - Live stack path: `/opt/localsend-nomad`
- User: `mctomqtt` - Repo source path: `/home/fizzlepoof/repos/truenas-stacks/localsend-nomad`
- Restart policy: `unless-stopped` - Trusted bind IP: `10.5.1.16`
- Network mode: `bridge` - Advertised device name: `Doris Trusted Inbox`
- Config bind mount: `/etc/mctomqtt:/etc/mctomqtt:ro` - Role: headless LocalSend receiver for Trusted-LAN handoff into `/home/fizzlepoof/private/inbox-secrets`
- Device passthrough: the Heltec serial device is mapped directly into the container at the same `/dev/serial/by-id/... ` path - Runtime model: standalone Docker Compose stack on a Trusted-LAN macvlan (`enp5s0.51`, VLAN 51) while the host stays on Servers (`10.5.30.7`)
- Systemd note: - LocalSend listener ports: `53317/tcp`, `53317/udp`
- The packaged repo also includes `/opt/mctomqtt/mctomqtt.service`, but it is not the active runtime on NOMAD - Same-host checks from NOMAD to `10.5.1.16` are unreliable because the receiver sits behind macvlan networking; verify from another Trusted-LAN peer instead
- No installed unit was present in `/etc/systemd/system` or `/lib/systemd/system` - Inbox watcher for Minerva staging: `minerva-localsend-autosort.path`
- The bundled unit expects `/opt/mctomqtt/venv/bin/python3`, but no `venv/` directory exists under `/opt/mctomqtt` - Sorter service: `minerva-localsend-autosort.service`
- Treat the systemd unit as an unused package artifact for this host unless the deployment model changes later - Sorter script: `/home/fizzlepoof/.local/bin/minerva-localsend-autosort.py`
- Config layering:
- Base reference: `/etc/mctomqtt/config.toml` ### MeshCore companion observer
- Preset overlay: `/etc/mctomqtt/config.d/10-meshmapper.toml` - Current runtime: `meshcore-capture.service` (systemd)
- Local overrides: `/etc/mctomqtt/config.d/99-user.toml` - Install root: `/home/fizzlepoof/.meshcore-packet-capture`
- Current local overrides observed: - Service unit: `/etc/systemd/system/meshcore-capture.service`
- IATA region code set to `BNA` - Runtime command: `/home/fizzlepoof/.meshcore-packet-capture/venv/bin/python3 /home/fizzlepoof/.meshcore-packet-capture/packet_capture.py`
- Serial device pinned to `/dev/serial/by-id/usb-Espressif_Systems_heltec_wifi_lora_32_v4__16_MB_FLASH__2_MB_PSRAM__441BF670B684-if00` - Connection mode: direct serial on the Heltec via `/dev/serial/by-id/usb-Espressif_Systems_heltec_wifi_lora_32_v4__16_MB_FLASH__2_MB_PSRAM__441BF670B684-if00`
- Enabled broker targets include MeshMapper, LetsMesh US, and NashMe - Observer role:
- Credentials/secrets are stored inline in `99-user.toml`; do not mirror them into git-backed docs - NOMAD now uses the LetsMesh companion observer flow instead of the legacy `mctomqtt` relay
- Container health observed during inspection: - The companion process connects to the Heltec locally, signs JWTs on-device, and publishes packet capture data upstream to MQTT brokers
- Logs show the relay loading all three config layers, opening the Heltec serial device, and connecting successfully to MeshMapper, NashMe, and LetsMesh US after restart - Service health observed on 2026-07-03:
- The current container had been up about 5 minutes during inspection with `MQTT: 3/3`, zero failures, and live RX air-time reported - `systemctl status meshcore-capture` shows the service active/running
- An earlier start logged one DNS resolution failure for a broker label shown as `custom-2`, but the current running container recovered and all three named brokers connected cleanly - Logs confirm successful serial connection to device name `Peachtree Mesh Monitor`
- Container logs / inspection: - Logs confirm MQTT connectivity to LetsMesh US, LetsMesh EU, and NashMe
- Use `docker logs mctomqtt` for runtime output - After removing the temporary PD embedded-broker test target, startup logs now show `Connected to 3 MQTT broker(s)`
- Use `docker inspect mctomqtt` for bind mounts, device mappings, and restart policy - Packet capture mode is enabled and waiting for packets
- Initial-start caveat:
- One installer-start attempt failed with `No such file or directory` for the `/dev/serial/by-id/...` path
- A subsequent manual `systemctl start meshcore-capture` succeeded once the device path was present again
- Legacy relay retirement:
- The old standalone Docker container `mctomqtt` was retired on 2026-06-13
- Backup of the retired relay files/config was saved under `/home/fizzlepoof/private/backups/mctomqtt-retire/20260613-174536`
- Documentation linkage: - Documentation linkage:
- Related mesh stack notes live in [MESHTASTIC.md](../reference/MESHTASTIC.md) - Related mesh stack notes live in [MESHTASTIC.md](../reference/MESHTASTIC.md)

View File

@@ -2,6 +2,8 @@
Primary Docker host running all production workloads. Primary Docker host running all production workloads.
> Planned direction: this host remains bare-metal TrueNAS Scale and becomes the long-term primary production platform after the HL15 Beast rebuild. See `docs/planning/PD_FUTURE_STATE_ARCHITECTURE.md`.
## Specs ## Specs
- **OS:** TrueNAS Scale 25.10.2.1 - **OS:** TrueNAS Scale 25.10.2.1
- **CPU:** Ryzen 9 3950X - **CPU:** Ryzen 9 3950X
@@ -45,14 +47,21 @@ Recommended models for 11GB VRAM:
| `phi4` | ~8GB | Analytical tasks | | `phi4` | ~8GB | Analytical tasks |
Current light-tier notes: Current light-tier notes:
- PD Ollama is the active light-tier endpoint for LiteLLM on `http://10.5.30.6:11434` - PD Ollama remains the shared light-tier endpoint for the broader AI stack on `http://10.5.30.6:11434`, but it should be protected from routine Honcho background load.
- Local Honcho currently points at PD Ollama over LAN (`http://10.5.30.6:11434/v1`) instead of using a separate local model host - Local Honcho now uses N.O.M.A.D.'s own Ollama on `http://127.0.0.1:11434/v1` so PD pressure does not take down MeshMonitor or shared Postgres.
- Current Honcho routing split keeps embeddings on `nomic-embed-text:latest`, cheap/default paths on `qwen2.5:7b`, and deeper high/max paths on `qwen2.5:14b` - Current Honcho routing keeps embeddings on `nomic-embed-text:v1.5`, cheap/default paths on `qwen2.5:3b`, and high/max paths on `llama3.1:latest`.
- Rocinante is John's PC and should be treated as an opportunistic/manual heavy tier, not a dependable always-on backend.
## Remote Access ## Remote Access
- Pangolin VPS + Newt for external services - Pangolin VPS + Newt for external services
- SSH from Windows 11 - SSH from Windows 11
## DNS Role
- Primary Technitium source-of-truth node binds `10.5.30.8` via the `technitium-pilot` stack
- Root cron runs `/mnt/docker-ssd/docker/compose/technitium-pilot/bin/sync_backup_nodes.sh` every 15 minutes to push the live Technitium config to the backup nodes on N.O.M.A.D. (`10.5.30.9`) and Serenity (`10.5.30.10`)
- Sync logs live at `/mnt/tank/docker/appdata/technitium-pilot/logs/sync-backup-nodes.log`
- External fallback `9.9.9.9` protects public DNS continuity, but authoritative `home.paccoco.com` answers still depend on at least one Technitium node being up
## NFS Mounts (from Serenity) ## NFS Mounts (from Serenity)
- Serenity `/mnt/user/data``/mnt/unraid/data` (media libraries) - Serenity `/mnt/user/data``/mnt/unraid/data` (media libraries)
- Serenity `/mnt/user/immich``/mnt/unraid/immich` (Immich photo library) - Serenity `/mnt/user/immich``/mnt/unraid/immich` (Immich photo library)
@@ -61,6 +70,8 @@ Current light-tier notes:
> ```bash > ```bash
> sudo bash /mnt/tank/docker/scripts/mount-unraid-nfs.sh > sudo bash /mnt/tank/docker/scripts/mount-unraid-nfs.sh
> ``` > ```
>
> **Important:** if `/mnt/unraid/data` or `/mnt/unraid/immich` gets remounted after dependent containers are already running, restart the bind-mounted consumers so they pick up the live NFS mount instead of the pre-remount underlay. Current restart set: Plex, Audiobookshelf, Calibre-Web, and Immich Server.
## Known Issues ## Known Issues
- immich-ml intermittently unhealthy — investigate GPU/CUDA health - immich-ml intermittently unhealthy — investigate GPU/CUDA health
@@ -70,4 +81,12 @@ Current light-tier notes:
## Pending ## Pending
- Add Pangolin resource + DNS for `ai.paccoco.com` - Add Pangolin resource + DNS for `ai.paccoco.com`
- Install fresh editor - Install fresh editor
- Set up private Gitea repo for .env file backups
## Planned future role
- Primary NAS after Serenity retirement
- Primary production Docker/app host
- Primary shared database host
- Primary media / automation / identity / monitoring host
- Primary Technitium source node
- Future home of qBittorrent + ARR after storage ownership moves local to PD
- Acceptable host for tightly segmented cybersecurity VMs, but not an unrestricted lab/free-for-all hypervisor

View File

@@ -2,11 +2,13 @@
Heavy AI inference server. Heavy AI inference server.
> Planned direction: Rocinante becomes optional if the upgraded PD build receives the 4090. If PD does not get the 4090, Rocinante remains the heavy-inference specialist.
## Hardware ## Hardware
| Component | Details | | Component | Details |
|-----------|---------| |-----------|---------|
| **IP** | 10.5.1.112 | | **IP** | 10.5.30.112 |
| **GPU** | NVIDIA RTX 4090 (24 GB VRAM) | | **GPU** | NVIDIA RTX 4090 (24 GB VRAM) |
| **Role** | Heavy AI inference (Ollama) | | **Role** | Heavy AI inference (Ollama) |
@@ -22,14 +24,15 @@ Ollama runs on port `11434` and hosts the large-model ("heavy") tier.
| `qwen2.5:32b` | Large-context tasks | | `qwen2.5:32b` | Large-context tasks |
| `gemma3:27b` | Multimodal / vision | | `gemma3:27b` | Multimodal / vision |
> **LiteLLM tier:** Rocinante is the **"heavy"** tier in LiteLLM `config.yaml`. > **LiteLLM tier:** Rocinante may serve as an **opportunistic heavy/manual tier** when the PC is online.
> Heavy-tier requests from OpenWebUI and n8n route to `http://10.5.1.112:11434`. > Do not depend on `http://10.5.30.112:11434` for routine background routing; verify live reachability first and prefer N.O.M.A.D.-local paths for guaranteed availability.
## Current operational note ## Current operational note
- During Doris validation from N.O.M.A.D. on 2026-05-21, `10.5.1.112` did not answer ping and did not accept connections on `11434` or `8787`. - Rocinante is John's personal PC and is not a dependable always-on homelab tier.
- Treat Rocinante-hosted Ollama heavy-tier traffic and CUDA Whisper as currently unreachable from N.O.M.A.D. until host/network reachability is restored. - Treat Ollama/Whisper on Rocinante as opportunistic capacity when the machine is online, not as a required background dependency.
- Re-validate from N.O.M.A.D. with `curl http://10.5.1.112:11434/api/tags` and `curl http://10.5.1.112:8787/health` after recovery. - If N.O.M.A.D. or PD need guaranteed inference, prefer local N.O.M.A.D. routing first and keep PD protected from routine background load.
- Re-check live reachability from N.O.M.A.D. before any Rocinante-dependent maintenance or cutover.
## Notes ## Notes

View File

@@ -1,6 +1,6 @@
# Serenity # Serenity
NAS, media ingestion, and CPU-based AI inference. Current NAS, media-ingestion, and CPU-based AI helper host. Planned to retire after the upgraded PD build absorbs its storage role.
## Hardware ## Hardware
@@ -18,6 +18,7 @@ NAS, media ingestion, and CPU-based AI inference.
- **NAS** — primary storage for Docker volumes, media, and backups via NFS - **NAS** — primary storage for Docker volumes, media, and backups via NFS
- **Media ingestion** — full ARR stack (Sonarr, Radarr, Prowlarr, qBittorrent, etc.) - **Media ingestion** — full ARR stack (Sonarr, Radarr, Prowlarr, qBittorrent, etc.)
- **Reranker host** — CPU-based TEI reranker for the AI RAG pipeline - **Reranker host** — CPU-based TEI reranker for the AI RAG pipeline
- **Pangolin site host** — Serenity's local Newt is intentionally kept because Pangolin tunnels Serenity resources through that Newt instead of targeting Serenity resources from PD or NOMAD over Serenity LAN IPs
## Services ## Services
@@ -32,6 +33,8 @@ NAS, media ingestion, and CPU-based AI inference.
> ```bash > ```bash
> sudo bash /mnt/tank/docker/scripts/mount-unraid-nfs.sh > sudo bash /mnt/tank/docker/scripts/mount-unraid-nfs.sh
> ``` > ```
>
> **Important:** if PD remounts `/mnt/unraid/data` or `/mnt/unraid/immich` after media containers are already running, restart the bind-mounted consumers so they repoint at the live NFS mount instead of the pre-remount underlay. Current restart set: Plex, Audiobookshelf, Calibre-Web, and Immich Server.
### Reranker (TEI) ### Reranker (TEI)
@@ -42,18 +45,41 @@ Text Embeddings Inference reranker for OpenWebUI RAG pipeline.
| **Model** | `BAAI/bge-reranker-v2-m3` | | **Model** | `BAAI/bge-reranker-v2-m3` |
| **Image** | `ghcr.io/huggingface/text-embeddings-inference:cpu-latest` | | **Image** | `ghcr.io/huggingface/text-embeddings-inference:cpu-latest` |
| **Port** | `9787` | | **Port** | `9787` |
| **Volume** | `/mnt/user/appdata/reranker` | | **Volume** | `/mnt/user/appdate/reranker` |
| **LiteLLM model name** | `reranker` | | **LiteLLM model name** | `reranker` |
| **API base** | `http://10.5.30.5:9787` | | **API base** | `http://10.5.30.5:9787` |
> Moved from PlausibleDeniability (port 8787) to Serenity (port 9787) on 2026-05-09. > Moved from PlausibleDeniability (port 8787) to Serenity (port 9787) on 2026-05-09.
> CPU-only inference is acceptable for RAG reranking workloads. > CPU-only inference is acceptable for RAG reranking workloads.
> Live runtime currently uses `/mnt/user/appdate/reranker`; preserve that path as-is until a deliberate migration normalizes it.
### ARR Stack ### ARR Stack
Managed via Unraid Community Apps. See Unraid dashboard for individual service ports and status. Managed via Unraid Community Apps. See Unraid dashboard for individual service ports and status.
### Technitium Backup Resolver
Serenity hosts the secondary backup Technitium node on `10.5.30.10`.
- Live stack path: `/mnt/user/appdata/technitium-serenity`
- Live secrets file: `/mnt/user/appdata/technitium-serenity/.env`
- Resolver bind IP: `10.5.30.10`
- Config is refreshed from PD's live Technitium directory by the PD-side sync runner every 15 minutes
- The live `.env` is also backed up into the encrypted PD secrets repo as `env/technitium-serenity.env`
- Same-host checks from Serenity to `10.5.30.10` are unreliable because the resolver sits behind macvlan networking; verify from another LAN peer instead
## Notes ## Notes
- No GPU; reranker uses CPU-only TEI image - No GPU; reranker uses CPU-only TEI image
- Tailscale active — reachable at `100.94.87.79` from any Tailscale node - Tailscale active — reachable at `100.94.87.79` from any Tailscale node
- Local Serenity DB containers for the old GameVault and RomM stacks (`postgresql15` and `MariaDB-Official`) were retired on 2026-05-25 after both apps validated healthy on PD.
- The stale stopped source app containers for those old stacks were cleaned out afterward; rollback now lives in the retained appdata plus `/mnt/user/backups/doris/serenity-d7-db-retire-20260525-213531` rather than in lingering `docker ps -a` entries.
- Cooldown/rollback artifacts for that retirement were retained on Serenity under `/mnt/user/backups/doris/serenity-d7-db-retire-20260525-213531`, and the original DB appdata paths were left in place rather than deleted immediately.
## Planned future role
- Transitional only; do not design new long-term dependencies around Serenity.
- Near-term it continues to host storage locality, reranker, the backup Technitium node, and the Serenity-local Newt needed for Pangolin access to Serenity resources.
- Near-term cleanup already retired `Wizarr`, keeps `Hawser` + `dockersocket` because Dockhand depends on them for remote management of Serenity's remaining Docker stacks, and intentionally leaves `Notifiarr` and `netdata` on Serenity for now because PD headroom and host-local monitoring value do not justify an early move.
- Once PD directly owns the storage, qBittorrent/ARR locality should move to PD and Serenity should be drained and retired.
- The stale Pangolin/Newt health-check drift to `10.5.1.5` was fixed on 2026-05-25; future Newt work is now a deliberate rehome/retirement design task rather than an active incident.

View File

@@ -25,6 +25,15 @@ Per-service gotchas that aren't bugs but will bite you if you forget them.
- Env vars alone are insufficient for DB type selection - Env vars alone are insufficient for DB type selection
- Requires `/mnt/tank/docker/appdata/donetick/config/selfhosted.yaml` with full DB config - Requires `/mnt/tank/docker/appdata/donetick/config/selfhosted.yaml` with full DB config
- Uses viper config loader — `DT_ENV` controls config file path - Uses viper config loader — `DT_ENV` controls config file path
- Public exposure through Pangolin can stay unhealthy after renumbering if the target health-check hostname is stale even when the target IP/port are already fixed; verify both `ip` and `hcHostname`
- If PD-side Pangolin/Newt targets still probe a stale pre-renumbering host IP (for example `10.5.1.6`) but the backend is otherwise healthy, the fastest reversible recovery is a runtime `/32` compatibility alias on PD while the authoritative Pangolin target/health-check state is corrected
- If the PD `newt-loopback-bridge` helper is using `network_mode: "container:<newt>"`, restarting `ix-newt-newt-1` can leave the loopback relays broken until `newt-loopback-bridge` is also restarted; symptom is public 502/503 on `localhost`-backed Pangolin routes even after target health turns green
- OIDC metadata for the frontend is exposed from `/api/v1/resource`; if the login button is missing, check that endpoint before debugging the SPA
### homepage
- Live config is `/mnt/tank/docker/appdata/homepage/services.yaml`; it is not currently repo-managed, so live edits should be mirrored back into docs when they matter operationally
- `books.paccoco.com` is the working public Calibre-Web route; `calibre.paccoco.com` / `kindle.paccoco.com` are legacy/broken unless separate Pangolin resources are created for them
- RoMm widget URLs must use a backend Homepage can actually reach from PD; if the only working path is the auth-gated public Pangolin route, remove the widget instead of leaving a stale literal LAN IP
### shlink ### shlink
- Data directory must be `chmod 777` — runs as non-root user that doesn't match host default ownership - Data directory must be `chmod 777` — runs as non-root user that doesn't match host default ownership
@@ -33,6 +42,15 @@ Per-service gotchas that aren't bugs but will bite you if you forget them.
- DB: user=`openwebui`, db=`openwebui` on shared-postgres - DB: user=`openwebui`, db=`openwebui` on shared-postgres
- If restart-looping with auth errors: container wasn't on `ix-databases_shared-databases` at creation time — must `down && up` - If restart-looping with auth errors: container wasn't on `ix-databases_shared-databases` at creation time — must `down && up`
### qdrant
- If the container has to be manually recreated from `docker inspect` because compose labels are stale or missing, treat that as emergency stabilization only, not final cleanup
- After emergency recovery, immediately reconcile the runtime back to `/mnt/docker-ssd/docker/compose/ai/docker-compose.yaml`, back up the live state, and verify the container labels again before calling the incident closed
- The container being `Up` is not enough; also confirm `com.docker.compose.project.working_dir`, `com.docker.compose.project.config_files`, and `/healthz`
### seerr
- After adjacent media-stack incident work, verify both LAN/public availability and that the container still points at `/mnt/docker-ssd/docker/compose/media/docker-compose.yaml`
- Stale `*.pre-*` compose backups do not belong in the live compose directory long-term; back them up elsewhere, then prune them to reduce operator ambiguity
### plex ### plex
- Port `5353/udp` conflicts with system avahi/mDNS — remove from port mappings - Port `5353/udp` conflicts with system avahi/mDNS — remove from port mappings
@@ -50,6 +68,12 @@ Per-service gotchas that aren't bugs but will bite you if you forget them.
- `docker restart` or `docker compose restart` will NOT fix missing network attachments - `docker restart` or `docker compose restart` will NOT fix missing network attachments
- Must use `docker compose --env-file .env down <service> && docker compose --env-file .env up -d <service>` - Must use `docker compose --env-file .env down <service> && docker compose --env-file .env up -d <service>`
### UniFi Protect WiFi chimes after doorbell/network changes
- A UniFi Protect WiFi chime can look healthy in UniFi Network while still showing offline in Protect if a per-client Camera virtual-network override puts it on a lane that does not preserve the required Protect path.
- In John's current environment, the known-good fallback for the two Wi-Fi chimes is Management/default `UniFi Wireless`, not the Camera-lane override that was tried on 2026-05-22.
- After a doorbell is re-adopted, also verify each chime's `ringSettings.cameraId` still points at the current doorbell object; a stale camera binding can break ringing even when the network path is healthy.
- Treat "device online in UniFi Network" and "device healthy in Protect / rings for the current doorbell" as separate checks.
--- ---
## Serenity ## Serenity
@@ -59,7 +83,23 @@ Per-service gotchas that aren't bugs but will bite you if you forget them.
- MAM-init script updates MyAnonamouse IP inside qbit container on startup - MAM-init script updates MyAnonamouse IP inside qbit container on startup
### Unraid-Cloudflared-Tunnel ### Unraid-Cloudflared-Tunnel
- Dead container, should be removed - Older notes called this dead, but live inspection on 2026-05-25 showed active Cloudflare QUIC edge registrations and a configured tunnel token.
- Follow-up audit showed it is only transport-alive: current-run metrics reported zero proxied requests, its remote-managed ingress config still points at legacy `192.168.1.x` origins, and the listed public hostnames appear to be served elsewhere now.
- Retired on 2026-05-25: the container was stopped and removed, sampled public hostnames stayed healthy, and Cloudflare-side cleanup can now happen separately from Serenity runtime cleanup.
### Serenity Wave 1 cleanup guardrails
- `Unraid-Cloudflared-Tunnel` has already been retired from Serenity runtime; any remaining cleanup is Cloudflare-side control-plane cleanup, not local container cleanup.
- Serenity's legacy Pi-hole HA containers were retired on 2026-05-25 after mixed-host DNS verification; if future DNS issues appear, investigate the surviving Technitium/other-resolver path rather than trying to resurrect those old Pi-hole containers by default.
- Recent `Created` containers on Unraid may represent intentionally retained templates rather than stale debris; distinguish them from long-dead exited containers before pruning.
### Serenity Newt / Pangolin stale health-check IP drift
- Several Serenity Newt-backed Pangolin targets were corrected to `ip=localhost`/`10.5.30.5` but still retained stale `hcHostname=10.5.1.5`, so Newt health checks failed with `no route to host`
- Verified affected target IDs on 2026-05-25: `15`, `20`, `25`, `29`, `56`, `57`, `58`, `69`
- A temporary local `/32` alias on Serenity (`ip addr add 10.5.1.5/32 dev br0`) proved the diagnosis by flipping multiple targets back to healthy
- That alias was then removed because the old `10.5.1.5` address is no longer allowed on the VLAN
- After removal, targets immediately fell back to unhealthy on the same stale health-check URLs, confirming the real problem is authoritative Pangolin metadata drift, not local app failure
- Treat the alias only as a diagnostic probe; do not persist it. The correct fix is to rewrite Pangolin health-check hostnames away from the stale pre-renumbering IP.
- Authoritative fix completed for the Serenity audit set on 2026-05-25: targets `15`, `20`, `25`, `29`, `56`, `57`, `58`, and `69` now use `10.5.30.5` for both routing and health checks, and no audited Serenity Pangolin target still carries `10.5.1.5` in live API state.
--- ---

13
headscale/.env.example Normal file
View File

@@ -0,0 +1,13 @@
TZ=America/Chicago
# Public control-plane hostname for off-LAN clients.
HEADSCALE_SERVER_URL=https://headscale.paccoco.com
HEADPLANE_BASE_URL=http://headplane.home.paccoco.com:3005
# Must be replaced with an actual 32-character secret before deployment.
HEADPLANE_COOKIE_SECRET=CHANGE_ME_TO_EXACTLY_32_CHARS
# Optional future OIDC wiring for Headscale users. Leave blank for the initial pilot.
HEADSCALE_OIDC_ISSUER=
HEADSCALE_OIDC_CLIENT_ID=
HEADSCALE_OIDC_CLIENT_SECRET=

317
headscale/README.md Normal file
View File

@@ -0,0 +1,317 @@
# Headscale pilot on PD
Self-hosted Headscale + Headplane pilot stack for replacing the Tailscale free-tier 3-user limit without doing a blind cutover.
## Why this exists
- remove the 3-human-user ceiling from the current Tailscale free-tier setup
- keep the control plane on PD, the long-term primary Docker host
- prove a small household-safe access model before expanding scope
- keep the repo copy as the source of truth for stack config and policy
## Pilot shape
- Host: **PlausibleDeniability**
- Live path: `/mnt/docker-ssd/docker/compose/headscale`
- Services:
- `headscale`
- `headplane`
- Data paths:
- `/mnt/docker-ssd/docker/appdata/headscale`
- `/mnt/docker-ssd/docker/appdata/headplane`
- No OIDC on day one
- No subnet-router or exit-node rollout on day one
- Headscale can be published later as a dedicated public control-plane hostname without exposing Headplane the same way
## URLs for the pilot
- Headscale control plane for clients: `https://headscale.paccoco.com`
- Headplane admin UI (restricted/LAN-only): `http://headplane.home.paccoco.com:3005/admin`
This is the recommended **Option C** shape:
- publish **Headscale** on a stable public HTTPS hostname so phones and laptops can enroll/use it off-LAN
- keep **Headplane** off the public internet by default; treat it as an admin surface reached on LAN/private DNS (or a separately restricted admin path later)
The internal Traefik example in `examples/traefik-routes.yml` is still useful for private-DNS/LAN convenience, but it is not the public control-plane path for off-LAN device enrollment.
### Current live pilot note
Before the public hostname is wired, the live PD pilot uses direct-IP URLs:
- Headscale: `http://10.5.30.6:8084`
- Headplane: `http://10.5.30.6:3005/admin`
Once `https://headscale.paccoco.com` is created and verified through Pangolin, off-LAN clients should use that public Headscale URL. Headplane can stay on the restricted LAN/private-DNS path above.
## Initial users
- `fizzlepoof`
- `manndra`
Policy references them as:
- `fizzlepoof@`
- `manndra@`
## Initial tags
- `tag:infra`
- `tag:apps`
- `tag:admin`
## Access model
- `fizzlepoof@` = full pilot admin access to `tag:infra`, `tag:apps`, `tag:admin`, and his own devices
- `manndra@` = app access only via `tag:apps` plus her own devices
- no limited-user tier yet
- no routed-LAN access yet
## Files
- `docker-compose.yaml`
- `.env.example`
- `config/headscale/config.yaml`
- `config/headscale/policy.hujson`
- `config/headplane/config.yaml`
- `examples/traefik-routes.yml`
## Pre-deploy edits
Before first deploy, replace the placeholder values in:
- `.env` copied from `.env.example`
- `config/headplane/config.yaml`
Required changes:
- replace `CHANGE_ME_TO_EXACTLY_32_CHARS` with a real 32-character cookie secret
- confirm the restricted Headplane LAN/private-DNS record exists if you want the nicer admin hostname
- for off-LAN clients, create/verify the public Pangolin hostname for Headscale
- if you want different ports or hostnames, update both the config files and `.env`
Important live-ops note:
- the repo-tracked `config/headplane/config.yaml` intentionally keeps a placeholder `cookie_secret`
- before restarting the live Headplane container, render or restore the real 32-character secret from the live secret-bearing `.env`
- blindly syncing the tracked placeholder over the live config will make Headplane fail startup with `server.cookie_secret must be exactly length 32`
## Deploy on PD
1. Sync this directory into the live compose tree:
- `/mnt/docker-ssd/docker/compose/headscale`
2. Copy `.env.example` to `.env`
3. Create persistent appdata paths:
- `/mnt/docker-ssd/docker/appdata/headscale`
- `/mnt/docker-ssd/docker/appdata/headscale/run`
- `/mnt/docker-ssd/docker/appdata/headplane`
4. Validate:
```bash
cd /mnt/docker-ssd/docker/compose/headscale
docker compose --env-file .env config
```
5. Bring up Headscale first:
```bash
docker compose --env-file .env up -d headscale
docker logs headscale --tail=100
```
6. Create pilot users:
```bash
docker exec -it headscale headscale users create fizzlepoof
docker exec -it headscale headscale users create manndra
docker exec -it headscale headscale users list
```
7. Generate a Headscale API key for Headplane login:
```bash
docker exec -it headscale headscale apikeys create --expiration 90d
```
Save the returned key somewhere secure. Headplane uses it for the initial admin login.
8. Enroll John's admin device.
9. Bring up Headplane:
```bash
docker compose --env-file .env up -d headplane
docker logs headplane --tail=100
```
10. Log in to Headplane with the API key from step 7.
11. Register one tagged service node.
12. Enroll one Manndra device.
## Public Headscale via Pangolin
For the off-LAN/mobile path, publish only Headscale itself.
Desired public hostname:
- `https://headscale.paccoco.com`
Desired Pangolin target shape:
- resource name: `headscale`
- site: `Plausible Deniability` (siteId `4`)
- target: `headscale:8080`
- healthcheck path: `/health`
- Pangolin auth/SSO: **disabled** for this route
Why:
- Headscale clients need a plain reachable control-plane endpoint; adding browser SSO in front of it is the wrong shape
- Headplane is an admin UI and should stay restricted/admin-only instead of being published like a normal household app
Use `automation/bin/pangolin_upsert_headscale.py` to create or reconcile the public Pangolin resource.
## Policy reload
After editing `config/headscale/policy.hujson`:
```bash
docker exec -it headscale kill -HUP 1
```
Then inspect the container logs for policy parse results.
## Owner-facing Headplane pilot checklist
This section is for John as the stack owner using the Web UI. Doris should handle the CLI and backend admin work; this checklist is about what John should look for in Headplane and when to escalate.
### Where to go
- Open: `http://headplane.home.paccoco.com:3005/admin`
- Log in with the temporary Headscale API key Doris generated for the pilot
### What you should expect to see first
After initial pilot bring-up, Headplane should show:
- two users:
- `fizzlepoof`
- `manndra`
- John's enrolled device(s)
- one tagged pilot service node
- a generally healthy/online control plane with no obvious UI errors
If any of those are missing, that is a Doris problem to fix, not a John problem to debug.
### UI-first smoke test
#### 1. Confirm the users exist
Look for:
- `fizzlepoof`
- `manndra`
Good:
- both users appear once
Bad / message Doris if:
- a user is missing
- a duplicate or unexpected user exists
- user ownership looks wrong
#### 2. Confirm John's device is present
Look for at least one clearly identifiable John-owned device.
Good:
- device shows as online
- device is owned by `fizzlepoof`
- the name is recognizable enough to tell what it is
Bad / message Doris if:
- device is offline when it should be online
- device is attached to the wrong user
- device appears more than once unexpectedly
#### 3. Confirm Manndra's device is present
Good:
- device is owned by `manndra`
- it appears separately from John's devices
Bad / message Doris if:
- it shows under the wrong user
- it never appears after enrollment
- it appears to have broad access it should not have
#### 4. Confirm the tagged node looks like a service node
Look for one node tagged as a pilot service node, typically with `tag:apps` or `tag:infra`.
Good:
- the node is visibly tagged
- it is not owned like a normal human personal device
- it is online when the underlying service is online
Bad / message Doris if:
- the node is untagged
- it appears owned like a personal device when it should be infra
- it has the wrong tag
#### 5. Confirm the UI is usable enough to keep
Good:
- pages load consistently
- node/user details are understandable
- no obvious blank/error screens
- refreshing does not randomly lose state
Bad / message Doris if:
- login repeatedly fails with a known-good API key
- pages partially load or spin forever
- the UI looks disconnected from reality
- the UI feels too broken to trust for visibility
### Expected access behavior
John does not need to test raw ACL syntax. The practical expectations are:
- `fizzlepoof` should have broad pilot visibility/access
- `manndra` should be limited to app-level access
- Manndra should **not** have broad admin/infra access by default
If Manndra can reach something that feels like core infra/admin, treat that as a problem and tell Doris.
### What counts as pilot success from John's side
The pilot is good enough to continue if:
- Headplane reliably loads
- the users/nodes make sense at a glance
- John's devices are visible
- Manndra's device is visible and separated correctly
- the tagged service node is visible and clearly infra-like
- nothing suggests accidental overexposure
### When John should call Doris instead of poking at it
Call Doris if:
- login stops working
- users disappear or duplicate
- devices are attached to the wrong owner
- a tagged node loses its tag or looks wrong
- Manndra appears to have too much access
- the UI starts showing stale, contradictory, or obviously broken state
The point of the Web UI is visibility and confidence, not pushing John into VPN-control-plane babysitting.
## What to validate before expanding
- Headscale stays healthy
- Headplane is usable enough to justify keeping it
- John can reach `tag:infra`, `tag:admin`, and `tag:apps`
- Manndra can reach `tag:apps` but not `tag:infra` or `tag:admin`
- one tagged service node registers cleanly and behaves as expected
- existing Tailscale remains intact as rollback during the pilot
## Notes on Headplane integration
This stack enables Headplane's Docker integration so it can identify the Headscale container via the label:
- `me.tale.headplane.target=headscale`
It also mounts the tracked Headscale config into Headplane so the UI can inspect and, if you later choose, manage more than just node registration.
## Not done here on purpose
- no OIDC wiring yet
- no DERP customization yet
- no public auth/proxy routing yet
- no explicit subnet-router policy yet
- no migration of the existing infrastructure stack's `tailscale` container yet
Pilot first. Cutover later.

View File

@@ -0,0 +1,28 @@
server:
host: "0.0.0.0"
port: 3000
base_url: "http://headplane.home.paccoco.com:3005"
cookie_secret: "CHANGE_ME_TO_EXACTLY_32_CHARS"
cookie_secure: false
cookie_max_age: 86400
data_path: "/var/lib/headplane"
headscale:
url: "http://headscale:8080"
public_url: "https://headscale.paccoco.com"
config_path: "/etc/headscale/config.yaml"
config_strict: true
integration:
agent:
enabled: false
pre_authkey: ""
docker:
enabled: true
container_label: "me.tale.headplane.target=headscale"
socket: "unix:///var/run/docker.sock"
kubernetes:
enabled: false
pod_name: ""
proc:
enabled: false

View File

@@ -0,0 +1,99 @@
server_url: https://headscale.paccoco.com
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 127.0.0.1:9090
grpc_listen_addr: 0.0.0.0:50443
grpc_allow_insecure: false
trusted_proxies: []
noise:
private_key_path: /var/lib/headscale/noise_private.key
prefixes:
v4: 100.64.0.0/10
v6: fd7a:115c:a1e0::/48
allocation: sequential
derp:
server:
enabled: false
region_id: 999
region_code: "headscale"
region_name: "Headscale Embedded DERP"
verify_clients: true
stun_listen_addr: "0.0.0.0:3478"
private_key_path: /var/lib/headscale/derp_server_private.key
automatically_add_embedded_derp_region: true
ipv4: 198.51.100.1
ipv6: 2001:db8::1
urls:
- https://controlplane.tailscale.com/derpmap/default
paths: []
auto_update_enabled: true
update_frequency: 3h
disable_check_updates: false
node:
expiry: 0
ephemeral:
inactivity_timeout: 30m
routes:
ha:
probe_interval: 10s
probe_timeout: 5s
database:
type: sqlite
debug: false
gorm:
prepare_stmt: true
parameterized_queries: true
skip_err_record_not_found: true
slow_threshold: 1000
sqlite:
path: /var/lib/headscale/db.sqlite
write_ahead_log: true
wal_autocheckpoint: 1000
acme_url: https://acme-v02.api.letsencrypt.org/directory
acme_email: ""
tls_letsencrypt_hostname: ""
tls_letsencrypt_cache_dir: /var/lib/headscale/cache
tls_letsencrypt_challenge_type: HTTP-01
tls_letsencrypt_listen: ":http"
tls_cert_path: ""
tls_key_path: ""
log:
level: info
format: text
policy:
mode: file
path: /etc/headscale/policy.hujson
dns:
magic_dns: true
base_domain: tail.home.paccoco.com
override_local_dns: true
nameservers:
global:
- 10.5.30.8
- 10.5.30.9
- 10.5.30.10
split: {}
search_domains: []
extra_records: []
unix_socket: /var/run/headscale/headscale.sock
unix_socket_permission: "0770"
logtail:
enabled: false
taildrop:
enabled: true
auto_update:
enabled: false

View File

@@ -0,0 +1,49 @@
{
// Small deny-by-default-ish pilot policy for the household tailnet.
// Expand only after John + Manndra + one tagged service node validate cleanly.
"groups": {
"group:admins": ["fizzlepoof@"],
"group:vip": ["manndra@"]
},
"tagOwners": {
"tag:infra": ["group:admins"],
"tag:apps": ["group:admins"],
"tag:admin": ["group:admins"]
},
"acls": [
{
"action": "accept",
"src": ["group:admins"],
"dst": [
"tag:infra:*",
"tag:apps:*",
"tag:admin:*"
]
},
{
"action": "accept",
"src": ["group:vip"],
"dst": [
"tag:apps:*"
]
}
],
"ssh": [
{
"action": "accept",
"src": ["group:admins"],
"dst": ["tag:infra"],
"users": ["autogroup:nonroot"]
},
{
"action": "accept",
"src": ["group:admins"],
"dst": ["tag:admin"],
"users": ["autogroup:nonroot"]
}
]
}

View File

@@ -0,0 +1,55 @@
name: headscale
networks:
headscale-net:
driver: bridge
pangolin:
external: true
services:
headscale:
image: headscale/headscale:0.26.0
container_name: headscale
restart: unless-stopped
command: serve
labels:
me.tale.headplane.target: headscale
networks:
- headscale-net
- pangolin
ports:
- "8084:8080"
volumes:
- /mnt/docker-ssd/docker/compose/headscale/config/headscale/config.yaml:/etc/headscale/config.yaml
- /mnt/docker-ssd/docker/compose/headscale/config/headscale/policy.hujson:/etc/headscale/policy.hujson
- /mnt/docker-ssd/docker/appdata/headscale:/var/lib/headscale
- /mnt/docker-ssd/docker/appdata/headscale/run:/var/run/headscale
healthcheck:
test: ["CMD", "headscale", "version"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
headplane:
image: ghcr.io/tale/headplane:latest
container_name: headplane
restart: unless-stopped
depends_on:
- headscale
networks:
- headscale-net
- pangolin
ports:
- "3005:3000"
volumes:
- /mnt/docker-ssd/docker/compose/headscale/config/headplane/config.yaml:/etc/headplane/config.yaml
- /mnt/docker-ssd/docker/compose/headscale/config/headscale/config.yaml:/etc/headscale/config.yaml
- /mnt/docker-ssd/docker/appdata/headplane:/var/lib/headplane
- /var/run/docker.sock:/var/run/docker.sock:ro
healthcheck:
test: ["CMD", "/bin/hp_healthcheck"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s

View File

@@ -0,0 +1,26 @@
http:
routers:
headscale:
rule: "Host(`headscale.home.paccoco.com`)"
entryPoints: [web]
service: headscale
middlewares:
- security-headers
headplane:
rule: "Host(`headplane.home.paccoco.com`)"
entryPoints: [web]
service: headplane
middlewares:
- security-headers
services:
headscale:
loadBalancer:
servers:
- url: "http://headscale:8080"
headplane:
loadBalancer:
servers:
- url: "http://headplane:3000"

25
home/donetick/README.md Normal file
View File

@@ -0,0 +1,25 @@
# DoneTick
Live deployment
- Host: PlausibleDeniability
- Public URL: https://donetick.paccoco.com
- Internal URL: http://10.5.30.6:2021
- Live config path: `/mnt/tank/docker/appdata/donetick/config/selfhosted.yaml`
Auth
- Authentik OIDC app slug: `donetick`
- Web redirect URI: `https://donetick.paccoco.com/auth/oauth2`
- Native redirect URI: `donetick://auth/oauth2`
- DoneTick API resource endpoint exposing IdP metadata: `/api/v1/resource`
- Optional role mapping groups:
- `donetick-admins`
- `donetick-managers`
Pangolin
- Resource: `donetick.paccoco.com`
- Current target site: Plausible Deniability (site 4)
- Target health check must follow the current host/port after renumbering; stale `hcHostname` values can leave the route unhealthy even when the target IP/port are correct.
Rollback
- Restore the previous live config from `/mnt/tank/docker/appdata/donetick/config/selfhosted.yaml.bak-*` and restart the `donetick` container.
- For Pangolin, restore target 91 from `/home/fizzlepoof/pangolin-donetick-target-91-before.json` or from a fresh Pangolin API backup if the public route regresses.

View File

@@ -0,0 +1,48 @@
database:
type: postgres
host: shared-postgres
port: 5432
user: donetick
password: CHANGE_ME
name: donetick
migration: true
jwt:
secret: CHANGE_ME
session_time: 168h
max_refresh: 168h
server:
port: 2021
serve_frontend: true
public_host: https://donetick.paccoco.com
cors_allow_origins:
- https://donetick.paccoco.com
- https://localhost
- capacitor://localhost
- http://localhost
- http://localhost:8100
oauth2:
name: Authentik
client_id: donetick
client_secret: CHANGE_ME
redirect_url: https://donetick.paccoco.com/auth/oauth2
scopes:
- openid
- profile
- email
auth_url: https://authentik.paccoco.com/application/o/authorize/
token_url: https://authentik.paccoco.com/application/o/token/
user_info_url: https://authentik.paccoco.com/application/o/userinfo/
admin_groups:
- donetick-admins
manager_groups:
- donetick-managers
realtime:
enabled: true
sse_enabled: true
websocket_enabled: false
allowed_origins:
- https://donetick.paccoco.com
- https://localhost
- capacitor://localhost
- http://localhost
- http://localhost:8100

View File

@@ -41,12 +41,20 @@ Unless John overrides them later, V1 is being built with these defaults:
The current scaffold already supports: The current scaffold already supports:
- seeded users for `john` and stub `manndra` - seeded users for `john` and stub `manndra`
- seeded exercise templates and John's current five-day split:
- Monday: Chest
- Tuesday: Back
- Wednesday: Arms
- Thursday: Shoulders
- Friday: Legs
- profile updates - profile updates
- weight logging with BMI calculation - weight logging with BMI calculation
- imperial body measurements for waist, hips, chest, and neck - imperial body measurements for waist, hips, chest, and neck
- exercise template creation/listing - exercise template creation/listing
- routine creation/listing, even before the final real workout split is known - routine creation/listing on top of John's imported base split
- structured workout logging with nested exercises and sets - structured workout logging with nested exercises and sets
- dedicated routine log sheets with dropdown exercise selection plus set-by-set reps and weight entry
- live workout pages that let John start a session and save one exercise at a time mid-workout
- history endpoints for weight, measurements, and workouts - history endpoints for weight, measurements, and workouts
- exercise progression summaries with max weight and estimated 1RM per exercise - exercise progression summaries with max weight and estimated 1RM per exercise
- mobile-friendly homepage with HTML forms for: - mobile-friendly homepage with HTML forms for:
@@ -58,6 +66,7 @@ The current scaffold already supports:
- basic dashboard summary plus John-facing history snapshots - basic dashboard summary plus John-facing history snapshots
John is currently seeded at a default height assumption of `68.5` inches until he says otherwise. John is currently seeded at a default height assumption of `68.5` inches until he says otherwise.
His current imported split is stored with a default prescription of `3` sets and `10` reps for each listed movement, while working weights remain `TBD` until he fills them in.
## Current API surface ## Current API surface

View File

@@ -61,7 +61,7 @@ class RoutineCreate(BaseModel):
class WorkoutSetCreate(BaseModel): class WorkoutSetCreate(BaseModel):
reps: int = Field(ge=1) reps: int = Field(ge=1)
weight_lbs: float = Field(ge=0) weight_lbs: float | None = Field(default=None, ge=0)
rpe: float | None = Field(default=None, ge=0) rpe: float | None = Field(default=None, ge=0)
rest_seconds: int | None = Field(default=None, ge=0) rest_seconds: int | None = Field(default=None, ge=0)
notes: str | None = None notes: str | None = None

View File

@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Form, HTTPException, Request, status from fastapi import APIRouter, Form, HTTPException, Request, status
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
@@ -121,9 +123,136 @@ def quick_log_workout(
return RedirectResponse(url='/', status_code=status.HTTP_303_SEE_OTHER) return RedirectResponse(url='/', status_code=status.HTTP_303_SEE_OTHER)
@router.get('/routines/{routine_id}/log')
def routine_log_sheet(request: Request, routine_id: str):
routine = _get_routine_or_404(request, routine_id)
return templates.TemplateResponse(
request=request,
name='routine_log.html',
context={
'title': f"Log {routine['name']}",
'routine': routine,
'routine_sheet': _build_routine_log_sheet(request, routine),
'exercise_options': _exercise_options_for_routine(request, routine),
'routine_action_label': _routine_action_label(routine['name']),
'default_performed_at': _default_performed_at_value(),
},
)
@router.get('/routines/{routine_id}/live')
def live_workout_logger(request: Request, routine_id: str):
routine = _get_routine_or_404(request, routine_id)
return templates.TemplateResponse(
request=request,
name='live_workout.html',
context=_live_workout_context(request, routine),
)
@router.post('/routines/{routine_id}/live/start', status_code=303)
def start_live_workout_session(
request: Request,
routine_id: str,
user_id: str = Form(...),
performed_at: str = Form(...),
):
routine = _get_routine_or_404(request, routine_id)
if routine['user_id'] != user_id:
raise HTTPException(status_code=400, detail='Routine does not belong to that user.')
request.app.state.store.start_active_workout_session(
{
'routine_id': routine['id'],
'routine_name': routine['name'],
'user_id': user_id,
'performed_at': performed_at,
}
)
return RedirectResponse(url=f"/routines/{routine_id}/live", status_code=status.HTTP_303_SEE_OTHER)
@router.post('/routines/{routine_id}/live/add-exercise', status_code=303)
async def add_live_workout_exercise(
request: Request,
routine_id: str,
user_id: str = Form(...),
performed_at: str = Form(...),
exercise_name: str = Form(...),
set_count: int = Form(...),
):
routine = _get_routine_or_404(request, routine_id)
if routine['user_id'] != user_id:
raise HTTPException(status_code=400, detail='Routine does not belong to that user.')
active_session = request.app.state.store.get_active_workout_session(routine_id, user_id)
if active_session is None:
request.app.state.store.start_active_workout_session(
{
'routine_id': routine['id'],
'routine_name': routine['name'],
'user_id': user_id,
'performed_at': performed_at,
}
)
exercise = await _parse_live_workout_exercise_form(request, routine, exercise_name, set_count)
request.app.state.store.add_active_workout_exercise(routine_id, user_id, exercise)
return RedirectResponse(url=f"/routines/{routine_id}/live", status_code=status.HTTP_303_SEE_OTHER)
@router.post('/routines/{routine_id}/live/finish', status_code=303)
def finish_live_workout_session(
request: Request,
routine_id: str,
user_id: str = Form(...),
performed_at: str = Form(...),
notes: str = Form(''),
):
routine = _get_routine_or_404(request, routine_id)
if routine['user_id'] != user_id:
raise HTTPException(status_code=400, detail='Routine does not belong to that user.')
active_session = request.app.state.store.get_active_workout_session(routine_id, user_id)
if active_session is None:
raise HTTPException(status_code=400, detail='No live workout session in progress.')
if active_session['performed_at'] != performed_at:
raise HTTPException(status_code=400, detail='Performed-at timestamp does not match the active session.')
if not active_session.get('exercises'):
raise HTTPException(status_code=400, detail='Log at least one exercise before finishing the session.')
request.app.state.store.finish_active_workout_session(routine_id, user_id, notes.strip() or None)
return RedirectResponse(url='/', status_code=status.HTTP_303_SEE_OTHER)
@router.post('/workouts/routine-log', status_code=303)
async def log_seeded_routine_workout(
request: Request,
user_id: str = Form(...),
routine_id: str = Form(...),
performed_at: str = Form(...),
routine_name: str = Form(...),
exercise_count: int = Form(...),
notes: str = Form(''),
):
routine = _get_routine_or_404(request, routine_id)
if routine['user_id'] != user_id:
raise HTTPException(status_code=400, detail='Routine does not belong to that user.')
payload = {
'user_id': user_id,
'performed_at': performed_at,
'routine_name': routine_name.strip(),
'notes': notes.strip() or None,
'exercises': await _parse_structured_routine_form(request, routine, exercise_count),
}
request.app.state.store.add_workout(payload)
return RedirectResponse(url='/', status_code=status.HTTP_303_SEE_OTHER)
def _dashboard_context(request: Request) -> dict: def _dashboard_context(request: Request) -> dict:
store = request.app.state.store store = request.app.state.store
summary = store.dashboard_summary() summary = store.dashboard_summary()
routines_with_live_state = []
for routine in summary['routines']:
active_session = store.get_active_workout_session(routine['id'], routine['user_id'])
routines_with_live_state.append({**routine, 'live_session': active_session})
summary['routines'] = routines_with_live_state
return { return {
'title': 'Doris Barbell', 'title': 'Doris Barbell',
'summary': summary, 'summary': summary,
@@ -134,6 +263,49 @@ def _dashboard_context(request: Request) -> dict:
} }
def _live_workout_context(request: Request, routine: dict) -> dict:
active_session = request.app.state.store.get_active_workout_session(routine['id'], routine['user_id'])
routine_sheet = _build_routine_log_sheet(request, routine)
remaining_exercises = _remaining_live_exercises(routine_sheet, active_session)
logged_exercises = []
if active_session is not None:
for exercise in active_session.get('exercises', []):
logged_exercises.append(
{
'exercise_name': exercise['exercise_name'],
'set_count': len(exercise.get('sets', [])),
'notes': exercise.get('notes'),
}
)
return {
'title': f"Live log · {routine['name']}",
'routine': routine,
'routine_sheet': routine_sheet,
'active_session': active_session,
'logged_exercises': logged_exercises,
'remaining_exercises': remaining_exercises,
'default_performed_at': active_session['performed_at'] if active_session else _default_performed_at_value(),
'routine_action_label': _routine_action_label(routine['name']),
}
def _remaining_live_exercises(routine_sheet: list[dict], active_session: dict | None) -> list[dict]:
completed_counts: dict[str, int] = {}
if active_session is not None:
for exercise in active_session.get('exercises', []):
normalized_name = exercise['exercise_name'].strip().lower()
completed_counts[normalized_name] = completed_counts.get(normalized_name, 0) + 1
remaining = []
for exercise in routine_sheet:
normalized_name = exercise['exercise_name'].strip().lower()
completed_count = completed_counts.get(normalized_name, 0)
if completed_count > 0:
completed_counts[normalized_name] = completed_count - 1
continue
remaining.append(exercise)
return remaining
def _optional_float(value: str) -> float | None: def _optional_float(value: str) -> float | None:
cleaned = value.strip() cleaned = value.strip()
if not cleaned: if not cleaned:
@@ -208,3 +380,203 @@ def _parse_workout_exercises(raw_lines: str) -> list[dict]:
if not exercises: if not exercises:
raise HTTPException(status_code=400, detail='At least one workout exercise is required.') raise HTTPException(status_code=400, detail='At least one workout exercise is required.')
return exercises return exercises
def _get_routine_or_404(request: Request, routine_id: str) -> dict:
routine = request.app.state.store.get_routine(routine_id)
if routine is None:
raise HTTPException(status_code=404, detail='Unknown routine')
return routine
def _build_routine_log_sheet(request: Request, routine: dict) -> list[dict]:
equipment_by_name = _exercise_equipment_lookup(request)
sheet = []
for exercise_index, exercise in enumerate(routine.get('exercises', [])):
exercise_name = exercise['exercise_name']
target_sets = max(int(exercise.get('target_sets', 1) or 1), 1)
default_reps = _default_reps_value(exercise.get('target_reps'))
is_band = equipment_by_name.get(exercise_name.strip().lower()) == 'band'
sheet.append(
{
'exercise_index': exercise_index,
'exercise_name': exercise_name,
'target_sets': target_sets,
'target_reps': exercise.get('target_reps'),
'default_reps': default_reps,
'notes': exercise.get('notes'),
'is_band': is_band,
'sets': [
{
'exercise_index': exercise_index,
'index': set_index,
'default_reps': default_reps,
'is_band': is_band,
}
for set_index in range(target_sets)
],
}
)
return sheet
def _exercise_options_for_routine(request: Request, routine: dict) -> list[str]:
routine_names = [exercise['exercise_name'] for exercise in routine.get('exercises', []) if exercise.get('exercise_name')]
template_names = [
template['name']
for template in request.app.state.store.list_exercise_templates()
if template.get('name')
]
ordered = []
seen = set()
for name in [*routine_names, *template_names]:
normalized = name.strip().lower()
if normalized in seen:
continue
ordered.append(name)
seen.add(normalized)
return ordered
def _exercise_equipment_lookup(request: Request) -> dict[str, str]:
lookup: dict[str, str] = {}
for template in request.app.state.store.list_exercise_templates():
name = str(template.get('name', '')).strip().lower()
equipment = str(template.get('equipment', '')).strip().lower()
if not name or not equipment:
continue
lookup[name] = equipment
return lookup
def _default_reps_value(target_reps: str | None) -> str:
if target_reps is None:
return ''
cleaned = str(target_reps).strip()
return cleaned if cleaned.isdigit() else ''
def _default_performed_at_value() -> str:
return datetime.now().astimezone().strftime('%Y-%m-%dT%H:%M')
def _routine_action_label(routine_name: str) -> str:
if ': ' in routine_name:
label = routine_name.split(': ', 1)[1].strip().lower()
else:
label = routine_name.strip().lower()
day_aliases = {
'shoulders': 'shoulder day',
'arms': 'arm day',
'legs': 'leg day',
}
return day_aliases.get(label, label)
async def _parse_live_workout_exercise_form(
request: Request,
routine: dict,
exercise_name: str,
set_count: int,
) -> dict:
form = await request.form()
sheet = _build_routine_log_sheet(request, routine)
matching_exercise = next(
(exercise for exercise in sheet if exercise['exercise_name'].strip().lower() == exercise_name.strip().lower()),
None,
)
if matching_exercise is None:
raise HTTPException(status_code=400, detail=f'Unknown routine exercise: {exercise_name}')
default_reps_raw = matching_exercise.get('default_reps', '')
is_band_exercise = bool(matching_exercise.get('is_band'))
sets = []
for set_index in range(set_count):
reps_raw = str(form.get(f'set_{set_index}_reps', '')).strip()
weight_raw = str(form.get(f'set_{set_index}_weight_lbs', '')).strip()
if not reps_raw and not weight_raw:
if is_band_exercise and default_reps_raw.isdigit():
reps_raw = default_reps_raw
else:
continue
if not reps_raw and default_reps_raw.isdigit():
reps_raw = default_reps_raw
if not reps_raw:
raise HTTPException(
status_code=400,
detail=f'Exercise {exercise_name} set {set_index + 1} needs reps.',
)
if not weight_raw and not is_band_exercise:
raise HTTPException(
status_code=400,
detail=f'Exercise {exercise_name} set {set_index + 1} needs both reps and weight.',
)
try:
reps = int(reps_raw)
weight_lbs = None if not weight_raw else float(weight_raw)
except ValueError as exc:
raise HTTPException(
status_code=400,
detail=f'Exercise {exercise_name} set {set_index + 1} has invalid reps or weight.',
) from exc
sets.append({'reps': reps, 'weight_lbs': weight_lbs})
if not sets:
raise HTTPException(status_code=400, detail=f'Log at least one set for {exercise_name}.')
exercise_notes = str(form.get('exercise_notes', '')).strip() or None
return {'exercise_name': matching_exercise['exercise_name'], 'notes': exercise_notes, 'sets': sets}
async def _parse_structured_routine_form(request: Request, routine: dict, exercise_count: int) -> list[dict]:
form = await request.form()
routine_exercises = routine.get('exercises', [])
equipment_by_name = _exercise_equipment_lookup(request)
exercises = []
for exercise_index in range(exercise_count):
exercise_name = str(form.get(f'exercise_{exercise_index}_name', '')).strip()
if not exercise_name:
continue
is_band_exercise = equipment_by_name.get(exercise_name.lower()) == 'band'
try:
set_count = int(str(form.get(f'exercise_{exercise_index}_set_count', '0')).strip())
except ValueError as exc:
raise HTTPException(status_code=400, detail='Invalid set count in routine log form.') from exc
target_reps = None
if exercise_index < len(routine_exercises):
target_reps = routine_exercises[exercise_index].get('target_reps')
default_reps_raw = _default_reps_value(target_reps)
sets = []
for set_index in range(set_count):
reps_raw = str(form.get(f'exercise_{exercise_index}_set_{set_index}_reps', '')).strip()
weight_raw = str(form.get(f'exercise_{exercise_index}_set_{set_index}_weight_lbs', '')).strip()
if not reps_raw and not weight_raw:
if is_band_exercise and default_reps_raw.isdigit():
reps_raw = default_reps_raw
else:
continue
if not reps_raw and default_reps_raw.isdigit():
reps_raw = default_reps_raw
if not reps_raw:
raise HTTPException(
status_code=400,
detail=f'Exercise {exercise_name} set {set_index + 1} needs reps.',
)
if not weight_raw and not is_band_exercise:
raise HTTPException(
status_code=400,
detail=f'Exercise {exercise_name} set {set_index + 1} needs both reps and weight.',
)
try:
reps = int(reps_raw)
weight_lbs = None if not weight_raw else float(weight_raw)
except ValueError as exc:
raise HTTPException(
status_code=400,
detail=f'Exercise {exercise_name} set {set_index + 1} has invalid reps or weight.',
) from exc
sets.append({'reps': reps, 'weight_lbs': weight_lbs})
if not sets:
continue
exercise_notes = str(form.get(f'exercise_{exercise_index}_notes', '')).strip() or None
exercises.append({'exercise_name': exercise_name, 'notes': exercise_notes, 'sets': sets})
if not exercises:
raise HTTPException(status_code=400, detail='Log at least one completed exercise set.')
return exercises

View File

@@ -6,32 +6,160 @@ from pathlib import Path
from typing import Any from typing import Any
from uuid import uuid4 from uuid import uuid4
DEFAULT_STATE = { DEFAULT_USERS = {
'users': { 'john': {
'john': { 'id': 'john',
'id': 'john', 'display_name': 'John',
'display_name': 'John', 'is_stub': False,
'is_stub': False, 'measurement_system': 'imperial',
'measurement_system': 'imperial', 'height_inches': 68.5,
'height_inches': 68.5, 'goal_weight_lbs': None,
'goal_weight_lbs': None, 'notes': 'Seeded with John default height assumption from current intake.',
'notes': 'Seeded with John default height assumption from current intake.',
},
'manndra': {
'id': 'manndra',
'display_name': 'Manndra',
'is_stub': True,
'measurement_system': 'imperial',
'height_inches': None,
'goal_weight_lbs': None,
'notes': 'Stubbed for future shared use.',
},
}, },
'exercise_templates': [], 'manndra': {
'routines': [], 'id': 'manndra',
'display_name': 'Manndra',
'is_stub': True,
'measurement_system': 'imperial',
'height_inches': None,
'goal_weight_lbs': None,
'notes': 'Stubbed for future shared use.',
},
}
SEEDED_EXERCISE_TEMPLATES = [
{'name': 'Bench Press', 'primary_muscle': 'chest', 'equipment': 'barbell'},
{'name': 'Incline Bench Press', 'primary_muscle': 'upper chest', 'equipment': 'barbell'},
{'name': 'Decline Bench Press', 'primary_muscle': 'lower chest', 'equipment': 'barbell'},
{'name': 'Butterflies', 'primary_muscle': 'chest', 'equipment': 'machine'},
{'name': 'Lat Pulldowns', 'primary_muscle': 'back', 'equipment': 'cable'},
{'name': 'Bent-Over Supported Rows', 'primary_muscle': 'back', 'equipment': 'machine'},
{'name': 'Landmine Rows', 'primary_muscle': 'back', 'equipment': 'barbell'},
{'name': 'Seated Machine Rows', 'primary_muscle': 'back', 'equipment': 'machine'},
{'name': 'Resistance Band Ab Holds', 'primary_muscle': 'abs', 'equipment': 'band'},
{'name': 'Lat Pulldown Drop Set', 'primary_muscle': 'back', 'equipment': 'cable'},
{'name': 'Bicep Curls', 'primary_muscle': 'biceps', 'equipment': 'dumbbells'},
{'name': 'Front Tricep Extensions', 'primary_muscle': 'triceps', 'equipment': 'cable'},
{'name': 'Overhead Tricep Extensions', 'primary_muscle': 'triceps', 'equipment': 'cable'},
{'name': 'Incline Barbell Curls', 'primary_muscle': 'biceps', 'equipment': 'barbell'},
{'name': 'Isolation Bicep Curls', 'primary_muscle': 'biceps', 'equipment': 'machine'},
{'name': '7-7-7 Barbell Curl', 'primary_muscle': 'biceps', 'equipment': 'barbell'},
{'name': 'Suitcase Carry', 'primary_muscle': 'abs', 'equipment': 'dumbbell'},
{'name': 'Shoulder Press', 'primary_muscle': 'shoulders', 'equipment': 'machine'},
{'name': 'Arnold Press', 'primary_muscle': 'shoulders', 'equipment': 'dumbbells'},
{'name': 'Side Lateral Raise', 'primary_muscle': 'shoulders', 'equipment': 'dumbbells'},
{'name': 'Front Lateral Raise', 'primary_muscle': 'shoulders', 'equipment': 'dumbbells'},
{'name': 'Upright Row', 'primary_muscle': 'shoulders', 'equipment': 'barbell'},
{'name': 'Shoulder Shrugs', 'primary_muscle': 'traps', 'equipment': 'dumbbells'},
{'name': '7-7-7 Shoulder Press', 'primary_muscle': 'shoulders', 'equipment': 'machine'},
{'name': 'Resistance Band Punch-Out Training', 'primary_muscle': 'shoulders', 'equipment': 'band'},
{'name': 'Squats', 'primary_muscle': 'legs', 'equipment': 'barbell'},
{'name': 'Leg Extensions', 'primary_muscle': 'quads', 'equipment': 'machine'},
{'name': 'Step Ups', 'primary_muscle': 'legs', 'equipment': 'bodyweight'},
{'name': 'Deadlifts', 'primary_muscle': 'posterior chain', 'equipment': 'barbell'},
{'name': 'Calf Raises', 'primary_muscle': 'calves', 'equipment': 'machine'},
{'name': 'Resistance Band Crunches', 'primary_muscle': 'abs', 'equipment': 'band'},
]
SEEDED_ROUTINES = [
{
'user_id': 'john',
'name': 'Monday: Chest',
'notes': "Imported from John's current routine. Default prescription is 3 sets of 10 reps. Weights are still TBD.",
'exercises': [
{'exercise_name': 'Bench Press', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Incline Bench Press', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Decline Bench Press', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Butterflies', 'target_sets': 3, 'target_reps': '10'},
],
},
{
'user_id': 'john',
'name': 'Tuesday: Back',
'notes': "Imported from John's current routine. Default prescription is 3 sets of 10 reps. Weights are still TBD.",
'exercises': [
{'exercise_name': 'Lat Pulldowns', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Bent-Over Supported Rows', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Landmine Rows', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Seated Machine Rows', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Resistance Band Ab Holds', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Lat Pulldown Drop Set', 'target_sets': 3, 'target_reps': '10'},
],
},
{
'user_id': 'john',
'name': 'Wednesday: Arms',
'notes': "Imported from John's current routine. Default prescription is 3 sets of 10 reps. Weights are still TBD. Includes Bis and Tris focus plus suitcase carries for abs.",
'exercises': [
{'exercise_name': 'Bicep Curls', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Front Tricep Extensions', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Overhead Tricep Extensions', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Incline Barbell Curls', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Isolation Bicep Curls', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': '7-7-7 Barbell Curl', 'target_sets': 1, 'target_reps': '7-7-7'},
{'exercise_name': 'Suitcase Carry', 'target_sets': 3, 'target_reps': '10'},
],
},
{
'user_id': 'john',
'name': 'Thursday: Shoulders',
'notes': "Imported from John's current routine. Default prescription is 3 sets of 10 reps. Weights are still TBD. Includes resistance band punch-out training.",
'exercises': [
{'exercise_name': 'Shoulder Press', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Arnold Press', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Side Lateral Raise', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Front Lateral Raise', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Upright Row', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Shoulder Shrugs', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': '7-7-7 Shoulder Press', 'target_sets': 1, 'target_reps': '7-7-7'},
{'exercise_name': 'Resistance Band Punch-Out Training', 'target_sets': 3, 'target_reps': '10'},
],
},
{
'user_id': 'john',
'name': 'Friday: Legs',
'notes': "Imported from John's current routine. Default prescription is 3 sets of 10 reps. Weights are still TBD. Includes resistance band crunches for abs.",
'exercises': [
{'exercise_name': 'Squats', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Leg Extensions', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Step Ups', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Deadlifts', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Calf Raises', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Resistance Band Crunches', 'target_sets': 3, 'target_reps': '10'},
],
},
]
DEFAULT_STATE = {
'users': DEFAULT_USERS,
'exercise_templates': [
{
'id': f'seed-template-{index + 1:02d}',
'name': template['name'],
'primary_muscle': template.get('primary_muscle'),
'equipment': template.get('equipment'),
'notes': template.get('notes'),
}
for index, template in enumerate(SEEDED_EXERCISE_TEMPLATES)
],
'routines': [
{
'id': f'seed-routine-{index + 1:02d}',
'user_id': routine['user_id'],
'name': routine['name'],
'notes': routine.get('notes'),
'exercises': [dict(exercise) for exercise in routine['exercises']],
}
for index, routine in enumerate(SEEDED_ROUTINES)
],
'weight_entries': [], 'weight_entries': [],
'measurement_entries': [], 'measurement_entries': [],
'workout_sessions': [], 'workout_sessions': [],
'active_workout_sessions': [],
}
LEGACY_TEMPLATE_ALIASES = {
'resistance band punch-out holds': 'Resistance Band Punch-Out Training',
} }
@@ -42,9 +170,18 @@ class JSONStore:
self.state_dir.mkdir(parents=True, exist_ok=True) self.state_dir.mkdir(parents=True, exist_ok=True)
if not self.state_path.exists(): if not self.state_path.exists():
self._write(deepcopy(DEFAULT_STATE)) self._write(deepcopy(DEFAULT_STATE))
return
state = self._read()
seeded_state = _ensure_seeded_library(state)
if seeded_state != state:
self._write(seeded_state)
def _read(self) -> dict[str, Any]: def _read(self) -> dict[str, Any]:
return json.loads(self.state_path.read_text()) payload = json.loads(self.state_path.read_text())
seeded_payload = _ensure_seeded_library(payload)
if seeded_payload != payload:
self._write(seeded_payload)
return seeded_payload
def _write(self, payload: dict[str, Any]) -> None: def _write(self, payload: dict[str, Any]) -> None:
self.state_path.write_text(json.dumps(payload, indent=2, sort_keys=True)) self.state_path.write_text(json.dumps(payload, indent=2, sort_keys=True))
@@ -139,6 +276,91 @@ class JSONStore:
state = self._read() state = self._read()
return sorted(state['routines'], key=lambda item: item['name'].lower()) return sorted(state['routines'], key=lambda item: item['name'].lower())
def get_routine(self, routine_id: str) -> dict[str, Any] | None:
state = self._read()
for routine in state['routines']:
if routine.get('id') == routine_id:
return routine
return None
def get_active_workout_session(self, routine_id: str, user_id: str) -> dict[str, Any] | None:
state = self._read()
self._user_or_raise(state, user_id)
for session in state['active_workout_sessions']:
if session['routine_id'] == routine_id and session['user_id'] == user_id:
return session
return None
def start_active_workout_session(self, payload: dict[str, Any]) -> dict[str, Any]:
state = self._read()
self._user_or_raise(state, payload['user_id'])
state['active_workout_sessions'] = [
session
for session in state['active_workout_sessions']
if not (
session['routine_id'] == payload['routine_id'] and session['user_id'] == payload['user_id']
)
]
saved = {
'id': uuid4().hex,
'routine_id': payload['routine_id'],
'routine_name': payload['routine_name'],
'user_id': payload['user_id'],
'performed_at': payload['performed_at'],
'notes': payload.get('notes'),
'exercises': [],
}
state['active_workout_sessions'].append(saved)
self._write(state)
return saved
def add_active_workout_exercise(self, routine_id: str, user_id: str, exercise: dict[str, Any]) -> dict[str, Any]:
state = self._read()
self._user_or_raise(state, user_id)
for session in state['active_workout_sessions']:
if session['routine_id'] == routine_id and session['user_id'] == user_id:
session['exercises'].append(
{
'exercise_name': exercise['exercise_name'],
'notes': exercise.get('notes'),
'sets': [dict(item) for item in exercise['sets']],
}
)
self._write(state)
return session
raise KeyError((routine_id, user_id))
def finish_active_workout_session(self, routine_id: str, user_id: str, notes: str | None = None) -> dict[str, Any]:
state = self._read()
self._user_or_raise(state, user_id)
for session in state['active_workout_sessions']:
if session['routine_id'] != routine_id or session['user_id'] != user_id:
continue
workout_payload = {
'user_id': user_id,
'performed_at': session['performed_at'],
'routine_name': session['routine_name'],
'notes': notes,
'exercises': [
{
'exercise_name': exercise['exercise_name'],
'notes': exercise.get('notes'),
'sets': [dict(item) for item in exercise['sets']],
}
for exercise in session['exercises']
],
}
saved_workout = self.add_workout(workout_payload)
refreshed_state = self._read()
refreshed_state['active_workout_sessions'] = [
active_session
for active_session in refreshed_state['active_workout_sessions']
if active_session.get('id') != session['id']
]
self._write(refreshed_state)
return saved_workout
raise KeyError((routine_id, user_id))
def add_workout(self, payload: dict[str, Any]) -> dict[str, Any]: def add_workout(self, payload: dict[str, Any]) -> dict[str, Any]:
state = self._read() state = self._read()
self._user_or_raise(state, payload['user_id']) self._user_or_raise(state, payload['user_id'])
@@ -150,7 +372,9 @@ class JSONStore:
normalized_sets = [] normalized_sets = []
for item in exercise['sets']: for item in exercise['sets']:
set_count += 1 set_count += 1
total_volume_lbs += item['reps'] * item['weight_lbs'] weight_lbs = item.get('weight_lbs')
if weight_lbs is not None:
total_volume_lbs += item['reps'] * weight_lbs
normalized_sets.append(dict(item)) normalized_sets.append(dict(item))
exercises.append({ exercises.append({
'exercise_name': exercise['exercise_name'], 'exercise_name': exercise['exercise_name'],
@@ -201,10 +425,13 @@ class JSONStore:
stats['last_performed_at'] = workout['performed_at'] stats['last_performed_at'] = workout['performed_at']
for item in exercise['sets']: for item in exercise['sets']:
stats['set_count'] += 1 stats['set_count'] += 1
stats['max_weight_lbs'] = max(stats['max_weight_lbs'], item['weight_lbs']) weight_lbs = item.get('weight_lbs')
if weight_lbs is None:
continue
stats['max_weight_lbs'] = max(stats['max_weight_lbs'], weight_lbs)
stats['best_set_estimated_1rm'] = max( stats['best_set_estimated_1rm'] = max(
stats['best_set_estimated_1rm'], stats['best_set_estimated_1rm'],
_estimated_1rm(item['weight_lbs'], item['reps']), _estimated_1rm(weight_lbs, item['reps']),
) )
results = [] results = []
for item in progression.values(): for item in progression.values():
@@ -243,11 +470,13 @@ class JSONStore:
key=lambda item: item['performed_at'], key=lambda item: item['performed_at'],
reverse=True, reverse=True,
)[:5] )[:5]
recent_templates = list(reversed(state['exercise_templates'][-5:]))
routine_board = _routine_board(state['routines'])
return { return {
'users': users, 'users': users,
'recent_workouts': recent_workouts, 'recent_workouts': recent_workouts,
'exercise_templates': self.list_exercise_templates()[:5], 'exercise_templates': recent_templates,
'routines': self.list_routines()[:5], 'routines': routine_board,
'counts': { 'counts': {
'exercise_templates': len(state['exercise_templates']), 'exercise_templates': len(state['exercise_templates']),
'measurement_entries': len(state['measurement_entries']), 'measurement_entries': len(state['measurement_entries']),
@@ -276,6 +505,88 @@ def _history_for_user(entries: list[dict[str, Any]], user_id: str, sort_key: str
return sorted(matches, key=lambda item: item[sort_key], reverse=True) return sorted(matches, key=lambda item: item[sort_key], reverse=True)
def _ensure_seeded_library(state: dict[str, Any]) -> dict[str, Any]:
payload = deepcopy(state)
payload.setdefault('users', deepcopy(DEFAULT_USERS))
payload.setdefault('exercise_templates', [])
payload.setdefault('routines', [])
payload.setdefault('weight_entries', [])
payload.setdefault('measurement_entries', [])
payload.setdefault('workout_sessions', [])
payload.setdefault('active_workout_sessions', [])
for template in payload['exercise_templates']:
legacy_name = template.get('name', '').strip().lower()
replacement_name = LEGACY_TEMPLATE_ALIASES.get(legacy_name)
if replacement_name is None:
continue
seeded_template = next(item for item in SEEDED_EXERCISE_TEMPLATES if item['name'] == replacement_name)
template['name'] = seeded_template['name']
template['primary_muscle'] = seeded_template.get('primary_muscle')
template['equipment'] = seeded_template.get('equipment')
template['notes'] = seeded_template.get('notes')
existing_template_names = {
item.get('name', '').strip().lower()
for item in payload['exercise_templates']
if item.get('name')
}
for index, template in enumerate(SEEDED_EXERCISE_TEMPLATES, start=1):
normalized_name = template['name'].strip().lower()
if normalized_name in existing_template_names:
continue
payload['exercise_templates'].append(
{
'id': f'seed-template-{index:02d}',
'name': template['name'],
'primary_muscle': template.get('primary_muscle'),
'equipment': template.get('equipment'),
'notes': template.get('notes'),
}
)
existing_template_names.add(normalized_name)
existing_routines = {
item.get('name', '').strip().lower(): item
for item in payload['routines']
if item.get('name')
}
for index, routine in enumerate(SEEDED_ROUTINES, start=1):
normalized_name = routine['name'].strip().lower()
existing_routine = existing_routines.get(normalized_name)
seeded_exercises = [dict(exercise) for exercise in routine['exercises']]
if existing_routine is not None:
existing_routine['user_id'] = routine['user_id']
existing_routine['notes'] = routine.get('notes')
existing_routine['exercises'] = seeded_exercises
continue
new_routine = {
'id': f'seed-routine-{index:02d}',
'user_id': routine['user_id'],
'name': routine['name'],
'notes': routine.get('notes'),
'exercises': seeded_exercises,
}
payload['routines'].append(new_routine)
existing_routines[normalized_name] = new_routine
return payload
def _routine_board(routines: list[dict[str, Any]]) -> list[dict[str, Any]]:
by_name = {
routine.get('name', '').strip().lower(): routine
for routine in routines
if routine.get('name')
}
ordered = []
for seeded_routine in SEEDED_ROUTINES:
matched = by_name.get(seeded_routine['name'].strip().lower())
if matched is not None:
ordered.append(matched)
return ordered
def _trim_measurement_entry(entry: dict[str, Any] | None) -> dict[str, Any] | None: def _trim_measurement_entry(entry: dict[str, Any] | None) -> dict[str, Any] | None:
if entry is None: if entry is None:
return None return None

View File

@@ -89,6 +89,171 @@ body {
flex: 0 1 320px; flex: 0 1 320px;
min-width: min(100%, 280px); min-width: min(100%, 280px);
} }
.art-stack {
display: grid;
gap: 12px;
flex: 0 1 320px;
min-width: min(100%, 280px);
}
.art-stack .hot-fuzz-art {
flex: initial;
min-width: 0;
}
.photo-evidence-card {
position: relative;
margin: 0;
padding: 10px;
border-radius: 22px;
background: linear-gradient(180deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02));
border: 1px solid rgba(242, 239, 221, 0.14);
box-shadow: 0 18px 36px rgba(0, 0, 0, 0.28);
overflow: hidden;
}
.photo-evidence-card::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(242, 193, 78, 0.08), transparent 28%, rgba(145, 15, 63, 0.16));
pointer-events: none;
}
.photo-evidence-card img {
width: 100%;
aspect-ratio: 16 / 10;
object-fit: cover;
display: block;
border-radius: 14px;
filter: saturate(0.82) sepia(0.18) contrast(1.02);
}
.photo-evidence-card figcaption {
position: absolute;
left: 18px;
right: 18px;
bottom: 16px;
z-index: 1;
margin: 0;
font-size: 0.72rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: #fff0cb;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.75);
}
.evidence-contact-grid {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(140px, 0.85fr);
gap: 12px;
align-items: stretch;
}
.evidence-note-card {
position: relative;
padding: 14px 16px;
border-radius: 22px;
background: linear-gradient(180deg, rgba(255,255,255,0.05), rgba(255,255,255,0.015)), rgba(15,18,25,0.96);
border: 1px solid rgba(242, 239, 221, 0.11);
box-shadow: 0 16px 30px rgba(0, 0, 0, 0.24);
overflow: hidden;
}
.evidence-note-card::before {
content: '';
position: absolute;
inset: 0 auto auto 0;
width: 100%;
height: 4px;
background: linear-gradient(90deg, var(--hf-red), var(--hf-amber), var(--hf-blue));
}
.case-contact-top {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 10px;
}
.case-contact-kicker,
.case-contact-badge {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 6px 10px;
font-size: 0.74rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.case-contact-kicker {
background: rgba(242, 193, 78, 0.12);
border: 1px solid rgba(242, 193, 78, 0.24);
color: #ffe6aa;
}
.case-contact-badge {
background: rgba(123, 143, 178, 0.16);
border: 1px solid rgba(123, 143, 178, 0.24);
color: #dbe5ff;
}
.contact-sheet-illustration {
width: 100%;
height: auto;
display: block;
margin-bottom: 10px;
filter: drop-shadow(0 14px 22px rgba(0, 0, 0, 0.24));
}
.contact-frame {
fill: rgba(11, 14, 20, 0.92);
stroke: rgba(242, 239, 221, 0.12);
stroke-width: 2;
}
.contact-photo {
fill: rgba(123, 143, 178, 0.22);
stroke: rgba(242, 239, 221, 0.16);
stroke-width: 1.5;
}
.contact-photo.alt {
fill: rgba(213, 54, 0, 0.18);
}
.contact-lines,
.contact-thread,
.beacon-arc {
fill: none;
stroke-linecap: round;
}
.contact-lines {
stroke: rgba(242, 239, 221, 0.86);
stroke-width: 4;
}
.contact-thread,
.map-thread {
stroke: rgba(213, 54, 0, 0.82);
stroke-width: 3;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
.contact-marker {
fill: rgba(242, 193, 78, 0.18);
stroke: rgba(242, 193, 78, 0.84);
stroke-width: 2;
}
.contact-label {
fill: rgba(242, 239, 221, 0.9);
font-size: 15px;
font-weight: 800;
letter-spacing: 0.16em;
text-anchor: middle;
}
.case-contact-list {
margin: 0;
padding-left: 18px;
color: #d5dded;
display: grid;
gap: 8px;
line-height: 1.4;
}
.supporting-photo-card { min-height: 100%; }
.evidence-card,
.radio-body { fill: rgba(242, 239, 221, 0.92); }
.evidence-lines { fill: none; stroke: rgba(10, 12, 18, 0.82); stroke-width: 4; stroke-linecap: round; }
.marker-dot { fill: rgba(242, 193, 78, 0.94); }
.marker-dot-alt { fill: rgba(123, 143, 178, 0.9); }
.beacon-arc { stroke: rgba(242, 193, 78, 0.9); stroke-width: 4; }
.radio-antenna { fill: rgba(242, 193, 78, 0.9); }
.poster-illustration { .poster-illustration {
width: 100%; width: 100%;
height: auto; height: auto;
@@ -248,10 +413,86 @@ button:focus, input:focus, select:focus, textarea:focus { outline: 2px solid rgb
padding: 14px; padding: 14px;
} }
.compact-casefile-header {
padding-bottom: 0.35rem;
}
.compact-title-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(220px, 300px);
gap: 16px;
}
.compact-hot-fuzz-art {
min-width: 0;
}
.compact-hero-mark {
max-width: 300px;
margin-left: auto;
}
.compact-page-shell {
padding-bottom: 1.5rem;
}
.compact-panel {
padding: 1rem;
}
.compact-stat-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
margin-bottom: 0.9rem;
}
.stat-slab {
display: grid;
gap: 0.3rem;
}
.stat-label,
.section-label {
color: var(--hf-amber);
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.72rem;
}
.metric-value-sm {
font-size: 1.5rem;
}
.compact-summary-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: 0.65rem;
}
.compact-summary-list li {
margin: 0;
padding: 0.7rem 0.8rem;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
background: rgba(255, 255, 255, 0.03);
}
.compact-summary-list li + li {
margin-top: 0;
}
.compact-history-block h3 {
margin-bottom: 0.65rem;
}
@media (max-width: 700px) { @media (max-width: 700px) {
.page-header, .page-header,
.page-shell { .page-shell {
width: min(100% - 1rem, 1100px); width: min(100% - 1.25rem, 1100px);
}
.evidence-contact-grid {
grid-template-columns: 1fr;
} }
.brand-row, .brand-row,
@@ -263,4 +504,107 @@ button:focus, input:focus, select:focus, textarea:focus { outline: 2px solid rgb
flex-basis: 100%; flex-basis: 100%;
min-width: 0; min-width: 0;
} }
.compact-title-row {
grid-template-columns: 1fr;
}
.compact-hero-mark {
margin-left: 0;
}
.compact-stat-grid {
grid-template-columns: 1fr;
}
}
.doris-family-shell .case-nav {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.doris-family-shell .case-nav-link,
.doris-family-shell .family-app-card {
text-decoration: none;
}
.doris-family-shell .case-nav-link {
display: inline-flex;
align-items: center;
padding: 0.58rem 0.9rem;
border-radius: 999px;
border: 1px solid rgba(242, 193, 78, 0.18);
background: rgba(13, 19, 33, 0.72);
color: var(--muted);
font-size: 0.88rem;
letter-spacing: 0.02em;
}
.doris-family-shell .case-nav-link:hover,
.doris-family-shell .case-nav-link.active {
color: var(--ink);
border-color: rgba(242, 193, 78, 0.48);
background: linear-gradient(135deg, rgba(213, 54, 0, 0.24), rgba(145, 15, 63, 0.24));
}
.doris-family-shell .nav-link.active {
color: var(--ink);
border-color: rgba(242, 193, 78, 0.58);
background: linear-gradient(135deg, rgba(213, 54, 0, 0.24), rgba(145, 15, 63, 0.24));
}
.family-portal {
display: grid;
gap: 14px;
}
.family-directory-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.family-app-card {
display: grid;
gap: 6px;
padding: 14px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: linear-gradient(180deg, rgba(17, 24, 39, 0.92), rgba(8, 12, 22, 0.96));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 14px 28px rgba(0, 0, 0, 0.22);
color: var(--ink);
}
.family-app-card:hover,
.family-app-card.current-desk {
border-color: rgba(242, 193, 78, 0.4);
transform: translateY(-1px);
}
.family-app-kicker {
font-size: 0.72rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--hf-amber);
}
.family-app-card strong {
font-size: 1rem;
}
.family-app-card small {
color: var(--muted);
line-height: 1.45;
}
@media (max-width: 768px) {
.doris-family-shell .case-nav {
overflow-x: auto;
padding-bottom: 4px;
}
.family-directory-grid {
grid-template-columns: 1fr;
}
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

View File

@@ -4,11 +4,11 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title or 'Doris Barbell' }}</title> <title>{{ title or 'Doris Barbell' }}</title>
<link rel="stylesheet" href="/static/app.css?v=4"> <link rel="stylesheet" href="/static/app.css?v=7">
</head> </head>
<body class="doris-hot-fuzz app-barbell"> <body class="doris-hot-fuzz doris-family-shell app-barbell">
<div class="incident-tape" aria-hidden="true"></div> <div class="incident-tape" aria-hidden="true"></div>
<header class="page-header casefile-header app-casefile app-casefile-barbell"> <header class="page-header casefile-header app-casefile app-casefile-barbell compact-casefile-header">
<div class="film-grain" aria-hidden="true"></div> <div class="film-grain" aria-hidden="true"></div>
<div class="cinematic-glow" aria-hidden="true"></div> <div class="cinematic-glow" aria-hidden="true"></div>
<div class="brand-row"> <div class="brand-row">
@@ -16,53 +16,102 @@
<span class="nav-kicker">N.W.A. Case File</span> <span class="nav-kicker">N.W.A. Case File</span>
<strong>Doris Constabulary</strong> <strong>Doris Constabulary</strong>
</div> </div>
<nav class="nav-links" aria-label="Doris Barbell navigation"> <nav class="nav-links doris-family-nav" aria-label="Doris family navigation">
<a class="nav-link {{ 'active' if request.url.path == '/' else '' }}" href="https://gym.paccoco.com/">Barbell</a>
<a class="nav-link" href="http://10.5.30.7:8787/">Dashboard</a> <a class="nav-link" href="http://10.5.30.7:8787/">Dashboard</a>
<a class="nav-link" href="http://10.5.30.7:8787/services.html">Services</a>
<a class="nav-link" href="http://10.5.30.7:8092/">Kitchen</a> <a class="nav-link" href="http://10.5.30.7:8092/">Kitchen</a>
<a class="nav-link" href="https://schoolhouse.paccoco.com">Schoolhouse</a> <a class="nav-link" href="https://schoolhouse.paccoco.com/">Schoolhouse</a>
<a class="nav-link" href="/">Barbell</a>
</nav> </nav>
</div> </div>
<div class="casefile-title-row"> <div class="casefile-title-row compact-title-row">
<div> <div class="hero-copy">
<p class="eyebrow">Station training desk</p> <p class="eyebrow">Station training desk</p>
<div class="casefile-stamp barbell-evidence-tag">Training Dossier</div> <div class="casefile-stamp barbell-evidence-tag">Training Dossier</div>
<h1>Doris Barbell</h1> <h1>Doris Barbell</h1>
<p class="lede">Reps, weight, BMI, and body metrics now look like a proper performance dossier instead of generic gym CRUD.</p> <p class="lede">Log lifts, body weight, and routines.</p>
<div class="brand-badges" aria-label="Theme badges"> <div class="brand-badges" aria-label="Theme badges">
<span class="pill badge-hotfuzz">For the Greater Good</span> <span class="pill badge-hotfuzz">For the Greater Good</span>
<span class="pill">Evidence locker: reps</span>
<span class="pill">Case type: Training Dossier</span>
</div> </div>
</div> </div>
<div class="hot-fuzz-art" aria-hidden="true"> <div class="art-stack">
<svg viewBox="0 0 320 220" class="poster-illustration" role="presentation"> <div class="hot-fuzz-art compact-hot-fuzz-art" aria-hidden="true">
<defs> <svg viewBox="0 0 320 140" class="poster-illustration compact-hero-mark" role="presentation">
<linearGradient id="barbell-sunset" x1="0%" y1="0%" x2="100%" y2="100%"> <defs>
<stop offset="0%" stop-color="#f2c14e"></stop> <linearGradient id="barbell-sunset" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="50%" stop-color="#d53600"></stop> <stop offset="0%" stop-color="#f2c14e"></stop>
<stop offset="100%" stop-color="#910f3f"></stop> <stop offset="50%" stop-color="#d53600"></stop>
</linearGradient> <stop offset="100%" stop-color="#910f3f"></stop>
</defs> </linearGradient>
<rect x="8" y="8" width="304" height="204" rx="24" class="poster-frame"></rect> </defs>
<circle cx="228" cy="72" r="58" class="poster-halo"></circle> <rect x="8" y="8" width="304" height="124" rx="24" class="poster-frame"></rect>
<path d="M18 170 L112 100 L134 124 L40 198 Z" class="poster-tape tape-left"></path> <circle cx="250" cy="45" r="34" class="poster-halo"></circle>
<path d="M202 82 L302 32 L314 60 L214 112 Z" class="poster-tape tape-right"></path> <path d="M34 104 L86 58 L102 74 L50 118 Z" class="poster-tape tape-left"></path>
<path d="M112 180 C110 150 116 126 132 110 C140 100 148 94 154 90 C160 94 168 100 176 110 C192 126 198 150 196 180 Z" class="constable-silhouette lead"></path> <path d="M218 44 L292 18 L300 38 L226 66 Z" class="poster-tape tape-right"></path>
<path d="M174 184 C172 154 178 132 194 118 C202 110 210 104 218 100 C226 104 234 110 242 118 C258 132 264 154 262 184 Z" class="constable-silhouette partner"></path> <path d="M112 106 C112 82 118 64 132 52 C140 44 147 39 154 36 C161 39 168 44 176 52 C190 64 196 82 196 106 Z" class="constable-silhouette lead"></path>
<circle cx="84" cy="60" r="28" class="swan-stamp"></circle> <path d="M168 108 C170 86 178 70 192 60 C201 53 210 48 218 45 C226 48 235 53 242 60 C255 70 262 86 260 108 Z" class="constable-silhouette partner"></path>
<path d="M76 64 C80 54 91 48 101 53 C93 52 88 57 89 63 C90 69 101 68 103 76 C94 78 83 76 78 69 L71 76 L66 71 L74 64 Z" class="swan-mark"></path> <circle cx="76" cy="44" r="20" class="swan-stamp"></circle>
<rect x="122" y="110" width="86" height="8" rx="4" class="barbell-bar"></rect> <path d="M71 47 C74 40 82 35 90 39 C84 39 80 43 81 48 C82 53 90 53 92 58 C85 60 77 58 73 53 L68 58 L64 55 L70 49 Z" class="swan-mark"></path>
<circle cx="120" cy="114" r="16" class="plate-outer"></circle> <rect x="114" y="62" width="92" height="10" rx="5" class="barbell-bar"></rect>
<circle cx="120" cy="114" r="8" class="plate-inner"></circle> <circle cx="112" cy="67" r="16" class="plate-outer"></circle>
<circle cx="210" cy="114" r="16" class="plate-outer"></circle> <circle cx="112" cy="67" r="8" class="plate-inner"></circle>
<circle cx="210" cy="114" r="8" class="plate-inner"></circle> <circle cx="208" cy="67" r="16" class="plate-outer"></circle>
<text x="160" y="204" class="poster-callout">TRAINING DOSSIER</text> <circle cx="208" cy="67" r="8" class="plate-inner"></circle>
</svg> <path d="M88 76 L120 68 L154 82" class="map-thread"></path>
<text x="160" y="118" class="poster-callout">TRAINING DOSSIER</text>
</svg>
</div>
<figure class="photo-evidence-card hero-photo-card supporting-photo-card">
<img src="/static/images/hotfuzz-training-dossier.jpg" alt="Gym training photo with cable machine station, bench, and weight stacks." loading="eager">
<figcaption>Training still · source logged in docs</figcaption>
</figure>
</div> </div>
</div> </div>
<nav class="case-nav" aria-label="Doris Barbell sections">
<a class="case-nav-link active" href="/#body-metrics">Body metrics</a>
<a class="case-nav-link" href="/#weight-log">Weight log</a>
<a class="case-nav-link" href="/#routine-board">Routine board</a>
<a class="case-nav-link" href="/#recent-workouts">Workout reports</a>
</nav>
</header> </header>
<main class="page-shell"> <main class="page-shell compact-page-shell">
<section class="family-portal evidence-board casefile-directory">
<div class="section-heading">
<div>
<p class="section-label">Singular front door</p>
<h2>Doris family directory</h2>
</div>
<span class="pill">Casefile directory</span>
</div>
<p class="muted case-legend">One shell, separate runtimes. Jump between training, household ops, and coursework without losing the same caseboard language.</p>
<div class="family-directory-grid">
<a class="family-app-card current-desk" href="https://gym.paccoco.com/">
<span class="family-app-kicker">Current desk</span>
<strong>Doris Barbell</strong>
<small>Workout logs, body metrics, and progression dossiers.</small>
</a>
<a class="family-app-card" href="http://10.5.30.7:8787/">
<span class="family-app-kicker">Operator portal</span>
<strong>Doris Dashboard</strong>
<small>Canonical front door for alerts, briefings, and launch control.</small>
</a>
<a class="family-app-card" href="http://10.5.30.7:8787/services.html">
<span class="family-app-kicker">Switchboard</span>
<strong>Services Directory</strong>
<small>Dense portal card wall for the broader stack.</small>
</a>
<a class="family-app-card" href="http://10.5.30.7:8092/">
<span class="family-app-kicker">Pantry desk</span>
<strong>Doris Kitchen</strong>
<small>Recipe triage, search warrants, and pantry planning.</small>
</a>
<a class="family-app-card" href="https://schoolhouse.paccoco.com/">
<span class="family-app-kicker">Records desk</span>
<strong>Doris Schoolhouse</strong>
<small>Assignments, recordings, and archive intake.</small>
</a>
</div>
</section>
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
</body> </body>

View File

@@ -1,51 +1,70 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block content %} {% block content %}
<section class="grid cols-2 evidence-board dossier-grid"> <section id="body-metrics" class="grid cols-2 dossier-grid">
<article class="card evidence-card"> <article class="card evidence-card compact-panel">
<div class="case-legend"> <div class="case-legend">
<span class="section-label">Training floor board</span> <span class="section-label">Training floor board</span>
<span class="pill">Training Dossier</span> <span class="pill">John</span>
</div> </div>
<h2>Current body metrics</h2> <h2>Current body metrics</h2>
<div class="grid dossier-grid"> {% for user in summary.users if user.id == 'john' %}
{% for user in summary.users %} <div class="compact-stat-grid">
<section class="evidence-slab"> <section class="evidence-slab stat-slab">
<h3>{{ user.display_name }} {% if user.is_stub %}<span class="badge">stub</span>{% endif %}</h3> <span class="stat-label">Weight</span>
<p class="metric-value">{{ user.current_weight_lbs if user.current_weight_lbs is not none else '—' }}{% if user.current_weight_lbs is not none %} lb{% endif %}</p> <p class="metric-value">{{ user.current_weight_lbs if user.current_weight_lbs is not none else '—' }}{% if user.current_weight_lbs is not none %} lb{% endif %}</p>
<p class="muted">BMI: {{ '%.1f'|format(user.current_bmi) if user.current_bmi is not none else 'Not set yet' }}</p>
<p class="muted">System: {{ user.measurement_system }}</p>
{% if user.latest_measurements %}
<p class="muted">
Waist {{ user.latest_measurements.waist_inches or '—' }} in ·
Hips {{ user.latest_measurements.hip_inches or '—' }} in ·
Chest {{ user.latest_measurements.chest_inches or '—' }} in ·
Neck {{ user.latest_measurements.neck_inches or '—' }} in
</p>
{% else %}
<p class="muted">No body measurements logged yet.</p>
{% endif %}
</section> </section>
{% endfor %} <section class="evidence-slab stat-slab">
<span class="stat-label">BMI</span>
<p class="metric-value metric-value-sm">{{ '%.1f'|format(user.current_bmi) if user.current_bmi is not none else '—' }}</p>
</section>
</div> </div>
{% if user.latest_measurements %}
<ul class="list compact-summary-list">
<li>Waist {{ user.latest_measurements.waist_inches or '—' }} in</li>
<li>Chest {{ user.latest_measurements.chest_inches or '—' }} in</li>
<li>Hips {{ user.latest_measurements.hip_inches or '—' }} in</li>
<li>Neck {{ user.latest_measurements.neck_inches or '—' }} in</li>
</ul>
{% else %}
<p class="muted">No body measurements logged yet.</p>
{% endif %}
{% endfor %}
</article> </article>
<article class="card evidence-card"> <article id="routine-board" class="card evidence-board compact-panel">
<div class="case-legend"> <div class="case-legend">
<span class="section-label">Case priorities</span> <span class="section-label">Current split</span>
<span class="pill">Operator notes</span> <span class="pill">Routine board</span>
</div> </div>
<h2>V1 focus</h2> <h2>Current routine board</h2>
<ul class="list case-list"> <ul class="list compact-summary-list">
<li>Structured workout logging with routines and exercises.</li> <li>Routines: {{ summary.counts.routines }}</li>
<li>Body-weight, BMI, waist/hip/chest/neck history in imperial units.</li> <li>Exercise templates: {{ summary.counts.exercise_templates }}</li>
<li>Recent workouts, volume trends, and simple PR tracking.</li> <li>Workout sessions: {{ summary.counts.workout_sessions }}</li>
</ul> </ul>
{% if summary.routines %}
<div class="history-block compact-history-block">
<ul class="list">
{% for routine in summary.routines %}
<li>
<strong>{{ routine.name }}</strong>
<p class="muted">{{ routine.exercises|length }} exercises</p>
<p>
<a class="nav-link" href="/routines/{{ routine.id }}/log">Open log sheet</a>
·
<a class="nav-link" href="/routines/{{ routine.id }}/live">{% if routine.live_session %}Resume {{ routine.name }} live log{% else %}Resume live log{% endif %}</a>
</p>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</article> </article>
</section> </section>
<section class="grid cols-2 dossier-grid"> <section id="weight-log" class="grid cols-2 dossier-grid">
<article class="card evidence-board"> <article class="card evidence-board compact-panel">
<div class="case-legend"><span class="section-label">Intake docket</span><span class="pill">Weight log</span></div> <div class="case-legend"><span class="section-label">Intake docket</span><span class="pill">Weight log</span></div>
<h2>Log weight</h2> <h2>Log weight</h2>
<form class="stack-form intake-docket" method="post" action="/log/weight"> <form class="stack-form intake-docket" method="post" action="/log/weight">
@@ -74,7 +93,7 @@
</form> </form>
</article> </article>
<article class="card evidence-board"> <article class="card evidence-board compact-panel">
<div class="case-legend"><span class="section-label">Intake docket</span><span class="pill">Measurements</span></div> <div class="case-legend"><span class="section-label">Intake docket</span><span class="pill">Measurements</span></div>
<h2>Log body measurements</h2> <h2>Log body measurements</h2>
<form class="stack-form intake-docket" method="post" action="/log/measurements"> <form class="stack-form intake-docket" method="post" action="/log/measurements">
@@ -104,101 +123,8 @@
</article> </article>
</section> </section>
<section class="grid cols-2 dossier-grid"> <section id="recent-workouts" class="grid cols-2 dossier-grid">
<article class="card evidence-board"> <article class="card evidence-board compact-panel">
<div class="case-legend"><span class="section-label">Exercise library</span><span class="pill">Template intake</span></div>
<h2>Add exercise template</h2>
<form class="stack-form intake-docket" method="post" action="/templates">
<label>Name
<input type="text" name="name" placeholder="Bench Press" required>
</label>
<div class="grid cols-2 compact-grid">
<label>Primary muscle
<input type="text" name="primary_muscle" placeholder="Chest">
</label>
<label>Equipment
<input type="text" name="equipment" placeholder="Barbell">
</label>
</div>
<label>Notes
<textarea name="notes" rows="3" placeholder="Technique cue, setup reminder, variation notes."></textarea>
</label>
<button type="submit">Add template</button>
</form>
</article>
<article class="card evidence-board">
<div class="case-legend"><span class="section-label">Routine intake</span><span class="pill">Draft split</span></div>
<h2>Build routine draft</h2>
<form class="stack-form intake-docket" method="post" action="/routines">
<input type="hidden" name="user_id" value="john">
<label>Routine name
<input type="text" name="name" placeholder="Push Day Draft" required>
</label>
<label>Exercise lines
<textarea name="exercise_lines" rows="6" placeholder="Bench Press|4|6-8&#10;Incline Dumbbell Press|3|8-10&#10;Triceps Pushdown|3|12-15" required></textarea>
</label>
<p class="hint">Format: exercise | target sets | target reps | optional notes</p>
<label>Notes
<textarea name="notes" rows="2" placeholder="Temporary until the real split gets entered."></textarea>
</label>
<button type="submit">Save routine draft</button>
</form>
</article>
</section>
<section class="grid cols-2 dossier-grid">
<article class="card evidence-board">
<div class="case-legend"><span class="section-label">Workout report</span><span class="pill">Quick log</span></div>
<h2>Quick log workout</h2>
<form class="stack-form intake-docket" method="post" action="/workouts/quick-log">
<input type="hidden" name="user_id" value="john">
<label>Workout name
<input type="text" name="routine_name" placeholder="Push Day" required>
</label>
<label>Performed at
<input type="datetime-local" name="performed_at" required>
</label>
<label>Exercise lines
<textarea name="exercise_lines" rows="6" placeholder="Bench Press|8@135, 8@145&#10;Incline Dumbbell Press|10@50, 8@55|Last set close to failure" required></textarea>
</label>
<p class="hint">Format: exercise | reps@weight, reps@weight | optional notes</p>
<label>Notes
<textarea name="notes" rows="2" placeholder="Energy, pain, wins, misses."></textarea>
</label>
<button type="submit">Save workout</button>
</form>
</article>
<article class="card evidence-board">
<div class="case-legend"><span class="section-label">Library snapshot</span><span class="pill">Scaffold</span></div>
<h2>Template and routine scaffold</h2>
<p class="muted">You can start building the library now, even before the real split lands tomorrow.</p>
<ul class="list case-list">
<li>Exercise templates: {{ summary.counts.exercise_templates }}</li>
<li>Routines: {{ summary.counts.routines }}</li>
</ul>
{% if summary.exercise_templates %}
<p><strong>Templates</strong></p>
<ul class="list">
{% for template in summary.exercise_templates %}
<li>{{ template.name }}{% if template.equipment %} · {{ template.equipment }}{% endif %}</li>
{% endfor %}
</ul>
{% endif %}
{% if summary.routines %}
<p><strong>Routines</strong></p>
<ul class="list">
{% for routine in summary.routines %}
<li>{{ routine.name }} · {{ routine.exercises|length }} exercises</li>
{% endfor %}
</ul>
{% endif %}
</article>
</section>
<section class="grid cols-2 dossier-grid">
<article class="card evidence-board">
<div class="case-legend"><span class="section-label">Recent logs</span><span class="pill">Session reports</span></div> <div class="case-legend"><span class="section-label">Recent logs</span><span class="pill">Session reports</span></div>
<h2>Recent workouts</h2> <h2>Recent workouts</h2>
{% if john_workout_history %} {% if john_workout_history %}
@@ -215,50 +141,7 @@
{% endif %} {% endif %}
</article> </article>
<article class="card evidence-board"> <article class="card evidence-board compact-panel">
<div class="case-legend"><span class="section-label">History snapshot</span><span class="pill">John file</span></div>
<h2>John history snapshot</h2>
<div class="history-block">
<h3>Recent weights</h3>
{% if john_weight_history %}
<ul class="list">
{% for entry in john_weight_history %}
<li>{{ entry.recorded_at }} · {{ entry.weight_lbs }} lb{% if entry.bmi is not none %} · BMI {{ '%.1f'|format(entry.bmi) }}{% endif %}</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No weight entries yet.</p>
{% endif %}
</div>
<div class="history-block">
<h3>Recent measurements</h3>
{% if john_measurement_history %}
<ul class="list">
{% for entry in john_measurement_history %}
<li>{{ entry.recorded_at }} · Waist {{ entry.waist_inches or '—' }} · Chest {{ entry.chest_inches or '—' }} · Hips {{ entry.hip_inches or '—' }} · Neck {{ entry.neck_inches or '—' }}</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No measurement entries yet.</p>
{% endif %}
</div>
</article>
</section>
<section class="grid cols-2 dossier-grid">
<article class="card evidence-board">
<div class="case-legend"><span class="section-label">Current counts</span><span class="pill">Inventory</span></div>
<h2>Current counts</h2>
<ul class="list case-list">
<li>Workout sessions: {{ summary.counts.workout_sessions }}</li>
<li>Weight entries: {{ summary.counts.weight_entries }}</li>
<li>Measurement entries: {{ summary.counts.measurement_entries }}</li>
<li>Exercise templates: {{ summary.counts.exercise_templates }}</li>
<li>Routines: {{ summary.counts.routines }}</li>
</ul>
</article>
<article class="card evidence-board">
<div class="case-legend"><span class="section-label">Exercise bests</span><span class="pill">PR board</span></div> <div class="case-legend"><span class="section-label">Exercise bests</span><span class="pill">PR board</span></div>
<h2>Exercise bests</h2> <h2>Exercise bests</h2>
{% if john_progression %} {% if john_progression %}
@@ -277,14 +160,108 @@
</section> </section>
<section class="grid cols-2 dossier-grid"> <section class="grid cols-2 dossier-grid">
<article class="card evidence-board"> <article class="card evidence-board compact-panel">
<div class="case-legend"><span class="section-label">Next likely build</span><span class="pill">Open casework</span></div> <div class="case-legend"><span class="section-label">Library tools</span><span class="pill">Templates</span></div>
<h2>Next likely build</h2> <h2>Add exercise template</h2>
<ul class="list case-list"> <form class="stack-form intake-docket" method="post" action="/templates">
<li>Replace placeholder routines with Johns real day-by-day program tomorrow.</li> <label>Name
<li>Routine-specific workout entry flow driven directly from the saved split.</li> <input type="text" name="name" placeholder="Bench Press" required>
<li>Exercise progression and PR views.</li> </label>
</ul> <div class="grid cols-2 compact-grid">
<label>Primary muscle
<input type="text" name="primary_muscle" placeholder="Chest">
</label>
<label>Equipment
<input type="text" name="equipment" placeholder="Barbell">
</label>
</div>
<label>Notes
<textarea name="notes" rows="2" placeholder="Technique cue or setup reminder."></textarea>
</label>
<button type="submit">Add template</button>
</form>
{% if summary.exercise_templates %}
<div class="history-block compact-history-block">
<h3>Template snapshot</h3>
<ul class="list">
{% for template in summary.exercise_templates %}
<li>{{ template.name }}{% if template.equipment %} · {{ template.equipment }}{% endif %}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</article>
<article class="card evidence-board compact-panel">
<div class="case-legend"><span class="section-label">Workout report</span><span class="pill">Quick log</span></div>
<h2>Quick log workout</h2>
<form class="stack-form intake-docket" method="post" action="/workouts/quick-log">
<input type="hidden" name="user_id" value="john">
<label>Workout name
<input type="text" name="routine_name" placeholder="Push Day" required>
</label>
<label>Performed at
<input type="datetime-local" name="performed_at" required>
</label>
<label>Exercise lines
<textarea name="exercise_lines" rows="5" placeholder="Bench Press|8@135, 8@145&#10;Incline Dumbbell Press|10@50, 8@55|Last set close to failure" required></textarea>
</label>
<p class="hint">Format: exercise | reps@weight, reps@weight | optional notes</p>
<label>Notes
<textarea name="notes" rows="2" placeholder="Energy, pain, wins, misses."></textarea>
</label>
<button type="submit">Save workout</button>
</form>
</article>
</section>
<section class="grid cols-2 dossier-grid">
<article class="card evidence-board compact-panel">
<div class="case-legend"><span class="section-label">Routine tools</span><span class="pill">Draft split</span></div>
<h2>Build routine draft</h2>
<form class="stack-form intake-docket" method="post" action="/routines">
<input type="hidden" name="user_id" value="john">
<label>Routine name
<input type="text" name="name" placeholder="Push Day Draft" required>
</label>
<label>Exercise lines
<textarea name="exercise_lines" rows="5" placeholder="Bench Press|4|6-8&#10;Incline Dumbbell Press|3|8-10&#10;Triceps Pushdown|3|12-15" required></textarea>
</label>
<p class="hint">Format: exercise | target sets | target reps | optional notes</p>
<label>Notes
<textarea name="notes" rows="2" placeholder="Optional notes."></textarea>
</label>
<button type="submit">Save routine draft</button>
</form>
</article>
<article class="card evidence-board compact-panel">
<div class="case-legend"><span class="section-label">History snapshot</span><span class="pill">John file</span></div>
<h2>Recent body history</h2>
<div class="history-block compact-history-block">
<h3>Recent weights</h3>
{% if john_weight_history %}
<ul class="list">
{% for entry in john_weight_history %}
<li>{{ entry.recorded_at }} · {{ entry.weight_lbs }} lb{% if entry.bmi is not none %} · BMI {{ '%.1f'|format(entry.bmi) }}{% endif %}</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No weight entries yet.</p>
{% endif %}
</div>
<div class="history-block compact-history-block">
<h3>Recent measurements</h3>
{% if john_measurement_history %}
<ul class="list">
{% for entry in john_measurement_history %}
<li>{{ entry.recorded_at }} · Waist {{ entry.waist_inches or '—' }} · Chest {{ entry.chest_inches or '—' }} · Hips {{ entry.hip_inches or '—' }} · Neck {{ entry.neck_inches or '—' }}</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No measurement entries yet.</p>
{% endif %}
</div>
</article> </article>
</section> </section>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,132 @@
{% extends 'base.html' %}
{% block content %}
<section class="grid cols-2 dossier-grid">
<article class="card evidence-board">
<div class="case-legend">
<span class="section-label">Live workout logger</span>
<span class="pill">One-at-a-time entry</span>
</div>
<h2>{{ routine.name }}</h2>
<p class="muted">Log one exercise at a time while you work through the session. Save each movement as you finish it, then close out the workout when you're done.</p>
{% if routine.notes %}
<p class="muted">{{ routine.notes }}</p>
{% endif %}
</article>
<article class="card evidence-board">
<div class="case-legend">
<span class="section-label">Routine field sheet</span>
<span class="pill">{{ routine_sheet|length }} exercises</span>
</div>
<h2>Loaded exercises</h2>
<ul class="list case-list">
{% for exercise in routine_sheet %}
<li>
<strong>{{ exercise.exercise_name }}</strong>
<p class="muted">{{ exercise.target_sets }} sets{% if exercise.target_reps %} · target reps {{ exercise.target_reps }}{% endif %}</p>
</li>
{% endfor %}
</ul>
</article>
</section>
{% if not active_session %}
<section class="grid dossier-grid">
<article class="card evidence-board">
<div class="case-legend">
<span class="section-label">Session control</span>
<span class="pill">Ready</span>
</div>
<h2>Start live session</h2>
<form class="stack-form intake-docket" method="post" action="/routines/{{ routine.id }}/live/start">
<input type="hidden" name="user_id" value="{{ routine.user_id }}">
<label>Performed at
<input type="datetime-local" name="performed_at" value="{{ default_performed_at }}" required>
</label>
<button type="submit">Start {{ routine.name }} session</button>
</form>
</article>
</section>
{% else %}
<section class="grid cols-2 dossier-grid">
<article class="card evidence-board">
<div class="case-legend">
<span class="section-label">Session control</span>
<span class="pill">In progress</span>
</div>
<h2>Session in progress</h2>
<p class="muted">Started at {{ active_session.performed_at }} · Logged exercises so far: {{ logged_exercises|length }}</p>
{% if logged_exercises %}
<ul class="list case-list">
{% for exercise in logged_exercises %}
<li>
<strong>{{ exercise.exercise_name }} · {{ exercise.set_count }} sets</strong>
{% if exercise.notes %}<p class="muted">{{ exercise.notes }}</p>{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No exercises saved yet.</p>
{% endif %}
<form class="stack-form intake-docket" method="post" action="/routines/{{ routine.id }}/live/finish">
<input type="hidden" name="user_id" value="{{ routine.user_id }}">
<input type="hidden" name="performed_at" value="{{ active_session.performed_at }}">
<label>Session notes
<textarea name="notes" rows="2" placeholder="Energy, pain, machine swaps, or anything worth remembering."></textarea>
</label>
<button type="submit">Finish {{ routine_action_label }} workout</button>
</form>
</article>
<article class="card evidence-board">
<div class="case-legend">
<span class="section-label">Remaining queue</span>
<span class="pill">{{ remaining_exercises|length }} left</span>
</div>
<h2>Ready to log next</h2>
{% if remaining_exercises %}
<p class="muted">Each card saves one exercise at a time, so you can log mid-workout without rewriting the whole session.</p>
{% for exercise in remaining_exercises %}
<section class="evidence-slab">
<form class="stack-form intake-docket" method="post" action="/routines/{{ routine.id }}/live/add-exercise">
<input type="hidden" name="user_id" value="{{ routine.user_id }}">
<input type="hidden" name="performed_at" value="{{ active_session.performed_at }}">
<input type="hidden" name="exercise_name" value="{{ exercise.exercise_name }}">
<input type="hidden" name="set_count" value="{{ exercise.target_sets }}">
<div class="case-legend">
<span class="section-label">Exercise</span>
<span class="pill">{{ exercise.target_sets }} sets</span>
</div>
<h3>{{ exercise.exercise_name }}</h3>
<p class="muted">{% if exercise.target_reps %}Target reps {{ exercise.target_reps }}{% else %}Enter reps manually{% endif %}</p>
{% if exercise.is_band %}
<p class="muted">Resistance-band exercise: leave weight blank to log sets and default reps only.</p>
{% endif %}
<div class="grid cols-2 compact-grid">
{% for set in exercise.sets %}
<div class="evidence-slab compact-slab">
<strong>Set {{ set.index + 1 }}</strong>
<label>Reps
<input type="number" name="set_{{ set.index }}_reps" min="1" step="1" placeholder="{{ set.default_reps }}">
</label>
<label>Weight (lb)
<input type="number" name="set_{{ set.index }}_weight_lbs" min="0" step="0.1">
</label>
</div>
{% endfor %}
</div>
<label>Exercise notes
<textarea name="exercise_notes" rows="2" placeholder="Technique note, pain flag, or machine change."></textarea>
</label>
<button type="submit">Save {{ exercise.exercise_name }}</button>
</form>
</section>
{% endfor %}
{% else %}
<p class="muted">Everything in this routine is logged. Finish the workout to move it into history.</p>
{% endif %}
</article>
</section>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,95 @@
{% extends 'base.html' %}
{% block content %}
<section class="grid cols-2 dossier-grid">
<article class="card evidence-board">
<div class="case-legend">
<span class="section-label">Routine field sheet</span>
<span class="pill">Live training log</span>
</div>
<h2>{{ routine.name }}</h2>
<p class="muted">Fill in today's completed reps and weight for each set. Leave any unfinished exercise blank and it will be skipped.</p>
{% if routine.notes %}
<p class="muted">{{ routine.notes }}</p>
{% endif %}
</article>
<article class="card evidence-board">
<div class="case-legend">
<span class="section-label">Current prescription</span>
<span class="pill">Shoulder day</span>
</div>
<h2>Loaded exercises</h2>
<ul class="list case-list">
{% for exercise in routine_sheet %}
<li>
<strong>{{ exercise.exercise_name }}</strong>
<p class="muted">{{ exercise.target_sets }} sets{% if exercise.target_reps %} · target reps {{ exercise.target_reps }}{% endif %}{% if exercise.notes %} · {{ exercise.notes }}{% endif %}</p>
</li>
{% endfor %}
</ul>
</article>
</section>
<section class="grid dossier-grid">
<article class="card evidence-board">
<div class="case-legend">
<span class="section-label">Workout report</span>
<span class="pill">Structured entry</span>
</div>
<h2>Log this session</h2>
<form class="stack-form intake-docket" method="post" action="/workouts/routine-log">
<input type="hidden" name="user_id" value="{{ routine.user_id }}">
<input type="hidden" name="routine_id" value="{{ routine.id }}">
<input type="hidden" name="routine_name" value="{{ routine.name }}">
<input type="hidden" name="exercise_count" value="{{ routine_sheet|length }}">
<label>Performed at
<input type="datetime-local" name="performed_at" value="{{ default_performed_at }}" required>
</label>
<label>Session notes
<textarea name="notes" rows="2" placeholder="Pain, wins, machine availability, energy, anything worth remembering."></textarea>
</label>
{% for exercise in routine_sheet %}
<section class="evidence-slab">
<input type="hidden" name="exercise_{{ exercise.exercise_index }}_set_count" value="{{ exercise.target_sets }}">
<input type="hidden" name="exercise_{{ exercise.exercise_index }}_default_reps" value="{{ exercise.default_reps }}">
<div class="case-legend">
<span class="section-label">Exercise {{ loop.index }}</span>
<span class="pill">{{ exercise.target_sets }} sets</span>
</div>
<label>Exercise
<select name="exercise_{{ exercise.exercise_index }}_name">
{% for option_name in exercise_options %}
<option value="{{ option_name }}" {% if option_name == exercise.exercise_name %}selected{% endif %}>{{ option_name }}</option>
{% endfor %}
</select>
</label>
<p class="muted">Target reps: {{ exercise.target_reps or 'enter manually' }}</p>
{% if exercise.is_band %}
<p class="muted">Resistance-band exercise: leave weight blank to log sets and default reps only.</p>
{% endif %}
<div class="grid cols-2 compact-grid">
{% for set in exercise.sets %}
<div class="evidence-slab compact-slab">
<strong>Set {{ set.index + 1 }}</strong>
<label>Reps
<input type="number" name="exercise_{{ set.exercise_index }}_set_{{ set.index }}_reps" min="1" step="1" placeholder="{{ set.default_reps }}">
</label>
<label>Weight (lb)
<input type="number" name="exercise_{{ set.exercise_index }}_set_{{ set.index }}_weight_lbs" min="0" step="0.1">
</label>
</div>
{% endfor %}
</div>
<label>Exercise notes
<textarea name="exercise_{{ exercise.exercise_index }}_notes" rows="2" placeholder="Technique note, pain flag, or machine change."></textarea>
</label>
</section>
{% endfor %}
<button type="submit">Save {{ routine_action_label }} workout</button>
</form>
</article>
</section>
{% endblock %}

View File

@@ -1,3 +1,5 @@
import json
import re
from pathlib import Path from pathlib import Path
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -30,14 +32,49 @@ def test_dashboard_summary_seeds_john_and_manndra(tmp_path: Path) -> None:
assert user_ids == ['john', 'manndra'] assert user_ids == ['john', 'manndra']
assert payload['counts'] == { assert payload['counts'] == {
'exercise_templates': 0, 'exercise_templates': 31,
'measurement_entries': 0, 'measurement_entries': 0,
'routines': 0, 'routines': 5,
'weight_entries': 0, 'weight_entries': 0,
'workout_sessions': 0, 'workout_sessions': 0,
} }
john = next(user for user in payload['users'] if user['id'] == 'john') john = next(user for user in payload['users'] if user['id'] == 'john')
assert john['measurement_system'] == 'imperial' assert john['measurement_system'] == 'imperial'
routine_names = [routine['name'] for routine in payload['routines']]
assert routine_names == [
'Monday: Chest',
'Tuesday: Back',
'Wednesday: Arms',
'Thursday: Shoulders',
'Friday: Legs',
]
assert payload['routines'][0]['exercises'][0] == {
'exercise_name': 'Bench Press',
'target_sets': 3,
'target_reps': '10',
}
assert '3 sets of 10 reps' in payload['routines'][0]['notes']
assert 'Weights are still TBD' in payload['routines'][0]['notes']
shoulders = next(routine for routine in payload['routines'] if routine['name'] == 'Thursday: Shoulders')
assert shoulders['exercises'][6] == {
'exercise_name': '7-7-7 Shoulder Press',
'target_sets': 1,
'target_reps': '7-7-7',
}
assert shoulders['exercises'][7] == {
'exercise_name': 'Resistance Band Punch-Out Training',
'target_sets': 3,
'target_reps': '10',
}
assert 'resistance band punch-out training' in shoulders['notes']
template_names = [template['name'] for template in payload['exercise_templates']]
assert template_names == [
'Resistance Band Crunches',
'Calf Raises',
'Deadlifts',
'Step Ups',
'Leg Extensions',
]
def test_updating_profile_and_logging_weight_computes_bmi(tmp_path: Path) -> None: def test_updating_profile_and_logging_weight_computes_bmi(tmp_path: Path) -> None:
@@ -132,11 +169,14 @@ def test_can_create_and_list_exercise_templates(tmp_path: Path) -> None:
list_response = client.get('/api/exercises/templates') list_response = client.get('/api/exercises/templates')
assert list_response.status_code == 200 assert list_response.status_code == 200
payload = list_response.json() payload = list_response.json()
assert len(payload) == 1 assert len(payload) == 32
assert payload[0]['name'] == 'Bench Press' assert any(
item['name'] == 'Bench Press' and item['notes'] == 'Flat bench barbell press.'
for item in payload
)
summary = client.get('/api/dashboard/summary').json() summary = client.get('/api/dashboard/summary').json()
assert summary['counts']['exercise_templates'] == 1 assert summary['counts']['exercise_templates'] == 32
def test_can_create_routine_before_real_program_arrives(tmp_path: Path) -> None: def test_can_create_routine_before_real_program_arrives(tmp_path: Path) -> None:
@@ -181,11 +221,11 @@ def test_can_create_routine_before_real_program_arrives(tmp_path: Path) -> None:
list_response = client.get('/api/routines') list_response = client.get('/api/routines')
assert list_response.status_code == 200 assert list_response.status_code == 200
payload = list_response.json() payload = list_response.json()
assert len(payload) == 1 assert len(payload) == 6
assert payload[0]['name'] == 'Push Day Placeholder' assert any(routine['name'] == 'Push Day Placeholder' for routine in payload)
summary = client.get('/api/dashboard/summary').json() summary = client.get('/api/dashboard/summary').json()
assert summary['counts']['routines'] == 1 assert summary['counts']['routines'] == 6
def test_history_endpoints_return_newest_first(tmp_path: Path) -> None: def test_history_endpoints_return_newest_first(tmp_path: Path) -> None:
@@ -339,11 +379,451 @@ def test_homepage_renders_core_sections_and_forms(tmp_path: Path) -> None:
assert response.status_code == 200 assert response.status_code == 200
body = response.text body = response.text
assert 'Doris Barbell' in body assert 'Doris Barbell' in body
assert 'Log lifts, body weight, and routines.' in body
assert 'Recent workouts' in body assert 'Recent workouts' in body
assert 'Current body metrics' in body assert 'Current body metrics' in body
assert 'Log weight' in body assert 'Log weight' in body
assert 'Add exercise template' in body assert 'Add exercise template' in body
assert 'Build routine draft' in body assert 'Current routine board' in body
assert 'Monday: Chest' in body
assert 'Doris family directory' in body
assert 'Singular front door' in body
assert 'V1 focus' not in body
assert 'Next likely build' not in body
def test_seeded_routine_can_render_prefilled_log_sheet(tmp_path: Path) -> None:
client = make_client(tmp_path)
summary = client.get('/api/dashboard/summary').json()
shoulders = next(routine for routine in summary['routines'] if routine['name'] == 'Thursday: Shoulders')
response = client.get(f"/routines/{shoulders['id']}/log")
assert response.status_code == 200
body = response.text
assert 'Thursday: Shoulders' in body
assert 'Shoulder Press' in body
assert 'Save shoulder day workout' in body
assert 'name="exercise_0_name"' in body
assert '<option value="Shoulder Press" selected>' in body
assert 'value="Upright Row"' in body
assert 'name="exercise_0_set_0_reps"' in body
assert 'name="exercise_0_set_0_weight_lbs"' in body
assert 'placeholder="10"' in body
assert 'name="exercise_0_set_0_reps" min="1" step="1" placeholder="10"' in body
assert re.search(r'name="performed_at" value="\d{4}-\d{2}-\d{2}T\d{2}:\d{2}"', body)
def test_seeded_routine_can_render_live_workout_page(tmp_path: Path) -> None:
client = make_client(tmp_path)
summary = client.get('/api/dashboard/summary').json()
shoulders = next(routine for routine in summary['routines'] if routine['name'] == 'Thursday: Shoulders')
response = client.get(f"/routines/{shoulders['id']}/live")
assert response.status_code == 200
body = response.text
assert 'Live workout logger' in body
assert 'Log one exercise at a time' in body
assert 'Start Thursday: Shoulders session' in body
assert 'Resume live log' in client.get('/').text
def test_live_workout_page_backfills_older_state_without_active_session_key(tmp_path: Path) -> None:
client = make_client(tmp_path)
legacy_state = json.loads((tmp_path / 'state.json').read_text())
legacy_state.pop('active_workout_sessions', None)
(tmp_path / 'state.json').write_text(json.dumps(legacy_state))
response = client.get('/routines/seed-routine-04/live')
assert response.status_code == 200
assert 'Start Thursday: Shoulders session' in response.text
def test_live_workout_session_can_log_exercises_one_at_a_time_and_finish(tmp_path: Path) -> None:
client = make_client(tmp_path)
summary = client.get('/api/dashboard/summary').json()
shoulders = next(routine for routine in summary['routines'] if routine['name'] == 'Thursday: Shoulders')
start_response = client.post(
f"/routines/{shoulders['id']}/live/start",
data={
'user_id': 'john',
'performed_at': '2026-05-28T12:15:00',
},
follow_redirects=True,
)
assert start_response.status_code == 200
start_body = start_response.text
assert 'Session in progress' in start_body
assert 'Logged exercises so far: 0' in start_body
assert 'name="exercise_name"' in start_body
assert 'name="set_0_reps"' in start_body
assert 'name="set_0_weight_lbs"' in start_body
add_response = client.post(
f"/routines/{shoulders['id']}/live/add-exercise",
data={
'user_id': 'john',
'performed_at': '2026-05-28T12:15:00',
'exercise_name': 'Shoulder Press',
'set_count': '3',
'set_0_reps': '10',
'set_0_weight_lbs': '70',
'set_1_reps': '10',
'set_1_weight_lbs': '80',
'set_2_reps': '8',
'set_2_weight_lbs': '90',
},
follow_redirects=True,
)
assert add_response.status_code == 200
add_body = add_response.text
assert 'Logged exercises so far: 1' in add_body
assert 'Shoulder Press · 3 sets' in add_body
assert 'Upright Row' in add_body
finish_response = client.post(
f"/routines/{shoulders['id']}/live/finish",
data={
'user_id': 'john',
'performed_at': '2026-05-28T12:15:00',
'notes': 'Logged between sets.',
},
follow_redirects=False,
)
assert finish_response.status_code == 303
assert finish_response.headers['location'] == '/'
workout_history = client.get('/api/history/john/workouts')
assert workout_history.status_code == 200
payload = workout_history.json()
assert payload[0]['routine_name'] == 'Thursday: Shoulders'
assert payload[0]['exercise_count'] == 1
assert payload[0]['set_count'] == 3
assert payload[0]['notes'] == 'Logged between sets.'
assert payload[0]['exercises'][0]['exercise_name'] == 'Shoulder Press'
assert payload[0]['exercises'][0]['sets'][2] == {'reps': 8, 'weight_lbs': 90.0}
dashboard = client.get('/').text
assert 'Resume Thursday: Shoulders live log' not in dashboard
def test_seeded_routine_log_submission_creates_structured_workout(tmp_path: Path) -> None:
client = make_client(tmp_path)
summary = client.get('/api/dashboard/summary').json()
shoulders = next(routine for routine in summary['routines'] if routine['name'] == 'Thursday: Shoulders')
response = client.post(
'/workouts/routine-log',
data={
'user_id': 'john',
'routine_id': shoulders['id'],
'performed_at': '2026-05-28T12:00:00',
'routine_name': shoulders['name'],
'notes': 'Shoulder day at the gym.',
'exercise_count': '2',
'exercise_0_name': 'Shoulder Press',
'exercise_0_set_count': '3',
'exercise_0_set_0_reps': '10',
'exercise_0_set_0_weight_lbs': '70',
'exercise_0_set_1_reps': '10',
'exercise_0_set_1_weight_lbs': '80',
'exercise_0_set_2_reps': '8',
'exercise_0_set_2_weight_lbs': '90',
'exercise_1_name': 'Upright Row',
'exercise_1_set_count': '3',
'exercise_1_set_0_reps': '10',
'exercise_1_set_0_weight_lbs': '35',
'exercise_1_set_1_reps': '10',
'exercise_1_set_1_weight_lbs': '35',
'exercise_1_set_2_reps': '8',
'exercise_1_set_2_weight_lbs': '40',
},
follow_redirects=False,
)
assert response.status_code == 303
assert response.headers['location'] == '/'
workout_history = client.get('/api/history/john/workouts')
assert workout_history.status_code == 200
payload = workout_history.json()
assert payload[0]['routine_name'] == 'Thursday: Shoulders'
assert payload[0]['exercise_count'] == 2
assert payload[0]['set_count'] == 6
assert payload[0]['total_volume_lbs'] == 3240
assert payload[0]['exercises'][0]['exercise_name'] == 'Shoulder Press'
assert payload[0]['exercises'][0]['sets'][2] == {'reps': 8, 'weight_lbs': 90.0}
assert payload[0]['exercises'][1]['exercise_name'] == 'Upright Row'
def test_seeded_routine_log_uses_numeric_target_reps_when_reps_left_blank(tmp_path: Path) -> None:
client = make_client(tmp_path)
summary = client.get('/api/dashboard/summary').json()
shoulders = next(routine for routine in summary['routines'] if routine['name'] == 'Thursday: Shoulders')
response = client.post(
'/workouts/routine-log',
data={
'user_id': 'john',
'routine_id': shoulders['id'],
'performed_at': '2026-05-28T12:00:00',
'routine_name': shoulders['name'],
'notes': 'Use default target reps when left blank.',
'exercise_count': '1',
'exercise_0_name': 'Shoulder Press',
'exercise_0_set_count': '3',
'exercise_0_set_0_reps': '',
'exercise_0_set_0_weight_lbs': '45',
'exercise_0_set_1_reps': '',
'exercise_0_set_1_weight_lbs': '45',
'exercise_0_set_2_reps': '',
'exercise_0_set_2_weight_lbs': '45',
},
follow_redirects=False,
)
assert response.status_code == 303
assert response.headers['location'] == '/'
workout_history = client.get('/api/history/john/workouts')
assert workout_history.status_code == 200
payload = workout_history.json()
assert payload[0]['routine_name'] == 'Thursday: Shoulders'
assert payload[0]['exercises'][0]['sets'] == [
{'reps': 10, 'weight_lbs': 45.0},
{'reps': 10, 'weight_lbs': 45.0},
{'reps': 10, 'weight_lbs': 45.0},
]
def test_seeded_routine_log_allows_band_exercises_without_weight(tmp_path: Path) -> None:
client = make_client(tmp_path)
summary = client.get('/api/dashboard/summary').json()
shoulders = next(routine for routine in summary['routines'] if routine['name'] == 'Thursday: Shoulders')
response = client.post(
'/workouts/routine-log',
data={
'user_id': 'john',
'routine_id': shoulders['id'],
'performed_at': '2026-05-28T12:05:00',
'routine_name': shoulders['name'],
'notes': 'Band work without tracked weight.',
'exercise_count': '1',
'exercise_0_name': 'Resistance Band Punch-Out Training',
'exercise_0_set_count': '3',
'exercise_0_set_0_reps': '',
'exercise_0_set_0_weight_lbs': '',
'exercise_0_set_1_reps': '',
'exercise_0_set_1_weight_lbs': '',
'exercise_0_set_2_reps': '',
'exercise_0_set_2_weight_lbs': '',
},
follow_redirects=False,
)
assert response.status_code == 303
assert response.headers['location'] == '/'
workout_history = client.get('/api/history/john/workouts')
assert workout_history.status_code == 200
payload = workout_history.json()
assert payload[0]['routine_name'] == 'Thursday: Shoulders'
assert payload[0]['set_count'] == 3
assert payload[0]['total_volume_lbs'] == 0
assert payload[0]['exercises'][0]['exercise_name'] == 'Resistance Band Punch-Out Training'
assert payload[0]['exercises'][0]['sets'] == [
{'reps': 10, 'weight_lbs': None},
{'reps': 10, 'weight_lbs': None},
{'reps': 10, 'weight_lbs': None},
]
def test_existing_state_gets_seeded_routine_backfill(tmp_path: Path) -> None:
state_path = tmp_path / 'state.json'
state_path.write_text(
json.dumps(
{
'users': {
'john': {
'id': 'john',
'display_name': 'John',
'is_stub': False,
'measurement_system': 'imperial',
'height_inches': 68.5,
'goal_weight_lbs': None,
'notes': 'Legacy state',
},
'manndra': {
'id': 'manndra',
'display_name': 'Manndra',
'is_stub': True,
'measurement_system': 'imperial',
'height_inches': None,
'goal_weight_lbs': None,
'notes': 'Legacy stub',
},
},
'exercise_templates': [],
'routines': [],
'weight_entries': [],
'measurement_entries': [],
'workout_sessions': [],
}
)
)
client = make_client(tmp_path)
payload = client.get('/api/dashboard/summary').json()
assert payload['counts']['exercise_templates'] == 31
assert payload['counts']['routines'] == 5
assert payload['routines'][0]['name'] == 'Monday: Chest'
assert payload['routines'][0]['exercises'][0]['target_sets'] == 3
assert payload['routines'][0]['exercises'][0]['target_reps'] == '10'
def test_existing_seeded_routines_upgrade_prescriptions_to_three_by_ten(tmp_path: Path) -> None:
state_path = tmp_path / 'state.json'
state_path.write_text(
json.dumps(
{
'users': {
'john': {
'id': 'john',
'display_name': 'John',
'is_stub': False,
'measurement_system': 'imperial',
'height_inches': 68.5,
'goal_weight_lbs': None,
'notes': 'Legacy state',
},
'manndra': {
'id': 'manndra',
'display_name': 'Manndra',
'is_stub': True,
'measurement_system': 'imperial',
'height_inches': None,
'goal_weight_lbs': None,
'notes': 'Legacy stub',
},
},
'exercise_templates': [],
'routines': [
{
'id': 'seed-routine-01',
'user_id': 'john',
'name': 'Monday: Chest',
'notes': 'Imported from John\'s current routine. Set and rep targets were not provided yet.',
'exercises': [
{'exercise_name': 'Bench Press', 'target_sets': 1, 'target_reps': 'TBD'},
{'exercise_name': 'Incline Bench Press', 'target_sets': 1, 'target_reps': 'TBD'},
],
}
],
'weight_entries': [],
'measurement_entries': [],
'workout_sessions': [],
}
)
)
client = make_client(tmp_path)
payload = client.get('/api/dashboard/summary').json()
monday = next(routine for routine in payload['routines'] if routine['name'] == 'Monday: Chest')
assert monday['exercises'][0]['target_sets'] == 3
assert monday['exercises'][0]['target_reps'] == '10'
assert 'Weights are still TBD' in monday['notes']
def test_existing_seeded_shoulders_routine_gets_777_and_punch_out_corrections(tmp_path: Path) -> None:
state_path = tmp_path / 'state.json'
state_path.write_text(
json.dumps(
{
'users': {
'john': {
'id': 'john',
'display_name': 'John',
'is_stub': False,
'measurement_system': 'imperial',
'height_inches': 68.5,
'goal_weight_lbs': None,
'notes': 'Legacy state',
},
'manndra': {
'id': 'manndra',
'display_name': 'Manndra',
'is_stub': True,
'measurement_system': 'imperial',
'height_inches': None,
'goal_weight_lbs': None,
'notes': 'Legacy stub',
},
},
'exercise_templates': [
{
'id': 'seed-template-25',
'name': 'Resistance Band Punch-Out Holds',
'primary_muscle': 'abs',
'equipment': 'band',
'notes': None,
}
],
'routines': [
{
'id': 'seed-routine-04',
'user_id': 'john',
'name': 'Thursday: Shoulders',
'notes': "Imported from John's current routine. Default prescription is 3 sets of 10 reps. Weights are still TBD. Includes resistance band punch-out ab holds.",
'exercises': [
{'exercise_name': 'Shoulder Press', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Arnold Press', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Side Lateral Raise', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Front Lateral Raise', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Upright Row', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Shoulder Shrugs', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': '7-7-7 Shoulder Press', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Resistance Band Punch-Out Holds', 'target_sets': 3, 'target_reps': '10'},
],
}
],
'weight_entries': [],
'measurement_entries': [],
'workout_sessions': [],
}
)
)
client = make_client(tmp_path)
payload = client.get('/api/dashboard/summary').json()
shoulders = next(routine for routine in payload['routines'] if routine['name'] == 'Thursday: Shoulders')
assert shoulders['exercises'][6] == {
'exercise_name': '7-7-7 Shoulder Press',
'target_sets': 1,
'target_reps': '7-7-7',
}
assert shoulders['exercises'][7] == {
'exercise_name': 'Resistance Band Punch-Out Training',
'target_sets': 3,
'target_reps': '10',
}
assert 'resistance band punch-out training' in shoulders['notes']
def test_weight_form_submission_redirects_and_updates_dashboard(tmp_path: Path) -> None: def test_weight_form_submission_redirects_and_updates_dashboard(tmp_path: Path) -> None:
@@ -404,8 +884,8 @@ def test_template_and_routine_form_submissions_redirect_and_render_entries(tmp_p
page = client.get('/') page = client.get('/')
assert page.status_code == 200 assert page.status_code == 200
assert 'Leg Press' in page.text assert 'Leg Press' in page.text
assert 'Leg Day Draft' in page.text
routines = client.get('/api/routines').json() routines = client.get('/api/routines').json()
assert routines[0]['exercises'][0]['exercise_name'] == 'Leg Press' saved_routine = next(routine for routine in routines if routine['name'] == 'Leg Day Draft')
assert routines[0]['exercises'][1]['notes'] == 'Slow negative' assert saved_routine['exercises'][0]['exercise_name'] == 'Leg Press'
assert saved_routine['exercises'][1]['notes'] == 'Slow negative'

View File

@@ -0,0 +1,116 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def read(relative_path: str) -> str:
return (ROOT / relative_path).read_text()
def test_family_directory_markers_exist_across_doris_apps() -> None:
expectations = {
'doris-kitchen/app/templates/base.html': [
'doris-family-shell',
'doris-family-nav',
'Singular front door',
'Doris family directory',
'Casefile directory',
'family-directory-grid',
'Doris Dashboard',
'Services Directory',
'Doris Schoolhouse',
'Doris Barbell',
'case-nav',
'Queue board',
],
'doris-schoolhouse/app/templates/base.html': [
'doris-family-shell',
'doris-family-nav',
'Singular front door',
'Doris family directory',
'Casefile directory',
'family-directory-grid',
'Doris Dashboard',
'Services Directory',
'Doris Kitchen',
'Doris Barbell',
'case-nav',
'Submission packet',
],
'doris-barbell/app/templates/base.html': [
'doris-family-shell',
'doris-family-nav',
'Singular front door',
'Doris family directory',
'Casefile directory',
'family-directory-grid',
'Doris Dashboard',
'Services Directory',
'Doris Kitchen',
'Doris Schoolhouse',
'case-nav',
'Workout reports',
],
'doris-dashboard/bin/generate_dashboard.py': [
'render_family_directory',
'Doris family directory',
'Services Directory',
'Doris Kitchen',
'Doris Schoolhouse',
'Doris Barbell',
'family-directory-grid',
],
}
for relative_path, markers in expectations.items():
content = read(relative_path)
for marker in markers:
assert marker in content, f'{marker} missing from {relative_path}'
def test_family_navigation_styles_exist() -> None:
expectations = {
'doris-kitchen/app/static/app.css': ['.doris-family-shell .case-nav', '.family-directory-grid', '.family-app-card', '.family-app-kicker'],
'doris-schoolhouse/app/static/app.css': ['.doris-family-shell .case-nav', '.family-directory-grid', '.family-app-card', '.family-app-kicker'],
'doris-barbell/app/static/app.css': ['.doris-family-shell .case-nav', '.family-directory-grid', '.family-app-card', '.family-app-kicker'],
'doris-dashboard/public/style.css': ['.family-portal', '.family-directory-grid', '.family-app-card', '.family-app-kicker'],
}
for relative_path, markers in expectations.items():
content = read(relative_path)
for marker in markers:
assert marker in content, f'{marker} missing from {relative_path}'
def test_hotfuzz_photo_assets_exist_and_are_wired() -> None:
assets = {
'doris-dashboard/public/assets/hotfuzz-operator-desk.jpg': 'Operator still · source logged in docs',
'doris-barbell/app/static/images/hotfuzz-training-dossier.jpg': 'Training still · source logged in docs',
'doris-kitchen/app/static/images/hotfuzz-pantry-evidence.jpg': 'Pantry still · source logged in docs',
'doris-schoolhouse/app/static/images/hotfuzz-records-desk.jpg': 'Records still · source logged in docs',
}
for relative_path, marker in assets.items():
assert (ROOT / relative_path).exists(), f'missing asset file: {relative_path}'
if relative_path.startswith('doris-dashboard/'):
content = read('doris-dashboard/bin/generate_dashboard.py')
elif 'barbell' in relative_path:
content = read('doris-barbell/app/templates/base.html')
elif 'kitchen' in relative_path:
content = read('doris-kitchen/app/templates/base.html')
else:
content = read('doris-schoolhouse/app/templates/base.html')
assert Path(relative_path).name in content, f'{relative_path} not wired into template/generator'
assert marker in content, f'{marker} missing where {relative_path} is referenced'
provenance_doc = ROOT / 'doris-family-hotfuzz-graphics-sources-2026-05-24.md'
assert provenance_doc.exists(), 'graphics provenance doc missing'
provenance_text = provenance_doc.read_text()
for marker in ['CC BY-SA', 'CC BY-NC-SA', 'CC BY-NC-ND', 'CC BY-NC']:
assert marker in provenance_text, f'{marker} missing from provenance doc'
def test_barbell_interior_anchor_markers_exist() -> None:
content = read('doris-barbell/app/templates/dashboard.html')
for marker in ['id="body-metrics"', 'id="weight-log"', 'id="routine-board"', 'id="recent-workouts"']:
assert marker in content, f'{marker} missing from Doris Barbell dashboard'

View File

@@ -73,10 +73,10 @@ SERVICE_DIRECTORY = [
{'name': 'OpenWebUI', 'url': 'https://openwebui.paccoco.com', 'health_url': 'http://10.5.30.6:8282', 'purpose': 'Chat UI for the lab models, docs, and retrieval workflows.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'PD', 'port': '8282'}, {'name': 'OpenWebUI', 'url': 'https://openwebui.paccoco.com', 'health_url': 'http://10.5.30.6:8282', 'purpose': 'Chat UI for the lab models, docs, and retrieval workflows.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'PD', 'port': '8282'},
{'name': 'Qdrant', 'url': 'http://10.5.30.6:6333/dashboard', 'health_url': 'http://10.5.30.6:6333/healthz', 'purpose': 'Vector store for Project NOMAD and local retrieval systems.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '6333/6334'}, {'name': 'Qdrant', 'url': 'http://10.5.30.6:6333/dashboard', 'health_url': 'http://10.5.30.6:6333/healthz', 'purpose': 'Vector store for Project NOMAD and local retrieval systems.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '6333/6334'},
{'name': 'Whisper (CPU)', 'url': 'http://10.5.30.7:8786/docs', 'health_url': 'http://10.5.30.7:8786/health', 'purpose': 'Fast local CPU transcription endpoint on NOMAD.', 'exposure': 'LAN', 'state': 'ok', 'host': 'NOMAD', 'port': '8786'}, {'name': 'Whisper (CPU)', 'url': 'http://10.5.30.7:8786/docs', 'health_url': 'http://10.5.30.7:8786/health', 'purpose': 'Fast local CPU transcription endpoint on NOMAD.', 'exposure': 'LAN', 'state': 'ok', 'host': 'NOMAD', 'port': '8786'},
{'name': 'Whisper (CUDA)', 'url': 'http://10.5.1.112:8787/docs', 'health_url': 'http://10.5.1.112:8787/health', 'purpose': 'Heavy GPU transcription endpoint on Rocinante.', 'exposure': 'LAN', 'state': 'ok', 'host': 'ROCINANTE', 'port': '8787'}, {'name': 'Whisper (CUDA)', 'url': 'http://10.5.30.112:8787/docs', 'health_url': 'http://10.5.30.112:8787/health', 'purpose': 'Heavy GPU transcription endpoint on Rocinante.', 'exposure': 'LAN', 'state': 'ok', 'host': 'ROCINANTE', 'port': '8787'},
{'name': 'SearXNG', 'url': 'http://10.5.30.6:8888', 'health_url': 'http://10.5.30.6:8888', 'purpose': 'Private metasearch box for research and agent-side browsing.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '8888'}, {'name': 'SearXNG', 'url': 'http://10.5.30.6:8888', 'health_url': 'http://10.5.30.6:8888', 'purpose': 'Private metasearch box for research and agent-side browsing.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '8888'},
{'name': 'Ollama — light tier', 'url': 'http://10.5.30.6:11434/api/tags', 'health_url': 'http://10.5.30.6:11434/api/tags', 'purpose': 'PD light-tier Ollama runtime backing cheaper/default model paths.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '11434'}, {'name': 'Ollama — light tier', 'url': 'http://10.5.30.6:11434/api/tags', 'health_url': 'http://10.5.30.6:11434/api/tags', 'purpose': 'PD light-tier Ollama runtime backing cheaper/default model paths.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '11434'},
{'name': 'Ollama — heavy tier', 'url': 'http://10.5.1.112:11434/api/tags', 'health_url': 'http://10.5.1.112:11434/api/tags', 'purpose': 'Rocinante heavy-tier Ollama runtime for larger model requests.', 'exposure': 'LAN', 'state': 'ok', 'host': 'ROCINANTE', 'port': '11434'}, {'name': 'Ollama — heavy tier', 'url': 'http://10.5.30.112:11434/api/tags', 'health_url': 'http://10.5.30.112:11434/api/tags', 'purpose': 'Rocinante heavy-tier Ollama runtime for larger model requests.', 'exposure': 'LAN', 'state': 'ok', 'host': 'ROCINANTE', 'port': '11434'},
{'name': 'Reranker (TEI)', 'url': 'http://10.5.30.5:9787', 'health_url': 'http://10.5.30.5:9787', 'purpose': 'Serenity-hosted reranker used by retrieval flows.', 'exposure': 'LAN', 'state': 'ok', 'host': 'SERENITY', 'port': '9787'}, {'name': 'Reranker (TEI)', 'url': 'http://10.5.30.5:9787', 'health_url': 'http://10.5.30.5:9787', 'purpose': 'Serenity-hosted reranker used by retrieval flows.', 'exposure': 'LAN', 'state': 'ok', 'host': 'SERENITY', 'port': '9787'},
], ],
}, },
@@ -112,9 +112,6 @@ SERVICE_DIRECTORY = [
'items': [ 'items': [
{'name': 'Home Assistant', 'url': '', 'purpose': 'Smart-home control hub documented on PD, but Doris does not yet have a stable operator-safe route.', 'exposure': 'PRIVATE', 'state': 'attention', 'host': 'PD', 'port': '8123', 'probe': False, 'badge': 'Manual', 'detail': 'documented active on PD; local port currently refuses from NOMAD, so keep this manual until routed cleanly'}, {'name': 'Home Assistant', 'url': '', 'purpose': 'Smart-home control hub documented on PD, but Doris does not yet have a stable operator-safe route.', 'exposure': 'PRIVATE', 'state': 'attention', 'host': 'PD', 'port': '8123', 'probe': False, 'badge': 'Manual', 'detail': 'documented active on PD; local port currently refuses from NOMAD, so keep this manual until routed cleanly'},
{'name': 'Kima Hub', 'url': 'http://10.5.30.6:3333', 'health_url': 'http://10.5.30.6:3333', 'purpose': 'Custom IoT gateway and smart-home integration surface.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '3333'}, {'name': 'Kima Hub', 'url': 'http://10.5.30.6:3333', 'health_url': 'http://10.5.30.6:3333', 'purpose': 'Custom IoT gateway and smart-home integration surface.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '3333'},
{'name': 'Pi-hole VIP', 'url': 'http://10.5.30.53/ui/', 'health_url': 'http://10.5.30.53/admin/', 'purpose': 'Floating DNS/admin surface for the HA Pi-hole pair your clients should target.', 'exposure': 'LAN', 'state': 'ok', 'host': 'VIP', 'port': '53 / 80'},
{'name': 'Pi-hole PD', 'url': 'http://10.5.30.6/ui/', 'health_url': 'http://10.5.30.6/admin/', 'purpose': 'Primary PD Pi-hole node behind the floating DNS VIP.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '53 / 80'},
{'name': 'Pi-hole NOMAD', 'url': 'https://10.5.30.7/login', 'purpose': 'NOMAD backup Pi-hole replica for the HA DNS pair.', 'exposure': 'LAN', 'state': 'attention', 'host': 'NOMAD', 'port': '53 / 80/443', 'probe': False, 'badge': 'Manual', 'detail': 'documented active; local admin currently presents a self-signed TLS flow from NOMAD so Doris leaves this manual'},
{'name': 'Authelia', 'url': 'http://10.5.30.6:9091', 'health_url': 'http://10.5.30.6:9091', 'purpose': 'Identity and auth gate backing protected public routes.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '9091'}, {'name': 'Authelia', 'url': 'http://10.5.30.6:9091', 'health_url': 'http://10.5.30.6:9091', 'purpose': 'Identity and auth gate backing protected public routes.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '9091'},
], ],
}, },
@@ -2020,12 +2017,14 @@ def top_nav(active:str)->str:
items=[ items=[
('Home','index.html','home'), ('Home','index.html','home'),
('Services','services.html','services'), ('Services','services.html','services'),
('Kitchen','http://10.5.30.7:8092','kitchen'),
('Schoolhouse','https://schoolhouse.paccoco.com','schoolhouse'),
('Barbell','https://gym.paccoco.com','barbell'),
('Donetick','https://donetick.paccoco.com','donetick'), ('Donetick','https://donetick.paccoco.com','donetick'),
('Paperless','https://paperless.paccoco.com','paperless'), ('Paperless','https://paperless.paccoco.com','paperless'),
('n8n','https://n8n.paccoco.com','n8n'), ('n8n','https://n8n.paccoco.com','n8n'),
('Grafana','https://grafana.paccoco.com','grafana'), ('Grafana','https://grafana.paccoco.com','grafana'),
('Gitea','https://gitea.paccoco.com','gitea'), ('Gitea','https://gitea.paccoco.com','gitea'),
('Schoolhouse','https://schoolhouse.paccoco.com','schoolhouse'),
] ]
links=[] links=[]
for label,url,key in items: for label,url,key in items:
@@ -2043,7 +2042,30 @@ def top_nav(active:str)->str:
def hot_fuzz_art(label:str)->str: def hot_fuzz_art(label:str)->str:
safe_label=esc(label.upper()) safe_label=esc(label.upper())
return f"""<div class='hot-fuzz-art' aria-hidden='true'><div class='film-grain'></div><div class='cinematic-glow'></div><div class='casefile-stamp operator-evidence-tag'>Operator Evidence Board</div><svg viewBox='0 0 320 220' class='poster-illustration' role='presentation'><defs><linearGradient id='dashboard-sunset' x1='0%' y1='0%' x2='100%' y2='100%'><stop offset='0%' stop-color='#f2c14e'></stop><stop offset='50%' stop-color='#d53600'></stop><stop offset='100%' stop-color='#910f3f'></stop></linearGradient></defs><rect x='8' y='8' width='304' height='204' rx='24' class='poster-frame'></rect><circle cx='228' cy='72' r='58' class='poster-halo'></circle><path d='M18 170 L112 100 L134 124 L40 198 Z' class='poster-tape tape-left'></path><path d='M202 82 L302 32 L314 60 L214 112 Z' class='poster-tape tape-right'></path><path d='M112 180 C110 150 116 126 132 110 C140 100 148 94 154 90 C160 94 168 100 176 110 C192 126 198 150 196 180 Z' class='constable-silhouette lead'></path><path d='M174 184 C172 154 178 132 194 118 C202 110 210 104 218 100 C226 104 234 110 242 118 C258 132 264 154 262 184 Z' class='constable-silhouette partner'></path><circle cx='84' cy='60' r='28' class='swan-stamp'></circle><path d='M76 64 C80 54 91 48 101 53 C93 52 88 57 89 63 C90 69 101 68 103 76 C94 78 83 76 78 69 L71 76 L66 71 L74 64 Z' class='swan-mark'></path><path d='M126 82 h68 v14 h-68z' class='dossier-strip'></path><path d='M126 104 h82 v10 h-82z' class='dossier-strip'></path><path d='M126 122 h54 v10 h-54z' class='dossier-strip'></path><text x='160' y='204' class='poster-callout'>{safe_label}</text></svg></div>""" return f"""<div class='art-stack'><div class='hot-fuzz-art' aria-hidden='true'><div class='film-grain'></div><div class='cinematic-glow'></div><div class='casefile-stamp operator-evidence-tag'>Operator Evidence Board</div><svg viewBox='0 0 320 220' class='poster-illustration' role='presentation'><defs><linearGradient id='dashboard-sunset' x1='0%' y1='0%' x2='100%' y2='100%'><stop offset='0%' stop-color='#f2c14e'></stop><stop offset='50%' stop-color='#d53600'></stop><stop offset='100%' stop-color='#910f3f'></stop></linearGradient></defs><rect x='8' y='8' width='304' height='204' rx='24' class='poster-frame'></rect><circle cx='236' cy='64' r='54' class='poster-halo'></circle><path d='M18 170 L112 100 L134 124 L40 198 Z' class='poster-tape tape-left'></path><path d='M202 82 L302 32 L314 60 L214 112 Z' class='poster-tape tape-right'></path><path d='M112 182 C109 148 116 124 134 108 C143 100 150 94 155 90 C162 94 170 100 178 108 C196 124 202 148 199 182 Z' class='constable-silhouette lead'></path><path d='M176 184 C174 154 180 132 196 116 C204 108 212 102 220 98 C229 102 236 108 244 116 C260 132 266 154 264 184 Z' class='constable-silhouette partner'></path><circle cx='72' cy='58' r='26' class='swan-stamp'></circle><path d='M64 62 C68 52 79 46 89 51 C81 50 76 55 77 61 C78 67 89 66 91 74 C82 76 71 74 66 67 L59 74 L54 69 L62 62 Z' class='swan-mark'></path><path d='M28 166 L94 124 L98 130 L32 172 Z' class='village-lane'></path><path d='M98 130 L136 150 L132 158 L88 136 Z' class='village-lane'></path><rect x='30' y='112' width='62' height='34' rx='6' class='evidence-card'></rect><path d='M38 120 h46 M38 128 h36 M38 136 h28' class='evidence-lines'></path><circle cx='120' cy='84' r='9' class='marker-dot'></circle><circle cx='138' cy='74' r='7' class='marker-dot marker-dot-alt'></circle><path d='M116 88 L104 116 L86 138' class='map-thread'></path><path d='M136 78 L160 96 L186 92' class='map-thread'></path><rect x='190' y='114' width='54' height='18' rx='6' class='radio-body'></rect><rect x='214' y='98' width='10' height='18' rx='4' class='radio-antenna'></rect><circle cx='238' cy='122' r='4' class='radio-knob'></circle><path d='M198 94 C214 86 232 86 246 94' class='beacon-arc'></path><path d='M202 86 C218 76 238 76 252 86' class='beacon-arc faint'></path><path d='M246 150 L272 130 L292 146 L292 184 L246 184 Z' class='church-block'></path><rect x='262' y='108' width='10' height='32' class='church-spire'></rect><path d='M252 150 H286 M258 160 H282' class='window-line'></path><text x='160' y='204' class='poster-callout'>{safe_label}</text></svg></div><div class='evidence-contact-grid'><section class='evidence-note-card case-contact-sheet' aria-label='Operator caseboard details'><div class='case-contact-top'><span class='case-contact-kicker'>Sandford caseboard</span><span class='case-contact-badge'>Neighbourhood Watch</span></div><svg viewBox='0 0 320 160' class='contact-sheet-illustration' role='presentation'><rect x='10' y='14' width='116' height='132' rx='16' class='contact-frame'></rect><rect x='22' y='30' width='92' height='56' rx='12' class='contact-photo'></rect><path d='M28 96 h80 M28 108 h68 M28 120 h50' class='contact-lines'></path><rect x='146' y='22' width='76' height='48' rx='10' class='contact-photo alt'></rect><rect x='154' y='84' width='108' height='52' rx='12' class='contact-photo alt'></rect><circle cx='254' cy='36' r='16' class='contact-marker'></circle><path d='M136 94 C154 82 174 80 190 88 C206 96 224 100 246 96' class='contact-thread'></path><path d='M126 42 C148 40 164 48 176 62' class='contact-thread faint'></path><text x='68' y='62' class='contact-label'>SWAN</text><text x='184' y='50' class='contact-label'>PUB</text><text x='208' y='114' class='contact-label'>SEA MINE</text></svg><ul class='case-contact-list'><li>Village-map pins and pub callouts keep the portal reading like a British police case wall.</li><li>Emergency arcs, spire silhouette, and evidence slips push the Hot Fuzz vibe ahead of the old stock-desk still.</li></ul></section><figure class='photo-evidence-card hero-photo-card supporting-photo-card'><img src='assets/hotfuzz-operator-desk.jpg' alt='Operator desk photo with corkboard, papers, and desktop gear.' loading='eager'><figcaption>Operator still · source logged in docs</figcaption></figure></div></div>"""
def render_family_directory(active:str)->str:
entries=[
('Doris Dashboard','index.html','dashboard','Operator portal','Canonical front door for briefings, alerts, and launch control.'),
('Services Directory','services.html','services','Switchboard','Dense launch wall for the wider stack once you know where you need to go.'),
('Doris Kitchen','http://10.5.30.7:8092','kitchen','Pantry desk','Recipe leads, import repair, and meal-planning evidence review.'),
('Doris Schoolhouse','https://schoolhouse.paccoco.com','schoolhouse','Records desk','Assignments, recordings, D2L sync, and archive intake.'),
('Doris Barbell','https://gym.paccoco.com','barbell','Training desk','Body metrics, routines, workouts, and progression dossiers.'),
]
cards=[]
active_key='dashboard' if active=='home' else active
for title,url,key,kicker,summary in entries:
classes=['family-app-card']
if key==active_key:
classes.append('current-desk')
kicker='Current desk'
attrs=[]
if url.startswith('http'):
attrs.append("target='_blank'")
attrs.append("rel='noreferrer'")
cards.append(f"<a class='{' '.join(classes)}' href='{esc(url)}' {' '.join(attrs)}><span class='family-app-kicker'>{esc(kicker)}</span><strong>{esc(title)}</strong><small>{esc(summary)}</small></a>")
return f"<section class='family-portal evidence-board casefile-directory'><div class='section-heading-inline case-legend'><div><p class='eyebrow'>Singular front door</p><h2>Doris family directory</h2></div><span class='muted'>One shell, separate runtimes.</span></div><div class='family-directory-grid'>{''.join(cards)}</div></section>"
def build_services_inventory()->tuple[list[dict[str,Any]],list[dict[str,Any]]]: def build_services_inventory()->tuple[list[dict[str,Any]],list[dict[str,Any]]]:
@@ -2119,8 +2141,9 @@ def render_services_page(hero_summary:str,candidate_count:int,source_count:int,h
groups_data, flat_items=build_services_inventory() groups_data, flat_items=build_services_inventory()
services_html=build_services_directory(groups_data) services_html=build_services_directory(groups_data)
inventory_html=render_services_inventory(flat_items) inventory_html=render_services_inventory(flat_items)
family_html=render_family_directory('services')
utility_html=collapsible_section('Portal notes','Keep Doris as the front door, not a monolith.',"<section class='summary-grid'><article class='summary-card'><h3>How to use this</h3><p>Use Home for today, alerts, and the briefing. Use Services when you want the rest of the stack quickly without hunting bookmarks.</p></article><article class='summary-card'><h3>Design rule</h3><p>Shared navigation and status live here; full workflows still stay in their own apps.</p></article></section>",'portal-notes',open_by_default=True)+collapsible_section('Inventory','Coverage, access model, and host distribution.',inventory_html,'services-inventory',open_by_default=True) utility_html=collapsible_section('Portal notes','Keep Doris as the front door, not a monolith.',"<section class='summary-grid'><article class='summary-card'><h3>How to use this</h3><p>Use Home for today, alerts, and the briefing. Use Services when you want the rest of the stack quickly without hunting bookmarks.</p></article><article class='summary-card'><h3>Design rule</h3><p>Shared navigation and status live here; full workflows still stay in their own apps.</p></article></section>",'portal-notes',open_by_default=True)+collapsible_section('Inventory','Coverage, access model, and host distribution.',inventory_html,'services-inventory',open_by_default=True)
return f"<!doctype html><html lang='en'><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>Doris Services</title><link rel='stylesheet' href='style.css'></head><body><div class='incident-tape' aria-hidden='true'></div><main>{top_nav('services')}<header class='hero casefile-header evidence-board'><div><p class='eyebrow'>Sandford services desk</p><div class='case-legend'><h1>Services Directory</h1><span class='hero-chip'>Operator board</span></div><p class='sub'>Shared navigation across your sites, with Doris still acting as the operational front door. {esc(hero_summary)}</p>{hot_fuzz_art('services')}<div class='brand-badges'><span class='pill badge-hotfuzz for-the-greater-good'>For the Greater Good</span><span class='pill'>Evidence locker: services</span></div></div><div class='status'><div><strong>{esc(now.strftime('%a %b %-d, %-I:%M %p'))}</strong><span>Central time</span></div><div><strong>{candidate_count}</strong><span>News candidates</span></div><div><strong>{source_count}</strong><span>Sources</span></div><div><strong>{esc(hero_tokens)}</strong><span>30-day Hermes tokens</span></div></div></header><div class='dashboard-shell services-shell dossier-grid'><section class='primary-column'>{services_html}</section><aside class='secondary-column'>{utility_html}</aside></div><footer><p>Operator portal links are curated from the homelab docs and current live dashboard context.</p><p>Local-only on NOMAD unless John says otherwise.</p></footer></main></body></html>" return f"<!doctype html><html lang='en'><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>Doris Services</title><link rel='stylesheet' href='style.css'></head><body><div class='incident-tape' aria-hidden='true'></div><main>{top_nav('services')}<header class='hero casefile-header evidence-board'><div><p class='eyebrow'>Sandford services desk</p><div class='case-legend'><h1>Services Directory</h1><span class='hero-chip'>Operator board</span></div><p class='sub'>Shared navigation across your sites, with Doris still acting as the operational front door. {esc(hero_summary)}</p>{hot_fuzz_art('services')}<div class='brand-badges'><span class='pill badge-hotfuzz for-the-greater-good'>For the Greater Good</span><span class='pill'>Evidence locker: services</span></div></div><div class='status'><div><strong>{esc(now.strftime('%a %b %-d, %-I:%M %p'))}</strong><span>Central time</span></div><div><strong>{candidate_count}</strong><span>News candidates</span></div><div><strong>{source_count}</strong><span>Sources</span></div><div><strong>{esc(hero_tokens)}</strong><span>30-day Hermes tokens</span></div></div></header>{family_html}<div class='dashboard-shell services-shell dossier-grid'><section class='primary-column'>{services_html}</section><aside class='secondary-column'>{utility_html}</aside></div><footer><p>Operator portal links are curated from the homelab docs and current live dashboard context.</p><p>Local-only on NOMAD unless John says otherwise.</p></footer></main></body></html>"
def render()->str: def render()->str:
candidates=load_json(DIGEST_ROOT/'data/candidates.json',[]); prefs=load_json(DIGEST_ROOT/'data/preferences.json',{}) candidates=load_json(DIGEST_ROOT/'data/candidates.json',[]); prefs=load_json(DIGEST_ROOT/'data/preferences.json',{})
@@ -2154,6 +2177,7 @@ def render()->str:
system_html=build_system_section(system) system_html=build_system_section(system)
hero_tokens=compact_number(hermes.get('total_tokens',0)) if hermes.get('ok') else '' hero_tokens=compact_number(hermes.get('total_tokens',0)) if hermes.get('ok') else ''
snapshot_html=build_operator_snapshot(todos,pulse,paperless,hermes) snapshot_html=build_operator_snapshot(todos,pulse,paperless,hermes)
family_html=render_family_directory('home')
hero_caseboard_html=build_hero_caseboard(feature_item,todos,paperless) hero_caseboard_html=build_hero_caseboard(feature_item,todos,paperless)
feature_html=build_feature_story(secondary_feature_item) feature_html=build_feature_story(secondary_feature_item)
session_window=find_usage_window(hermes.get('account_windows',[]),'session') if hermes.get('ok') else None session_window=find_usage_window(hermes.get('account_windows',[]),'session') if hermes.get('ok') else None
@@ -2180,7 +2204,7 @@ def render()->str:
return f"""<!doctype html><html lang='en'><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><meta http-equiv='refresh' content='1800'><title>Doris Dashboard</title><link rel='stylesheet' href='style.css'></head><body data-donetick-focus-signature='{esc(focus_signature)}'><div class='incident-tape' aria-hidden='true'></div><main>{top_nav('home')} return f"""<!doctype html><html lang='en'><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><meta http-equiv='refresh' content='1800'><title>Doris Dashboard</title><link rel='stylesheet' href='style.css'></head><body data-donetick-focus-signature='{esc(focus_signature)}'><div class='incident-tape' aria-hidden='true'></div><main>{top_nav('home')}
<header class='hero casefile-header evidence-board'><div class='hero-main'><p class='eyebrow'>Sandford operator desk</p><div class='case-legend'><h1>Doris Dashboard</h1><span class='hero-chip'>Operator board</span></div><div class='hero-title-row'><div class='hero-chips'><span class='hero-chip'>Hermes {esc((hermes.get('models') or [{}])[0].get('model') or hermes.get('default_model') or 'unknown')}</span><span class='hero-chip'>Session {esc(pct_text((session_window or {}).get('remaining_percent')) if hermes.get('ok') else '')}</span><span class='hero-chip'>24h {esc(compact_number(hermes.get('last_24h_tokens',0)) if hermes.get('ok') else '')} tokens</span></div></div><p class='sub'>{esc(hero_summary)}</p>{hero_caseboard_html}</div><div class='hero-rail'><div class='status'><div><strong>{esc(now.strftime('%a %b %-d, %-I:%M %p'))}</strong><span>Central time · refreshes every 30m</span></div><div><strong>{len(candidates)}</strong><span>Candidates</span></div><div><strong>{len(set(i.get('source','') for i in candidates))}</strong><span>Sources</span></div><div><strong>{esc(hero_tokens)}</strong><span>30-day Hermes tokens</span></div></div></div></header> <header class='hero casefile-header evidence-board'><div class='hero-main'><p class='eyebrow'>Sandford operator desk</p><div class='case-legend'><h1>Doris Dashboard</h1><span class='hero-chip'>Operator board</span></div><div class='hero-title-row'><div class='hero-chips'><span class='hero-chip'>Hermes {esc((hermes.get('models') or [{}])[0].get('model') or hermes.get('default_model') or 'unknown')}</span><span class='hero-chip'>Session {esc(pct_text((session_window or {}).get('remaining_percent')) if hermes.get('ok') else '')}</span><span class='hero-chip'>24h {esc(compact_number(hermes.get('last_24h_tokens',0)) if hermes.get('ok') else '')} tokens</span></div></div><p class='sub'>{esc(hero_summary)}</p>{hero_caseboard_html}</div><div class='hero-rail'><div class='status'><div><strong>{esc(now.strftime('%a %b %-d, %-I:%M %p'))}</strong><span>Central time · refreshes every 30m</span></div><div><strong>{len(candidates)}</strong><span>Candidates</span></div><div><strong>{len(set(i.get('source','') for i in candidates))}</strong><span>Sources</span></div><div><strong>{esc(hero_tokens)}</strong><span>30-day Hermes tokens</span></div></div></div></header>
<section class='snapshot-block evidence-board'><div class='section-heading-inline case-legend'><div><p class='eyebrow'>Today's operator snapshot</p><h2>What matters before the scroll</h2></div><span class='muted'>Glanceable heat across chores, Hermes, and the lab.</span></div>{snapshot_html}</section> <section class='snapshot-block evidence-board'><div class='section-heading-inline case-legend'><div><p class='eyebrow'>Today's operator snapshot</p><h2>What matters before the scroll</h2></div><span class='muted'>Glanceable heat across chores, Hermes, and the lab.</span></div>{snapshot_html}</section>
<div class='dashboard-shell dossier-grid'><section class='primary-column'>{primary_html}</section><aside class='secondary-column'>{secondary_html}</aside></div> {family_html}<div class='dashboard-shell dossier-grid'><section class='primary-column'>{primary_html}</section><aside class='secondary-column'>{secondary_html}</aside></div>
<footer><p>Category mix: {esc(', '.join(f'{k}: {v}' for k,v in counts.most_common(10)))}</p><p>Local-only on NOMAD unless John says otherwise. Weather from Open-Meteo; moon phase calculated locally. Hermes stats come from ~/.hermes/state.db.</p></footer></main>{feedback_bootstrap()}</body></html>""" <footer><p>Category mix: {esc(', '.join(f'{k}: {v}' for k,v in counts.most_common(10)))}</p><p>Local-only on NOMAD unless John says otherwise. Weather from Open-Meteo; moon phase calculated locally. Hermes stats come from ~/.hermes/state.db.</p></footer></main>{feedback_bootstrap()}</body></html>"""
def main()->int: def main()->int:

View File

@@ -0,0 +1,41 @@
# Baselines Directory Policy
This directory is for **sanitized operator artifacts only**.
## What belongs here
- markdown summaries of controller state
- redacted JSON examples that have been intentionally scrubbed
- operator notes that support future change windows
## What does not belong here
- raw UniFi controller exports
- full `networkconf` / `portconf` dumps captured straight from the API
- VPN client/server config exports
- any artifact containing keys, tokens, passwords, cookies, CSRF material, or embedded auth config
## Why this rule exists
A raw UniFi baseline export committed during the 2026-05-22 network redesign documentation pass contained WireGuard private keys and triggered a real secret-leak incident.
See:
- `docs/operations/INCIDENT_2026-05-22_UNIFI_BASELINE_SECRET_LEAK.md`
- `docs/operations/SECRETS_MANAGEMENT.md`
## Safe workflow
1. Capture raw exports outside the main repo or in the encrypted secrets repo.
2. Inspect them for secret-bearing fields.
3. Commit only a markdown summary or an explicitly redacted artifact.
4. Prefer human-readable summaries over raw appliance dumps.
5. Enable the repo guardrails in each clone:
```bash
git config core.hooksPath .githooks
chmod +x .githooks/pre-commit .githooks/pre-push scripts/scan-secret-bearing-artifacts.sh
```
6. Before a risky push or cleanup branch, run the manual checks too:
```bash
scripts/scan-secret-bearing-artifacts.sh --tracked
scripts/scan-secret-bearing-artifacts.sh --git-range origin/main..HEAD
```

View File

@@ -0,0 +1,76 @@
{
"custom_policies": [
{
"_id": "6963d6bb1131084f05461b71",
"action": "ALLOW",
"connection_state_type": "ALL",
"connection_states": [],
"create_allow_respond": true,
"description": "",
"destination": {
"match_opposite_ports": false,
"matching_target": "ANY",
"port_matching_type": "ANY",
"zone_id": "6963d5a91131084f05461a0d"
},
"enabled": true,
"icmp_typename": "ANY",
"icmp_v6_typename": "ANY",
"index": 10000,
"ip_version": "BOTH",
"logging": false,
"match_ip_sec": false,
"match_opposite_protocol": false,
"name": "Allow Internal to Untrusted",
"predefined": false,
"protocol": "all",
"schedule": {
"mode": "ALWAYS"
},
"source": {
"match_opposite_ports": false,
"matching_target": "ANY",
"port_matching_type": "ANY",
"zone_id": "6963d42a1131084f054618df"
}
},
{
"_id": "6a10cbca0ab9980e4eac4553",
"action": "BLOCK",
"connection_state_type": "ALL",
"connection_states": [],
"create_allow_respond": false,
"description": "",
"destination": {
"match_opposite_ports": false,
"matching_target": "ANY",
"port_matching_type": "ANY",
"zone_id": "6963d5a91131084f05461a0d"
},
"enabled": false,
"icmp_typename": "ANY",
"icmp_v6_typename": "ANY",
"index": 10001,
"ip_version": "BOTH",
"logging": false,
"match_ip_sec": false,
"match_opposite_protocol": false,
"name": "DORIS-TEMP",
"predefined": false,
"protocol": "all",
"schedule": {
"date": "2026-05-22",
"mode": "ONE_TIME_ONLY",
"time_range_end": "12:00",
"time_range_start": "09:00"
},
"source": {
"match_opposite_ports": false,
"matching_target": "ANY",
"port_matching_type": "ANY",
"zone_id": "6963d42a1131084f054618df"
}
}
],
"policy_count": 109
}

View File

@@ -0,0 +1,153 @@
[
{
"_id": "6a10c5fe0ab9980e4eac37be",
"external_id": "535d893d-90ef-45ac-8d84-fc0b9d711b22",
"group_members": [
"10.5.0.0/24"
],
"group_type": "address-group",
"name": "NET-MGMT",
"site_id": "6963c5321131084f05460911"
},
{
"_id": "6a10c5fe0ab9980e4eac37c1",
"external_id": "146a4b9d-86c7-4d15-afef-2ea02374e9be",
"group_members": [
"10.5.1.0/24"
],
"group_type": "address-group",
"name": "NET-TRUSTED",
"site_id": "6963c5321131084f05460911"
},
{
"_id": "6a10c5fe0ab9980e4eac37c4",
"external_id": "81e1423a-74ce-43cf-9b42-33f641452ac7",
"group_members": [
"10.5.30.0/24"
],
"group_type": "address-group",
"name": "NET-SERVERS",
"site_id": "6963c5321131084f05460911"
},
{
"_id": "6a10c5fe0ab9980e4eac37c7",
"external_id": "e5a36792-c65d-434b-9fef-5259bee6e653",
"group_members": [
"10.5.10.0/24"
],
"group_type": "address-group",
"name": "NET-IOT",
"site_id": "6963c5321131084f05460911"
},
{
"_id": "6a10c5fe0ab9980e4eac37ca",
"external_id": "49c1cd44-998e-4c36-bfc4-42eeb5ce9a11",
"group_members": [
"10.5.90.0/24"
],
"group_type": "address-group",
"name": "NET-GUEST",
"site_id": "6963c5321131084f05460911"
},
{
"_id": "6a10c5ff0ab9980e4eac37cd",
"external_id": "e933b5f7-ecc0-4e93-a9e7-3924f507e0bf",
"group_members": [
"10.5.20.0/24"
],
"group_type": "address-group",
"name": "NET-CAMERAS",
"site_id": "6963c5321131084f05460911"
},
{
"_id": "6a10c5ff0ab9980e4eac37cf",
"external_id": "6a05ad73-6fda-4a3d-b1bf-c4eb6ffffee3",
"group_members": [
"192.168.1.0/24"
],
"group_type": "address-group",
"name": "NET-LEGACY-CIA",
"site_id": "6963c5321131084f05460911"
},
{
"_id": "6a10c5ff0ab9980e4eac37d3",
"external_id": "2fac4800-7ec2-48d1-8ecb-c3c357330c1e",
"group_members": [
"53"
],
"group_type": "port-group",
"name": "PORT-DNS",
"site_id": "6963c5321131084f05460911"
},
{
"_id": "6a10c5ff0ab9980e4eac37d6",
"external_id": "3ac9f355-c4c4-4121-b437-47f2ebd72c49",
"group_members": [
"67-68"
],
"group_type": "port-group",
"name": "PORT-DHCP",
"site_id": "6963c5321131084f05460911"
},
{
"_id": "6a10c5ff0ab9980e4eac37d9",
"external_id": "e361fb19-a9b8-4d14-b117-18356ad79c9b",
"group_members": [
"123"
],
"group_type": "port-group",
"name": "PORT-NTP",
"site_id": "6963c5321131084f05460911"
},
{
"_id": "6a10c5ff0ab9980e4eac37dc",
"external_id": "4ae091e5-0139-4e10-9e88-6b0abe950538",
"group_members": [
"80",
"443",
"8443",
"9443"
],
"group_type": "port-group",
"name": "PORT-WEB-ADMIN",
"site_id": "6963c5321131084f05460911"
},
{
"_id": "6a10c5ff0ab9980e4eac37df",
"external_id": "db376d79-c9aa-4f3a-b040-a6a76c195733",
"group_members": [
"22"
],
"group_type": "port-group",
"name": "PORT-SSH",
"site_id": "6963c5321131084f05460911"
},
{
"_id": "6a10c5ff0ab9980e4eac37e2",
"external_id": "0af620e5-ef70-41a2-bc57-d9fed44c4bcb",
"group_members": [
"5353"
],
"group_type": "port-group",
"name": "PORT-MDNS",
"site_id": "6963c5321131084f05460911"
},
{
"_id": "6a10c6580ab9980e4eac3914",
"external_id": "3f903463-6305-4c09-ba0c-85b729268a99",
"group_members": [
"10.5.0.0/24",
"10.5.1.0/24",
"10.5.10.0/24",
"10.5.20.0/24",
"10.5.30.0/24",
"10.5.90.0/24",
"192.168.1.0/24",
"192.168.2.0/24",
"192.168.3.0/24"
],
"group_type": "address-group",
"name": "NET-RFC1918-ALL",
"site_id": "6963c5321131084f05460911"
}
]

View File

@@ -1,608 +0,0 @@
{
"captured_at": "2026-05-21T21:46:53-05:00",
"site": "default",
"networkconf": [
{
"setting_preference": "auto",
"purpose": "wan",
"external_id": "d488cbbc-05dd-4bab-9efb-4b58340df685",
"wan_type_v6": "disabled",
"routing_table_id": 201,
"ipv6_wan_delegation_type": "none",
"wan_dhcpv6_pd_size_auto": true,
"firewall_zone_id": "6963d42a1131084f054618e0",
"igmp_proxy_upstream": false,
"mac_override_enabled": false,
"wan_load_balance_type": "failover-only",
"wan_failover_priority": 2,
"wan_ipv6_dns_preference": "auto",
"wan_networkgroup": "WAN",
"wan_dslite_remote_host_auto": false,
"wan_smartq_enabled": false,
"wan_dns_preference": "auto",
"wan_vlan_enabled": false,
"wan_load_balance_weight": 99,
"site_id": "6963c5321131084f05460911",
"name": "Internet 1",
"report_wan_event": false,
"_id": "6963c5831131084f05460929",
"attr_no_delete": true,
"wan_type": "dhcp",
"attr_hidden_id": "WAN"
},
{
"setting_preference": "manual",
"purpose": "wan",
"wan_dhcp_cos": 0,
"external_id": "c10cd8c4-7f9c-4d12-a7ea-e1a34ab8ead7",
"wan_type_v6": "disabled",
"routing_table_id": 202,
"enabled": true,
"wan_dhcp_options": [],
"ipv6_wan_delegation_type": "none",
"wan_dhcpv6_pd_size_auto": true,
"firewall_zone_id": "6963d42a1131084f054618e0",
"igmp_proxy_upstream": false,
"mac_override_enabled": false,
"wan_load_balance_type": "weighted",
"wan_failover_priority": 1,
"ipv6_setting_preference": "manual",
"wan_ipv6_dns_preference": "auto",
"single_network_lan": "",
"wan_dns2": "9.9.9.9",
"wan_ipv6_dns1": "",
"wan_dns1": "10.5.1.53",
"wan_ipv6_dns2": "",
"wan_networkgroup": "WAN2",
"wan_dslite_remote_host_auto": false,
"wan_provider_capabilities": {
"upload_kilobits_per_second": 2409000,
"download_kilobits_per_second": 2120000
},
"wan_ip_aliases": [],
"wan_smartq_enabled": false,
"wan_dns_preference": "manual",
"wan_vlan_enabled": false,
"wan_load_balance_weight": 50,
"site_id": "6963c5321131084f05460911",
"name": "Internet 2",
"report_wan_event": false,
"_id": "6963c5831131084f0546092a",
"wan_type": "dhcp",
"attr_no_delete": true,
"igmp_proxy_for": "none"
},
{
"setting_preference": "manual",
"dhcpdv6_dns_auto": true,
"ipv6_pd_stop": "::7d1",
"dhcpd_gateway_enabled": false,
"ipv6_client_address_assignment": "slaac",
"network_isolation_enabled": false,
"dhcp_relay_servers": [],
"dhcpd_start": "10.5.0.100",
"dhcpd_unifi_controller": "",
"ipv6_ra_enabled": true,
"domain_name": "localdomain",
"ip_subnet": "10.5.0.1/24",
"ipv6_interface_type": "none",
"dhcpd_wins_2": "",
"dhcpdv6_stop": "::7d1",
"is_nat": true,
"dhcpd_wins_1": "",
"dhcpd_dns_enabled": false,
"internet_access_enabled": true,
"nat_outbound_ip_addresses": [],
"dhcp_relay_enabled": false,
"dhcpd_conflict_checking": true,
"dhcpd_wins_enabled": false,
"dhcpdv6_leasetime": 86400,
"site_id": "6963c5321131084f05460911",
"name": "Management",
"_id": "6963c5831131084f0546092b",
"lte_lan_enabled": true,
"purpose": "corporate",
"dhcpd_leasetime": 86400,
"dhcpguard_enabled": false,
"dhcpd_time_offset_enabled": false,
"external_id": "652279eb-41e5-440d-aad5-231ca9683890",
"ipv6_ra_preferred_lifetime": 14400,
"dhcpd_stop": "10.5.0.199",
"is_smart_subnet_detected": false,
"enabled": true,
"dhcpd_enabled": true,
"dhcpd_wpad_url": "",
"networkgroup": "LAN",
"firewall_zone_id": "6963d42a1131084f054618df",
"dhcpdv6_start": "::2",
"vlan_enabled": false,
"ipv6_setting_preference": "auto",
"ipv6_aliases": [],
"gateway_type": "default",
"ipv6_ra_priority": "high",
"dhcpd_boot_enabled": false,
"ipv6_pd_start": "::2",
"upnp_lan_enabled": false,
"dhcpd_ntp_enabled": false,
"mdns_enabled": true,
"ip_aliases": [],
"attr_no_delete": true,
"attr_hidden_id": "LAN",
"dhcpd_tftp_server": "",
"auto_scale_enabled": false
},
{
"setting_preference": "manual",
"dhcpd_gateway_enabled": false,
"network_isolation_enabled": false,
"dhcp_relay_servers": [],
"dhcpd_dns_1": "192.168.1.10",
"dhcpd_start": "10.5.1.100",
"dhcpd_unifi_controller": "",
"domain_name": "",
"ip_subnet": "10.5.1.1/24",
"dhcpd_dns_4": "",
"ipv6_interface_type": "none",
"dhcpd_dns_2": "",
"dhcpd_dns_3": "",
"dhcpd_wins_2": "",
"is_nat": true,
"dhcpd_wins_1": "",
"dhcpd_dns_enabled": false,
"internet_access_enabled": true,
"nat_outbound_ip_addresses": [],
"dhcp_relay_enabled": false,
"dhcpd_conflict_checking": true,
"dhcpd_wins_enabled": false,
"name": "Trusted",
"site_id": "6963c5321131084f05460911",
"_id": "6963cb201131084f05461635",
"lte_lan_enabled": true,
"dhcpd_leasetime": 86400,
"purpose": "corporate",
"dhcpd_time_offset_enabled": false,
"dhcpguard_enabled": false,
"external_id": "87b66e27-93a9-4fb7-93d2-3b7bca9e6414",
"enabled": true,
"dhcpd_stop": "10.5.1.199",
"dhcpd_enabled": true,
"vlan": 51,
"dhcpd_wpad_url": "",
"networkgroup": "LAN",
"firewall_zone_id": "6963d42a1131084f054618df",
"vlan_enabled": true,
"ipv6_setting_preference": "manual",
"ipv6_aliases": [],
"gateway_type": "default",
"dhcpd_boot_enabled": false,
"upnp_lan_enabled": false,
"dhcpd_ntp_enabled": false,
"mdns_enabled": true,
"ip_aliases": [],
"dhcpd_tftp_server": "",
"auto_scale_enabled": false
},
{
"setting_preference": "manual",
"dhcpd_leasetime": 86400,
"purpose": "corporate",
"dhcpd_gateway_enabled": false,
"dhcpd_time_offset_enabled": false,
"dhcpguard_enabled": false,
"network_isolation_enabled": true,
"dhcp_relay_servers": [],
"dhcpd_start": "10.5.10.6",
"dhcpd_unifi_controller": "",
"external_id": "88180bc3-2c34-4f30-9b57-aed15da2a12a",
"enabled": true,
"dhcpd_stop": "10.5.10.254",
"domain_name": "",
"dhcpd_enabled": true,
"ip_subnet": "10.5.10.1/24",
"vlan": 510,
"dhcpd_wpad_url": "",
"ipv6_interface_type": "none",
"networkgroup": "LAN",
"firewall_zone_id": "6963d5a91131084f05461a0d",
"vlan_enabled": true,
"dhcpd_wins_2": "",
"ipv6_setting_preference": "manual",
"dhcpd_wins_1": "",
"dhcpd_dns_enabled": false,
"ipv6_aliases": [],
"internet_access_enabled": true,
"gateway_type": "default",
"nat_outbound_ip_addresses": [],
"dhcpd_boot_enabled": false,
"dhcp_relay_enabled": false,
"dhcpd_conflict_checking": true,
"dhcpd_wins_enabled": false,
"dhcpd_ntp_enabled": false,
"name": "IoT",
"site_id": "6963c5321131084f05460911",
"mdns_enabled": true,
"ip_aliases": [],
"_id": "6963cb461131084f05461642",
"lte_lan_enabled": true,
"dhcpd_tftp_server": "",
"auto_scale_enabled": true
},
{
"setting_preference": "auto",
"dhcpd_leasetime": 86400,
"purpose": "guest",
"dhcpd_gateway_enabled": false,
"dhcpd_time_offset_enabled": false,
"dhcpguard_enabled": false,
"network_isolation_enabled": false,
"dhcp_relay_servers": [],
"dhcpd_start": "10.5.90.6",
"dhcpd_unifi_controller": "",
"external_id": "2986eea1-bf02-4879-a7b0-7d9f91e45c53",
"enabled": true,
"dhcpd_stop": "10.5.90.254",
"domain_name": "",
"dhcpd_enabled": true,
"ip_subnet": "10.5.90.1/24",
"vlan": 590,
"dhcpd_wpad_url": "",
"ipv6_interface_type": "none",
"networkgroup": "LAN",
"firewall_zone_id": "6963d42a1131084f054618e3",
"vlan_enabled": true,
"dhcpd_wins_2": "",
"ipv6_setting_preference": "manual",
"is_nat": true,
"dhcpd_wins_1": "",
"dhcpd_dns_enabled": false,
"ipv6_aliases": [],
"internet_access_enabled": true,
"gateway_type": "default",
"nat_outbound_ip_addresses": [],
"dhcpd_boot_enabled": false,
"dhcp_relay_enabled": false,
"dhcpd_conflict_checking": true,
"dhcpd_wins_enabled": false,
"upnp_lan_enabled": false,
"dhcpd_ntp_enabled": false,
"name": "Guest",
"site_id": "6963c5321131084f05460911",
"mdns_enabled": true,
"ip_aliases": [],
"_id": "6963cb941131084f054616af",
"lte_lan_enabled": true,
"dhcpd_tftp_server": "",
"auto_scale_enabled": true
},
{
"setting_preference": "auto",
"dhcpd_leasetime": 86400,
"purpose": "corporate",
"dhcpd_gateway_enabled": false,
"dhcpd_time_offset_enabled": false,
"dhcpguard_enabled": false,
"network_isolation_enabled": false,
"dhcp_relay_servers": [],
"dhcpd_start": "10.5.20.6",
"dhcpd_unifi_controller": "",
"external_id": "98f2aff1-b394-493d-908f-ced4c83bde99",
"enabled": true,
"dhcpd_stop": "10.5.20.254",
"domain_name": "",
"dhcpd_enabled": true,
"ip_subnet": "10.5.20.1/24",
"vlan": 520,
"dhcpd_wpad_url": "",
"ipv6_interface_type": "none",
"networkgroup": "LAN",
"firewall_zone_id": "6963d5a91131084f05461a0d",
"vlan_enabled": true,
"dhcpd_wins_2": "",
"ipv6_setting_preference": "manual",
"is_nat": true,
"dhcpd_wins_1": "",
"dhcpd_dns_enabled": false,
"ipv6_aliases": [],
"internet_access_enabled": true,
"gateway_type": "default",
"nat_outbound_ip_addresses": [],
"dhcpd_boot_enabled": false,
"dhcp_relay_enabled": false,
"dhcpd_conflict_checking": true,
"dhcpd_wins_enabled": false,
"upnp_lan_enabled": false,
"dhcpd_ntp_enabled": false,
"name": "Camera",
"site_id": "6963c5321131084f05460911",
"mdns_enabled": true,
"ip_aliases": [],
"_id": "6963cbbf1131084f054616c1",
"lte_lan_enabled": true,
"dhcpd_tftp_server": "",
"auto_scale_enabled": true
},
{
"setting_preference": "manual",
"dhcpd_leasetime": 86400,
"purpose": "corporate",
"dhcpd_gateway_enabled": false,
"dhcpd_time_offset_enabled": false,
"dhcpguard_enabled": false,
"network_isolation_enabled": false,
"dhcp_relay_servers": [],
"dhcpd_start": "192.168.1.100",
"dhcpd_unifi_controller": "",
"external_id": "9bd0ee41-3303-4c03-ad81-305248301921",
"enabled": true,
"dhcpd_stop": "192.168.1.199",
"domain_name": "",
"dhcpd_enabled": true,
"ip_subnet": "192.168.1.1/24",
"vlan": 2,
"dhcpd_wpad_url": "",
"ipv6_interface_type": "none",
"networkgroup": "LAN",
"firewall_zone_id": "6963d5a91131084f05461a0d",
"vlan_enabled": true,
"dhcpd_wins_2": "",
"ipv6_setting_preference": "manual",
"is_nat": true,
"dhcpd_wins_1": "",
"dhcpd_dns_enabled": false,
"ipv6_aliases": [],
"internet_access_enabled": true,
"gateway_type": "default",
"nat_outbound_ip_addresses": [],
"dhcpd_boot_enabled": false,
"dhcp_relay_enabled": false,
"dhcpd_conflict_checking": true,
"dhcpd_wins_enabled": false,
"upnp_lan_enabled": false,
"dhcpd_ntp_enabled": false,
"name": "Old IoT",
"site_id": "6963c5321131084f05460911",
"mdns_enabled": true,
"ip_aliases": [],
"_id": "6963e11c1131084f054622f1",
"lte_lan_enabled": true,
"dhcpd_tftp_server": "",
"auto_scale_enabled": false
},
{
"wireguard_client_configuration_filename": "Unifi2-US-IL-267.conf",
"purpose": "vpn-client",
"wireguard_client_mode": "file",
"external_id": "8673a0e4-4f1b-42d6-9563-e6f6440065de",
"interface_mtu_enabled": false,
"routing_table_id": 178,
"enabled": true,
"vpn_type": "wireguard-client",
"mss_clamp": "auto",
"ip_subnet": "10.2.0.2/32",
"name": "Proton Chicago",
"site_id": "6963c5321131084f05460911",
"wireguard_id": 1,
"firewall_zone_id": "6963d42a1131084f054618e0",
"wireguard_client_configuration_file": "[REDACTED: exported WireGuard client config removed from tracked baseline]",
"_id": "696411c51131084f05465f8d"
},
{
"setting_preference": "auto",
"wireguard_interface_binding_mode_ip_version": "v4",
"purpose": "remote-user-vpn",
"local_port": 51820,
"x_wireguard_private_key": "REDACTED",
"external_id": "c625964c-8663-4ef0-bf04-53a5466a168c",
"enabled": true,
"wireguard_local_wan_ip": "any",
"vpn_type": "wireguard-server",
"ip_subnet": "192.168.2.1/24",
"wireguard_interface": "wan2",
"name": "One-Click VPN",
"site_id": "6963c5321131084f05460911",
"wireguard_id": 1,
"firewall_zone_id": "6963d42a1131084f054618e2",
"_id": "69a31399822347a1daadae2e"
},
{
"setting_preference": "auto",
"wireguard_interface_binding_mode_ip_version": "v4",
"purpose": "remote-user-vpn",
"x_wireguard_private_key": "REDACTED",
"dhcpd_start": "192.168.3.2",
"interface_mtu_enabled": false,
"external_id": "2431e2ed-2d1f-48ed-9b2f-3ed5b8021504",
"enabled": true,
"dhcpd_stop": "192.168.3.254",
"vpn_type": "wireguard-server",
"ip_subnet": "192.168.3.1/24",
"wireguard_interface": "wan2",
"wireguard_id": 2,
"firewall_zone_id": "6963d42a1131084f054618e2",
"vpn_client_configuration_remote_ip_override_enabled": false,
"dhcpd_dns_enabled": false,
"local_port": 51821,
"wireguard_local_wan_ip": "any",
"mss_clamp": "auto",
"dhcpd_wins_enabled": false,
"vpn_binding_mode": "any",
"name": "Slate 7",
"site_id": "6963c5321131084f05460911",
"_id": "69dd799da02014d2f4acc4e9",
"mss_clamp_ipv6": "auto"
}
],
"portconf": [
{
"setting_preference": "auto",
"port_security_enabled": false,
"stormctrl_ucast_rate": 100,
"egress_rate_limit_kbps_enabled": false,
"stormctrl_mcast_enabled": false,
"lldpmed_notify_enabled": false,
"tagged_vlan_mgmt": "auto",
"multicast_router_networkconf_ids": [],
"stormctrl_bcast_enabled": false,
"port_keepalive_enabled": false,
"stormctrl_mcast_rate": 100,
"native_networkconf_id": "6963c5831131084f0546092b",
"qos_profile": {
"qos_profile_mode": "custom",
"qos_policies": []
},
"port_security_mac_address": [],
"dot1x_idle_timeout": 300,
"op_mode": "switch",
"poe_mode": "auto",
"forward": "all",
"stormctrl_ucast_enabled": false,
"stormctrl_bcast_rate": 100,
"isolation": false,
"voice_networkconf_id": "",
"stp_port_mode": true,
"name": "Management",
"site_id": "6963c5321131084f05460911",
"_id": "6963cdbf1131084f0546177c",
"autoneg": true,
"lldpmed_enabled": true,
"dot1x_ctrl": "force_authorized"
},
{
"setting_preference": "auto",
"port_security_enabled": false,
"stormctrl_ucast_rate": 100,
"egress_rate_limit_kbps_enabled": false,
"stormctrl_mcast_enabled": false,
"lldpmed_notify_enabled": false,
"tagged_vlan_mgmt": "auto",
"multicast_router_networkconf_ids": [],
"stormctrl_bcast_enabled": false,
"port_keepalive_enabled": false,
"excluded_networkconf_ids": [],
"stormctrl_mcast_rate": 100,
"native_networkconf_id": "6963cb201131084f05461635",
"qos_profile": {
"qos_profile_mode": "custom",
"qos_policies": []
},
"port_security_mac_address": [],
"dot1x_idle_timeout": 300,
"op_mode": "switch",
"poe_mode": "auto",
"forward": "customize",
"stormctrl_ucast_enabled": false,
"stormctrl_bcast_rate": 100,
"isolation": false,
"voice_networkconf_id": "",
"stp_port_mode": true,
"name": "Trusted",
"site_id": "6963c5321131084f05460911",
"_id": "6963cdfe1131084f0546178d",
"autoneg": true,
"lldpmed_enabled": true,
"dot1x_ctrl": "force_authorized"
},
{
"setting_preference": "auto",
"port_security_enabled": false,
"stormctrl_ucast_rate": 100,
"egress_rate_limit_kbps_enabled": false,
"stormctrl_mcast_enabled": false,
"lldpmed_notify_enabled": false,
"tagged_vlan_mgmt": "block_all",
"multicast_router_networkconf_ids": [],
"stormctrl_bcast_enabled": false,
"port_keepalive_enabled": false,
"stormctrl_mcast_rate": 100,
"native_networkconf_id": "6963cb461131084f05461642",
"qos_profile": {
"qos_profile_mode": "custom",
"qos_policies": []
},
"port_security_mac_address": [],
"dot1x_idle_timeout": 300,
"op_mode": "switch",
"poe_mode": "auto",
"forward": "native",
"stormctrl_ucast_enabled": false,
"stormctrl_bcast_rate": 100,
"isolation": false,
"voice_networkconf_id": "",
"stp_port_mode": true,
"name": "IoT",
"site_id": "6963c5321131084f05460911",
"_id": "6963ce221131084f05461791",
"autoneg": true,
"lldpmed_enabled": true,
"dot1x_ctrl": "force_authorized"
},
{
"setting_preference": "auto",
"port_security_enabled": false,
"stormctrl_ucast_rate": 100,
"egress_rate_limit_kbps_enabled": false,
"stormctrl_mcast_enabled": false,
"lldpmed_notify_enabled": false,
"tagged_vlan_mgmt": "block_all",
"multicast_router_networkconf_ids": [],
"stormctrl_bcast_enabled": false,
"port_keepalive_enabled": false,
"stormctrl_mcast_rate": 100,
"native_networkconf_id": "6963cbbf1131084f054616c1",
"qos_profile": {
"qos_profile_mode": "custom",
"qos_policies": []
},
"port_security_mac_address": [],
"dot1x_idle_timeout": 300,
"op_mode": "switch",
"poe_mode": "auto",
"forward": "native",
"stormctrl_ucast_enabled": false,
"stormctrl_bcast_rate": 100,
"isolation": false,
"voice_networkconf_id": "",
"stp_port_mode": true,
"name": "Camera",
"site_id": "6963c5321131084f05460911",
"_id": "6963ce3b1131084f05461798",
"autoneg": true,
"lldpmed_enabled": true,
"dot1x_ctrl": "force_authorized"
},
{
"setting_preference": "auto",
"port_security_enabled": false,
"stormctrl_ucast_rate": 100,
"egress_rate_limit_kbps_enabled": false,
"stormctrl_mcast_enabled": false,
"lldpmed_notify_enabled": false,
"tagged_vlan_mgmt": "block_all",
"multicast_router_networkconf_ids": [],
"stormctrl_bcast_enabled": false,
"port_keepalive_enabled": false,
"stormctrl_mcast_rate": 100,
"native_networkconf_id": "6963e11c1131084f054622f1",
"qos_profile": {
"qos_profile_mode": "custom",
"qos_policies": []
},
"port_security_mac_address": [],
"dot1x_idle_timeout": 300,
"op_mode": "switch",
"poe_mode": "auto",
"forward": "native",
"stormctrl_ucast_enabled": false,
"stormctrl_bcast_rate": 100,
"isolation": false,
"voice_networkconf_id": "",
"stp_port_mode": true,
"name": "Old IoT",
"site_id": "6963c5321131084f05460911",
"_id": "6963e3511131084f054624cf",
"autoneg": true,
"lldpmed_enabled": true,
"dot1x_ctrl": "force_authorized"
}
]
}

View File

@@ -0,0 +1,51 @@
# Google/Cast Batch Move Result — 2026-05-23
Purpose: record the live move of the Google/Chromecast-class clients onto IoT for household testing.
## First pilot device
The first device I kicked over earlier was:
- `dc:e5:5b:8f:57:d2`
That device is now verified on IoT with:
- IP: `10.5.10.161`
- network: `IoT`
- VLAN: `510`
## Additional Google/cast-class devices moved to IoT
Using the same per-client virtual-network override method plus reassociation kick, these were also moved and verified:
- `3c:8d:20:f3:92:36` -> `10.5.10.56`
- `90:ca:fa:b6:7f:6e` -> `10.5.10.6`
- `f8:0f:f9:42:08:6d` (`Google-Home-Mini`) -> `10.5.10.196`
## Current verified state
All four target Google/cast-class clients now show:
- network: `IoT`
- network id: `6963cb461131084f05461642`
- VLAN: `510`
- virtual network override: enabled to the IoT network
Verified client set:
- `dc:e5:5b:8f:57:d2` -> `10.5.10.161`
- `3c:8d:20:f3:92:36` -> `10.5.10.56`
- `90:ca:fa:b6:7f:6e` -> `10.5.10.6`
- `f8:0f:f9:42:08:6d` -> `10.5.10.196`
## Method used
For each client:
1. `PUT rest/user/<user_id>` with:
- `virtual_network_override_enabled=true`
- `virtual_network_override_id=6963cb461131084f05461642`
2. `POST cmd/stamgr` with `{"cmd":"kick-sta","mac":"<mac>"}`
3. wait for reassociation and DHCP settle to a `10.5.10.x` address
## Next step
John should now test from the real household path:
- Plex playback
- casting/discovery
- Dispatcharr if relevant
If behavior is bad, rollback is straightforward per client:
- disable that client virtual network override
- kick the station again
- verify it returns to the prior lane/subnet

View File

@@ -0,0 +1,38 @@
# Google/Cast IoT Test Failure and Rollback — 2026-05-23
Purpose: record the failed Chromecast/Plex discovery test after moving Google/cast-class devices to IoT, and the immediate rollback.
## Observed failure
User-tested result:
- Plex on Chromecast could not see the Plex server after the Google/cast batch was moved to IoT.
This is consistent with discovery/cast behavior breaking across the current segmentation boundary.
## Rollback action taken
I reverted the per-client virtual network override and kicked each client to reassociate back to its prior lane.
Rolled back devices:
- `dc:e5:5b:8f:57:d2`
- `3c:8d:20:f3:92:36`
- `90:ca:fa:b6:7f:6e`
- `f8:0f:f9:42:08:6d` (`Google-Home-Mini`)
## Verified post-rollback state
All four now show:
- network: `Old IoT`
- network id: `6963e11c1131084f054622f1`
- virtual network override: disabled
Observed IPs after rollback settle:
- `dc:e5:5b:8f:57:d2` -> `192.168.1.132`
- `3c:8d:20:f3:92:36` -> `192.168.1.192`
- `90:ca:fa:b6:7f:6e` -> `192.168.1.129`
- `f8:0f:f9:42:08:6d` -> `192.168.1.185`
## Interpretation
The current clean IoT placement is not yet compatible with Plex discovery/cast behavior for these Google devices under the present network/policy setup.
## Safe next options
1. Leave Google/cast-class devices on `Old IoT` for now.
2. Finish other cleanup first.
3. Later, if desired, design a narrow, intentional cast/discovery exception instead of broad trust expansion.

View File

@@ -0,0 +1,43 @@
# Google/Cast Pilot Result — 2026-05-23
Purpose: record the first live Google/cast-class pilot move from Trusted into IoT.
## Device moved
- MAC: `dc:e5:5b:8f:57:d2`
- Vendor: Google
- Prior lane: `Trusted`
- Prior SSID: `Whiskey Neat Fuck Ice`
- Method: enabled per-client virtual network override to `IoT`, then kicked the station to reassociate
## UniFi evidence
Before:
- network: `Trusted`
- network id: `6963cb201131084f05461635`
- IP: `10.5.1.132`
- virtual network override: disabled
Applied:
- `virtual_network_override_enabled=true`
- `virtual_network_override_id=6963cb461131084f05461642`
- `cmd/stamgr` kick-sta for `dc:e5:5b:8f:57:d2`
Verified after reassociation:
- network: `IoT`
- network id: `6963cb461131084f05461642`
- SSID seen after move: `CIA Via`
- VLAN: `510`
- final observed IP after DHCP settle: `10.5.10.161`
## Interpretation
The client successfully moved from Trusted into IoT using a client-specific virtual network override and reassociated cleanly onto the IoT network.
## Still pending
Human validation still needed from the laptop / household use path:
- Plex playback
- casting/discovery behavior
- Dispatcharr if relevant
If the household behavior is bad, rollback is simple:
- disable the client's virtual network override
- kick the station again
- verify it returns to Trusted / `10.5.1.x`

View File

@@ -0,0 +1,81 @@
# Hermes dashboard Trusted-only Policy Engine apply — 2026-05-24
## Live change summary
Applied the narrow UniFi Policy Engine service slice that makes the Hermes dashboard on Nomad effectively Trusted-only.
Dashboard endpoint:
- `10.5.30.7:9119`
Apply time artifact stamp:
- `2026-05-24-061715`
## What changed
Created these two custom firewall policies:
1. `Allow Trusted to Hermes Dashboard`
- id: `6a1297eefb913dd84d4a3fbb`
- action: `ALLOW`
- protocol: `tcp`
- source: `10.5.1.0/24`
- destination: `10.5.30.7`
- destination port: `9119`
- schedule: `ALWAYS`
2. `Block Management and Servers to Hermes Dashboard`
- id: `6a1297eefb913dd84d4a3fc0`
- action: `BLOCK`
- protocol: `tcp`
- source: `10.5.0.0/24`, `10.5.30.0/24`
- destination: `10.5.30.7`
- destination port: `9119`
- schedule: `ALWAYS`
## Verification result
- Pre-apply baseline did not already contain either Hermes-dashboard policy.
- Post-apply readback shows both rules present as non-predefined custom policies.
- Apply helper returned no API errors.
## Artifacts
Baselines and apply output saved in:
- `home/doris-dashboard/docs/baselines/unifi-firewall-policies-custom-2026-05-24-061715-pre-hermes-dashboard-slice.json`
- `home/doris-dashboard/docs/baselines/unifi-hermes-dashboard-policy-apply-2026-05-24-061715.json`
- `home/doris-dashboard/docs/baselines/unifi-firewall-policies-custom-2026-05-24-061715-post-hermes-dashboard-slice.json`
Helper added to repo and copied to PD runtime for repeatable staging/apply:
- repo: `automation/bin/unifi_stage_hermes_dashboard_policy.py`
- PD runtime: `/mnt/docker-ssd/docker/compose/automation/bin/unifi_stage_hermes_dashboard_policy.py`
## Representative lane probe after apply
Follow-up live check from PD showed an important enforcement caveat.
From PD (`10.5.30.6`) to Nomad (`10.5.30.7:9119`):
- direct TCP connect succeeded
- HTTP GET succeeded with `200 OK`
- route on PD is on-link: `10.5.30.7 dev enp3s0f0 src 10.5.30.6`
Interpretation:
- PD and Nomad are both on the `Servers` subnet (`10.5.30.0/24`)
- this flow stays L2-local and does not traverse the UniFi gateway
- therefore the gateway Policy Engine block does not stop PD from reaching the dashboard even though the custom rule exists
What the current rule set still likely achieves:
- Trusted clients on `10.5.1.0/24` can reach the dashboard
- routed Management-lane access should still be governed by the custom block
- same-subnet Servers peers are not isolated by this gateway-only approach
## Operator validation still worth doing
Because same-subnet east-west traffic bypasses the gateway enforcement point, the right checks are now:
- from a Trusted client: confirm `http://hermes.home.paccoco.com:9119` or the intended dashboard URL still loads
- from a Management client on a different subnet: confirm `10.5.30.7:9119` is blocked
- do not treat PD->Nomad reachability as proof the rule failed globally; it specifically proves intra-subnet server peers need host-local enforcement if they must be excluded
## If true Trusted-only must exclude PD and other Servers peers
Use one of these instead of relying only on the gateway policy:
- add a host firewall on Nomad permitting only Trusted-source addresses/subnets to port `9119`
- move the dashboard to a lane where disallowed sources must cross a routed boundary
- use switch/ACL mechanisms that can enforce same-subnet isolation if available
## Rollback
Fastest rollback is to delete the two Hermes-dashboard custom rules by id or rerun a small helper update that disables/removes them:
- `6a1297eefb913dd84d4a3fbb`
- `6a1297eefb913dd84d4a3fc0`

View File

@@ -0,0 +1,37 @@
# Intellirocks Trusted-Lane Triage Result — 2026-05-23
Purpose: record the disposition of the remaining Intellirocks client that was still sitting in Trusted.
## Device
- MAC: `d4:ad:fc:f2:df:d2`
- Vendor: `Shenzhen Intellirocks Tech co.,ltd`
- Prior state:
- network: `Trusted`
- SSID: `Yer a Wifi Harry`
- IP: `10.5.1.154`
## Rationale
This device did not earn Trusted placement.
Evidence used:
- the existing cleanup shortlist already identified it as likely a sibling to the other Intellirocks devices on `Old IoT`
- vendor-family clustering suggests embedded / smart-home class, not a human/operator endpoint
- quarantine-first is safer than leaving an unidentified embedded client in Trusted
## Action taken
Moved the client out of `Trusted` into `Old IoT` using a per-client virtual network override, then kicked it to reassociate.
Applied:
- `virtual_network_override_enabled=true`
- `virtual_network_override_id=6963e11c1131084f054622f1`
- `cmd/stamgr` kick-sta for `d4:ad:fc:f2:df:d2`
## Verified result
After reassociation and DHCP settle:
- network: `Old IoT`
- network id: `6963e11c1131084f054622f1`
- IP: `192.168.1.154`
- VLAN: `2`
## Conclusion
The device no longer has Trusted-lane placement and is now quarantine-aligned with the other unidentified Intellirocks-class devices.

View File

@@ -2,6 +2,11 @@
> For Doris: this worksheet exists so tomorrow does not devolve into vibes and guessing. Every device on Legacy CIA gets one label: migrate, quarantine, or kill. > For Doris: this worksheet exists so tomorrow does not devolve into vibes and guessing. Every device on Legacy CIA gets one label: migrate, quarantine, or kill.
Historical status note:
- this is a 2026-05-22 migration worksheet, not the current remaining-work checklist
- do not treat its "tomorrow" language as active backlog without checking later result docs
- current truth for Protect accessories lives in `unifi-protect-doorbell-chime-current-state-2026-05-26.md`
Goal: classify every remaining device on the legacy CIA Via VLAN/SSID so we can make deliberate decisions during and after the cutover. Goal: classify every remaining device on the legacy CIA Via VLAN/SSID so we can make deliberate decisions during and after the cutover.
Rules: Rules:
@@ -47,8 +52,8 @@ Use `kill` when all or most are true:
- MyQ -> IoT VLAN 40 - MyQ -> IoT VLAN 40
- Samsung FamilyHub -> IoT VLAN 40 - Samsung FamilyHub -> IoT VLAN 40
- LG dryer -> IoT VLAN 40 - LG dryer -> IoT VLAN 40
- doorbell -> Cameras VLAN 60 - doorbell -> Cameras VLAN 60 only if immediate validation is available
- Protect chimes -> Cameras VLAN 60 - Protect chimes -> Cameras VLAN 60 only after explicit Protect-path validation; current known-good live state keeps them on Management/default `UniFi Wireless`
### Migrate later / careful handling ### Migrate later / careful handling
- Google Home Mini -> IoT VLAN 40 after discovery testing - Google Home Mini -> IoT VLAN 40 after discovery testing
@@ -114,7 +119,7 @@ If answers are bad, it does not earn migration tomorrow.
### Protect chimes / doorbell ### Protect chimes / doorbell
- These are not Legacy CIA end-state residents. - These are not Legacy CIA end-state residents.
- Move to Cameras/Security as early cleanup. - Treat Cameras/Security as the target design, not an automatic next action. The Wi-Fi chimes currently have a documented Management/default-SSID exception because the Camera override broke Protect health.
### old bulbs / old plugs ### old bulbs / old plugs
- If still useful but irrecoverable, quarantine. - If still useful but irrecoverable, quarantine.

View File

@@ -2,6 +2,11 @@
> For Doris: this is the one-page live operator sheet for tomorrow. Use this instead of bouncing between five docs while tired. > For Doris: this is the one-page live operator sheet for tomorrow. Use this instead of bouncing between five docs while tired.
Historical status note:
- this file is a 2026-05-22 live cutover sheet, not the current remaining-work source of truth
- do not treat its open checkboxes or "tomorrow" language as active backlog
- current truth lives in `network-migration-remaining-checklist-2026-05-22.md` and `unifi-protect-doorbell-chime-current-state-2026-05-26.md`
Goal: execute the UniFi network redesign with minimal improvisation, explicit stop/go gates, per-port actions, per-device dispositions, and fast rollback discipline. Goal: execute the UniFi network redesign with minimal improvisation, explicit stop/go gates, per-port actions, per-device dispositions, and fast rollback discipline.
Architecture: Architecture:
@@ -43,7 +48,7 @@ Current client counts:
- Trusted: 14 - Trusted: 14
Current hazards: Current hazards:
- two unnamed `espressif` devices are on Management via `UniFi Wireless` on the U7 Pro - the two historical `espressif` Management hazards are now known Protect WiFi chimes, not unidentified junk
- almost all WiFi clients are on the U7 Pro - almost all WiFi clients are on the U7 Pro
- there are no custom firewall rules/groups yet, so tomorrows segmentation policy is basically greenfield - there are no custom firewall rules/groups yet, so tomorrows segmentation policy is basically greenfield
@@ -110,7 +115,7 @@ Stop/go gate:
- [ ] Trusted admin path still fine - [ ] Trusted admin path still fine
### Minute 20-35: management cleanup first ### Minute 20-35: management cleanup first
- [ ] identify/address the 2 Management `espressif` devices - [ ] confirm the two former Management `espressif` devices are still understood as the documented Protect-chime exception
- [ ] ensure infra devices remain on Management intent - [ ] ensure infra devices remain on Management intent
- [ ] remove obvious non-infra junk from Management - [ ] remove obvious non-infra junk from Management
@@ -120,7 +125,7 @@ Stop/go gate:
### Minute 35-50: security cleanup ### Minute 35-50: security cleanup
- [ ] validate Camera lane - [ ] validate Camera lane
- [ ] move Protect chimes if ready - [ ] move Protect chimes only if Protect health is explicitly validated during the move
- [ ] confirm doorbell remains healthy - [ ] confirm doorbell remains healthy
### Minute 50-85: core server move wave ### Minute 50-85: core server move wave
@@ -160,22 +165,22 @@ Stop/go gate:
## 5. Management Offender Sheet ## 5. Management Offender Sheet
These two devices are currently on Management and need identification or removal from that lane: These two devices were the historical Management offenders that needed identification. They are no longer mystery devices, and they are not a rediscovery task:
1. `espressif` 1. `espressif`
- IP: `10.5.0.123` - IP: `10.5.0.123`
- AP: `U7 Pro` - AP: `U7 Pro`
- SSID: `UniFi Wireless` - SSID: `UniFi Wireless`
- Default call: identify first; if it is smart-junk, move/quarantine off Management - Current call: known Protect WiFi chime; keep on the documented Management/default-SSID exception unless doing an explicit Protect-validation move
2. `espressif` 2. `espressif`
- IP: `10.5.0.189` - IP: `10.5.0.189`
- AP: `U7 Pro` - AP: `U7 Pro`
- SSID: `UniFi Wireless` - SSID: `UniFi Wireless`
- Default call: identify first; if it is smart-junk, move/quarantine off Management - Current call: known Protect WiFi chime; keep on the documented Management/default-SSID exception unless doing an explicit Protect-validation move
Operator note: Operator note:
- these are almost certainly exactly the sort of device that should not live on your infrastructure lane - target architecture still says these belong with security estate, but the current known-good live state keeps them on Management/default `UniFi Wireless` because the Camera override failed in Protect
## 6. Exact Core Port Move Sheet ## 6. Exact Core Port Move Sheet
@@ -272,10 +277,14 @@ Rule:
Already true: Already true:
- `front-doorbell` is already on Camera at `10.5.20.217` - `front-doorbell` is already on Camera at `10.5.20.217`
Tomorrows security work is therefore mainly: Current known-good chime exception:
- move/clean up chimes - Protect WiFi Chime `upstairs landing` is intentionally on Management/default `UniFi Wireless` at `10.5.0.123`
- tighten policy - Protect WiFi Chime `Living Room` is intentionally on Management/default `UniFi Wireless` at `10.5.0.189`
If revisiting security work, the goal is therefore:
- preserve the doorbells healthy state - preserve the doorbells healthy state
- tighten policy carefully
- only revisit chime placement during an explicit Protect-validation test
## 9. Firewall Build Priorities ## 9. Firewall Build Priorities

View File

@@ -2,6 +2,11 @@
> For Doris: this is the live change-window script. Use it like a pilot checklist, not a brainstorming prompt. > For Doris: this is the live change-window script. Use it like a pilot checklist, not a brainstorming prompt.
Historical status note:
- this file is a 2026-05-22 cutover script, not the current remaining-work checklist
- do not treat unchecked boxes or "tomorrow" phrasing here as active backlog
- current truth lives in `network-migration-remaining-checklist-2026-05-22.md` and `unifi-protect-doorbell-chime-current-state-2026-05-26.md`
Goal: execute the redesign tomorrow with deliberate pauses, validation after every block of changes, and clean rollback points. Goal: execute the redesign tomorrow with deliberate pauses, validation after every block of changes, and clean rollback points.
Assumptions: Assumptions:
@@ -83,7 +88,7 @@ Stop/go check:
- [ ] Confirm UDM, switches, and APs are mapped to Management intent. - [ ] Confirm UDM, switches, and APs are mapped to Management intent.
- [ ] Remove obvious non-infrastructure devices from Management. - [ ] Remove obvious non-infrastructure devices from Management.
- [ ] Identify any stragglers remaining in Management and list them. - [ ] Identify any stragglers remaining in Management and list them.
- [ ] If Security lane is ready, prepare to move chimes immediately. - [ ] If revisiting Protect accessory placement, first confirm the documented chime Management exception and only move them during explicit Protect validation.
Validation: Validation:
- [ ] UniFi still sees gateway, switches, APs. - [ ] UniFi still sees gateway, switches, APs.
@@ -97,9 +102,9 @@ Rollback trigger:
- [ ] Activate/validate Cameras VLAN 60. - [ ] Activate/validate Cameras VLAN 60.
- [ ] Activate Security SSID if needed. - [ ] Activate Security SSID if needed.
- [ ] Move Protect chimes. - [ ] Move Protect chimes only if Protect health is validated during the move.
- [ ] Move doorbell if ready. - [ ] Move doorbell only if immediate post-move validation is available.
- [ ] Confirm no security devices remain polluting Management. - [ ] Confirm any remaining security-device Management exception is documented explicitly.
Validation: Validation:
- [ ] device rejoins expected lane - [ ] device rejoins expected lane
@@ -107,7 +112,7 @@ Validation:
- [ ] no broad emergency rules added yet - [ ] no broad emergency rules added yet
Rollback trigger: Rollback trigger:
- [ ] If doorbell/chimes become unrecoverable fast, revert that device only. - [ ] If doorbell/chimes become unrecoverable fast, revert that device only; for the Wi-Fi chimes, clearing the Camera override and returning to Management/default `UniFi Wireless` is the known-good rollback.
## Minute 50-75: move core servers to Servers VLAN 30 ## Minute 50-75: move core servers to Servers VLAN 30

View File

@@ -2,6 +2,10 @@
Use this during the live window when your brain gets dumb. Use this during the live window when your brain gets dumb.
Historical status note:
- this card belongs to the 2026-05-22 cutover-planning wave
- keep it as operator evidence, but do not treat it as the current backlog without checking later migration result docs
Goal: Goal:
- make one clean change - make one clean change
- validate it - validate it

View File

@@ -5,14 +5,14 @@
Goal: enforce clean segmentation between Management, Trusted, Servers, IoT, Guest, Cameras, and Legacy CIA quarantine without using broad lazy exceptions. Goal: enforce clean segmentation between Management, Trusted, Servers, IoT, Guest, Cameras, and Legacy CIA quarantine without using broad lazy exceptions.
Assumptions: Assumptions:
- Final networks: - Current live networks:
- VLAN 10 Management -> `10.5.10.0/24` - Management -> `10.5.0.0/24`
- VLAN 20 Trusted -> `10.5.20.0/24` - Trusted -> `10.5.1.0/24`
- VLAN 30 Servers -> `10.5.30.0/24` - IoT -> `10.5.10.0/24`
- VLAN 40 IoT -> `10.5.40.0/24` - Cameras -> `10.5.20.0/24`
- VLAN 50 Guest -> `10.5.50.0/24` - Servers -> `10.5.30.0/24`
- VLAN 60 Cameras -> `10.5.60.0/24` - Guest -> `10.5.90.0/24`
- Legacy CIA quarantine -> existing legacy subnet/VLAN retained temporarily - Legacy CIA quarantine -> `192.168.1.0/24` retained temporarily
- Use address/object groups where UniFi allows them. - Use address/object groups where UniFi allows them.
- Apply stateful best practice first: established/related before policy rules. - Apply stateful best practice first: established/related before policy rules.
- Specific allows always go above broad denies. - Specific allows always go above broad denies.

View File

@@ -0,0 +1,111 @@
# Network Migration Remaining Checklist
Current intent: only safe, low-drama follow-up work remains. The easy-value client cleanup already completed should not be re-run.
Current truth note:
- use this file plus `unifi-protect-doorbell-chime-current-state-2026-05-26.md` as the source of truth for what is actually still pending
- treat earlier cutover-prep docs with "tomorrow" language as historical planning artifacts unless they were explicitly updated later
## Already completed
- Servers network/profile exists in UniFi
- Historical attempt moved both Protect WiFi chimes from Management -> Camera, but that change was later reversed after Protect health failed on the Camera-lane override
- Current known-good state keeps both Protect WiFi chimes on Management / default `UniFi Wireless`; see `unifi-protect-doorbell-chime-current-state-2026-05-26.md`
- `LG_Smart_Laundry2_open` moved from Trusted -> IoT
- `MyQ-29B` moved from Old IoT -> IoT
- `LG_Smart_Dryer2_open` moved from Old IoT -> IoT
- `Samsung-FamilyHub` moved from Old IoT -> IoT
- `Main-Floor` ecobee moved from Old IoT -> IoT
- `Upstairs` ecobee moved from Old IoT -> IoT
- These 8 client moves were verified live from UniFi `stat/sta`
- Firewall object/group scaffolding exists
- Basic custom outbound policy scaffolding exists (`Allow Internal to Untrusted`)
- First live management-plane hardening slice is now applied: `Block Untrusted to Gateway Admin Surfaces`
- Reference: `unifi-firewall-enforcement-result-2026-05-23.md`
## Safe to do now
1. Documentation cleanup
- treat older pre-move docs as historical snapshots, not live next-step instructions
- use this file as the current remaining-work reference
2. Finalize remaining task list
- keep Google/cast pilot explicitly deferred until John is on the laptop
- keep unknown Legacy CIA devices explicitly quarantine-first
- Trusted-lane Intellirocks cleanup is now resolved separately; see `intellirocks-triage-result-2026-05-23.md`
- Legacy CIA leftovers are now dispositioned separately; see `unifi-legacy-cia-closeout-2026-05-23.md`
- Protect chime lane cleanup is intentionally deferred until Protect connectivity is explicitly re-validated on a non-Management lane
3. Non-disruptive firewall planning cleanup
- compare staged firewall objects/policies against desired rule order
- produce a precise missing-rules gap list before any live rule apply
## Still pending before the migration is truly finished
### A. DHCP/DNS and gateway-dependency closeout
- Fresh live verification is written up in `unifi-dhcp-dns-verification-2026-05-23.md`
- Recommended design is written up in `unifi-restricted-lane-dns-ntp-recommendation-2026-05-23.md`
- Live DNS cutover result is written up in `unifi-restricted-dns-cutover-result-2026-05-23.md`
- Broader gateway shield result is written up in `unifi-broader-gateway-shield-result-2026-05-23.md`
- Completed live changes:
- `IoT`, `Camera`, and `Old IoT` now DHCP-advertise DNS `10.5.30.53`
- explicit `Untrusted -> Internal` DNS allow now preserves access to `10.5.30.53:53`
- broader `Untrusted -> Gateway` block is now in place with DHCP and mDNS preserved
- NTP remains intentionally unchanged for now
- Remaining follow-up is validation, not additional broad gateway-rule design
### B. Google/cast validation batch
Do later, with user present on laptop:
- pilot one Google/cast-class device only
- validate Plex playback
- validate Plex discovery/casting
- validate Dispatcharr once relevant
- if ugly, revert that one device and defer the rest
Likely candidates still needing that treatment:
- `dc:e5:5b:8f:57:d2` (Google client currently in Trusted)
- `Google-Home-Mini`
- `3c:8d:20:f3:92:36`
- `90:ca:fa:b6:7f:6e`
### C. Remaining Trusted-lane cleanup
- Completed: `d4:ad:fc:f2:df:d2` was removed from Trusted and re-homed to `Old IoT`
- Reference: `intellirocks-triage-result-2026-05-23.md`
### D. Legacy CIA quarantine closeout
- Completed as a disposition decision: the remaining unidentified leftovers stay quarantined on `Old IoT`
- Reference: `unifi-legacy-cia-closeout-2026-05-23.md`
### E. Real firewall enforcement
Substantially completed:
- live custom rule blocks `IoT` / `Camera` / `Old IoT` access to the UDM Pro admin TCP ports
- live custom rule allows restricted-lane DNS to `10.5.30.53:53`
- live custom rule blocks general restricted-lane `Untrusted -> Gateway` access
- live custom rules preserve DHCP and mDNS for the restricted lanes
- references:
- `unifi-firewall-enforcement-result-2026-05-23.md`
- `unifi-broader-gateway-shield-result-2026-05-23.md`
Still needed if the full design is to be enforced rather than just the current safe slices:
- real client validation from each restricted lane after lease renewal
- explicit Trusted admin -> Management allows if broad internal->gateway restrictions are introduced later
- Trusted -> Servers allow only if later intra-Internal restrictions are introduced
- any camera/Protect helper exceptions after port-level verification
- any Google/cast discovery exceptions only after laptop validation
Important distinction:
- custom rules now cover the main restricted-lane gateway hardening path
- the full inter-VLAN segmentation matrix is still not yet fully enforced
### F. Final cleanup
- SSID simplification once Google/cast behavior is understood
- reference SSID retire/keep plan: `unifi-ssid-cleanup-proposal-2026-05-23.md`
- final validation sweep
- confirm no temporary panic exceptions remain
- document any intentionally deferred weird devices
- keep the Protect doorbell/chime exception documented until a future validated lane migration replaces it
## Suggested execution order from here
1. Wait for laptop session
2. Human-validate Google/cast behavior from the laptop
3. Renew/validate one real client on each restricted lane against the new DNS + gateway shield posture
4. Add only any proven narrow exceptions that real validation actually requires
5. Do final SSID simplification and closeout using `unifi-ssid-cleanup-proposal-2026-05-23.md`

View File

@@ -2,6 +2,11 @@
> For Doris: use this during the live UniFi change window. Goal is a controlled cutover with explicit validation and rollback at each boundary. > For Doris: use this during the live UniFi change window. Goal is a controlled cutover with explicit validation and rollback at each boundary.
Historical status note:
- this runbook came from the 2026-05-22 cutover-planning wave
- preserve it as the detailed plan, but do not treat every "tomorrow" step as still pending without checking later result docs
- current remaining-work truth lives in `network-migration-remaining-checklist-2026-05-22.md` and `unifi-protect-doorbell-chime-current-state-2026-05-26.md`
Goal: deploy the target VLAN/SSID/firewall design with minimum drama, keep legacy CIA Via as a quarantine lane for devices that cannot yet be migrated, and finish the window with an understandable, supportable network. Goal: deploy the target VLAN/SSID/firewall design with minimum drama, keep legacy CIA Via as a quarantine lane for devices that cannot yet be migrated, and finish the window with an understandable, supportable network.
Architecture summary: Architecture summary:
@@ -258,7 +263,7 @@ Exit criteria:
### Phase C: Management cleanup first ### Phase C: Management cleanup first
1. Ensure UDM, switches, and AP management surfaces are on VLAN 10 intent. 1. Ensure UDM, switches, and AP management surfaces are on VLAN 10 intent.
2. Remove obvious non-infrastructure devices from Management. 2. Remove obvious non-infrastructure devices from Management.
3. Move Protect chimes out of Management as top cleanup item if Security lane is ready. 3. Treat Protect chimes as a documented temporary Management exception unless you are explicitly re-validating their Protect health on a non-Management lane.
4. Re-validate UniFi visibility of gateway, switches, APs. 4. Re-validate UniFi visibility of gateway, switches, APs.
Rollback if: Rollback if:
@@ -272,14 +277,14 @@ Exit criteria:
### Phase D: Build/activate Cameras lane ### Phase D: Build/activate Cameras lane
1. Activate/validate `NET-CAMERAS`. 1. Activate/validate `NET-CAMERAS`.
2. Activate `Security` SSID if used. 2. Activate `Security` SSID if used.
3. Move doorbell and Protect chimes to Cameras/Security. 3. Move doorbell and Protect chimes to Cameras/Security only if Protect health is explicitly validated during the move.
4. Validate app/admin behavior. 4. Validate app/admin behavior.
Rollback if: Rollback if:
- security devices fall offline and cannot be recovered quickly - security devices fall offline and cannot be recovered quickly
Exit criteria: Exit criteria:
- doorbell/chimes are no longer polluting Management - either doorbell/chimes are healthy on Cameras/Security, or the documented Management exception is preserved intentionally
- Camera lane exists for future growth - Camera lane exists for future growth
### Phase E: Move core servers to Servers VLAN 30 ### Phase E: Move core servers to Servers VLAN 30
@@ -353,8 +358,8 @@ Exit criteria:
## 9. Explicit Device Triage ## 9. Explicit Device Triage
Move tomorrow if practical: Move tomorrow if practical:
- Protect chimes - Protect chimes only during an explicit Protect-validation test
- doorbell - doorbell only if healthy-state validation is immediate and reversible
- PD - PD
- Serenity - Serenity
- Nomad - Nomad
@@ -403,6 +408,7 @@ Minimum successful tomorrow outcome:
- IoT lane exists and at least easy-value devices are moved - IoT lane exists and at least easy-value devices are moved
- Legacy CIA is converted into explicit quarantine/sunset lane - Legacy CIA is converted into explicit quarantine/sunset lane
- no broad insecure exceptions were added out of fatigue - no broad insecure exceptions were added out of fatigue
- any documented temporary Protect chime exception is written down instead of being rediscovered later
Nice-to-have, not mandatory tomorrow: Nice-to-have, not mandatory tomorrow:
- U6 LR restored and tuned - U6 LR restored and tuned

View File

@@ -49,6 +49,11 @@ Architecture summary:
- Contains: doorbell, Protect chimes, future cameras, NVR-adjacent accessories, security-specific Wi-Fi endpoints - Contains: doorbell, Protect chimes, future cameras, NVR-adjacent accessories, security-specific Wi-Fi endpoints
- Policy: no broad access to LAN; only specific flows to Protect/NVR services and operator/admin clients - Policy: no broad access to LAN; only specific flows to Protect/NVR services and operator/admin clients
Current live exception:
- this is still the target design, not the currently proven-safe lane for every Protect accessory
- a 2026-05-22 client-override attempt put the Wi-Fi chimes on the Camera lane, but they later had to be returned to Management/default `UniFi Wireless` because Protect health failed there
- future camera/security-lane work must preserve this documented exception until Protect connectivity is explicitly re-validated
## SSID Plan ## SSID Plan
Preferred steady-state SSIDs: Preferred steady-state SSIDs:
@@ -107,7 +112,7 @@ IoT / Smart Home:
Cameras / Security: Cameras / Security:
- front-doorbell - front-doorbell
- Protect chimes - Protect chimes (target design; current live exception keeps the Wi-Fi chimes on Management/default `UniFi Wireless` until Protect validation exists on the security lane)
- future wired/PoE cameras - future wired/PoE cameras
- future Wi-Fi security accessories - future Wi-Fi security accessories
@@ -176,7 +181,7 @@ Phase 2: servers
- update DNS/static mappings/firewall rules - update DNS/static mappings/firewall rules
Phase 3: cameras/security Phase 3: cameras/security
- move doorbell and chimes to VLAN 60 - move doorbell and chimes to VLAN 60 only after explicit Protect-path validation proves that the Wi-Fi chimes stay healthy there
- prepare room for future camera growth - prepare room for future camera growth
Phase 4: IoT migration Phase 4: IoT migration

View File

@@ -2,6 +2,10 @@
> For Doris: this is the concrete disposition call for every current live Old IoT client seen during read-only recon. This is the list to use tomorrow unless live validation proves otherwise. > For Doris: this is the concrete disposition call for every current live Old IoT client seen during read-only recon. This is the list to use tomorrow unless live validation proves otherwise.
Historical status note:
- this is a 2026-05-22 planning artifact
- several "tomorrow" items here were completed later; do not use this file as the current backlog without checking `network-migration-remaining-checklist-2026-05-22.md`
Source basis: Source basis:
- live UniFi read-only recon on 2026-05-22 - live UniFi read-only recon on 2026-05-22
- all devices listed were active on SSID `CIA Via` through the `U7 Pro` - all devices listed were active on SSID `CIA Via` through the `U7 Pro`

View File

@@ -0,0 +1,66 @@
# UniFi Broader Gateway Shield Result — 2026-05-23
Purpose: record the live broader `Untrusted -> Gateway` shield after the restricted-lane DHCP DNS cutover.
## Live outcome
Applied the broader restricted-lane gateway hardening successfully.
Restricted source lanes covered:
- `IoT` (`10.5.10.0/24`)
- `Camera` (`10.5.20.0/24`)
- `Old IoT` (`192.168.1.0/24`)
## Custom policies now present live
1. `Block Untrusted to Gateway Admin Surfaces`
- blocks TCP admin surfaces on `10.5.0.1`
- ports: `22,80,443,8443,9443`
2. `Allow Untrusted to DNS Resolver`
- preserves restricted-lane DNS to `10.5.30.53:53`
- direction: `Untrusted -> Internal`
- protocol: `tcp_udp`
3. `Block Untrusted to Gateway Default Services`
- blocks general restricted-lane access to the gateway zone
4. `Allow Untrusted to Gateway DHCP`
- preserves DHCP while the broader gateway shield is active
- UDP `68 -> 67`
5. `Allow Untrusted to Gateway mDNS`
- preserves mDNS multicast behavior
- UDP `5353 -> 224.0.0.251:5353`
## Important implementation note
During staging, one hidden dependency became obvious:
- after moving restricted-lane DHCP DNS to `10.5.30.53`, those clients still needed an explicit `Untrusted -> Internal` allow for the DNS resolver
So the broader shield was not considered complete until that allow was added live.
## Verified live after apply
Read back from the UniFi Policy Engine on PD and confirmed the following custom rule order exists live:
- idx 108: `Block Untrusted to Gateway Admin Surfaces`
- idx 109: `Allow Untrusted to DNS Resolver`
- idx 110: `Block Untrusted to Gateway Default Services`
- idx 111: `Allow Untrusted to Gateway DHCP`
- idx 112: `Allow Untrusted to Gateway mDNS`
## Net effect
For `IoT`, `Camera`, and `Old IoT`:
- gateway admin surfaces are blocked
- general gateway access is blocked
- DNS to `10.5.30.53` is preserved
- DHCP is preserved
- mDNS is preserved
## What is still not proven by controller reads alone
This does not by itself prove every device behavior is perfect.
Still validate with real clients:
- renew at least one DHCP lease per restricted lane
- confirm DNS resolution works through `10.5.30.53`
- confirm gateway UI/admin access is blocked from those lanes
- treat any additional exception as earned only by a real failing client
## Bottom line
The broader restricted-lane `Untrusted -> Gateway` shield is now live, with explicit DNS, DHCP, and mDNS preservation in place.

View File

@@ -0,0 +1,106 @@
# UniFi Client Cleanup Shortlist
Status note: this file started as a live shortlist on 2026-05-22. Most of the appliance/thermostat cleanup in here is completed. For the current Protect doorbell/chime truth, do not rely on the original chime recommendation below without also reading `unifi-protect-doorbell-chime-current-state-2026-05-26.md`.
Live basis:
- Captured from PD with `python3 automation/bin/unifi_network.py --raw`
- Capture time: 2026-05-22 21:45:20 UTC
- Scope: active clients only
## What looks correct already
- Servers lane: `PlausibleDeniability`, `Serenity`, `Nomad`
- Camera lane: `front-doorbell`
- Trusted human/operator lane: `Rocinante`, `FlyingDutchman`, `Pixel-7`, `Pixel-9-Pro-XL`, `Valkyrie`, `steamdeck`
- Trusted tooling/device that is probably intentional: `MeshMonitor (MT)`
## Immediate cleanup candidates
### 1. Move out of Trusted first
These are the clearest remaining misclassifications, and the smart-appliance targets are now approved for `IoT`.
- `LG_Smart_Laundry2_open` (`10.5.1.184`, `60:ab:14:5f:b4:ba`)
- Current lane: `Trusted`
- Approved target: `IoT`
- Identification confidence: high
- Why: named LG appliance endpoint and sibling to `LG_Smart_Dryer2_open`; does not belong in the human/operator lane.
- `dc:e5:5b:8f:57:d2` (`10.5.1.132`, Google)
- Current lane: `Trusted`
- Likely identity: Google cast/speaker/display-class client
- Likely target: `IoT`, but late-batch after discovery validation
- Why:
- Google vendor fingerprint
- same family as the three Google clients still on `Old IoT`
- consumer Wi-Fi client on the compat Trusted SSID, not a human endpoint
- heavy traffic pattern is consistent with a media/cast-style household device
- Caution: treat like the other Google/cast discovery-sensitive devices. Do not broaden policy just to appease casting.
- `d4:ad:fc:f2:df:d2` (`10.5.1.154`, Shenzhen Intellirocks Tech)
- Current lane: `Trusted`
- Likely identity: sibling to the two unnamed Intellirocks devices already on `Old IoT`; likely smart-home / embedded device class
- Likely target: `IoT` or quarantine later if still unidentified
- Why: vendor-family clustering strongly suggests it belongs with the other low-trust embedded devices, not with human/operator endpoints.
- Caution: identify before moving if possible; if unknown, do not promote it by leaving it in Trusted.
## Management offenders (historical identification, but not current next-step guidance)
These were correctly identified on 2026-05-22, but the original recommendation to re-home them immediately to `Camera` / security is no longer the standing advice.
- `58:d6:1f:54:e5:6d` (`10.5.0.123`, hostname `espressif`)
- Identified as: UniFi Protect WiFi Chime
- Friendly name from controller fingerprint: `upstairs landing`
- Best-fit target lane: `Camera` / security
- `58:d6:1f:54:e5:af` (`10.5.0.189`, hostname `espressif`)
- Identified as: UniFi Protect WiFi Chime
- Friendly name from controller fingerprint: `Living Room`
- Best-fit target lane: `Camera` / security
Notes:
- Both are Wi-Fi clients on `UniFi Wireless`.
- UniFi fingerprint metadata reports `product_line=unifi-protect` and `product_model=Protect WiFi Chime`.
- This means they are not mystery ESP junk anymore; they are known Protect accessories.
- Current known-good live state keeps them on Management/default `UniFi Wireless` because the Camera-lane override caused them to show offline in Protect.
- Do not re-home these two chimes again unless Protect connectivity is explicitly validated during the move.
## Old IoT move-now set
These still look like the cleanest deliberate moves from `CIA Via` into `IoT`.
1. `MyQ-29B` (`192.168.1.130`)
2. `LG_Smart_Dryer2_open` (`192.168.1.186`)
3. `Samsung-FamilyHub` (`192.168.1.149`)
4. `Main-Floor` ecobee (`192.168.1.102`)
5. `Upstairs` ecobee (`192.168.1.131`)
## Old IoT move-later / discovery-sensitive
These are probably IoT-class eventually, but not the first things to touch if the goal is a calm cleanup.
- `Google-Home-Mini` (`192.168.1.185`)
- `3c:8d:20:f3:92:36` (`192.168.1.192`, Google)
- `90:ca:fa:b6:7f:6e` (`192.168.1.129`, Google)
## Old IoT quarantine-first leftovers
Leave these in legacy quarantine unless/until they are identified better.
- `5c:61:99:41:73:40` (`192.168.1.172`)
- `60:74:f4:54:fd:ec` (`192.168.1.136`)
- `60:74:f4:7b:6a:11` (`192.168.1.117`)
- `c0:f5:35:20:5d:94` (`192.168.1.183`)
- `d4:ad:fc:60:90:6a` (`192.168.1.100`)
- `d4:ad:fc:ea:7f:65` (`192.168.1.101`)
## Recommended next move order
If doing a low-drama cleanup pass, use this order:
1. Move `LG_Smart_Laundry2_open` out of `Trusted` into `IoT`
2. Leave the two identified Protect chimes on their current known-good Management/default-SSID path unless you are doing an explicit Protect-validation test
3. Migrate the approved Old IoT move-now set into `IoT`, one app-validated batch at a time
4. Leave Google/cast-class gear for later unless everything else is stable
5. Keep unknown leftovers quarantined; do not “clean them up” by granting `Trusted`
## Bottom line
The clearest remaining lane problems are now:
- one approved LG appliance still sitting in `Trusted`
- one likely Google cast/display-class client in `Trusted`
- one likely Intellirocks smart-home client in `Trusted`
- the Protect chimes are a documented temporary exception, not a rediscovery task
- the approved appliance/thermostat/MyQ devices still lingering in `Old IoT`

View File

@@ -0,0 +1,42 @@
# UniFi Client Rehome Results
Historical execution note: this file records what was applied on 2026-05-22, not the final lasting truth for every device. For the current known-good Protect doorbell/chime state, see `unifi-protect-doorbell-chime-current-state-2026-05-26.md`.
Executed from PD against the live UniFi controller using per-client virtual network overrides plus targeted `kick-sta` reconnects.
## Requested changes applied on 2026-05-22
### Trusted -> IoT
- `LG_Smart_Laundry2_open` (`60:ab:14:5f:b4:ba`) -> `IoT` -> `10.5.10.141`
### Management -> Camera (historical attempt; later reverted for the chimes)
- Protect WiFi Chime `upstairs landing` (`58:d6:1f:54:e5:6d`) -> `Camera` -> `10.5.20.191`
- Protect WiFi Chime `Living Room` (`58:d6:1f:54:e5:af`) -> `Camera` -> `10.5.20.8`
### Old IoT -> IoT
- `MyQ-29B` (`0c:95:05:0b:76:65`) -> `IoT` -> `10.5.10.88`
- `LG_Smart_Dryer2_open` (`60:ab:14:94:e4:aa`) -> `IoT` -> `10.5.10.11`
- `Samsung-FamilyHub` (`fc:03:9f:e5:64:50`) -> `IoT` -> `10.5.10.117`
- `Main-Floor` ecobee (`44:61:32:1d:ee:8f`) -> `IoT` -> `10.5.10.160`
- `Upstairs` ecobee (`44:61:32:3f:0b:4c`) -> `IoT` -> `10.5.10.64`
## Validation result at the time
All 8 targeted clients were re-read live from `stat/sta` and matched their intended target lanes during the 2026-05-22 apply window.
Important caveat:
- lane placement success in UniFi Network did not prove Protect health for the chimes
- a later follow-up showed the two chimes could appear associated in UniFi Network while still showing offline in Protect
## Follow-up result that supersedes the chime portion
Later known-good state:
- Protect WiFi Chime `upstairs landing` was returned to `Management` / default `UniFi Wireless` -> `10.5.0.123`
- Protect WiFi Chime `Living Room` was returned to `Management` / default `UniFi Wireless` -> `10.5.0.189`
- the Camera-lane virtual-network override for the chimes should now be treated as a failed experiment in this environment, not as the standing recommendation
## Notes
- Rehome mechanism used: `PUT /rest/user/<id>` with `network_id`, `virtual_network_override_enabled=true`, and `virtual_network_override_id=<target_network_id>`
- Reconnect mechanism used: `POST /cmd/stamgr` with `cmd=kick-sta`
- `LG_Smart_Laundry2_open` had a temporary probe note during discovery; it was cleared during the live apply
- Some clients still show their original SSID names in UniFi telemetry even after landing on the target virtual network override; the authoritative validation point for this pass was the live `network` / `network_id` state
- The Protect lesson learned here is specific and important: a client can look correctly placed in UniFi Network while still being unhealthy in Protect.

View File

@@ -0,0 +1,90 @@
# UniFi DHCP/DNS Verification — 2026-05-23
Purpose: determine whether the restricted lanes (`IoT`, `Camera`, `Old IoT`) are safe for a broader `Untrusted -> Gateway` management shield, specifically by checking what DHCP/DNS behavior they actually appear to rely on.
## Live verification performed
Fresh read-only UniFi poll executed from PD against the live controller.
Verified live from UniFi:
- site: `default`
- controller path: `rest/networkconf`
- supporting reads: `stat/sta`, `v2/api/site/default/firewall-policies`
- current custom policy still present: `Block Untrusted to Gateway Admin Surfaces`
Observed restricted-lane client counts during the same read:
- `IoT`: 6
- `Camera`: 3
- `Old IoT`: 11
## Restricted-lane DHCP/DNS findings from the live controller
### IoT
- subnet: `10.5.10.1/24`
- VLAN: `510`
- DHCP range: `10.5.10.6` - `10.5.10.254`
- `dhcpd_enabled=true`
- `dhcpd_dns_enabled=false`
- `dhcpd_ntp_enabled=false`
- `dhcpd_gateway_enabled=false`
- `mdns_enabled=true`
- `network_isolation_enabled=true`
### Camera
- subnet: `10.5.20.1/24`
- VLAN: `520`
- DHCP range: `10.5.20.6` - `10.5.20.254`
- `dhcpd_enabled=true`
- `dhcpd_dns_enabled=false`
- `dhcpd_ntp_enabled=false`
- `dhcpd_gateway_enabled=false`
- `mdns_enabled=true`
- `network_isolation_enabled=false`
### Old IoT
- subnet: `192.168.1.1/24`
- VLAN: `2`
- DHCP range: `192.168.1.100` - `192.168.1.199`
- `dhcpd_enabled=true`
- `dhcpd_dns_enabled=false`
- `dhcpd_ntp_enabled=false`
- `dhcpd_gateway_enabled=false`
- `mdns_enabled=true`
- `network_isolation_enabled=false`
## Interpretation
The common pattern across all three restricted lanes is unchanged:
- DHCP is on
- custom DHCP DNS is off
- custom DHCP NTP is off
- no explicit per-network DNS servers are configured for those three lanes
Operationally, that means these lanes still look gateway-dependent for their default DHCP-delivered DNS behavior.
Safe working assumption from the live config:
- `IoT` clients likely still use `10.5.10.1` for default DNS delivery
- `Camera` clients likely still use `10.5.20.1` for default DNS delivery
- `Old IoT` clients likely still use `192.168.1.1` for default DNS delivery
- NTP is likewise not explicitly overridden per network
## Decision
Do not apply a broad `Untrusted -> Gateway` deny yet.
The currently deployed surgical rule remains the right safe stopping point:
- `Block Untrusted to Gateway Admin Surfaces`
A broader management shield is still unsafe until one of these becomes true and is verified:
1. restricted-lane clients are intentionally moved to explicit non-gateway DNS/NTP targets, or
2. the broader gateway policy explicitly preserves the exact gateway services those lanes still need, or
3. a later live validation proves those clients no longer depend on gateway DNS/NTP despite the current network definitions
## Practical next step before any broader gateway block
Preferred next live order:
1. decide whether `10.5.30.53` should be the DHCP-advertised DNS target for `IoT`, `Camera`, and `Old IoT`
2. decide whether NTP should stay public, stay gateway-provided, or move to a local service
3. if DNS/NTP stay gateway-dependent, model the exact gateway exceptions first
4. only then convert the current surgical admin-surface block into a broader `Untrusted -> Gateway` shield
## Bottom line
Fresh live UniFi reads confirm the restricted lanes still look gateway-dependent for default DHCP-delivered DNS behavior.
So the answer to "can we safely broaden the gateway shield right now?" is: not yet.

View File

@@ -0,0 +1,65 @@
# UniFi Firewall Enforcement Result — 2026-05-23
Purpose: record the actual first live enforcement change made after the read-only planning pass, plus the exact remaining work that was intentionally not guessed into production.
## What was live-validated before the change
- UniFi controller reachable at `https://10.5.0.1`
- Live zone model confirmed from the controller API:
- `Internal` = Management + Trusted + Servers
- `Untrusted` = IoT + Camera + Old IoT
- `Hotspot` = Guest
- Existing custom policies before this change:
- `Allow Internal to Untrusted`
- disabled temp policy `DORIS-TEMP`
- Existing UniFi built-in defaults already provided:
- Guest internet-only / hotspot restrictions
- Untrusted zone isolation from `Internal`, `Hotspot`, `Dmz`, `Untrusted`, and `Vpn`
- broad `Untrusted -> Gateway` allow remained in place by default
## Live change applied
Added one custom Policy Engine rule:
- `Block Untrusted to Gateway Admin Surfaces`
- source zone: `Untrusted`
- source CIDRs:
- `10.5.10.0/24`
- `10.5.20.0/24`
- `192.168.1.0/24`
- destination zone: `Gateway`
- destination IP: `10.5.0.1`
- destination TCP ports: `22,80,443,8443,9443`
- intent: block IoT / Camera / Old IoT clients from reaching the UDM Pro admin surfaces while leaving DHCP, DNS, mDNS, and normal internet behavior to the existing zone defaults
## Why this was the safe first enforcement slice
This closes the most obvious management-plane exposure left by the default zone policy without guessing at:
- Protect/NVR helper ports
- Google/cast discovery exceptions
- local-vs-public NTP design
- whether every restricted lane is already using the intended DNS target instead of the gateway
A broader `Untrusted -> Gateway` block was deliberately not applied because the current live DHCP/DNS details still need cleanup and verification.
## Validation evidence
- Dry-run from the PD runtime helper showed exactly one new policy create and no mutation to `Allow Internal to Untrusted`
- Apply completed successfully through the same helper
- Follow-up helper run removed the disabled temporary custom policy `DORIS-TEMP`
- Post-apply custom policy readback now shows only:
- `Allow Internal to Untrusted`
- `Block Untrusted to Gateway Admin Surfaces`
- Controller API access from PD remained healthy immediately after apply
## Things intentionally left for later
Still not safe to guess into production without targeted validation:
- final `HOST-ADMIN-TRUSTED` membership
- full management shield for every internal lane
- explicit Trusted admin allow objects before any broad `Internal -> Gateway` deny
- exact camera/Protect helper ports
- Google/cast discovery/control exceptions
- final SSID retirement / simplification
## Recommended next live order
1. verify what DNS target the restricted lanes are actually receiving from DHCP today
2. verify whether any restricted clients still need gateway access beyond DHCP/mDNS
3. only then decide whether to convert this first surgical block into the broader management shield design
4. do Google/cast validation from a laptop before any cast-related firewall or SSID cleanup
5. finish SSID simplification after the cast/discovery behavior is understood

View File

@@ -0,0 +1,218 @@
# UniFi Firewall Gap List
Purpose: compare the intended segmentation design against the current staged UniFi firewall artifacts without making any live traffic changes.
Evidence used:
- Desired design: `home/doris-dashboard/docs/network-firewall-rule-order.md`
- Current groups baseline: `home/doris-dashboard/docs/baselines/unifi-firewallgroup-baseline-2026-05-22-post-stage.json`
- Current custom policy baseline: `home/doris-dashboard/docs/baselines/unifi-firewall-policies-custom-2026-05-22-post-ui-probe.json`
## Executive summary
Current state is scaffolding, not finished enforcement.
What exists now:
- network/address groups for the major lanes
- a handful of port groups
- one enabled custom outbound-style policy: `Allow Internal to Untrusted`
- one disabled temp policy: `DORIS-TEMP`
What does not yet exist in the captured custom-policy baseline:
- the actual inter-VLAN segmentation matrix
- the management shield
- the quarantine posture for Legacy CIA
- the lane-specific allow/deny structure described in the design doc
So the honest answer is:
- groups/objects: mostly present
- real custom segmentation policy: largely still missing
## 1. Present in the current staged baseline
### Network groups present
- `NET-MGMT`
- `NET-TRUSTED`
- `NET-SERVERS`
- `NET-IOT`
- `NET-GUEST`
- `NET-CAMERAS`
- `NET-LEGACY-CIA`
- `NET-RFC1918-ALL`
### Port groups present
- `PORT-DNS`
- `PORT-DHCP`
- `PORT-NTP`
- `PORT-WEB-ADMIN`
- `PORT-SSH`
- `PORT-MDNS`
### Custom policies present
1. `Allow Internal to Untrusted`
- enabled
- effectively basic internal -> internet/outside allowance scaffolding
2. `DORIS-TEMP`
- disabled
- temporary/non-production artifact, not part of the final design
## 2. Missing object inventory compared to the design
The rule-order design expects these host/device groups, but they do not appear in the captured firewall-group baseline:
- `HOST-ADMIN-TRUSTED`
- `HOST-CORE-SERVICES`
- `HOST-PROTECT-SERVICES`
- `HOST-DNS`
- `HOST-NTP`
- `HOST-IOT-HELPERS`
- `HOST-CAMERA-HELPERS`
- `HOST-LEGACY-EXCEPTIONS`
The design also mentions port groups not seen in the captured baseline:
- `PORT-PROTECT`
- `PORT-CAST`
Impact:
- exact narrow allow rules for management, Protect, helper traffic, and Google/cast exceptions cannot be cleanly expressed yet using the intended group model
## 3. Missing rule sections compared to the design
### Section A: Core state handling
Missing or not evidenced in the captured custom-policy baseline:
- `ALLOW Established/Related`
- `DROP Invalid`
### Section B: Management protection
Missing or not evidenced:
- `ALLOW Trusted Admin -> Management Admin Surfaces`
- `ALLOW Trusted Admin -> Gateway Infra Utilities`
- `DROP IoT -> Management`
- `DROP Cameras -> Management`
- `DROP Guest -> Management`
- `DROP Legacy CIA -> Management`
- `DROP Any Internal -> Management`
Meaning:
- the intended management shield is not yet represented in the captured custom-policy set
### Section C: Trusted human lane
Missing or not evidenced:
- `ALLOW Trusted -> Servers Approved Access`
- `ALLOW Trusted -> Cameras Admin/Viewer Access`
- `ALLOW Trusted -> IoT Control Exceptions`
Meaning:
- the designs explicit human/operator access model is not yet staged in a visible way
### Section D: Server/helper traffic
Missing or not evidenced:
- `ALLOW Servers -> IoT Approved Helpers`
- `ALLOW Cameras -> Protect Services`
- `ALLOW IoT -> Approved Server Helpers`
- `ALLOW Legacy CIA -> Approved One-Off Exception`
Meaning:
- no captured evidence yet of the narrow internal service exceptions the design wants
### Section E: DNS/NTP baseline for restricted lanes
Missing or not evidenced:
- `ALLOW IoT -> DNS`
- `ALLOW Cameras -> DNS`
- `ALLOW Legacy CIA -> DNS`
- `ALLOW IoT -> NTP`
- `ALLOW Cameras -> NTP`
- `ALLOW Legacy CIA -> NTP`
Meaning:
- the restricted-lane minimum-function posture is not yet fully expressed in custom rules
### Section F: Internet access for constrained lanes
Partially present at best:
- there is one broad custom policy, `Allow Internal to Untrusted`
Still missing as lane-specific explicit policy:
- `ALLOW Guest -> Internet`
- `ALLOW IoT -> Internet`
- `ALLOW Cameras -> Internet Updates`
- `ALLOW Legacy CIA -> Internet`
Meaning:
- outbound access is only evidenced in a broad/global way, not in the lane-specific shape called for by the design
### Section G: Broad internal denies for restricted lanes
Missing or not evidenced:
- `DROP Guest -> RFC1918/Internal`
- `DROP IoT -> Trusted`
- `DROP IoT -> Servers`
- `DROP IoT -> Cameras`
- `DROP Cameras -> Trusted`
- `DROP Cameras -> Servers`
- `DROP Cameras -> IoT`
- `DROP Legacy CIA -> Trusted`
- `DROP Legacy CIA -> Servers`
- `DROP Legacy CIA -> Cameras`
- `DROP Legacy CIA -> IoT`
Meaning:
- the core segmentation barriers between the restricted lanes and the rest of the network are not yet evidenced in the captured policy set
### Section H: Optional discovery exceptions
Correctly absent for now:
- no evidence of broad Google/cast discovery exception rules
- this is good; the design explicitly says these should only appear after real failure testing
## 4. Practical interpretation
If the captured baselines are still current, then the environment appears to be in this state:
1. The naming/object foundation is mostly there.
2. The network has at least one custom outbound-style policy.
3. The actual inter-VLAN enforcement plan is still largely unimplemented.
4. The current state is safer than random ad-hoc rules, but it is not yet the finished segmentation design.
## 5. Safe next implementation order
Before any live firewall apply, the safest order is:
1. Create the missing host groups
- `HOST-ADMIN-TRUSTED`
- `HOST-DNS`
- `HOST-NTP`
- `HOST-PROTECT-SERVICES`
- helper/exception groups as needed
2. Add the minimum safe rule skeleton first
- `ALLOW Established/Related`
- `DROP Invalid`
- management shield block set
- explicit Trusted admin -> Management allows
- Trusted -> Servers allow
3. Add the restricted-lane minimum-function rules
- DNS
- NTP
- outbound internet as appropriate
4. Add the broad internal deny matrix for Guest / IoT / Camera / Legacy CIA
5. Only after that, consider narrow discovery exceptions if the Google/cast pilot proves they are actually needed
## 6. What is safe to say right now
Accurate phrasing:
- firewall scaffolding exists
- the object/group layer is mostly staged
- a basic outbound custom policy exists
- the full inter-VLAN segmentation matrix is not yet fully implemented
Inaccurate phrasing to avoid:
- “the firewall is done”
- “inter-VLAN isolation is fully in place”
- “Legacy CIA is already fully quarantined by policy”
## 7. Recommended next operator action
Next safe action, still non-disruptive:
- define the missing host groups and map real devices/services into them on paper or in staging notes first
Next live-action phase after that:
- apply the minimum safe rule skeleton in a rollback-friendly order, validating management reachability after each step

View File

@@ -0,0 +1,249 @@
# UniFi Firewall Host-Group Membership Proposal
Purpose: propose concrete membership for the missing host/device groups referenced by the firewall design, using current repo documentation and the recent UniFi cleanup state. This is a planning artifact only; it does not apply live network changes.
Evidence used:
- `docs/architecture/SERVICES_DIRECTORY.md`
- `docs/architecture/NETWORKING_MODEL.md`
- `docs/servers/PLAUSIBLEDENABILITY.md`
- `docs/servers/SERENITY.md`
- `docs/servers/ROCINANTE.md`
- `pihole/README.md`
- `home/doris-dashboard/docs/unifi-client-cleanup-shortlist-2026-05-22.md`
- memory note: UniFi is `10.5.0.1`; PD is `10.5.30.6`; Serenity Pi-hole HA VIP is `10.5.30.53/24`
## Executive summary
These groups should be split into three confidence bands:
- High confidence: safe to define now from documented inventory
- Medium confidence: likely right, but validate in UniFi/UI before live enforcement
- Low confidence / leave empty for now: do not guess; create the group but keep it empty until a real dependent flow proves it is needed
## 1. Proposed group membership
### HOST-ADMIN-TRUSTED
Purpose:
- devices allowed to initiate management/admin traffic into the Management lane
Proposed members:
- Rocinante trusted endpoint/client
- John primary phone: `Pixel-9-Pro-XL`
- Optional: `Pixel-7` if you really want phone-based admin
Do NOT include by default:
- `FlyingDutchman`
- `steamdeck`
- `Valkyrie`
- random laptops/TVs/tablets just because they are on Trusted
Confidence:
- Medium
Reasoning:
- the cleanup shortlist says these human/operator devices are intentionally on Trusted
- Rocinante is the explicit operator station in the cutover docs
- the firewall design says this group should be small and deliberate
Implementation note:
- if UniFi requires IPs instead of clean client objects for this group, resolve the current IP/MACs from live client state before creating the actual group
### HOST-CORE-SERVICES
Purpose:
- core homelab service hosts on the Servers lane
Proposed members:
- PlausibleDeniability / PD -> `10.5.30.6`
- Serenity -> `10.5.30.5`
- N.O.M.A.D. -> `10.5.30.7`
- Rocinante -> `10.5.30.112`
Confidence:
- High
Reasoning:
- all four are documented infrastructure hosts
- these are the obvious server endpoints repeatedly referenced across the repo
### HOST-PROTECT-SERVICES
Purpose:
- target for camera/security devices that need to talk to the Protect/NVR side
Proposed members:
- UDM Pro / UniFi gateway-controller -> `10.5.0.1`
Confidence:
- Medium
Reasoning:
- the docs clearly identify `10.5.0.1` as the UDM Pro / controller
- the chimes and doorbell are UniFi Protect accessories
- no separate UNVR/NVR host is documented anywhere obvious in the repo inventory
Caution:
- verify in UniFi before hard enforcement whether Protect is actually hosted on the UDM Pro in this environment or whether there is another Protect endpoint not yet documented
### HOST-DNS
Purpose:
- approved DNS resolvers for restricted lanes
Proposed members:
- Pi-hole HA VIP -> `10.5.30.53`
- Optional explicit backing nodes if you want belt-and-suspenders:
- PD Pi-hole primary -> `10.5.30.6`
- NOMAD Pi-hole replica admin host -> `10.5.30.7`
Recommended practical membership:
- start with just `10.5.30.53`
Confidence:
- High for VIP
- Medium for adding the backing hosts directly
Reasoning:
- the repo explicitly calls `10.5.30.53` the VIP for client DNS
- using the VIP keeps the policy clean and decoupled from backend failover details
### HOST-NTP
Purpose:
- approved NTP targets for restricted lanes
Proposed members:
- leave empty for now if clients are intended to use public NTP directly
Alternative if you insist on local NTP later:
- add the actual documented local NTP server only after verifying one exists and is intended for clients
Confidence:
- Low / intentionally unresolved
Reasoning:
- the firewall design explicitly says this can be omitted if public NTP is used
- the repo docs do not currently provide a clear dedicated client-facing LAN NTP service
- the Pi-hole docs explicitly note Pi-hole NTP is disabled on PD because host `chronyd` owns UDP 123, but that alone is not enough proof that PD should become the universal client NTP target
Recommendation:
- for first-pass enforcement, model NTP as outbound internet allowed where needed rather than pretending a local NTP service is definitely part of the design
### HOST-IOT-HELPERS
Purpose:
- specific server-side helpers that IoT devices are allowed to contact
Proposed members for first pass:
- Home Assistant on PD -> `10.5.30.6` service port `8123`
- Kima Hub on PD -> `10.5.30.6` service port `3333`
Possible later additions only if proven necessary:
- any MQTT broker actually used by IoT devices
- any local appliance helper/integration endpoint that demonstrably breaks without local access
Confidence:
- Medium
Reasoning:
- Home Assistant and Kima Hub are the only clearly documented smart-home/helper services in the repo inventory
- these are plausible “approved server helpers” for IoT
- do not add Plex, Tautulli, or general app hosts here by default just because they exist
Caution:
- no explicit MQTT broker is clearly documented in the current inventory as a central IoT dependency; do not invent one into this group
### HOST-CAMERA-HELPERS
Purpose:
- server-side helpers cameras/security devices are allowed to contact beyond pure internet access
Proposed members:
- same initial target as HOST-PROTECT-SERVICES:
- UDM Pro / Protect endpoint -> `10.5.0.1`
Confidence:
- Medium
Reasoning:
- until a separate Protect/NVR host is documented, the safest concrete starting point is the documented UniFi gateway/controller
### HOST-LEGACY-EXCEPTIONS
Purpose:
- explicit ugly one-off quarantine exceptions for legacy devices
Proposed members:
- none initially; create the group empty
Confidence:
- High for “empty by default”
Reasoning:
- the design explicitly treats Legacy CIA as hospice, not production
- there is no documented exception that has already earned inclusion
- empty is safer than pretending one-offs are already justified
## 2. Port-group follow-up proposal
The firewall gap list noted two useful missing port groups.
### PORT-PROTECT
Proposed status:
- define later, only after verifying the actual required Protect ports for chime/doorbell traffic in this environment
Confidence:
- Low now
Reasoning:
- the repo docs do not yet give a trusted exact Protect port list for this design
- better to leave broad camera policy undone than to guess wrong and strand security devices
### PORT-CAST
Proposed status:
- do not define yet
Confidence:
- High for deferral
Reasoning:
- the design explicitly says cast/discovery rules should only appear after real failure testing
- wait for the Google/cast pilot
## 3. Suggested first-pass implementation set
If you want the cleanest minimal live enforcement pass later, the least-controversial groups to create/use first are:
1. `HOST-CORE-SERVICES`
- `10.5.30.5`
- `10.5.30.6`
- `10.5.30.7`
- `10.5.30.112`
2. `HOST-DNS`
- `10.5.30.53`
3. `HOST-ADMIN-TRUSTED`
- only the one or two devices John truly admins from
4. `HOST-PROTECT-SERVICES`
- `10.5.0.1` pending verification
5. `HOST-IOT-HELPERS`
- `10.5.30.6` only, if using Home Assistant / Kima Hub as the first-pass helper target
And leave these empty until proven:
- `HOST-NTP`
- `HOST-CAMERA-HELPERS` if different from Protect
- `HOST-LEGACY-EXCEPTIONS`
## 4. Validation questions before live rule apply
Before turning these into real enforced firewall objects, verify:
1. Which exact Trusted client(s) should truly admin Management?
2. Is Protect actually on the UDM Pro at `10.5.0.1`, or is there a separate undocumented host?
3. Do IoT devices need Home Assistant and/or Kima Hub locally right now, or can they live on cloud-only plus DNS/internet first?
4. Is there a real client-facing local NTP service, or should NTP just be allowed outbound?
5. Are there any Legacy CIA one-off exceptions that have actually earned existence? If not, keep that group empty.
## 5. Blunt recommendation
If we want a safe first real enforcement pass later:
- definitely create/use `HOST-CORE-SERVICES`, `HOST-DNS`, and a very small `HOST-ADMIN-TRUSTED`
- probably create `HOST-PROTECT-SERVICES` as `10.5.0.1` after one verification step
- treat `HOST-NTP`, `PORT-PROTECT`, and `PORT-CAST` as intentionally unresolved until verified
- keep `HOST-LEGACY-EXCEPTIONS` empty unless a specific ugly legacy device proves it needs one

View File

@@ -0,0 +1,101 @@
# UniFi Legacy CIA Closeout — 2026-05-23
Purpose: close out the remaining unidentified `CIA Via` / `Old IoT` clients after the easy-value migrations and the Trusted-lane Intellirocks cleanup.
## Scope
This document covers the leftover unidentified devices that should remain in legacy quarantine.
Historical basis used:
- `home/doris-dashboard/docs/unifi-client-cleanup-shortlist-2026-05-22.md`
- `home/doris-dashboard/docs/unifi-client-rehome-results-2026-05-22.md`
- `home/doris-dashboard/docs/old-iot-tomorrow-disposition-list.md`
- `home/doris-dashboard/docs/intellirocks-triage-result-2026-05-23.md`
Important note:
- The live UniFi read helper in this shell cannot currently re-poll the controller because local `automation/.env` here does not contain the `UNIFI_*` credentials.
- So this closeout is based on the latest verified operator artifacts already captured during the live session, plus the completed Intellirocks Trusted-lane cleanup from 2026-05-23.
## What is already resolved
These are no longer part of the legacy-quarantine leftovers:
- `MyQ-29B` -> moved `Old IoT` -> `IoT`
- `LG_Smart_Dryer2_open` -> moved `Old IoT` -> `IoT`
- `Samsung-FamilyHub` -> moved `Old IoT` -> `IoT`
- `Main-Floor` ecobee -> moved `Old IoT` -> `IoT`
- `Upstairs` ecobee -> moved `Old IoT` -> `IoT`
- Trusted-lane Intellirocks sibling `d4:ad:fc:f2:df:d2` -> moved `Trusted` -> `Old IoT`
## Final disposition for remaining unidentified Legacy CIA devices
### 1) 5c:61:99:41:73:40
- Last known IP: `192.168.1.172`
- Vendor: `Cloud Network Technology Singapore Pte. Ltd.`
- Final disposition: `keep quarantined on Old IoT`
- Why:
- unnamed
- unclear function
- insufficient evidence to grant clean `IoT`
- Follow-up class: `identify later / possible kill-candidate if nobody can identify it`
### 2) 60:74:f4:54:fd:ec
- Last known IP: `192.168.1.136`
- Vendor: `Private`
- Final disposition: `keep quarantined on Old IoT`
- Why:
- private/randomized identity
- no useful hostname
- no evidence it deserves promotion
- Follow-up class: `identify later / strong kill-candidate pool`
### 3) 60:74:f4:7b:6a:11
- Last known IP: `192.168.1.117`
- Vendor: `Private`
- Final disposition: `keep quarantined on Old IoT`
- Why:
- same reasoning as the other private/randomized leftover
- no positive identification
- Follow-up class: `identify later / strong kill-candidate pool`
### 4) c0:f5:35:20:5d:94
- Last known IP: `192.168.1.183`
- Vendor: `AMPAK Technology,Inc.`
- Final disposition: `keep quarantined on Old IoT`
- Why:
- broad embedded/vendor bucket
- no positive identification
- no justification for moving into cleaner `IoT`
- Follow-up class: `identify later / possible kill-candidate`
### 5) d4:ad:fc:60:90:6a
- Last known IP: `192.168.1.100`
- Vendor: `Shenzhen Intellirocks Tech co., ltd`
- Final disposition: `keep quarantined on Old IoT`
- Why:
- still unidentified
- vendor family now has three low-trust/quarantine-aligned siblings, including the former Trusted device moved back out on 2026-05-23
- this strengthens the case that these are embedded smart-home / disposable low-trust devices, not operator endpoints
- Follow-up class: `identify later / likely kill-candidate if never claimed`
### 6) d4:ad:fc:ea:7f:65
- Last known IP: `192.168.1.101`
- Vendor: `Shenzhen Intellirocks Tech co., ltd`
- Final disposition: `keep quarantined on Old IoT`
- Why:
- same reasoning as sibling Intellirocks device above
- no positive identification
- Follow-up class: `identify later / likely kill-candidate if never claimed`
## Operational conclusion
The remaining Legacy CIA leftovers do not need migration work right now.
The correct closeout posture is:
- leave all six unidentified leftovers on `Old IoT`
- do not promote them into `Trusted`, `Management`, or clean `IoT`
- treat them as explicit quarantine residents pending later identification or eventual retirement
## What this means for the remaining task list
Legacy CIA closeout is complete as a policy/disposition decision.
The remaining network cleanup work is now mainly:
- Google/cast validation and any resulting placement decisions
- final SSID simplification once Google behavior is understood
- optional future kill-pass for unclaimed legacy leftovers

View File

@@ -1,5 +1,10 @@
# UniFi Live Preflight Snapshot # UniFi Live Preflight Snapshot
Historical snapshot note:
- this file is a dated preflight capture from 2026-05-22
- preserve it as evidence, but do not treat it as the current source of truth for remaining work or final device placement
- later results and reversals are documented in `network-migration-remaining-checklist-2026-05-22.md` and `unifi-protect-doorbell-chime-current-state-2026-05-26.md`
Date: 2026-05-22 Date: 2026-05-22
Mode: read-only live pull from PD using the Doris UniFi operator account Mode: read-only live pull from PD using the Doris UniFi operator account
Controller: `https://10.5.0.1` Controller: `https://10.5.0.1`
@@ -82,7 +87,7 @@ Interpretation:
- `58:d6:1f:54:e5:6d` -> `10.5.0.123` - `58:d6:1f:54:e5:6d` -> `10.5.0.123`
- `58:d6:1f:54:e5:af` -> `10.5.0.189` - `58:d6:1f:54:e5:af` -> `10.5.0.189`
These are still the two management-lane ESP-class offenders to identify or evict. Historical interpretation only: these were later identified as the two Protect WiFi chimes, so this section is evidence of the pre-identification state, not an open rediscovery task.
## Fresh anomalies vs the earlier planning assumptions ## Fresh anomalies vs the earlier planning assumptions
@@ -103,7 +108,7 @@ These are still the two management-lane ESP-class offenders to identify or evict
- confirmed named host placement for the server batch and Old IoT migration set - confirmed named host placement for the server batch and Old IoT migration set
- confirmed management offenders remain present - confirmed management offenders remain present
- confirmed U6 LR still appears offline/disconnected - confirmed U6 LR still appears offline/disconnected
- captured a raw pre-write baseline of `networkconf` and `portconf` at `/home/fizzlepoof/repos/truenas-stacks/home/doris-dashboard/docs/baselines/unifi-object-baseline-2026-05-22-024653.json` - captured a raw pre-write baseline of `networkconf` and `portconf` outside the main repo; only sanitized summaries/redacted artifacts belong under `docs/baselines/`
- authored an idempotent staging helper at `/home/fizzlepoof/repos/truenas-stacks/automation/bin/unifi_stage_low_risk_objects.py` - authored an idempotent staging helper at `/home/fizzlepoof/repos/truenas-stacks/automation/bin/unifi_stage_low_risk_objects.py`
- copied that helper to PD at `/mnt/docker-ssd/docker/compose/automation/bin/unifi_stage_low_risk_objects.py` - copied that helper to PD at `/mnt/docker-ssd/docker/compose/automation/bin/unifi_stage_low_risk_objects.py`
- confirmed the current Doris UniFi account is still read-only for writes: `POST /rest/networkconf` returns `403 Forbidden` - confirmed the current Doris UniFi account is still read-only for writes: `POST /rest/networkconf` returns `403 Forbidden`

View File

@@ -0,0 +1,261 @@
# UniFi Minimum Safe Rule Skeleton
Purpose: convert the desired firewall design, the captured gap list, and the host-group proposal into the smallest sane live-apply rule set and exact operator order. This is still a planning artifact. It is written to minimize the chance of locking out admin access or accidentally broadening trust during first enforcement.
Evidence used:
- `home/doris-dashboard/docs/network-firewall-rule-order.md`
- `home/doris-dashboard/docs/unifi-firewall-gap-list-2026-05-22.md`
- `home/doris-dashboard/docs/unifi-firewall-host-group-proposal-2026-05-22.md`
- `home/doris-dashboard/docs/network-migration-remaining-checklist-2026-05-22.md`
- skill guidance: `unifi-network-operations`
## Executive summary
Do not try to apply the entire final segmentation design in one shot.
For the first real live firewall enforcement pass, the minimum safe skeleton should be:
1. Stateful baseline
2. Management shield with explicit admin allows first
3. Trusted -> Servers allow
4. Guest internet-only pair
5. DNS/NTP minimum-function rules for restricted lanes
6. IoT/Cameras/Legacy broad internal deny matrix
7. Only after validation, consider helper exceptions and any Google/cast discovery carve-outs
That order preserves the management plane, keeps the trusted human lane usable, and delays the riskiest discovery-sensitive exceptions until there is proof they are needed.
## 1. Preconditions before any live apply
Do not start the live rule phase unless all of these are true:
- A trusted admin session is already working from Rocinante or another confirmed operator device
- `HOST-ADMIN-TRUSTED` is defined narrowly and correctly
- `HOST-CORE-SERVICES` is defined
- `HOST-DNS` is defined at least as `10.5.30.53`
- `NET-MGMT`, `NET-TRUSTED`, `NET-SERVERS`, `NET-IOT`, `NET-GUEST`, `NET-CAMERAS`, `NET-LEGACY-CIA`, and `NET-RFC1918-ALL` exist
- `PORT-DNS`, `PORT-NTP`, `PORT-WEB-ADMIN`, and `PORT-SSH` exist
- `HOST-PROTECT-SERVICES` is either verified or deliberately deferred
- Google/cast pilot is still treated as unresolved; do not guess its exceptions into day-one policy
- Legacy CIA devices remain quarantine-first; no broad trust promotion to make the rules easier
## 2. Object set to have ready for day-one enforcement
### Must-have now
- `HOST-ADMIN-TRUSTED`
- `HOST-CORE-SERVICES`
- `HOST-DNS`
### Nice to have, but can be deferred if uncertain
- `HOST-PROTECT-SERVICES`
- `HOST-IOT-HELPERS`
- `HOST-CAMERA-HELPERS`
- `HOST-NTP`
- `HOST-LEGACY-EXCEPTIONS`
### Port groups to use now
- `PORT-DNS`
- `PORT-NTP`
- `PORT-WEB-ADMIN`
- `PORT-SSH`
### Port groups to defer
- `PORT-PROTECT`
- `PORT-CAST`
## 3. Exact first-pass live apply order
Top-to-bottom intended live order:
### Block 1: Stateful safety baseline
1. `ALLOW Established/Related`
- Why first: keeps return traffic alive once restrictive rules appear
- Validation immediately after add:
- current UniFi UI session remains usable
- SSH from trusted admin box to one server still works
2. `DROP Invalid`
- Why second: low drama hygiene rule, safe to place early
- Validation:
- no obvious reachability loss to controller or PD
### Block 2: Management shield
3. `ALLOW Trusted Admin -> Management Admin Surfaces`
- Source: `HOST-ADMIN-TRUSTED`
- Destination: `NET-MGMT`
- Ports: `PORT-WEB-ADMIN`, `PORT-SSH`
- Validation:
- load UniFi from the trusted admin box
- SSH/ping at least one management-plane infra endpoint if applicable
4. `ALLOW Trusted Admin -> Gateway Infra Utilities`
- Source: `HOST-ADMIN-TRUSTED`
- Destination: `NET-MGMT`
- Ports/protocols: ICMP and only other clearly required infra utilities
- Validation:
- gateway reachability checks still pass from the trusted admin box
5. `DROP IoT -> Management`
6. `DROP Cameras -> Management`
7. `DROP Guest -> Management`
8. `DROP Legacy CIA -> Management`
9. `DROP Any Internal -> Management`
- Why this order: explicit admin allows must exist before the broad management shield closes
- Validation after the full block:
- UniFi still reachable from trusted admin box
- no urgent household functionality unexpectedly depended on talking to Management
- Rollback note:
- if management reachability breaks, disable/remove rule 9 first, then 8/7/6/5 in reverse order
### Block 3: Preserve the human/operator lane
10. `ALLOW Trusted -> Servers Approved Access`
- Source: `NET-TRUSTED`
- Destination: `NET-SERVERS`
- Day-one recommendation: allow broadly enough to preserve normal operator/admin use, then tighten later if desired
- Validation:
- SSH to PD, Serenity, NOMAD, and Rocinante from trusted admin device
- key dashboards/apps reachable from Trusted
11. `ALLOW Trusted -> Cameras Admin/Viewer Access`
- Recommendation: create disabled or defer unless you already know the exact need
- Reason: safer than inventing camera-viewer requirements blindly
12. `ALLOW Trusted -> IoT Control Exceptions`
- Recommendation: do not enable broad versions of this on day one
- Reason: this is where cast/discovery sprawl sneaks in
## 4. Minimum restricted-lane function block
Apply this before the broad internal denies so the constrained lanes still have basic services.
### Block 4A: DNS
13. `ALLOW IoT -> DNS`
14. `ALLOW Cameras -> DNS`
15. `ALLOW Legacy CIA -> DNS`
- Destination: `HOST-DNS`
- Ports: `PORT-DNS`
- Validation:
- one client on each lane still resolves DNS
### Block 4B: NTP
16. `ALLOW IoT -> NTP`
17. `ALLOW Cameras -> NTP`
18. `ALLOW Legacy CIA -> NTP`
- Destination:
- if a real local NTP service is verified, use `HOST-NTP`
- otherwise model as outbound/public NTP according to UniFis rule model
- Validation:
- no obvious time-sync failures on representative devices
- Caution:
- do not pretend PD is the universal NTP server unless verified
### Block 4C: Internet access
19. `ALLOW Guest -> Internet`
20. `ALLOW IoT -> Internet`
21. `ALLOW Cameras -> Internet Updates`
22. `ALLOW Legacy CIA -> Internet`
- Validation:
- guest gets internet but not local access
- IoT devices retain cloud/app functionality where expected
- cameras only keep expected update/cloud behavior
## 5. Broad internal deny matrix
Only add this after the basic function rules above are in place.
23. `DROP Guest -> RFC1918/Internal`
24. `DROP IoT -> Trusted`
25. `DROP IoT -> Servers`
26. `DROP IoT -> Cameras`
27. `DROP Cameras -> Trusted`
28. `DROP Cameras -> Servers`
29. `DROP Cameras -> IoT`
30. `DROP Legacy CIA -> Trusted`
31. `DROP Legacy CIA -> Servers`
32. `DROP Legacy CIA -> Cameras`
33. `DROP Legacy CIA -> IoT`
Validation after this block:
- guest can browse internet but cannot reach local RFC1918 targets
- IoT can still do DNS/NTP/internet, but cannot hit Trusted/Servers/Cameras except where later explicit exceptions exist
- Cameras can still do DNS/NTP/internet or Protect-only needs if that rule has been added
- Legacy CIA remains hospice-only and cannot laterally move inside the house
Rollback note:
- if a constrained lane breaks in an unclear way, remove the most recent deny rule in reverse order before touching the earlier management block
## 6. Rules to defer on the first live pass
These are real design items, but they should not be guessed into the first enforcement wave.
### Defer until verified
- `ALLOW Cameras -> Protect Services`
- `ALLOW Servers -> IoT Approved Helpers`
- `ALLOW IoT -> Approved Server Helpers`
- `ALLOW Legacy CIA -> Approved One-Off Exception`
- `ALLOW Trusted -> Cameras Admin/Viewer Access` if ports/needs are unknown
- `ALLOW Trusted -> IoT Control Exceptions` if it would be broad or discovery-heavy
Why defer:
- `HOST-PROTECT-SERVICES` still wants verification
- `PORT-PROTECT` is intentionally unresolved
- Google/cast behavior is still pending a one-device pilot
- helper rules are where accidental over-permissive policy usually appears
## 7. Suggested operator wave plan
If this becomes a real live session, the safest waves are:
### Wave 1: low-drama core
- rules 1-10 only
- stop and validate
### Wave 2: restricted-lane minimum function
- rules 13-22
- stop and validate
### Wave 3: broad deny matrix
- rules 23-33
- stop and validate
### Wave 4: narrow helper/protect exceptions
- only after proof from real failures or explicit use-cases
## 8. Validation checklist after each wave
Minimum checks from a trusted admin endpoint:
- UniFi UI still loads
- SSH to PD works
- SSH to NOMAD works
- SSH to Serenity works
- dashboard/homepage still loads if expected
Restricted-lane checks after waves 2 and 3:
- one Guest client has internet and cannot reach local RFC1918 targets
- one IoT client still has DNS/internet
- one Camera client still behaves normally
- one Legacy CIA client is still contained and not silently promoted by exception
## 9. Blunt recommendation
If doing the first live firewall enforcement pass soon, I would treat these as the true minimum safe starting set:
Definitely include:
- rules 1-10
- rules 13-22
- rules 23-33
Do not force in yet unless verified:
- rule 11
- rule 12
- rules involving Protect, helper hosts, or cast/discovery exceptions
That produces a real skeleton with management protection, operator reachability, guest internet-only posture, and broad quarantine behavior for IoT/Cameras/Legacy without inventing fragile discovery exceptions on day one.
## 10. Immediate follow-up after this planning artifact
Best non-disruptive next planning steps:
1. verify the final intended members of `HOST-ADMIN-TRUSTED`
2. decide whether `HOST-PROTECT-SERVICES` really equals `10.5.0.1`
3. complete the one-device Google/cast pilot before any cast/discovery exception design
4. only then translate this ordered skeleton into exact UniFi Policy Engine objects/payloads for live apply

View File

@@ -0,0 +1,60 @@
# UniFi Protect Doorbell / Chime Current State — 2026-05-26
Purpose: capture the current known-good state for the adopted doorbell and the two Protect WiFi chimes so future cleanup work does not accidentally re-run a reverted change.
## Status summary
Current known-good state:
- `front-doorbell` remains the Protect doorbell endpoint
- Protect WiFi Chime `upstairs landing` is intentionally on `Management` / default `UniFi Wireless`
- Protect WiFi Chime `Living Room` is intentionally on `Management` / default `UniFi Wireless`
- per-client Camera virtual-network overrides for the chimes should remain disabled unless a later validation proves a safe Protect path on the Camera/Security lane
Current chime IPs from the last known-good state:
- `upstairs landing` -> `10.5.0.123`
- `Living Room` -> `10.5.0.189`
## What changed from the earlier migration notes
Historical 2026-05-22 action:
- both chimes were moved from `Management` to `Camera` using per-client virtual network overrides
- UniFi Network showed them associated on the target lane
Why that is not the source of truth anymore:
- in this environment, the Camera-lane override made the chimes show offline in Protect even though UniFi Network still showed them connected
- the working fix was to remove the Camera override and let the chimes return to the known-good Management/default-SSID path
Operational conclusion:
- treat the 2026-05-22 chime move as a historical experiment, not a standing recommendation
- do not re-home the chimes to `Camera` / `Security` as routine cleanup without fresh Protect validation
## Doorbell adoption note
After a doorbell is re-adopted:
- verify the chimes are still logically bound to the current adopted doorbell object
- if chime ringing breaks even though the devices are online, check whether each chime's `ringSettings.cameraId` still points at the old doorbell object instead of the current adopted one
This is separate from the network-lane issue:
- network-lane failure symptom: chime looks connected in UniFi Network but offline in Protect
- adoption-binding failure symptom: doorbell is adopted and devices are online, but the chimes do not ring for the current doorbell object
## Future-state intent versus current safe state
Long-term design intent still favors a dedicated Camera/Security lane and SSID for Protect accessories.
Current safe live state is narrower:
- doorbell can stay where it is if healthy
- chimes stay on Management/default `UniFi Wireless` until Protect connectivity is explicitly re-validated on the Camera/Security lane
- future migration is blocked on proving the required Protect path, not on re-discovering the device identities
## Files that were updated to reflect this
Key current-truth docs:
- `network-migration-remaining-checklist-2026-05-22.md`
- `unifi-client-cleanup-shortlist-2026-05-22.md`
- `unifi-client-rehome-results-2026-05-22.md`
- `unifi-ssid-cleanup-proposal-2026-05-23.md`
- `network-redesign-plan.md`
- `network-redesign-implementation-runbook.md`
Historical planning/snapshot docs were also marked so they are not mistaken for current next-step instructions.

View File

@@ -1,5 +1,10 @@
# UniFi Read-Only Recon Notes # UniFi Read-Only Recon Notes
Historical snapshot note:
- this file records the 2026-05-22 read-only discovery pass
- preserve it as evidence, but do not use it as the current next-step checklist without checking later outcome docs
- later results and reversals are documented in `network-migration-remaining-checklist-2026-05-22.md` and `unifi-protect-doorbell-chime-current-state-2026-05-26.md`
Date: 2026-05-22 Date: 2026-05-22
Mode: read-only only Mode: read-only only
Auth status: verified working with upgraded Doris admin login Auth status: verified working with upgraded Doris admin login
@@ -79,9 +84,9 @@ Likely quarantine until identified better:
### 6. Camera lane live state ### 6. Camera lane live state
- `front-doorbell` is already on Camera network at `10.5.20.217` - `front-doorbell` is already on Camera network at `10.5.20.217`
Interpretation: Interpretation at the time:
- the doorbell is already where it belongs logically - the doorbell was already in the expected camera lane during this snapshot
- tomorrows security cleanup work is mainly about chimes and future policy tightening - later follow-up changed the chime guidance, so do not treat this paragraph as the final remaining-work instruction set
### 7. Trusted lane still carrying infrastructure-class hosts ### 7. Trusted lane still carrying infrastructure-class hosts
Trusted currently includes: Trusted currently includes:
@@ -131,8 +136,8 @@ Interpretation:
- Rocinante needs a quick extra look because the client is live on port 2 but that port did not show an explicit profile name in the returned record - Rocinante needs a quick extra look because the client is live on port 2 but that port did not show an explicit profile name in the returned record
## Recommended next actions for tomorrow ## Recommended next actions for tomorrow
1. identify the two Management `espressif` devices before or during the window 1. historical note: the two Management `espressif` devices were later identified as Protect WiFi chimes; do not treat this as an open rediscovery task
2. move/changeprofile for server ports on the USW Pro HD 24 one at a time 2. move/changeprofile for server ports on the USW Pro HD 24 one at a time
3. treat Google devices as late-batch or defer 3. treat Google devices as late-batch or defer
4. treat unknown Old IoT clients as quarantine by default, not migration by default 4. treat unknown Old IoT clients as quarantine by default, not migration by default
5. keep Camera cleanup focused on chimes and policy, because the doorbell is already in the correct lane 5. keep Camera cleanup focused on validated policy and documented exceptions; the doorbell was already in the correct lane during this snapshot, but later chime guidance changed

View File

@@ -0,0 +1,47 @@
# UniFi Restricted-Lane DNS Cutover Result — 2026-05-23
Purpose: record the live DHCP DNS change for the restricted lanes and the immediate post-write verification.
## Live change applied
Updated the UniFi network definitions for:
- `IoT`
- `Camera`
- `Old IoT`
New DHCP DNS target on all three lanes:
- `10.5.30.53`
NTP was intentionally left unchanged:
- `dhcpd_ntp_enabled=false`
## Verified live after apply
### IoT
- subnet: `10.5.10.1/24`
- `dhcpd_dns_enabled=true`
- `dhcpd_dns_1=10.5.30.53`
### Camera
- subnet: `10.5.20.1/24`
- `dhcpd_dns_enabled=true`
- `dhcpd_dns_1=10.5.30.53`
### Old IoT
- subnet: `192.168.1.1/24`
- `dhcpd_dns_enabled=true`
- `dhcpd_dns_1=10.5.30.53`
## Why this matters
This removes the prior default-DNS dependency on each restricted lane's gateway address and puts those devices behind John's DNS blacklist policy.
That makes the next firewall phase materially safer because a broader `Untrusted -> Gateway` shield no longer has to preserve gateway DNS behavior for these three lanes.
## Still not done yet
This change alone does not prove the broader gateway shield is safe to apply immediately.
Before the next firewall wave, still verify:
- whether any restricted devices need gateway NTP behavior
- whether any restricted devices still need other specific gateway services besides DHCP/mDNS
- Google/cast behavior separately from a laptop session
## Recommended next live step
Stage the broader `Untrusted -> Gateway` shield carefully with only exact remaining exceptions preserved, instead of leaving the current broad gateway dependency in place.

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