Compare commits
170 Commits
35077b1f7c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
482a490d3d | ||
|
|
1eb4bb9981 | ||
|
|
43b94813ca | ||
|
|
e19d4a5be3 | ||
|
|
28b2edf2a3 | ||
|
|
7a67a2febc | ||
|
|
db8dce8c8b | ||
|
|
1227814277 | ||
|
|
ba958f7ef6 | ||
|
|
dd4134b1ce | ||
|
|
64e7ac82cf | ||
|
|
00100c5cba | ||
|
|
a6b268def5 | ||
|
|
3960e168bc | ||
|
|
6ba3fe208e | ||
|
|
8a61169ab9 | ||
|
|
24c6a29273 | ||
|
|
2ab4ff4428 | ||
|
|
dea83b64af | ||
|
|
b98827df40 | ||
|
|
57911b36f5 | ||
|
|
77da65e64a | ||
|
|
a282fe43bd | ||
|
|
1dddcbd37d | ||
|
|
186e6b1cc4 | ||
|
|
386911b70d | ||
|
|
d8bdc36392 | ||
|
|
a17d432b0f | ||
|
|
c7eaf1ab8d | ||
|
|
0c33bedbae | ||
|
|
0d7e133283 | ||
|
|
59a83590c5 | ||
|
|
09232f7d70 | ||
|
|
9826548fd7 | ||
|
|
e9b61ad3cd | ||
|
|
12452bac64 | ||
|
|
2a6cd8b1fd | ||
|
|
7e064397ec | ||
|
|
68bab888db | ||
|
|
f0764c4aa6 | ||
|
|
ad8131951c | ||
|
|
661f7b49b3 | ||
|
|
de188e6047 | ||
|
|
77dafc027f | ||
|
|
b6bb040de6 | ||
|
|
90027f9fa7 | ||
|
|
efb237efe7 | ||
|
|
60de92339d | ||
|
|
eba18beabe | ||
|
|
f2e751e1e6 | ||
|
|
ce7ed4d76c | ||
|
|
ae7fce4b95 | ||
|
|
733b661721 | ||
|
|
54bf27b208 | ||
|
|
cd98fcf49a | ||
|
|
493f3253b8 | ||
|
|
3811282fc3 | ||
|
|
05ad5cb27a | ||
|
|
a7b00e6742 | ||
|
|
dac033e263 | ||
|
|
0878877c0d | ||
|
|
2db6a23a2c | ||
|
|
6659fd929f | ||
|
|
4c716fcd36 | ||
|
|
13919cf4f7 | ||
|
|
73105c7794 | ||
|
|
a27ed384e7 | ||
|
|
2057c8aaa5 | ||
|
|
ffc1280f7c | ||
|
|
5d88f6cf93 | ||
|
|
a4c5ee7285 | ||
|
|
9e0f66c2b6 | ||
|
|
00d9c3ed5f | ||
|
|
147889c72d | ||
|
|
9bb48ee385 | ||
|
|
db41861935 | ||
|
|
26d5407b86 | ||
|
|
c1f6dce227 | ||
|
|
406efa102d | ||
|
|
ab348932ff | ||
|
|
5c6b3cab4d | ||
|
|
889783e621 | ||
|
|
080ba83989 | ||
|
|
462b39e572 | ||
|
|
bec21292de | ||
|
|
d62a391cbf | ||
|
|
0b528cbb5f | ||
|
|
c2c71cfe57 | ||
|
|
5f56fe75d8 | ||
|
|
9e6dde7695 | ||
|
|
4b1e3d4061 | ||
|
|
1631dcd1c1 | ||
|
|
9a24dd11ad | ||
|
|
8003f65f4d | ||
|
|
f88b90b4d8 | ||
|
|
9dc5a9a8a3 | ||
|
|
d5cfdbe8b5 | ||
|
|
3f333595a2 | ||
|
|
269354286d | ||
|
|
2c14d32208 | ||
|
|
c227628bb1 | ||
|
|
ee9a2cfcee | ||
|
|
92f88178e7 | ||
|
|
e3d2710ce5 | ||
|
|
6d033eaaca | ||
|
|
c149b1acf8 | ||
|
|
2e99873634 | ||
|
|
4c41b19e13 | ||
|
|
165e772946 | ||
|
|
e2470ff7c9 | ||
|
|
ee531c55f3 | ||
|
|
9f614ca0e7 | ||
|
|
19f1f8c5e3 | ||
|
|
ff9e94e95b | ||
|
|
e5a100d1a0 | ||
|
|
d31de19227 | ||
|
|
e9fd381ef9 | ||
|
|
ecf44fe65f | ||
|
|
383cd41ae7 | ||
|
|
3aad8d037b | ||
|
|
308ac205fb | ||
|
|
d014925bd1 | ||
|
|
ade91cc9ab | ||
|
|
546507e71c | ||
|
|
ce167138cf | ||
|
|
f3469e35fd | ||
|
|
66dda26f98 | ||
|
|
c7baa0054f | ||
|
|
adabd6a847 | ||
|
|
03de710d0e | ||
|
|
bcd06c61c4 | ||
|
|
b76cb1b520 | ||
|
|
7c28149f16 | ||
|
|
b8926a3b75 | ||
|
|
5f52b2e6cf | ||
|
|
295a22bc1f | ||
|
|
5bb519f895 | ||
|
|
c0b3a52074 | ||
|
|
255afad3eb | ||
|
|
50ca6361c1 | ||
|
|
4efc8dfbf3 | ||
|
|
e4e48394b8 | ||
|
|
991ed7a6ae | ||
|
|
8e182b31c7 | ||
|
|
df70c6cbca | ||
|
|
a650c1a56b | ||
|
|
31223c875d | ||
|
|
8165d7b2a6 | ||
|
|
56026cf542 | ||
|
|
5584de38e0 | ||
|
|
4d04077f51 | ||
|
|
686de9f543 | ||
|
|
09a29b0b3a | ||
|
|
5fd46b20ca | ||
|
|
33d0666a3e | ||
|
|
4d5fa8f4a0 | ||
|
|
c31656bca2 | ||
| 3c108b8a85 | |||
|
|
ae83086b06 | ||
|
|
4acc202c49 | ||
| c30f5cc4f6 | |||
| 22d2d75f6a | |||
|
|
2048a07abf | ||
|
|
bdc81cbc2e | ||
|
|
31edbf1970 | ||
|
|
2ee549d843 | ||
|
|
8736b52a6b | ||
|
|
d388aa3e90 | ||
|
|
7383458b0b | ||
|
|
71401d4128 |
5
.githooks/pre-commit
Executable file
5
.githooks/pre-commit
Executable 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
18
.githooks/pre-push
Executable 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
34
.github/workflows/secret-guardrails.yml
vendored
Normal 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 }}
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -16,6 +16,13 @@ __pycache__/
|
||||
# Temp scripts
|
||||
update-homelab-docs.py
|
||||
|
||||
# Generated stack config
|
||||
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
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
## Where We Left Off
|
||||
|
||||
Phase 2 (AI stack) is fully deployed and verified on PlausibleDeniability (10.5.1.6). We were about to verify Gotify (Phase 1) and then deploy n8n (Phase 3).
|
||||
Phase 2 (AI stack) is fully deployed and verified on PlausibleDeniability (10.5.30.6). We were about to verify Gotify (Phase 1) and then deploy n8n (Phase 3).
|
||||
|
||||
## What's Done
|
||||
|
||||
### Phase 2 — AI Stack (COMPLETE)
|
||||
All 6 containers running and verified on PD (10.5.1.6):
|
||||
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):
|
||||
- **Ollama:** port 11434 — qwen2.5:14b loaded
|
||||
- **OpenWebUI:** port 8282 (→8080)
|
||||
- **Qdrant:** port 6333 (HTTP), 6334 (gRPC)
|
||||
@@ -16,7 +17,7 @@ All 6 containers running and verified on PD (10.5.1.6):
|
||||
- **Whisper:** port 8786 (→8000)
|
||||
|
||||
### Key Fixes Applied (reference for future troubleshooting)
|
||||
1. **TrueNAS Scale Docker quirk:** `curl localhost:PORT` returns 000. Always use `curl 10.5.1.6:PORT`.
|
||||
1. **TrueNAS Scale Docker quirk:** `curl localhost:PORT` returns 000. Always use `curl 10.5.30.6:PORT`.
|
||||
2. **Reranker image:** Use `cpu-latest` not `cpu-1.5` (hf-hub bug).
|
||||
3. **LiteLLM config:** Remove `database_url` (newer versions try Prisma/PostgreSQL). Set `master_key: "os.environ/LITELLM_MASTER_KEY"` to read from env var.
|
||||
|
||||
@@ -32,7 +33,7 @@ docker ps --filter name=gotify --format "table {{.Names}}\t{{.Image}}\t{{.Status
|
||||
docker logs gotify --tail 20
|
||||
docker inspect gotify --format '{{json .Mounts}}' | python3 -m json.tool
|
||||
docker inspect gotify --format '{{json .NetworkSettings.Networks}}' | python3 -m json.tool
|
||||
curl -sw "\n%{http_code}" http://10.5.1.6:8484/health
|
||||
curl -sw "\n%{http_code}" http://10.5.30.6:8484/health
|
||||
ls -la /mnt/docker-ssd/docker/compose/automation/
|
||||
cat /mnt/docker-ssd/docker/compose/automation/docker-compose.yaml
|
||||
```
|
||||
@@ -57,9 +58,9 @@ Per HOMELAB_BUILDOUT_PLAN.md Phase 3:
|
||||
| pangolin | Reverse proxy / external exposure |
|
||||
|
||||
## Machine IPs
|
||||
- **PlausibleDeniability (PD):** 10.5.1.6 — TrueNAS Scale, RTX 2080 Ti
|
||||
- **ROCINANTE:** 10.5.1.112 — RTX 4090
|
||||
- **N.O.M.A.D.:** 10.5.1.16 — Ubuntu, GTX 1080
|
||||
- **PlausibleDeniability (PD):** 10.5.30.6 — TrueNAS Scale, RTX 2080 Ti
|
||||
- **ROCINANTE:** 10.5.30.112 — RTX 4090
|
||||
- **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)
|
||||
Already configured — do not regenerate. Master key is in the .env file as LITELLM_MASTER_KEY.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
11
README.md
11
README.md
@@ -6,9 +6,9 @@ Docker Compose stacks for PlausibleDeniability (TrueNAS Scale), plus documentati
|
||||
|
||||
| Server | Role | OS | IP |
|
||||
|--------|------|----|----|
|
||||
| Serenity | NAS / Media ingestion | Unraid 7.2.4 | 10.5.1.5 |
|
||||
| PlausibleDeniability | Primary Docker host | TrueNAS Scale 25.10.2.1 | LAN DHCP |
|
||||
| N.O.M.A.D. | Offline knowledge + game servers | Ubuntu 25.10 | 10.5.1.16 |
|
||||
| Serenity | NAS / Media ingestion | Unraid 7.2.4 | 10.5.30.5 |
|
||||
| PlausibleDeniability | Primary Docker host | TrueNAS Scale 25.10.2.1 | 10.5.30.6 |
|
||||
| N.O.M.A.D. | Offline knowledge + game servers | Ubuntu 25.10 | 10.5.30.7 |
|
||||
|
||||
## Repo Layout
|
||||
|
||||
@@ -17,10 +17,11 @@ databases/ # shared-postgres, shared-mariadb, shared-redis — deploy FIRS
|
||||
infrastructure/ # newt, homepage, uptime-kuma, dockhand, netdata, tailscale — deploy SECOND
|
||||
media/ # plex, tautulli, audiobookshelf, seerr, calibre-web-automated
|
||||
photos/ # immich server + machine learning
|
||||
home/ # kitchenowl, donetick, shlink, qui, dakboard bridges
|
||||
home/ # kitchenowl, donetick, shlink, qui, dakboard bridges, doris-dashboard
|
||||
dev/ # gitea, rackpeek
|
||||
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
|
||||
search/ # planned: qdrant
|
||||
docs/ # all homelab documentation
|
||||
|
||||
@@ -105,52 +105,3 @@ services:
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
whisper:
|
||||
container_name: whisper
|
||||
image: fedirz/faster-whisper-server:latest-cuda
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- ai-net
|
||||
- ai-services
|
||||
ports:
|
||||
- "8786:8000"
|
||||
environment:
|
||||
TZ: ${TZ}
|
||||
WHISPER__MODEL: ${WHISPER_MODEL}
|
||||
WHISPER__DEVICE: cuda
|
||||
WHISPER__COMPUTE_TYPE: float16
|
||||
volumes:
|
||||
- /mnt/docker-ssd/docker/appdata/whisper:/root/.cache/huggingface
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
|
||||
openclaw:
|
||||
container_name: openclaw
|
||||
image: ghcr.io/openclaw/openclaw:latest
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- ai-net
|
||||
- ai-services
|
||||
- pangolin
|
||||
ports:
|
||||
- "18789:18789"
|
||||
environment:
|
||||
TZ: ${TZ}
|
||||
OPENCLAW_GATEWAY_TOKEN: ${OPENCLAW_GATEWAY_TOKEN}
|
||||
OPENCLAW_DISABLE_BONJOUR: "1"
|
||||
volumes:
|
||||
- /mnt/docker-ssd/docker/appdata/openclaw/config:/home/node/.openclaw
|
||||
- /mnt/docker-ssd/docker/appdata/openclaw/workspace:/home/node/.openclaw/workspace
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:18789/healthz || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 90s
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
model_list:
|
||||
# ---- Reranker (Serenity, 10.5.1.5) ----
|
||||
# ---- Reranker (Serenity, 10.5.30.5) ----
|
||||
- model_name: "reranker"
|
||||
litellm_params:
|
||||
model: "huggingface/BAAI/bge-reranker-v2-m3"
|
||||
api_base: "http://10.5.1.5:9787"
|
||||
api_base: "http://10.5.30.5:9787"
|
||||
# ---- ROCINANTE (RTX 4090, 24GB) — heavy reasoning ----
|
||||
- model_name: "heavy"
|
||||
litellm_params:
|
||||
model: "ollama/qwen3.6:latest"
|
||||
api_base: "http://10.5.1.112:11434"
|
||||
api_base: "http://10.5.30.112:11434"
|
||||
timeout: 300
|
||||
stream_timeout: 300
|
||||
|
||||
- model_name: "heavy"
|
||||
litellm_params:
|
||||
model: "ollama/qwen2.5:32b"
|
||||
api_base: "http://10.5.1.112:11434"
|
||||
api_base: "http://10.5.30.112:11434"
|
||||
timeout: 300
|
||||
stream_timeout: 300
|
||||
|
||||
- model_name: "heavy"
|
||||
litellm_params:
|
||||
model: "ollama/gemma3:27b"
|
||||
api_base: "http://10.5.1.112:11434"
|
||||
api_base: "http://10.5.30.112:11434"
|
||||
timeout: 300
|
||||
stream_timeout: 300
|
||||
|
||||
@@ -30,7 +30,7 @@ model_list:
|
||||
- model_name: "medium"
|
||||
litellm_params:
|
||||
model: "ollama/qwen2.5:14b"
|
||||
api_base: "http://host.docker.internal:11434"
|
||||
api_base: "http://ollama:11434"
|
||||
timeout: 120
|
||||
stream_timeout: 120
|
||||
|
||||
@@ -38,14 +38,14 @@ model_list:
|
||||
- model_name: "light"
|
||||
litellm_params:
|
||||
model: "ollama/deepseek-r1"
|
||||
api_base: "http://10.5.1.16:11434"
|
||||
api_base: "http://10.5.30.7:11434"
|
||||
timeout: 60
|
||||
stream_timeout: 60
|
||||
|
||||
- model_name: "light"
|
||||
litellm_params:
|
||||
model: "ollama/qwen2.5:3b"
|
||||
api_base: "http://10.5.1.16:11434"
|
||||
api_base: "http://10.5.30.7:11434"
|
||||
timeout: 60
|
||||
stream_timeout: 60
|
||||
|
||||
@@ -53,43 +53,43 @@ model_list:
|
||||
- model_name: "embed"
|
||||
litellm_params:
|
||||
model: "ollama/nomic-embed-text:v1.5"
|
||||
api_base: "http://10.5.1.16:11434"
|
||||
api_base: "http://10.5.30.7:11434"
|
||||
|
||||
# ---- Direct model access (bypass tier routing) ----
|
||||
- model_name: "ollama/qwen3.6:latest"
|
||||
litellm_params:
|
||||
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"
|
||||
litellm_params:
|
||||
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"
|
||||
litellm_params:
|
||||
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"
|
||||
litellm_params:
|
||||
model: "ollama/qwen2.5:14b"
|
||||
api_base: "http://host.docker.internal:11434"
|
||||
api_base: "http://ollama:11434"
|
||||
|
||||
- model_name: "ollama/deepseek-r1"
|
||||
litellm_params:
|
||||
model: "ollama/deepseek-r1"
|
||||
api_base: "http://10.5.1.16:11434"
|
||||
api_base: "http://10.5.30.7:11434"
|
||||
|
||||
- model_name: "ollama/qwen2.5:3b"
|
||||
litellm_params:
|
||||
model: "ollama/qwen2.5:3b"
|
||||
api_base: "http://10.5.1.16:11434"
|
||||
api_base: "http://10.5.30.7:11434"
|
||||
|
||||
- model_name: "ollama/nomic-embed-text:v1.5"
|
||||
litellm_params:
|
||||
model: "ollama/nomic-embed-text:v1.5"
|
||||
api_base: "http://10.5.1.16:11434"
|
||||
api_base: "http://10.5.30.7:11434"
|
||||
|
||||
litellm_settings:
|
||||
drop_params: true
|
||||
|
||||
@@ -3,3 +3,56 @@ TZ=America/Chicago
|
||||
N8N_HOST=n8n.paccoco.com
|
||||
N8N_DB_PASS=CHANGE_ME
|
||||
N8N_ENCRYPTION_KEY=CHANGE_ME
|
||||
PAPERLESS_BASE_URL=https://paperless.paccoco.com
|
||||
PAPERLESS_API_TOKEN=CHANGE_ME
|
||||
PAPERLESS_CORRESPONDENT_SCHOOL=
|
||||
PAPERLESS_DOCUMENT_TYPE_SCHOOL=
|
||||
PAPERLESS_TAG_SCHOOL=
|
||||
PAPERLESS_TAG_ASSIGNMENTS=
|
||||
PAPERLESS_TAG_TELEGRAM=
|
||||
PAPERLESS_TAG_CLASS_MAP_JSON={}
|
||||
PAPERLESS_TAG_SUBMISSION_KIND_MAP_JSON={}
|
||||
PAPERLESS_DOCUMENT_TYPE_PAPER_KIND_MAP_JSON={}
|
||||
PAPERLESS_CORRESPONDENT_CLASS_MAP_JSON={}
|
||||
NODE_FUNCTION_ALLOW_BUILTIN=crypto
|
||||
|
||||
# KitchenOwl recipe importer
|
||||
KITCHENOWL_BASE_URL=https://owl.paccoco.com
|
||||
KITCHENOWL_API_TOKEN=CHANGE_ME
|
||||
KITCHENOWL_HOUSEHOLD_ID=1
|
||||
|
||||
# UniFi Network operator access (Doris)
|
||||
UNIFI_BASE_URL=https://10.5.0.1
|
||||
UNIFI_USERNAME=doris
|
||||
UNIFI_PASSWORD=***
|
||||
UNIFI_SITE=default
|
||||
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
|
||||
SERENITY_BACKUP_HOST=root@10.5.30.5
|
||||
SERENITY_BACKUP_ROOT=/mnt/user/backups/plausible-deniability
|
||||
PD_APPDATA_ROOT=/mnt/docker-ssd/docker/appdata
|
||||
PD_DATABASES_ROOT=/mnt/docker-ssd/docker/databases
|
||||
PD_TANK_DOCKER_ROOT=/mnt/tank/docker
|
||||
PD_DB_DUMP_ROOT=/mnt/tank/docker/backups/db-dumps
|
||||
POSTGRES_CONTAINER=shared-postgres
|
||||
POSTGRES_DB_LIST="gitea n8n paperless"
|
||||
BACKUP_SSH_KEY=/home/truenas_admin/.ssh/serenity_backup_ed25519
|
||||
BACKUP_KNOWN_HOSTS=/home/truenas_admin/.ssh/known_hosts
|
||||
DOCKER_BIN=/usr/bin/docker
|
||||
SUDO_BIN=/usr/bin/sudo
|
||||
USE_SUDO_FOR_DOCKER=true
|
||||
|
||||
# Restore verification
|
||||
RESTORE_VERIFY_WORK_ROOT=/mnt/tank/docker/backups/restore-verify
|
||||
RESTORE_VERIFY_POSTGRES_IMAGE=postgres:17-alpine
|
||||
RESTORE_VERIFY_CONFIG_SOURCE=tank-docker/appdata/grafana
|
||||
RESTORE_VERIFY_REQUIRED_PATHS="grafana.db dashboards"
|
||||
RESTORE_VERIFY_METRICS_DIR=/mnt/tank/docker/metrics/node-exporter
|
||||
|
||||
146
automation/README.md
Normal file
146
automation/README.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# Automation helpers
|
||||
|
||||
This directory includes repo-side helpers for homelab operational tasks.
|
||||
|
||||
## KitchenOwl recipe import helper
|
||||
|
||||
- `bin/kitchenowl_recipe_import.py` — Doris-managed KitchenOwl recipe importer
|
||||
- tries KitchenOwl's own scrape endpoint first
|
||||
- falls back to direct page fetch + schema.org `Recipe` JSON-LD parsing
|
||||
- normalizes ingredients/tags/timings into KitchenOwl's create-recipe payload
|
||||
- supports dry-run by default and live create with `--create`
|
||||
|
||||
See `docs/operations/KITCHENOWL_RECIPE_IMPORT.md` for setup and usage.
|
||||
|
||||
## Karakeep API helper
|
||||
|
||||
- `bin/karakeep_api.py` — tiny Karakeep REST helper backed by the local encrypted vault entry
|
||||
- reads `.secrets/karakeep_secrets.json.enc`
|
||||
- uses `baseUrl` + `apiKey`
|
||||
- targets `/api/v1/...` routes by default
|
||||
- supports ad hoc GET/POST/PATCH/DELETE calls with query/body args
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
automation/bin/karakeep_api.py bookmarks --query limit=5 --pretty
|
||||
automation/bin/karakeep_api.py tags --pretty
|
||||
automation/bin/karakeep_api.py /api/health --raw-path --pretty
|
||||
```
|
||||
|
||||
## 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="gitea n8n paperless"`)
|
||||
- `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/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_appdata.sh` — stages one appdata/config tree back from Serenity and verifies required files exist
|
||||
- `bin/run_pd_restore_verification.sh` — wrapper that runs both restore checks in order
|
||||
- writes Prometheus textfile metrics for run status, duration, and last successful verification timestamp
|
||||
|
||||
These scripts are the live PD backup runner model.
|
||||
|
||||
Repo-side deployment prerequisites remain:
|
||||
|
||||
1. real values in `.env`
|
||||
2. a writable dump destination on PD
|
||||
3. SSH trust / key path for Serenity (`root@10.5.30.5` by default)
|
||||
- recommended known_hosts path on PD: `/home/truenas_admin/.ssh/known_hosts`
|
||||
4. a real scheduler on the live host
|
||||
|
||||
Current documented live state:
|
||||
|
||||
- the PD → Serenity backup flow is the intended deployed model
|
||||
- root cron on PD is the preferred scheduler
|
||||
- first-run verification and the deployed status are tracked in `docs/operations/PD_BACKUP_DEPLOYMENT.md` and `docs/planning/TODO.md`
|
||||
- quarterly restore verification is now expected via `bin/run_pd_restore_verification.sh`
|
||||
|
||||
## PD / TrueNAS deployment recommendation
|
||||
|
||||
Use a plain cron job on PD, not n8n and not a custom systemd timer.
|
||||
|
||||
Reasons:
|
||||
|
||||
- survives n8n outages
|
||||
- keeps the backup runner close to the compose/data host
|
||||
- avoids extra appliance fights on TrueNAS SCALE
|
||||
- matches the existing shell-first operational style on PD
|
||||
|
||||
Preferred mode on PD: **root cron**.
|
||||
|
||||
Why: the dump path under `/mnt/tank/...` and Docker access are typically cleaner from root on TrueNAS than from a limited operator account.
|
||||
|
||||
TrueNAS-specific notes:
|
||||
|
||||
- scripts use `/usr/bin/bash` explicitly
|
||||
- default Docker path is `/usr/bin/docker`
|
||||
- if run as root, scripts call Docker directly
|
||||
- if run as a non-root user, default behavior is `sudo -n /usr/bin/docker ...`
|
||||
- keep all live paths under `/mnt/...`; never rely on rootfs write locations
|
||||
|
||||
## Example live flow
|
||||
|
||||
```bash
|
||||
cd /mnt/docker-ssd/docker/compose/automation
|
||||
cp .env.example .env
|
||||
chmod 600 .env
|
||||
mkdir -p /mnt/tank/docker/backups/db-dumps
|
||||
bin/run_pd_backups.sh
|
||||
```
|
||||
|
||||
## Recommended PD cron block
|
||||
|
||||
Install in **root's crontab on PD**:
|
||||
|
||||
```cron
|
||||
# BEGIN PD BACKUPS
|
||||
15 2 * * * cd /mnt/docker-ssd/docker/compose/automation && /usr/bin/bash bin/run_pd_backups.sh >> /mnt/tank/docker/backups/pd-backups.log 2>&1
|
||||
# END PD BACKUPS
|
||||
```
|
||||
|
||||
## Recommended quarterly restore verification
|
||||
|
||||
Run from PD after a backup has completed:
|
||||
|
||||
```bash
|
||||
cd /mnt/docker-ssd/docker/compose/automation
|
||||
/usr/bin/bash bin/run_pd_restore_verification.sh
|
||||
```
|
||||
|
||||
This validates:
|
||||
|
||||
- latest configured Postgres dumps can be restored cleanly
|
||||
- a real config/appdata sample can be pulled back from Serenity
|
||||
- expected files still exist in the restored copy
|
||||
|
||||
If node-exporter is configured with the textfile collector, the wrapper also publishes:
|
||||
|
||||
- `pd_restore_verification_success`
|
||||
- `pd_restore_verification_last_run_timestamp_seconds`
|
||||
- `pd_restore_verification_last_success_timestamp_seconds`
|
||||
- `pd_restore_verification_duration_seconds`
|
||||
|
||||
Recommended root cron entry on PD:
|
||||
|
||||
```cron
|
||||
30 3 1 */3 * cd /mnt/docker-ssd/docker/compose/automation && /usr/bin/bash bin/run_pd_restore_verification.sh >> /mnt/tank/docker/backups/pd-restore-verify.log 2>&1
|
||||
```
|
||||
|
||||
Recommended first-run checklist:
|
||||
|
||||
```bash
|
||||
cd /mnt/docker-ssd/docker/compose/automation
|
||||
cp .env.example .env # if not already present
|
||||
chmod 600 .env
|
||||
mkdir -p /mnt/tank/docker/backups/db-dumps
|
||||
/usr/bin/bash bin/run_pd_backups.sh
|
||||
tail -100 /mnt/tank/docker/backups/pd-backups.log
|
||||
```
|
||||
|
||||
## Safety notes
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
139
automation/bin/karakeep_api.py
Executable file
139
automation/bin/karakeep_api.py
Executable file
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Small Karakeep API helper that reads its token from the local encrypted vault.
|
||||
|
||||
Default behavior:
|
||||
- reads `.secrets/karakeep_secrets.json.enc`
|
||||
- uses `baseUrl` + `apiKey`
|
||||
- targets `/api/v1/...` routes unless an absolute path is given
|
||||
|
||||
Examples:
|
||||
automation/bin/karakeep_api.py bookmarks --pretty
|
||||
automation/bin/karakeep_api.py tags --pretty
|
||||
automation/bin/karakeep_api.py /api/health --raw-path --pretty
|
||||
automation/bin/karakeep_api.py bookmarks --query limit=5 --pretty
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib import error, parse, request
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = SCRIPT_DIR.parent.parent
|
||||
WORKSPACE = Path('/home/fizzlepoof/.openclaw/workspace')
|
||||
DEFAULT_SECRETS_DIR = WORKSPACE / '.secrets'
|
||||
DEFAULT_SECRET_FILE = DEFAULT_SECRETS_DIR / 'karakeep_secrets.json.enc'
|
||||
DEFAULT_KEY_FILE = DEFAULT_SECRETS_DIR / 'encryption.key'
|
||||
DEFAULT_TIMEOUT = 30
|
||||
|
||||
|
||||
def decrypt_json(path: Path, key_file: Path) -> dict[str, Any]:
|
||||
raw = subprocess.check_output([
|
||||
'openssl', 'enc', '-d', '-aes-256-cbc', '-pbkdf2',
|
||||
'-in', str(path),
|
||||
'-pass', f'file:{key_file}',
|
||||
])
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
def parse_kv(items: list[str]) -> dict[str, str]:
|
||||
out: dict[str, str] = {}
|
||||
for item in items:
|
||||
if '=' not in item:
|
||||
raise SystemExit(f'Expected key=value, got: {item}')
|
||||
key, value = item.split('=', 1)
|
||||
out[key] = value
|
||||
return out
|
||||
|
||||
|
||||
def build_url(base_url: str, endpoint: str, raw_path: bool) -> str:
|
||||
base = base_url.rstrip('/')
|
||||
if raw_path:
|
||||
if not endpoint.startswith('/'):
|
||||
endpoint = '/' + endpoint
|
||||
return base + endpoint
|
||||
endpoint = endpoint.strip('/')
|
||||
return f'{base}/api/v1/{endpoint}'
|
||||
|
||||
|
||||
def api_request(method: str, url: str, api_key: str, *, query: dict[str, str] | None = None,
|
||||
body: dict[str, Any] | list[Any] | None = None, timeout: int = DEFAULT_TIMEOUT) -> tuple[int, Any, dict[str, str]]:
|
||||
if query:
|
||||
url += ('&' if '?' in url else '?') + parse.urlencode(query, doseq=True)
|
||||
data = None
|
||||
headers = {
|
||||
'Authorization': f'Bearer {api_key}',
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'doris-karakeep-helper/1.0',
|
||||
}
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode('utf-8')
|
||||
headers['Content-Type'] = 'application/json'
|
||||
req = request.Request(url, data=data, method=method.upper(), headers=headers)
|
||||
try:
|
||||
with request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode('utf-8', 'replace')
|
||||
ctype = resp.headers.get('Content-Type', '')
|
||||
payload = json.loads(raw) if 'json' in ctype else raw
|
||||
return resp.status, payload, dict(resp.headers.items())
|
||||
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, dict(exc.headers.items())
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description='Query Karakeep using the encrypted local vault secret.')
|
||||
parser.add_argument('endpoint', help='Endpoint alias/path. Default base is /api/v1/. Example: bookmarks, tags, lists')
|
||||
parser.add_argument('--method', default='GET', help='HTTP method (default: GET)')
|
||||
parser.add_argument('--query', action='append', default=[], metavar='KEY=VALUE', help='Query params')
|
||||
parser.add_argument('--data', action='append', default=[], metavar='KEY=VALUE', help='JSON body fields')
|
||||
parser.add_argument('--json-body', help='Raw JSON body string')
|
||||
parser.add_argument('--raw-path', action='store_true', help='Use endpoint as-is instead of prefixing /api/v1/')
|
||||
parser.add_argument('--pretty', action='store_true', help='Pretty-print JSON output')
|
||||
parser.add_argument('--headers', action='store_true', help='Print response headers to stderr')
|
||||
parser.add_argument('--timeout', type=int, default=DEFAULT_TIMEOUT)
|
||||
parser.add_argument('--secret-file', default=str(DEFAULT_SECRET_FILE))
|
||||
parser.add_argument('--key-file', default=str(DEFAULT_KEY_FILE))
|
||||
args = parser.parse_args()
|
||||
|
||||
secret = decrypt_json(Path(args.secret_file), Path(args.key_file))
|
||||
base_url = secret['baseUrl']
|
||||
api_key = secret['apiKey']
|
||||
|
||||
query = parse_kv(args.query)
|
||||
body: dict[str, Any] | list[Any] | None = None
|
||||
if args.json_body:
|
||||
body = json.loads(args.json_body)
|
||||
elif args.data:
|
||||
body = parse_kv(args.data)
|
||||
|
||||
url = build_url(base_url, args.endpoint, args.raw_path)
|
||||
status, payload, headers = api_request(args.method, url, api_key, query=query, body=body, timeout=args.timeout)
|
||||
|
||||
if args.headers:
|
||||
for k, v in headers.items():
|
||||
print(f'{k}: {v}', file=sys.stderr)
|
||||
|
||||
if isinstance(payload, (dict, list)):
|
||||
if args.pretty:
|
||||
print(json.dumps(payload, indent=2, sort_keys=True))
|
||||
else:
|
||||
print(json.dumps(payload))
|
||||
else:
|
||||
print(payload)
|
||||
|
||||
return 0 if 200 <= status < 300 else 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
702
automation/bin/kitchenowl_recipe_import.py
Executable file
702
automation/bin/kitchenowl_recipe_import.py
Executable file
@@ -0,0 +1,702 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Doris-managed KitchenOwl recipe importer.
|
||||
|
||||
Flow:
|
||||
1. Try KitchenOwl's native scrape endpoint for supported sites.
|
||||
2. If that fails, fetch the page and extract schema.org Recipe JSON-LD.
|
||||
3. Normalize ingredients/tags into KitchenOwl's create-recipe payload.
|
||||
4. Optionally create the recipe via the KitchenOwl API.
|
||||
|
||||
Secrets stay in env vars or a local .env, never in git.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
from html import unescape
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib import error, parse, request
|
||||
|
||||
DEFAULT_TIMEOUT = 30
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = SCRIPT_DIR.parent.parent
|
||||
DEFAULT_ENV_PATH = REPO_ROOT / 'automation' / '.env'
|
||||
|
||||
UNIT_WORDS = {
|
||||
'teaspoon','teaspoons','tsp','tsp.','tablespoon','tablespoons','tbsp','tbsp.','cup','cups',
|
||||
'oz','ounce','ounces','lb','lbs','pound','pounds','gram','grams','g','kg','ml','l','pinch',
|
||||
'dash','clove','cloves','can','cans','package','packages','pkg','slice','slices','stalk','stalks'
|
||||
}
|
||||
STOPWORDS = {'fresh','large','small','medium','optional','to','taste','for','the','a','an'}
|
||||
TAG_KEYS = ('recipeCategory', 'recipeCuisine', 'keywords')
|
||||
|
||||
|
||||
class ScriptExtractor(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.in_script = False
|
||||
self.script_type = None
|
||||
self._buf: list[str] = []
|
||||
self.scripts: list[tuple[str | None, str]] = []
|
||||
self.title: str | None = None
|
||||
self._in_title = False
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
attrs_d = dict(attrs)
|
||||
if tag == 'script':
|
||||
self.in_script = True
|
||||
self.script_type = attrs_d.get('type')
|
||||
self._buf = []
|
||||
elif tag == 'title':
|
||||
self._in_title = True
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
if tag == 'script' and self.in_script:
|
||||
self.scripts.append((self.script_type, ''.join(self._buf)))
|
||||
self.in_script = False
|
||||
self.script_type = None
|
||||
self._buf = []
|
||||
elif tag == 'title':
|
||||
self._in_title = False
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if self.in_script:
|
||||
self._buf.append(data)
|
||||
elif self._in_title:
|
||||
self.title = (self.title or '') + data
|
||||
|
||||
|
||||
def load_env_file(path: Path) -> None:
|
||||
if not path.exists():
|
||||
return
|
||||
for raw_line in path.read_text(encoding='utf-8').splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith('#') or '=' not in line:
|
||||
continue
|
||||
key, value = line.split('=', 1)
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
os.environ.setdefault(key, value)
|
||||
|
||||
|
||||
def env(name: str, default: str | None = None) -> str | None:
|
||||
return os.environ.get(name, default)
|
||||
|
||||
|
||||
def api_request(method: str, url: str, token: str, *, params: dict[str, Any] | None = None,
|
||||
body: dict[str, Any] | None = None, timeout: int = DEFAULT_TIMEOUT) -> tuple[int, Any, dict[str, str]]:
|
||||
if params:
|
||||
url += ('&' if '?' in url else '?') + parse.urlencode(params, doseq=True)
|
||||
data = None
|
||||
headers = {
|
||||
'Authorization': f'Bearer {token}',
|
||||
'User-Agent': 'doris-kitchenowl-importer/1.0',
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode('utf-8')
|
||||
headers['Content-Type'] = 'application/json'
|
||||
req = request.Request(url, data=data, method=method.upper(), headers=headers)
|
||||
try:
|
||||
with request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode('utf-8', 'replace')
|
||||
ctype = resp.headers.get('Content-Type', '')
|
||||
payload = json.loads(raw) if 'json' in ctype else raw
|
||||
return resp.status, payload, dict(resp.headers.items())
|
||||
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, dict(exc.headers.items())
|
||||
|
||||
|
||||
|
||||
|
||||
class RecipeDomExtractor(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._stack: list[str] = []
|
||||
self._ingredient_depth: int | None = None
|
||||
self._direction_depth: int | None = None
|
||||
self._current_li: list[str] | None = None
|
||||
self._current_heading: list[str] | None = None
|
||||
self.ingredients: list[str] = []
|
||||
self.sections: list[dict[str, Any]] = []
|
||||
self._current_section: dict[str, Any] | None = None
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
attrs_d = dict(attrs)
|
||||
classes = set((attrs_d.get("class") or "").split())
|
||||
self._stack.append(tag)
|
||||
|
||||
if "ingredients-list" in classes:
|
||||
self._ingredient_depth = len(self._stack)
|
||||
if "directions-list" in classes:
|
||||
self._direction_depth = len(self._stack)
|
||||
|
||||
if self.in_ingredients and tag == "li":
|
||||
self._current_li = []
|
||||
elif self.in_directions and tag == "li":
|
||||
self._current_li = []
|
||||
|
||||
if self.in_directions and tag == "p":
|
||||
self._current_heading = []
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
if self.in_ingredients and tag == "li" and self._current_li is not None:
|
||||
text = normalize_whitespace("".join(self._current_li))
|
||||
if text:
|
||||
self.ingredients.append(text)
|
||||
self._current_li = None
|
||||
elif self.in_directions and tag == "li" and self._current_li is not None:
|
||||
text = normalize_whitespace("".join(self._current_li))
|
||||
if text:
|
||||
self._ensure_section().setdefault("itemListElement", []).append({"@type": "HowToStep", "text": text})
|
||||
self._current_li = None
|
||||
|
||||
if self.in_directions and tag == "p" and self._current_heading is not None:
|
||||
heading = normalize_whitespace("".join(self._current_heading)).rstrip(":*").strip()
|
||||
if heading:
|
||||
self._current_section = {"@type": "HowToSection", "name": heading, "itemListElement": []}
|
||||
self.sections.append(self._current_section)
|
||||
self._current_heading = None
|
||||
|
||||
if self._stack:
|
||||
self._stack.pop()
|
||||
|
||||
if self._ingredient_depth is not None and len(self._stack) < self._ingredient_depth:
|
||||
self._ingredient_depth = None
|
||||
if self._direction_depth is not None and len(self._stack) < self._direction_depth:
|
||||
self._direction_depth = None
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if self.in_ingredients and self._current_li is not None:
|
||||
self._current_li.append(data)
|
||||
return
|
||||
if not self.in_directions:
|
||||
return
|
||||
if self._current_li is not None:
|
||||
self._current_li.append(data)
|
||||
elif self._current_heading is not None:
|
||||
self._current_heading.append(data)
|
||||
|
||||
@property
|
||||
def in_ingredients(self) -> bool:
|
||||
return self._ingredient_depth is not None
|
||||
|
||||
@property
|
||||
def in_directions(self) -> bool:
|
||||
return self._direction_depth is not None
|
||||
|
||||
def _ensure_section(self) -> dict[str, Any]:
|
||||
if self._current_section is None:
|
||||
self._current_section = {"@type": "HowToSection", "name": "Instructions", "itemListElement": []}
|
||||
self.sections.append(self._current_section)
|
||||
return self._current_section
|
||||
|
||||
|
||||
def fetch_url(url: str, timeout: int = DEFAULT_TIMEOUT) -> str:
|
||||
req = request.Request(
|
||||
url,
|
||||
headers={
|
||||
'User-Agent': 'Mozilla/5.0 (Doris KitchenOwl Importer)',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
},
|
||||
)
|
||||
try:
|
||||
with request.urlopen(req, timeout=timeout) as resp:
|
||||
charset = resp.headers.get_content_charset() or 'utf-8'
|
||||
return resp.read().decode(charset, 'replace')
|
||||
except error.HTTPError as exc:
|
||||
if exc.code not in {403, 406, 429}:
|
||||
raise
|
||||
curl = subprocess.run(
|
||||
[
|
||||
'curl', '-fsSL', '--max-time', str(timeout),
|
||||
'-A', 'Mozilla/5.0 (Doris KitchenOwl Importer)',
|
||||
'-H', 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'-H', 'Accept-Language: en-US,en;q=0.9',
|
||||
url,
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
)
|
||||
if curl.returncode != 0:
|
||||
raise RuntimeError(f'Failed to fetch URL via urllib/curl: {url} ({curl.stderr.decode("utf-8", "replace").strip()})')
|
||||
return curl.stdout.decode('utf-8', 'replace')
|
||||
|
||||
|
||||
def flatten_graph(node: Any) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
if isinstance(node, list):
|
||||
for item in node:
|
||||
out.extend(flatten_graph(item))
|
||||
elif isinstance(node, dict):
|
||||
if '@graph' in node:
|
||||
out.extend(flatten_graph(node['@graph']))
|
||||
else:
|
||||
out.append(node)
|
||||
return out
|
||||
|
||||
|
||||
def is_recipe_node(node: dict[str, Any]) -> bool:
|
||||
t = node.get('@type')
|
||||
if isinstance(t, list):
|
||||
return 'Recipe' in t
|
||||
return t == 'Recipe'
|
||||
|
||||
|
||||
def choose_recipe_node(nodes: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||||
recipes = [n for n in nodes if is_recipe_node(n)]
|
||||
if not recipes:
|
||||
return None
|
||||
recipes.sort(key=lambda r: len(r.get('recipeIngredient') or []), reverse=True)
|
||||
return recipes[0]
|
||||
|
||||
|
||||
def parse_json_ld_recipe(html: str) -> tuple[dict[str, Any] | None, str | None]:
|
||||
parser = ScriptExtractor()
|
||||
parser.feed(html)
|
||||
nodes: list[dict[str, Any]] = []
|
||||
for script_type, content in parser.scripts:
|
||||
if script_type and 'ld+json' not in script_type:
|
||||
continue
|
||||
content = content.strip()
|
||||
if not content:
|
||||
continue
|
||||
try:
|
||||
data = json.loads(content)
|
||||
except Exception:
|
||||
continue
|
||||
nodes.extend(flatten_graph(data))
|
||||
return choose_recipe_node(nodes), (parser.title.strip() if parser.title else None)
|
||||
|
||||
def enrich_recipe_node_from_dom(recipe_node: dict[str, Any], html: str) -> dict[str, Any]:
|
||||
extractor = RecipeDomExtractor()
|
||||
extractor.feed(html)
|
||||
enriched = dict(recipe_node)
|
||||
if extractor.ingredients and not (enriched.get("recipeIngredient") or []):
|
||||
enriched["recipeIngredient"] = extractor.ingredients
|
||||
if extractor.sections and not extract_instruction_steps(enriched.get("recipeInstructions")):
|
||||
enriched["recipeInstructions"] = extractor.sections
|
||||
return enriched
|
||||
|
||||
|
||||
|
||||
DURATION_RE = re.compile(r'^P(?:T(?:(?P<hours>\d+)H)?(?:(?P<minutes>\d+)M)?(?:(?P<seconds>\d+)S)?)?$')
|
||||
TEXT_DURATION_PART_RE = re.compile(
|
||||
r'(?P<value>\d+(?:\.\d+)?)\s*(?P<unit>days?|day|hours?|hrs?|hr|minutes?|mins?|min|seconds?|secs?|sec|s)\b',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
OVERNIGHT_RE = re.compile(r'\bovernight\b', re.IGNORECASE)
|
||||
|
||||
|
||||
def parse_duration_minutes(value: Any) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return int(value)
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return None
|
||||
m = DURATION_RE.match(value)
|
||||
if m:
|
||||
hours = int(m.group('hours') or 0)
|
||||
minutes = int(m.group('minutes') or 0)
|
||||
seconds = int(m.group('seconds') or 0)
|
||||
total = hours * 60 + minutes + (1 if seconds >= 30 else 0)
|
||||
return total or None
|
||||
textual_matches = list(TEXT_DURATION_PART_RE.finditer(value))
|
||||
overnight_count = len(OVERNIGHT_RE.findall(value))
|
||||
if textual_matches or overnight_count:
|
||||
total_minutes = 0.0
|
||||
for part in textual_matches:
|
||||
amount = float(part.group('value'))
|
||||
unit = part.group('unit').lower()
|
||||
if unit.startswith('day'):
|
||||
total_minutes += amount * 24 * 60
|
||||
elif unit.startswith(('hour', 'hr')):
|
||||
total_minutes += amount * 60
|
||||
elif unit.startswith(('minute', 'min')):
|
||||
total_minutes += amount
|
||||
else:
|
||||
total_minutes += amount / 60
|
||||
total_minutes += overnight_count * 12 * 60
|
||||
rounded = int(total_minutes + 0.5)
|
||||
return rounded or None
|
||||
num = re.search(r'(\d+)', value)
|
||||
return int(num.group(1)) if num else None
|
||||
|
||||
|
||||
def coerce_text(value: Any) -> str:
|
||||
if value is None:
|
||||
return ''
|
||||
if isinstance(value, list):
|
||||
return '\n'.join(coerce_text(v) for v in value if coerce_text(v))
|
||||
if isinstance(value, dict):
|
||||
if 'text' in value:
|
||||
return coerce_text(value['text'])
|
||||
if '@value' in value:
|
||||
return coerce_text(value['@value'])
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
return unescape(str(value)).strip()
|
||||
|
||||
|
||||
def normalize_whitespace(value: str) -> str:
|
||||
cleaned = re.sub(r'\s+', ' ', value or '').strip()
|
||||
return re.sub(r'(?<=[.!?])(?=[A-Z])', ' ', cleaned)
|
||||
|
||||
|
||||
def split_paragraphs(value: str) -> list[str]:
|
||||
parts: list[str] = []
|
||||
for chunk in re.split(r'\n\s*\n|\r\n\r\n', value or ''):
|
||||
cleaned = normalize_whitespace(chunk)
|
||||
if cleaned:
|
||||
parts.append(cleaned)
|
||||
return parts
|
||||
|
||||
|
||||
def extract_instruction_steps(value: Any) -> list[str]:
|
||||
steps: list[str] = []
|
||||
|
||||
def visit(node: Any) -> None:
|
||||
if node is None:
|
||||
return
|
||||
if isinstance(node, str):
|
||||
for chunk in re.split(r'\n+', node):
|
||||
cleaned = normalize_whitespace(chunk)
|
||||
if cleaned:
|
||||
steps.append(cleaned)
|
||||
return
|
||||
if isinstance(node, list):
|
||||
for item in node:
|
||||
visit(item)
|
||||
return
|
||||
if isinstance(node, dict):
|
||||
if node.get('@type') == 'HowToSection':
|
||||
name = normalize_whitespace(coerce_text(node.get('name')))
|
||||
if name:
|
||||
steps.append(f'{name}:')
|
||||
visit(node.get('itemListElement'))
|
||||
return
|
||||
if node.get('@type') in {'HowToStep', 'ListItem'}:
|
||||
text = normalize_whitespace(coerce_text(node.get('text') or node.get('name') or node.get('item')))
|
||||
if text:
|
||||
steps.append(text)
|
||||
elif node.get('itemListElement') is not None:
|
||||
visit(node.get('itemListElement'))
|
||||
return
|
||||
if node.get('itemListElement') is not None:
|
||||
visit(node.get('itemListElement'))
|
||||
return
|
||||
text = normalize_whitespace(coerce_text(node.get('text') or node.get('name')))
|
||||
if text:
|
||||
steps.append(text)
|
||||
|
||||
visit(value)
|
||||
deduped: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for step in steps:
|
||||
key = step.lower()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
deduped.append(step)
|
||||
return deduped
|
||||
|
||||
|
||||
def build_description(recipe_node: dict[str, Any]) -> str:
|
||||
intro_parts = split_paragraphs(coerce_text(recipe_node.get('description')))
|
||||
intro = intro_parts[0] if intro_parts else ''
|
||||
notes = intro_parts[1:] if len(intro_parts) > 1 else []
|
||||
steps = extract_instruction_steps(recipe_node.get('recipeInstructions'))
|
||||
parts: list[str] = []
|
||||
if intro:
|
||||
parts.append(intro)
|
||||
if steps:
|
||||
rendered_steps = []
|
||||
step_number = 1
|
||||
in_notes = False
|
||||
for step in steps:
|
||||
if step.endswith(':'):
|
||||
rendered_steps.append(step)
|
||||
in_notes = step[:-1].strip().lower() == 'notes'
|
||||
step_number = 1
|
||||
else:
|
||||
if in_notes:
|
||||
rendered_steps.append(f'- {step}')
|
||||
else:
|
||||
rendered_steps.append(f'{step_number}. {step}')
|
||||
step_number += 1
|
||||
parts.append('\n'.join(rendered_steps))
|
||||
elif intro_parts[1:]:
|
||||
parts.append('\n'.join(intro_parts[1:]))
|
||||
if notes:
|
||||
parts.append('Notes:\n- ' + '\n- '.join(notes))
|
||||
return '\n\n'.join(part for part in parts if part).strip()
|
||||
|
||||
|
||||
def slugify_tag(value: str) -> str:
|
||||
value = value.strip().lower()
|
||||
value = re.sub(r'[^a-z0-9]+', '-', value)
|
||||
return value.strip('-')[:48]
|
||||
|
||||
|
||||
def extract_image_url(value: Any) -> str | None:
|
||||
if isinstance(value, str):
|
||||
cleaned = value.strip()
|
||||
return cleaned or None
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
found = extract_image_url(item)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
if isinstance(value, dict):
|
||||
for key in ('url', 'contentUrl'):
|
||||
found = extract_image_url(value.get(key))
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
def normalize_keywords(value: Any) -> list[str]:
|
||||
out: list[str] = []
|
||||
if isinstance(value, str):
|
||||
parts = re.split(r'[,;|]', value)
|
||||
out.extend(p.strip() for p in parts if p.strip())
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
out.extend(normalize_keywords(item))
|
||||
return out
|
||||
|
||||
|
||||
def split_ingredient(line: str) -> tuple[str, str]:
|
||||
original = ' '.join(line.strip().split())
|
||||
if not original:
|
||||
return '', ''
|
||||
cleaned = original.replace('–', '-').replace('—', '-')
|
||||
tokens = cleaned.split()
|
||||
desc_tokens: list[str] = []
|
||||
name_tokens = tokens[:]
|
||||
|
||||
while name_tokens:
|
||||
token = name_tokens[0].strip(',()').lower()
|
||||
if re.fullmatch(r'[\d/.-]+', token) or token in {'x', '×'}:
|
||||
desc_tokens.append(name_tokens.pop(0))
|
||||
continue
|
||||
if token in UNIT_WORDS:
|
||||
desc_tokens.append(name_tokens.pop(0))
|
||||
continue
|
||||
if token in {'of'} and desc_tokens:
|
||||
desc_tokens.append(name_tokens.pop(0))
|
||||
continue
|
||||
break
|
||||
|
||||
if not name_tokens:
|
||||
return original[:128], ''
|
||||
|
||||
while name_tokens and name_tokens[0].strip(',').lower() in STOPWORDS and len(name_tokens) > 1:
|
||||
desc_tokens.append(name_tokens.pop(0))
|
||||
|
||||
name = ' '.join(name_tokens).strip(' ,;')
|
||||
if ',' in name:
|
||||
first, rest = [p.strip() for p in name.split(',', 1)]
|
||||
if first and len(first.split()) <= 5:
|
||||
name = first
|
||||
if rest:
|
||||
desc_tokens.append(rest)
|
||||
description = ' '.join(desc_tokens).strip(' ,;')
|
||||
return name[:128], description[:512]
|
||||
|
||||
|
||||
def normalize_recipe(url: str, recipe_node: dict[str, Any], title_fallback: str | None = None) -> dict[str, Any]:
|
||||
name = coerce_text(recipe_node.get('name')) or (title_fallback or url)
|
||||
description = build_description(recipe_node)
|
||||
|
||||
ingredients = recipe_node.get('recipeIngredient') or []
|
||||
items = []
|
||||
seen_items: set[tuple[str, str]] = set()
|
||||
for raw in ingredients:
|
||||
line = coerce_text(raw)
|
||||
if not line:
|
||||
continue
|
||||
item_name, item_desc = split_ingredient(line)
|
||||
if not item_name:
|
||||
item_name = line[:128]
|
||||
key = (item_name.lower(), item_desc.lower())
|
||||
if key in seen_items:
|
||||
continue
|
||||
seen_items.add(key)
|
||||
items.append({'name': item_name, 'description': item_desc, 'optional': False})
|
||||
|
||||
tags: list[str] = []
|
||||
for key in TAG_KEYS:
|
||||
tags.extend(normalize_keywords(recipe_node.get(key)))
|
||||
deduped_tags: list[str] = []
|
||||
seen_tags: set[str] = set()
|
||||
for tag in tags:
|
||||
slug = slugify_tag(tag)
|
||||
if slug and slug not in seen_tags:
|
||||
seen_tags.add(slug)
|
||||
deduped_tags.append(slug)
|
||||
|
||||
yields_val = recipe_node.get('recipeYield')
|
||||
yields_num = None
|
||||
if isinstance(yields_val, list) and yields_val:
|
||||
yields_val = yields_val[0]
|
||||
if yields_val is not None:
|
||||
match = re.search(r'(\d+)', str(yields_val))
|
||||
if match:
|
||||
yields_num = int(match.group(1))
|
||||
|
||||
payload = {
|
||||
'name': name[:128],
|
||||
'description': description[:20000],
|
||||
'source': url,
|
||||
'visibility': 0,
|
||||
'items': items,
|
||||
'tags': deduped_tags[:20],
|
||||
}
|
||||
image_url = extract_image_url(recipe_node.get('image'))
|
||||
if image_url:
|
||||
payload['photo'] = image_url
|
||||
cook_time = parse_duration_minutes(recipe_node.get('cookTime'))
|
||||
prep_time = parse_duration_minutes(recipe_node.get('prepTime'))
|
||||
total_time = parse_duration_minutes(recipe_node.get('totalTime'))
|
||||
if cook_time is not None:
|
||||
payload['cook_time'] = cook_time
|
||||
if prep_time is not None:
|
||||
payload['prep_time'] = prep_time
|
||||
if total_time is not None:
|
||||
payload['time'] = total_time
|
||||
elif cook_time is not None or prep_time is not None:
|
||||
payload['time'] = (cook_time or 0) + (prep_time or 0)
|
||||
if yields_num is not None:
|
||||
payload['yields'] = yields_num
|
||||
return payload
|
||||
|
||||
|
||||
def find_existing_recipe(base_url: str, token: str, household_id: int, payload: dict[str, Any]) -> dict[str, Any] | None:
|
||||
status, data, _ = api_request('GET', f'{base_url}/api/household/{household_id}/recipe', token)
|
||||
if status != 200 or not isinstance(data, list):
|
||||
return None
|
||||
want_source = (payload.get('source') or '').strip().lower()
|
||||
want_name = (payload.get('name') or '').strip().lower()
|
||||
for recipe in data:
|
||||
source = str(recipe.get('source') or '').strip().lower()
|
||||
name = str(recipe.get('name') or '').strip().lower()
|
||||
if want_source and source and source == want_source:
|
||||
return recipe
|
||||
if want_name and name == want_name:
|
||||
return recipe
|
||||
return None
|
||||
|
||||
|
||||
def import_one(args: argparse.Namespace, base_url: str, token: str, household_id: int, url: str) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {'url': url}
|
||||
payload = None
|
||||
scrape_error = None
|
||||
|
||||
if not args.skip_kitchenowl_scrape:
|
||||
status, data, _ = api_request('GET', f'{base_url}/api/household/{household_id}/recipe/scrape', token, params={'url': url}, timeout=args.timeout)
|
||||
if status == 200 and isinstance(data, dict):
|
||||
payload = data
|
||||
result['strategy'] = 'kitchenowl-scrape'
|
||||
else:
|
||||
scrape_error = {'status': status, 'detail': data}
|
||||
|
||||
if payload is None:
|
||||
html = fetch_url(url, timeout=args.timeout)
|
||||
recipe_node, title = parse_json_ld_recipe(html)
|
||||
if not recipe_node:
|
||||
raise RuntimeError(f'No schema.org Recipe JSON-LD found for {url}')
|
||||
recipe_node = enrich_recipe_node_from_dom(recipe_node, html)
|
||||
payload = normalize_recipe(url, recipe_node, title)
|
||||
result['strategy'] = 'json-ld-fallback'
|
||||
if scrape_error:
|
||||
result['scrape_error'] = scrape_error
|
||||
|
||||
payload['visibility'] = args.visibility
|
||||
result['payload'] = payload
|
||||
|
||||
existing = None if args.allow_duplicates else find_existing_recipe(base_url, token, household_id, payload)
|
||||
if existing:
|
||||
result['existing'] = {'id': existing.get('id'), 'name': existing.get('name'), 'source': existing.get('source')}
|
||||
result['status'] = 'duplicate'
|
||||
return result
|
||||
|
||||
if args.create:
|
||||
status, created, _ = api_request('POST', f'{base_url}/api/household/{household_id}/recipe', token, body=payload, timeout=args.timeout)
|
||||
result['create_status'] = status
|
||||
result['created'] = created
|
||||
result['status'] = 'created' if status == 200 else 'error'
|
||||
else:
|
||||
result['status'] = 'dry-run'
|
||||
return result
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description='Import recipe links into KitchenOwl with Doris-managed fallback parsing.')
|
||||
p.add_argument('urls', nargs='+', help='Recipe URLs to normalize/import')
|
||||
p.add_argument('--env-file', default=str(DEFAULT_ENV_PATH), help='Optional .env file to load before reading env vars')
|
||||
p.add_argument('--base-url', default=None, help='KitchenOwl base URL, e.g. https://owl.paccoco.com')
|
||||
p.add_argument('--token', default=None, help='KitchenOwl bearer token (prefer env var instead)')
|
||||
p.add_argument('--household-id', type=int, default=None, help='KitchenOwl household id')
|
||||
p.add_argument('--create', action='store_true', help='Create recipes in KitchenOwl. Default is dry-run only.')
|
||||
p.add_argument('--allow-duplicates', action='store_true', help='Do not skip name/source duplicates.')
|
||||
p.add_argument('--skip-kitchenowl-scrape', action='store_true', help='Go straight to Doris JSON-LD parsing.')
|
||||
p.add_argument('--visibility', type=int, choices=[0,1,2], default=0, help='Recipe visibility: 0 private, 1 link, 2 public.')
|
||||
p.add_argument('--pretty', action='store_true', help='Pretty-print JSON output')
|
||||
p.add_argument('--timeout', type=int, default=DEFAULT_TIMEOUT, help='HTTP timeout in seconds')
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
load_env_file(Path(args.env_file).expanduser())
|
||||
|
||||
base_url = (args.base_url or env('KITCHENOWL_BASE_URL') or env('KITCHENOWL_URL') or '').rstrip('/')
|
||||
token = args.token or env('KITCHENOWL_API_TOKEN') or ''
|
||||
household_raw = args.household_id or env('KITCHENOWL_HOUSEHOLD_ID')
|
||||
|
||||
if not base_url:
|
||||
print('Missing KitchenOwl base URL. Set KITCHENOWL_BASE_URL or KITCHENOWL_URL.', file=sys.stderr)
|
||||
return 2
|
||||
if not token:
|
||||
print('Missing KitchenOwl API token. Set KITCHENOWL_API_TOKEN.', file=sys.stderr)
|
||||
return 2
|
||||
if not household_raw:
|
||||
print('Missing KitchenOwl household id. Set KITCHENOWL_HOUSEHOLD_ID.', file=sys.stderr)
|
||||
return 2
|
||||
|
||||
household_id = int(household_raw)
|
||||
results = []
|
||||
failures = 0
|
||||
for url in args.urls:
|
||||
try:
|
||||
results.append(import_one(args, base_url, token, household_id, url))
|
||||
except Exception as exc:
|
||||
failures += 1
|
||||
results.append({'url': url, 'status': 'error', 'error': str(exc)})
|
||||
|
||||
dump = json.dumps(results, ensure_ascii=False, indent=2 if args.pretty else None)
|
||||
print(dump)
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
251
automation/bin/pangolin_upsert_dispatcharr.py
Normal file
251
automation/bin/pangolin_upsert_dispatcharr.py
Normal 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())
|
||||
254
automation/bin/pangolin_upsert_headscale.py
Executable file
254
automation/bin/pangolin_upsert_headscale.py
Executable 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())
|
||||
78
automation/bin/pd_backup_lib.sh
Executable file
78
automation/bin/pd_backup_lib.sh
Executable file
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
AUTOMATION_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
ENV_FILE="${ENV_FILE:-$AUTOMATION_DIR/.env}"
|
||||
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
echo "Missing env file: $ENV_FILE" >&2
|
||||
echo "Copy automation/.env.example to automation/.env and fill in real values first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
|
||||
required_vars=(
|
||||
SERENITY_BACKUP_HOST
|
||||
SERENITY_BACKUP_ROOT
|
||||
PD_APPDATA_ROOT
|
||||
PD_DATABASES_ROOT
|
||||
PD_TANK_DOCKER_ROOT
|
||||
PD_DB_DUMP_ROOT
|
||||
POSTGRES_CONTAINER
|
||||
)
|
||||
|
||||
for var in "${required_vars[@]}"; do
|
||||
if [[ -z "${!var:-}" ]]; then
|
||||
echo "Required variable $var is empty in $ENV_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
mkdir -p "$PD_DB_DUMP_ROOT"
|
||||
|
||||
DOCKER_BIN="${DOCKER_BIN:-/usr/bin/docker}"
|
||||
SUDO_BIN="${SUDO_BIN:-/usr/bin/sudo}"
|
||||
USE_SUDO_FOR_DOCKER="${USE_SUDO_FOR_DOCKER:-true}"
|
||||
BACKUP_KNOWN_HOSTS="${BACKUP_KNOWN_HOSTS:-${HOME:-/root}/.ssh/known_hosts}"
|
||||
|
||||
if [[ ! -x "$DOCKER_BIN" ]]; then
|
||||
echo "Docker binary not executable: $DOCKER_BIN" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker_cmd() {
|
||||
if [[ "${EUID:-$(id -u)}" == "0" ]]; then
|
||||
"$DOCKER_BIN" "$@"
|
||||
elif [[ "$USE_SUDO_FOR_DOCKER" == "true" ]]; then
|
||||
"$SUDO_BIN" -n "$DOCKER_BIN" "$@"
|
||||
else
|
||||
"$DOCKER_BIN" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
timestamp_utc() {
|
||||
date -u +"%Y%m%dT%H%M%SZ"
|
||||
}
|
||||
|
||||
ssh_args() {
|
||||
local opts="-o BatchMode=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$BACKUP_KNOWN_HOSTS"
|
||||
if [[ -n "${BACKUP_SSH_KEY:-}" ]]; then
|
||||
printf '%s' "-i $BACKUP_SSH_KEY $opts"
|
||||
else
|
||||
printf '%s' "$opts"
|
||||
fi
|
||||
}
|
||||
|
||||
ssh_cmd() {
|
||||
printf 'ssh %s' "$(ssh_args)"
|
||||
}
|
||||
|
||||
latest_dump_for_db() {
|
||||
local db="$1"
|
||||
find "$PD_DB_DUMP_ROOT" -maxdepth 1 -type f -name "${db}_*.sql.gz" | sort | tail -n 1
|
||||
}
|
||||
22
automation/bin/pd_backup_postgres.sh
Executable file
22
automation/bin/pd_backup_postgres.sh
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck disable=SC1091
|
||||
source "$SCRIPT_DIR/pd_backup_lib.sh"
|
||||
|
||||
stamp="$(timestamp_utc)"
|
||||
dbs="${POSTGRES_DB_LIST:-}"
|
||||
|
||||
if [[ -z "$dbs" ]]; then
|
||||
echo "POSTGRES_DB_LIST is empty; nothing to dump." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for db in $dbs; do
|
||||
out="$PD_DB_DUMP_ROOT/${db}_${stamp}.sql.gz"
|
||||
echo "Dumping Postgres database '$db' to $out"
|
||||
docker_cmd exec "$POSTGRES_CONTAINER" pg_dump -U postgres "$db" | gzip -9 > "$out"
|
||||
done
|
||||
|
||||
echo "Postgres dumps complete: $PD_DB_DUMP_ROOT"
|
||||
51
automation/bin/pd_backup_sync_to_serenity.sh
Executable file
51
automation/bin/pd_backup_sync_to_serenity.sh
Executable file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck disable=SC1091
|
||||
source "$SCRIPT_DIR/pd_backup_lib.sh"
|
||||
|
||||
ssh_opts="$(ssh_args)"
|
||||
remote_root="$SERENITY_BACKUP_HOST:$SERENITY_BACKUP_ROOT"
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
ssh $ssh_opts "$SERENITY_BACKUP_HOST" mkdir -p \
|
||||
"$SERENITY_BACKUP_ROOT/appdata" \
|
||||
"$SERENITY_BACKUP_ROOT/databases" \
|
||||
"$SERENITY_BACKUP_ROOT/tank-docker" \
|
||||
"$SERENITY_BACKUP_ROOT/db-dumps"
|
||||
|
||||
rsync_cmd=(
|
||||
rsync -a --delete
|
||||
--exclude '*.sqlite-journal'
|
||||
--exclude '*.db-journal'
|
||||
--exclude '*.sqlite-wal'
|
||||
--exclude '*.db-wal'
|
||||
--exclude '*.tmp'
|
||||
)
|
||||
rsync_cmd+=( -e "$(ssh_cmd)" )
|
||||
|
||||
run_rsync() {
|
||||
local label="$1"
|
||||
shift
|
||||
echo "$label"
|
||||
set +e
|
||||
"${rsync_cmd[@]}" "$@"
|
||||
local rc=$?
|
||||
set -e
|
||||
if [[ $rc -eq 24 ]]; then
|
||||
echo "rsync completed with vanished-file warning (code 24); treating as acceptable for live appdata."
|
||||
return 0
|
||||
fi
|
||||
return $rc
|
||||
}
|
||||
|
||||
run_rsync "Syncing appdata to Serenity" "$PD_APPDATA_ROOT/" "$remote_root/appdata/"
|
||||
|
||||
run_rsync "Syncing database volume roots to Serenity" "$PD_DATABASES_ROOT/" "$remote_root/databases/"
|
||||
|
||||
run_rsync "Syncing selected tank docker data to Serenity" "$PD_TANK_DOCKER_ROOT/" "$remote_root/tank-docker/"
|
||||
|
||||
run_rsync "Syncing DB dumps to Serenity" "$PD_DB_DUMP_ROOT/" "$remote_root/db-dumps/"
|
||||
|
||||
echo "Serenity sync complete: $remote_root"
|
||||
34
automation/bin/pd_restore_verify_appdata.sh
Executable file
34
automation/bin/pd_restore_verify_appdata.sh
Executable file
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck disable=SC1091
|
||||
source "$SCRIPT_DIR/pd_backup_lib.sh"
|
||||
|
||||
verify_root="${RESTORE_VERIFY_WORK_ROOT:-/mnt/tank/docker/backups/restore-verify}"
|
||||
remote_source="${RESTORE_VERIFY_CONFIG_SOURCE:-tank-docker/appdata/grafana}"
|
||||
required_paths="${RESTORE_VERIFY_REQUIRED_PATHS:-grafana.db dashboards}"
|
||||
stage_dir="$verify_root/appdata-stage"
|
||||
|
||||
mkdir -p "$verify_root"
|
||||
rm -rf "$stage_dir"
|
||||
mkdir -p "$stage_dir"
|
||||
|
||||
remote_path="$SERENITY_BACKUP_ROOT/$remote_source/"
|
||||
|
||||
echo "Staging config restore sample from $SERENITY_BACKUP_HOST:$remote_path"
|
||||
rsync -a -e "$(ssh_cmd)" "$SERENITY_BACKUP_HOST:$remote_path" "$stage_dir/"
|
||||
|
||||
for rel in $required_paths; do
|
||||
if [[ ! -e "$stage_dir/$rel" ]]; then
|
||||
echo "Missing expected restored path: $rel" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -f "$stage_dir/grafana.db" ]] && command -v sqlite3 >/dev/null 2>&1; then
|
||||
sqlite3 "$stage_dir/grafana.db" 'PRAGMA integrity_check;' | grep -qx 'ok'
|
||||
echo "Verified SQLite integrity for grafana.db"
|
||||
fi
|
||||
|
||||
echo "Config restore verification passed for $remote_source."
|
||||
69
automation/bin/pd_restore_verify_postgres.sh
Executable file
69
automation/bin/pd_restore_verify_postgres.sh
Executable file
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck disable=SC1091
|
||||
source "$SCRIPT_DIR/pd_backup_lib.sh"
|
||||
|
||||
verify_root="${RESTORE_VERIFY_WORK_ROOT:-/mnt/tank/docker/backups/restore-verify}"
|
||||
dbs="${RESTORE_VERIFY_DB_LIST:-${POSTGRES_DB_LIST:-}}"
|
||||
|
||||
if [[ -z "$dbs" ]]; then
|
||||
echo "RESTORE_VERIFY_DB_LIST and POSTGRES_DB_LIST are both empty." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$verify_root"
|
||||
stamp="$(timestamp_utc)"
|
||||
container="pd-restore-verify-$stamp"
|
||||
password="verify-$stamp"
|
||||
|
||||
if [[ -n "${RESTORE_VERIFY_POSTGRES_IMAGE:-}" ]]; then
|
||||
verify_image="$RESTORE_VERIFY_POSTGRES_IMAGE"
|
||||
else
|
||||
source_major="$(docker_cmd exec "$POSTGRES_CONTAINER" postgres -V | awk '{print $3}' | cut -d. -f1)"
|
||||
verify_image="postgres:${source_major}-alpine"
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
docker_cmd rm -f "$container" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "Starting temporary Postgres verifier container $container"
|
||||
docker_cmd run -d --rm \
|
||||
--name "$container" \
|
||||
-e POSTGRES_PASSWORD="$password" \
|
||||
"$verify_image" >/dev/null
|
||||
|
||||
echo "Waiting for temporary Postgres to accept connections"
|
||||
for _ in {1..30}; do
|
||||
if docker_cmd exec "$container" pg_isready -U postgres >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
docker_cmd exec "$container" pg_isready -U postgres >/dev/null
|
||||
|
||||
for db in $dbs; do
|
||||
dump_file="$(latest_dump_for_db "$db")"
|
||||
if [[ -z "$dump_file" ]]; then
|
||||
echo "No dump found for database '$db' under $PD_DB_DUMP_ROOT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Restoring $dump_file into temporary database '$db'"
|
||||
while IFS= read -r role_name; do
|
||||
[[ -z "$role_name" ]] && continue
|
||||
docker_cmd exec "$container" createuser -U postgres "$role_name" >/dev/null 2>&1 || true
|
||||
done < <(gunzip -c "$dump_file" | sed -n "s/.*OWNER TO \\([^;]*\\);/\\1/p" | sort -u)
|
||||
|
||||
docker_cmd exec "$container" createdb -U postgres "$db"
|
||||
gunzip -c "$dump_file" | docker_cmd exec -i "$container" psql -v ON_ERROR_STOP=1 -U postgres -d "$db" >/dev/null
|
||||
|
||||
table_count="$(docker_cmd exec "$container" psql -At -U postgres -d "$db" -c "SELECT count(*) FROM information_schema.tables WHERE table_schema NOT IN ('pg_catalog','information_schema');")"
|
||||
echo "Verified database '$db' with $table_count non-system tables."
|
||||
done
|
||||
|
||||
echo "Postgres restore verification passed."
|
||||
9
automation/bin/run_pd_backups.sh
Executable file
9
automation/bin/run_pd_backups.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
"$SCRIPT_DIR/pd_backup_postgres.sh"
|
||||
"$SCRIPT_DIR/pd_backup_sync_to_serenity.sh"
|
||||
|
||||
echo "PD backup run complete."
|
||||
55
automation/bin/run_pd_restore_verification.sh
Executable file
55
automation/bin/run_pd_restore_verification.sh
Executable file
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck disable=SC1091
|
||||
source "$SCRIPT_DIR/pd_backup_lib.sh"
|
||||
|
||||
metrics_dir="${RESTORE_VERIFY_METRICS_DIR:-/mnt/tank/docker/metrics/node-exporter}"
|
||||
metrics_file="$metrics_dir/pd_restore_verification.prom"
|
||||
start_epoch="$(date +%s)"
|
||||
status=1
|
||||
|
||||
mkdir -p "$metrics_dir"
|
||||
|
||||
previous_success_epoch() {
|
||||
if [[ -f "$metrics_file" ]]; then
|
||||
awk '/^pd_restore_verification_last_success_timestamp_seconds /{print $2}' "$metrics_file" | tail -n 1
|
||||
else
|
||||
echo 0
|
||||
fi
|
||||
}
|
||||
|
||||
write_metrics() {
|
||||
local end_epoch duration last_success tmp
|
||||
end_epoch="$(date +%s)"
|
||||
duration="$((end_epoch - start_epoch))"
|
||||
last_success="$(previous_success_epoch)"
|
||||
if [[ "$status" -eq 0 ]]; then
|
||||
last_success="$end_epoch"
|
||||
fi
|
||||
tmp="${metrics_file}.tmp"
|
||||
cat > "$tmp" <<EOF
|
||||
# HELP pd_restore_verification_success Last PD restore verification result (1=success, 0=failure).
|
||||
# TYPE pd_restore_verification_success gauge
|
||||
pd_restore_verification_success $(( status == 0 ? 1 : 0 ))
|
||||
# HELP pd_restore_verification_last_run_timestamp_seconds Unix timestamp of the last PD restore verification run.
|
||||
# TYPE pd_restore_verification_last_run_timestamp_seconds gauge
|
||||
pd_restore_verification_last_run_timestamp_seconds $end_epoch
|
||||
# HELP pd_restore_verification_last_success_timestamp_seconds Unix timestamp of the last successful PD restore verification run.
|
||||
# TYPE pd_restore_verification_last_success_timestamp_seconds gauge
|
||||
pd_restore_verification_last_success_timestamp_seconds $last_success
|
||||
# HELP pd_restore_verification_duration_seconds Duration of the last PD restore verification run in seconds.
|
||||
# TYPE pd_restore_verification_duration_seconds gauge
|
||||
pd_restore_verification_duration_seconds $duration
|
||||
EOF
|
||||
mv "$tmp" "$metrics_file"
|
||||
}
|
||||
|
||||
trap write_metrics EXIT
|
||||
|
||||
"$SCRIPT_DIR/pd_restore_verify_postgres.sh"
|
||||
"$SCRIPT_DIR/pd_restore_verify_appdata.sh"
|
||||
|
||||
status=0
|
||||
echo "PD restore verification run complete."
|
||||
190
automation/bin/unifi_network.py
Normal file
190
automation/bin/unifi_network.py
Normal file
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Minimal Doris helper for UniFi OS / Network inventory reads.
|
||||
|
||||
Reads credentials from automation/.env by default and performs a cookie-based
|
||||
login against UniFi OS, then calls Network app endpoints through
|
||||
`/proxy/network/api/s/<site>/...`.
|
||||
|
||||
Default action prints a compact JSON summary suitable for RackPeek refresh work.
|
||||
No secrets are printed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import ssl
|
||||
import sys
|
||||
from http.cookiejar import CookieJar
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib import error, request
|
||||
|
||||
DEFAULT_TIMEOUT = 20
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = SCRIPT_DIR.parent.parent
|
||||
DEFAULT_ENV_PATH = REPO_ROOT / 'automation' / '.env'
|
||||
|
||||
|
||||
def load_env_file(path: Path) -> None:
|
||||
if not path.exists():
|
||||
return
|
||||
for raw_line in path.read_text(encoding='utf-8').splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith('#') or '=' not in line:
|
||||
continue
|
||||
key, value = line.split('=', 1)
|
||||
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
|
||||
|
||||
|
||||
def env_bool(name: str, default: bool) -> bool:
|
||||
value = os.environ.get(name)
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().lower() in {'1', 'true', 'yes', 'on'}
|
||||
|
||||
|
||||
class UniFiClient:
|
||||
def __init__(self, base_url: str, username: str, password: str, *, verify_tls: bool = True, timeout: int = DEFAULT_TIMEOUT) -> None:
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.timeout = timeout
|
||||
self.cookie_jar = CookieJar()
|
||||
handlers: list[Any] = [request.HTTPCookieProcessor(self.cookie_jar)]
|
||||
if not verify_tls:
|
||||
handlers.append(request.HTTPSHandler(context=ssl._create_unverified_context()))
|
||||
self.opener = request.build_opener(*handlers)
|
||||
|
||||
def _json_request(self, method: str, path: str, body: dict[str, Any] | None = None) -> tuple[int, Any]:
|
||||
url = self.base_url + path
|
||||
data = None
|
||||
headers = {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'doris-unifi-helper/1.0',
|
||||
}
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode('utf-8')
|
||||
req = request.Request(url, data=data, method=method.upper(), headers=headers)
|
||||
try:
|
||||
with self.opener.open(req, timeout=self.timeout) as resp:
|
||||
raw = resp.read().decode('utf-8', 'replace')
|
||||
ctype = resp.headers.get('Content-Type', '')
|
||||
payload = json.loads(raw) if 'json' in ctype else raw
|
||||
return resp.status, payload
|
||||
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 login(self) -> None:
|
||||
status, payload = self._json_request('POST', '/api/auth/login', {
|
||||
'username': self.username,
|
||||
'password': self.password,
|
||||
'rememberMe': True,
|
||||
})
|
||||
if status >= 300:
|
||||
raise SystemExit(f'UniFi login failed: HTTP {status} {payload!r}')
|
||||
|
||||
def get(self, path: str) -> Any:
|
||||
status, payload = self._json_request('GET', path)
|
||||
if status >= 300:
|
||||
raise SystemExit(f'UniFi GET {path} failed: HTTP {status} {payload!r}')
|
||||
return payload
|
||||
|
||||
def site_path(self, site: str, suffix: str) -> str:
|
||||
suffix = suffix.lstrip('/')
|
||||
return f'/proxy/network/api/s/{site}/{suffix}'
|
||||
|
||||
|
||||
def response_data(payload: Any) -> Any:
|
||||
if isinstance(payload, dict) and 'data' in payload:
|
||||
return payload['data']
|
||||
return payload
|
||||
|
||||
|
||||
def device_summary(devices: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
out = []
|
||||
for dev in devices:
|
||||
out.append({
|
||||
'name': dev.get('name') or dev.get('hostname') or dev.get('mac'),
|
||||
'model': dev.get('model'),
|
||||
'type': dev.get('type') or dev.get('device_type'),
|
||||
'state': dev.get('state'),
|
||||
'ip': dev.get('ip'),
|
||||
'mac': dev.get('mac'),
|
||||
'version': dev.get('version'),
|
||||
'uplink': ((dev.get('uplink') or {}).get('name') if isinstance(dev.get('uplink'), dict) else None),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def client_summary(clients: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
out = []
|
||||
for client in clients:
|
||||
out.append({
|
||||
'name': client.get('name') or client.get('hostname') or client.get('mac'),
|
||||
'ip': client.get('ip'),
|
||||
'mac': client.get('mac'),
|
||||
'network': client.get('network'),
|
||||
'ap_mac': client.get('ap_mac'),
|
||||
'oui': client.get('oui'),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def build_summary(client: UniFiClient, site: str) -> dict[str, Any]:
|
||||
devices = response_data(client.get(client.site_path(site, 'stat/device')))
|
||||
health = response_data(client.get(client.site_path(site, 'stat/health')))
|
||||
clients = response_data(client.get(client.site_path(site, 'stat/sta')))
|
||||
return {
|
||||
'site': site,
|
||||
'device_count': len(devices),
|
||||
'client_count': len(clients),
|
||||
'devices': device_summary(devices),
|
||||
'health': health,
|
||||
'clients': client_summary(clients),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description='Read UniFi Network inventory for Doris.')
|
||||
parser.add_argument('--env-file', default=str(DEFAULT_ENV_PATH), help='Path to .env containing UNIFI_* vars')
|
||||
parser.add_argument('--site', help='UniFi site name; defaults to UNIFI_SITE or default')
|
||||
parser.add_argument('--raw', action='store_true', help='Print raw device/client/health payloads instead of compact summary')
|
||||
parser.add_argument('--timeout', type=int, default=DEFAULT_TIMEOUT)
|
||||
args = parser.parse_args()
|
||||
|
||||
load_env_file(Path(args.env_file))
|
||||
base_url = os.environ.get('UNIFI_BASE_URL', 'https://10.5.0.1')
|
||||
username = os.environ.get('UNIFI_USERNAME')
|
||||
password = os.environ.get('UNIFI_PASSWORD')
|
||||
site = args.site or os.environ.get('UNIFI_SITE', 'default')
|
||||
verify_tls = env_bool('UNIFI_VERIFY_TLS', False)
|
||||
|
||||
if not username or not password:
|
||||
raise SystemExit('Missing UNIFI_USERNAME or UNIFI_PASSWORD in env file/environment.')
|
||||
|
||||
client = UniFiClient(base_url, username, password, verify_tls=verify_tls, timeout=args.timeout)
|
||||
client.login()
|
||||
|
||||
if args.raw:
|
||||
payload = {
|
||||
'devices': response_data(client.get(client.site_path(site, 'stat/device'))),
|
||||
'health': response_data(client.get(client.site_path(site, 'stat/health'))),
|
||||
'clients': response_data(client.get(client.site_path(site, 'stat/sta'))),
|
||||
}
|
||||
else:
|
||||
payload = build_summary(client, site)
|
||||
|
||||
print(json.dumps(payload, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
134
automation/bin/unifi_stage_firewall_groups.py
Normal file
134
automation/bin/unifi_stage_firewall_groups.py
Normal 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())
|
||||
244
automation/bin/unifi_stage_hermes_dashboard_policy.py
Executable file
244
automation/bin/unifi_stage_hermes_dashboard_policy.py
Executable 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())
|
||||
270
automation/bin/unifi_stage_low_risk_objects.py
Executable file
270
automation/bin/unifi_stage_low_risk_objects.py
Executable file
@@ -0,0 +1,270 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stage low-risk UniFi objects for the network cutover.
|
||||
|
||||
Default mode is read-only dry-run. Pass --apply to actually create missing objects.
|
||||
Currently stages:
|
||||
- Servers network object
|
||||
- Servers port profile
|
||||
|
||||
This is intentionally idempotent: if the objects already exist, it reports them and exits cleanly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import ssl
|
||||
import sys
|
||||
from http.cookiejar import CookieJar
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib import error, request
|
||||
|
||||
DEFAULT_TIMEOUT = 20
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = SCRIPT_DIR.parent.parent
|
||||
DEFAULT_ENV_PATH = REPO_ROOT / 'automation' / '.env'
|
||||
|
||||
|
||||
def load_env_file(path: Path) -> None:
|
||||
if not path.exists():
|
||||
return
|
||||
for raw_line in path.read_text(encoding='utf-8').splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith('#') or '=' not in line:
|
||||
continue
|
||||
key, value = line.split('=', 1)
|
||||
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
|
||||
|
||||
|
||||
def env_bool(name: str, default: bool) -> bool:
|
||||
value = os.environ.get(name)
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().lower() in {'1', 'true', 'yes', 'on'}
|
||||
|
||||
|
||||
class UniFiClient:
|
||||
def __init__(self, base_url: str, username: str, password: str, *, verify_tls: bool = True, timeout: int = DEFAULT_TIMEOUT) -> None:
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.timeout = timeout
|
||||
self.cookie_jar = CookieJar()
|
||||
self.csrf_token: str | None = None
|
||||
handlers: list[Any] = [request.HTTPCookieProcessor(self.cookie_jar)]
|
||||
if not verify_tls:
|
||||
handlers.append(request.HTTPSHandler(context=ssl._create_unverified_context()))
|
||||
self.opener = request.build_opener(*handlers)
|
||||
|
||||
def _json_request(self, method: str, path: str, body: dict[str, Any] | None = None) -> tuple[int, Any]:
|
||||
url = self.base_url + path
|
||||
data = None
|
||||
headers = {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'doris-unifi-stage/1.0',
|
||||
}
|
||||
if method.upper() != 'GET' and self.csrf_token:
|
||||
headers['X-Csrf-Token'] = self.csrf_token
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode('utf-8')
|
||||
req = request.Request(url, data=data, method=method.upper(), headers=headers)
|
||||
try:
|
||||
with self.opener.open(req, timeout=self.timeout) as resp:
|
||||
if method.upper() == 'POST':
|
||||
self.csrf_token = resp.headers.get('X-Csrf-Token', self.csrf_token)
|
||||
raw = resp.read().decode('utf-8', 'replace')
|
||||
ctype = resp.headers.get('Content-Type', '')
|
||||
payload = json.loads(raw) if 'json' in ctype and raw else raw
|
||||
return resp.status, payload
|
||||
except error.HTTPError as exc:
|
||||
raw = exc.read().decode('utf-8', 'replace')
|
||||
try:
|
||||
payload = json.loads(raw) if raw else {}
|
||||
except Exception:
|
||||
payload = raw
|
||||
return exc.code, payload
|
||||
|
||||
def login(self) -> None:
|
||||
status, payload = self._json_request('POST', '/api/auth/login', {
|
||||
'username': self.username,
|
||||
'password': self.password,
|
||||
'rememberMe': True,
|
||||
})
|
||||
if status >= 300:
|
||||
raise SystemExit(f'UniFi login failed: HTTP {status} {payload!r}')
|
||||
|
||||
def get(self, path: str) -> Any:
|
||||
status, payload = self._json_request('GET', path)
|
||||
if status >= 300:
|
||||
raise SystemExit(f'UniFi GET {path} failed: HTTP {status} {payload!r}')
|
||||
return payload.get('data', payload) if isinstance(payload, dict) else payload
|
||||
|
||||
def post(self, path: str, body: dict[str, Any]) -> Any:
|
||||
status, payload = self._json_request('POST', path, body)
|
||||
if status >= 300:
|
||||
raise SystemExit(f'UniFi POST {path} failed: HTTP {status} {payload!r}')
|
||||
return payload.get('data', payload) if isinstance(payload, dict) else payload
|
||||
|
||||
def site_path(self, site: str, suffix: str) -> str:
|
||||
suffix = suffix.lstrip('/')
|
||||
return f'/proxy/network/api/s/{site}/{suffix}'
|
||||
|
||||
|
||||
def find_by_name(items: list[dict[str, Any]], name: str) -> dict[str, Any] | None:
|
||||
return next((item for item in items if item.get('name') == name), None)
|
||||
|
||||
|
||||
def servers_network_payload() -> dict[str, Any]:
|
||||
return {
|
||||
'name': 'Servers',
|
||||
'purpose': 'corporate',
|
||||
'enabled': True,
|
||||
'networkgroup': 'LAN',
|
||||
'setting_preference': 'manual',
|
||||
'vlan_enabled': True,
|
||||
'vlan': 30,
|
||||
'ip_subnet': '10.5.30.1/24',
|
||||
'dhcpd_enabled': True,
|
||||
'dhcpd_start': '10.5.30.100',
|
||||
'dhcpd_stop': '10.5.30.199',
|
||||
'dhcpd_leasetime': 86400,
|
||||
'dhcpd_gateway_enabled': False,
|
||||
'dhcpd_dns_enabled': False,
|
||||
'dhcpd_ntp_enabled': False,
|
||||
'dhcpd_wins_enabled': False,
|
||||
'dhcpd_time_offset_enabled': False,
|
||||
'dhcpd_boot_enabled': False,
|
||||
'dhcpd_conflict_checking': True,
|
||||
'dhcp_relay_enabled': False,
|
||||
'dhcp_relay_servers': [],
|
||||
'domain_name': '',
|
||||
'ipv6_interface_type': 'none',
|
||||
'ipv6_setting_preference': 'manual',
|
||||
'ipv6_aliases': [],
|
||||
'ip_aliases': [],
|
||||
'internet_access_enabled': True,
|
||||
'network_isolation_enabled': False,
|
||||
'gateway_type': 'default',
|
||||
'nat_outbound_ip_addresses': [],
|
||||
'mdns_enabled': True,
|
||||
'lte_lan_enabled': True,
|
||||
'auto_scale_enabled': True,
|
||||
}
|
||||
|
||||
|
||||
def build_servers_port_profile_payload(template: dict[str, Any], servers_network_id: str) -> dict[str, Any]:
|
||||
payload = {
|
||||
k: v for k, v in template.items()
|
||||
if k not in {'_id', 'site_id', 'name', 'native_networkconf_id'}
|
||||
}
|
||||
payload['name'] = 'Servers'
|
||||
payload['native_networkconf_id'] = servers_network_id
|
||||
return payload
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description='Stage low-risk UniFi objects for the cutover.')
|
||||
parser.add_argument('--env-file', default=str(DEFAULT_ENV_PATH), help='Path to .env containing UNIFI_* vars')
|
||||
parser.add_argument('--site', help='UniFi site name; defaults to UNIFI_SITE or default')
|
||||
parser.add_argument('--apply', action='store_true', help='Actually create missing objects. Default is dry-run only.')
|
||||
parser.add_argument('--timeout', type=int, default=DEFAULT_TIMEOUT)
|
||||
args = parser.parse_args()
|
||||
|
||||
load_env_file(Path(args.env_file))
|
||||
base_url = os.environ.get('UNIFI_BASE_URL', 'https://10.5.0.1')
|
||||
username = os.environ.get('UNIFI_USERNAME')
|
||||
password = os.environ.get('UNIFI_PASSWORD')
|
||||
site = args.site or os.environ.get('UNIFI_SITE', 'default')
|
||||
verify_tls = env_bool('UNIFI_VERIFY_TLS', False)
|
||||
|
||||
if not username or not password:
|
||||
raise SystemExit('Missing UNIFI_USERNAME or UNIFI_PASSWORD in env file/environment.')
|
||||
|
||||
client = UniFiClient(base_url, username, password, verify_tls=verify_tls, timeout=args.timeout)
|
||||
client.login()
|
||||
|
||||
networks = client.get(client.site_path(site, 'rest/networkconf'))
|
||||
port_profiles = client.get(client.site_path(site, 'rest/portconf'))
|
||||
|
||||
result: dict[str, Any] = {
|
||||
'mode': 'apply' if args.apply else 'dry-run',
|
||||
'site': site,
|
||||
'changes': [],
|
||||
}
|
||||
|
||||
servers_network = find_by_name(networks, 'Servers')
|
||||
if servers_network:
|
||||
result['changes'].append({
|
||||
'object': 'network',
|
||||
'name': 'Servers',
|
||||
'status': 'exists',
|
||||
'id': servers_network.get('_id'),
|
||||
'vlan': servers_network.get('vlan'),
|
||||
'ip_subnet': servers_network.get('ip_subnet'),
|
||||
})
|
||||
else:
|
||||
payload = servers_network_payload()
|
||||
if args.apply:
|
||||
created = client.post(client.site_path(site, 'rest/networkconf'), payload)
|
||||
if isinstance(created, list):
|
||||
created = created[0] if created else {}
|
||||
servers_network = created
|
||||
result['changes'].append({
|
||||
'object': 'network',
|
||||
'name': 'Servers',
|
||||
'status': 'created',
|
||||
'id': created.get('_id'),
|
||||
'vlan': created.get('vlan'),
|
||||
'ip_subnet': created.get('ip_subnet'),
|
||||
})
|
||||
else:
|
||||
servers_network = {'_id': '<created-network-id>'}
|
||||
result['changes'].append({
|
||||
'object': 'network',
|
||||
'name': 'Servers',
|
||||
'status': 'would_create',
|
||||
'payload': payload,
|
||||
})
|
||||
|
||||
servers_port_profile = find_by_name(port_profiles, 'Servers')
|
||||
if servers_port_profile:
|
||||
result['changes'].append({
|
||||
'object': 'port_profile',
|
||||
'name': 'Servers',
|
||||
'status': 'exists',
|
||||
'id': servers_port_profile.get('_id'),
|
||||
'native_networkconf_id': servers_port_profile.get('native_networkconf_id'),
|
||||
})
|
||||
else:
|
||||
trusted_template = find_by_name(port_profiles, 'Trusted')
|
||||
if not trusted_template:
|
||||
raise SystemExit('Could not find existing Trusted port profile to clone.')
|
||||
payload = build_servers_port_profile_payload(trusted_template, servers_network['_id'])
|
||||
if args.apply:
|
||||
created = client.post(client.site_path(site, 'rest/portconf'), payload)
|
||||
if isinstance(created, list):
|
||||
created = created[0] if created else {}
|
||||
result['changes'].append({
|
||||
'object': 'port_profile',
|
||||
'name': 'Servers',
|
||||
'status': 'created',
|
||||
'id': created.get('_id'),
|
||||
'native_networkconf_id': created.get('native_networkconf_id'),
|
||||
})
|
||||
else:
|
||||
result['changes'].append({
|
||||
'object': 'port_profile',
|
||||
'name': 'Servers',
|
||||
'status': 'would_create',
|
||||
'payload': payload,
|
||||
})
|
||||
|
||||
print(json.dumps(result, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
394
automation/bin/unifi_stage_policy_engine_firewall.py
Normal file
394
automation/bin/unifi_stage_policy_engine_firewall.py
Normal 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())
|
||||
@@ -29,6 +29,7 @@ services:
|
||||
N8N_PROTOCOL: https
|
||||
WEBHOOK_URL: https://${N8N_HOST}/
|
||||
N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
|
||||
N8N_API_KEY: ${N8N_API_KEY}
|
||||
DB_TYPE: postgresdb
|
||||
DB_POSTGRESDB_HOST: shared-postgres
|
||||
DB_POSTGRESDB_PORT: 5432
|
||||
@@ -40,6 +41,18 @@ services:
|
||||
EXECUTIONS_DATA_MAX_AGE: 336
|
||||
N8N_BLOCK_ENV_ACCESS_IN_NODE: "false"
|
||||
LITELLM_API_KEY: ${LITELLM_API_KEY}
|
||||
PAPERLESS_BASE_URL: ${PAPERLESS_BASE_URL}
|
||||
PAPERLESS_API_TOKEN: ${PAPERLESS_API_TOKEN}
|
||||
PAPERLESS_CORRESPONDENT_SCHOOL: ${PAPERLESS_CORRESPONDENT_SCHOOL}
|
||||
PAPERLESS_DOCUMENT_TYPE_SCHOOL: ${PAPERLESS_DOCUMENT_TYPE_SCHOOL}
|
||||
PAPERLESS_TAG_SCHOOL: ${PAPERLESS_TAG_SCHOOL}
|
||||
PAPERLESS_TAG_ASSIGNMENTS: ${PAPERLESS_TAG_ASSIGNMENTS}
|
||||
PAPERLESS_TAG_TELEGRAM: ${PAPERLESS_TAG_TELEGRAM}
|
||||
PAPERLESS_TAG_CLASS_MAP_JSON: ${PAPERLESS_TAG_CLASS_MAP_JSON}
|
||||
PAPERLESS_TAG_SUBMISSION_KIND_MAP_JSON: ${PAPERLESS_TAG_SUBMISSION_KIND_MAP_JSON}
|
||||
PAPERLESS_DOCUMENT_TYPE_PAPER_KIND_MAP_JSON: ${PAPERLESS_DOCUMENT_TYPE_PAPER_KIND_MAP_JSON}
|
||||
PAPERLESS_CORRESPONDENT_CLASS_MAP_JSON: ${PAPERLESS_CORRESPONDENT_CLASS_MAP_JSON}
|
||||
NODE_FUNCTION_ALLOW_BUILTIN: ${NODE_FUNCTION_ALLOW_BUILTIN}
|
||||
volumes:
|
||||
- /mnt/tank/docker/appdata/n8n:/home/node/.n8n
|
||||
- /mnt/data/class-recordings:/data/class-recordings
|
||||
|
||||
@@ -30,6 +30,7 @@ services:
|
||||
GITEA__database__NAME: ${GITEA_DB_NAME}
|
||||
GITEA__database__USER: ${GITEA_DB_USER}
|
||||
GITEA__database__PASSWD: ${GITEA_DB_PASS}
|
||||
GITEA__service__DISABLE_REGISTRATION: true
|
||||
volumes:
|
||||
- /mnt/docker-ssd/docker/appdata/gitea:/data
|
||||
healthcheck:
|
||||
@@ -39,6 +40,25 @@ services:
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
# Run once to install git-crypt on the TrueNAS host (apt is blocked there).
|
||||
# Usage: docker compose --profile setup up git-crypt-init
|
||||
git-crypt-init:
|
||||
container_name: git-crypt-init
|
||||
image: debian:bookworm-slim
|
||||
profiles:
|
||||
- setup
|
||||
restart: "no"
|
||||
volumes:
|
||||
- /root/bin:/out
|
||||
command: >
|
||||
bash -c "
|
||||
apt-get update -qq &&
|
||||
apt-get install -y -qq git-crypt &&
|
||||
cp /usr/bin/git-crypt /out/git-crypt &&
|
||||
chmod +x /out/git-crypt &&
|
||||
echo 'git-crypt installed to /root/bin/git-crypt'
|
||||
"
|
||||
|
||||
rackpeek:
|
||||
container_name: ix-rackpeek-rackpeek-1
|
||||
image: aptacode/rackpeek:latest
|
||||
@@ -52,7 +72,7 @@ services:
|
||||
TZ: ${TZ}
|
||||
RPK_YAML_DIR: /app/config
|
||||
volumes:
|
||||
- /mnt/tank/docker/appdata/app/rackpeek/config:/app/config
|
||||
- /mnt/docker-ssd/docker/compose/dev/rackpeek:/app/config
|
||||
- /mnt/tank/docker/appdata/app/rackpeek/dpkeys:/home/app/.aspnet/DataProtection-Keys
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "cat /proc/net/tcp6 | grep -q 1F90 || exit 1"]
|
||||
|
||||
46
dev/rackpeek/README.md
Normal file
46
dev/rackpeek/README.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# RackPeek source of truth
|
||||
|
||||
This directory is the git-tracked RackPeek inventory for the homelab.
|
||||
|
||||
## Files
|
||||
- `config.yaml` — current RackPeek v3 inventory assembled from the homelab docs
|
||||
|
||||
## Data sources
|
||||
Update this inventory from the canonical repo docs first:
|
||||
- `docs/architecture/ARCHITECTURE_OVERVIEW.md`
|
||||
- `docs/architecture/SERVICES_DIRECTORY.md`
|
||||
- `docs/architecture/NETWORKING_MODEL.md`
|
||||
- `docs/servers/PLAUSIBLEDENABILITY.md`
|
||||
- `docs/servers/SERENITY.md`
|
||||
- `docs/servers/NOMAD.md`
|
||||
- `docs/servers/ROCINANTE.md`
|
||||
|
||||
For UniFi-managed network gear, refresh from the validated read-only helper after reconciling the docs:
|
||||
- `automation/bin/unifi_network.py`
|
||||
|
||||
## Runtime wiring
|
||||
The `dev/docker-compose.yaml` RackPeek service mounts this repo directory directly at `/app/config` on PD:
|
||||
- host path: `/mnt/docker-ssd/docker/compose/dev/rackpeek`
|
||||
- container path: `/app/config`
|
||||
|
||||
Data protection keys remain outside git at:
|
||||
- `/mnt/tank/docker/appdata/app/rackpeek/dpkeys`
|
||||
|
||||
## Validation
|
||||
From a repo checkout with Docker available:
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
-p 18283:8080 \
|
||||
-e RPK_YAML_DIR=/app/config \
|
||||
-v "$PWD/dev/rackpeek:/app/config" \
|
||||
aptacode/rackpeek:latest
|
||||
```
|
||||
|
||||
Then verify:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:18283/health
|
||||
```
|
||||
|
||||
Expected response: `rackpeek`
|
||||
744
dev/rackpeek/config.yaml
Normal file
744
dev/rackpeek/config.yaml
Normal file
@@ -0,0 +1,744 @@
|
||||
version: 3
|
||||
resources:
|
||||
- kind: Server
|
||||
name: plausibledeniability
|
||||
cpus:
|
||||
- model: AMD Ryzen 9 3950X
|
||||
cores: 16
|
||||
threads: 32
|
||||
ram:
|
||||
size: 32
|
||||
drives:
|
||||
- type: ssd
|
||||
size: 500
|
||||
- type: hdd
|
||||
size: 4000
|
||||
- type: hdd
|
||||
size: 4000
|
||||
gpus:
|
||||
- model: NVIDIA RTX 2080 Ti
|
||||
vram: 11
|
||||
ports:
|
||||
- type: rj45
|
||||
speed: 1
|
||||
count: 1
|
||||
tags:
|
||||
- prod
|
||||
- docker-host
|
||||
- ai-light-tier
|
||||
labels:
|
||||
lan-ip: 10.5.30.6
|
||||
os: TrueNAS Scale 25.10.2.1
|
||||
docker-root: /mnt/docker-ssd/docker/docker-data
|
||||
compose-root: /mnt/docker-ssd/docker/compose
|
||||
notes: |-
|
||||
Primary Docker host.
|
||||
Storage tiers:
|
||||
- /mnt/docker-ssd = Docker root, databases, write-heavy appdata, model caches
|
||||
- /mnt/tank = general appdata, configs, backups, downloads
|
||||
- /mnt/unraid = Serenity NFS mounts for data and Immich
|
||||
|
||||
- kind: Server
|
||||
name: serenity
|
||||
cpus:
|
||||
- model: Intel Xeon X5690
|
||||
cores: 6
|
||||
threads: 12
|
||||
- model: Intel Xeon X5690
|
||||
cores: 6
|
||||
threads: 12
|
||||
ram:
|
||||
size: 102
|
||||
ports:
|
||||
- type: rj45
|
||||
speed: 1
|
||||
count: 1
|
||||
tags:
|
||||
- prod
|
||||
- storage
|
||||
- reranker
|
||||
labels:
|
||||
lan-ip: 10.5.30.5
|
||||
tailscale-ip: 100.94.87.79
|
||||
os: Unraid 7.2.4
|
||||
notes: |-
|
||||
NAS, media ingestion, and CPU-based AI inference.
|
||||
NFS exports to PD:
|
||||
- /mnt/user/data -> /mnt/unraid/data
|
||||
- /mnt/user/immich -> /mnt/unraid/immich
|
||||
|
||||
- kind: Server
|
||||
name: nomad
|
||||
cpus:
|
||||
- model: Intel Core i7-4770K
|
||||
cores: 4
|
||||
threads: 8
|
||||
ram:
|
||||
size: 32
|
||||
drives:
|
||||
- type: ssd
|
||||
size: 233
|
||||
- type: hdd
|
||||
size: 3600
|
||||
- type: hdd
|
||||
size: 3600
|
||||
- type: ssd
|
||||
size: 224
|
||||
gpus:
|
||||
- model: NVIDIA GeForce GTX 1080
|
||||
vram: 8
|
||||
ports:
|
||||
- type: rj45
|
||||
speed: 1
|
||||
count: 1
|
||||
- type: rj45
|
||||
speed: 10
|
||||
count: 1
|
||||
tags:
|
||||
- prod
|
||||
- offline-knowledge
|
||||
- game-server
|
||||
labels:
|
||||
lan-ip: 10.5.30.7
|
||||
os: Ubuntu 25.10
|
||||
notes: |-
|
||||
Offline knowledge, game server, and local services host.
|
||||
Docker data-root: /mnt/hdd-2/docker
|
||||
Project N.O.M.A.D. runtime: /opt/project-nomad
|
||||
|
||||
- kind: Server
|
||||
name: rocinante
|
||||
gpus:
|
||||
- model: NVIDIA RTX 4090
|
||||
vram: 24
|
||||
ports:
|
||||
- type: rj45
|
||||
speed: 1
|
||||
count: 1
|
||||
tags:
|
||||
- prod
|
||||
- ai-heavy-tier
|
||||
- degraded
|
||||
labels:
|
||||
lan-ip: 10.5.30.112
|
||||
notes: |-
|
||||
Heavy AI inference server.
|
||||
Current operational note from 2026-05-21:
|
||||
- Unreachable from N.O.M.A.D. on ports 11434 and 8787 during Doris validation.
|
||||
|
||||
- kind: Server
|
||||
name: unifi-udm-pro
|
||||
ports:
|
||||
- type: rj45
|
||||
speed: 10
|
||||
count: 8
|
||||
- type: sfp+
|
||||
speed: 10
|
||||
count: 2
|
||||
tags:
|
||||
- prod
|
||||
- network
|
||||
- unifi
|
||||
- gateway
|
||||
labels:
|
||||
ip: 10.5.0.1
|
||||
management-ip: 10.5.0.1
|
||||
wan-ip: 69.166.182.157
|
||||
os: UniFi OS 5.1.11.33023
|
||||
mac: d0:21:f9:75:8a:ff
|
||||
serial: D021F9758AFF
|
||||
notes: |-
|
||||
UniFi gateway and controller.
|
||||
UniFi device name: UDM Pro - Old.
|
||||
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
|
||||
name: unifi-usw-24-poe
|
||||
ports:
|
||||
- type: rj45
|
||||
speed: 1
|
||||
count: 24
|
||||
tags:
|
||||
- prod
|
||||
- network
|
||||
- unifi
|
||||
- switch
|
||||
- poe
|
||||
labels:
|
||||
ip: 10.5.0.10
|
||||
management-ip: 10.5.0.10
|
||||
os: UniFi Switch 7.4.1.16850
|
||||
mac: ac:8b:a9:ad:ab:86
|
||||
serial: AC8BA9ADAB86
|
||||
notes: |-
|
||||
UniFi device name: USW-24-PoE.
|
||||
Active in UniFi; management uplink reported on eth0.
|
||||
|
||||
- kind: Server
|
||||
name: unifi-usw-pro-hd-24
|
||||
ports:
|
||||
- type: rj45
|
||||
speed: 1
|
||||
count: 24
|
||||
tags:
|
||||
- prod
|
||||
- network
|
||||
- unifi
|
||||
- switch
|
||||
labels:
|
||||
ip: 10.5.0.135
|
||||
management-ip: 10.5.0.135
|
||||
os: UniFi Switch 7.4.1.16850
|
||||
mac: 8c:30:66:d0:9f:48
|
||||
serial: 8C3066D09F48
|
||||
notes: |-
|
||||
UniFi device name: USW Pro HD 24.
|
||||
Active in UniFi; management uplink reported on eth0.
|
||||
|
||||
- kind: Server
|
||||
name: unifi-u7-pro
|
||||
ports:
|
||||
- type: rj45
|
||||
speed: 1
|
||||
count: 1
|
||||
tags:
|
||||
- prod
|
||||
- network
|
||||
- unifi
|
||||
- ap
|
||||
labels:
|
||||
ip: 10.5.0.21
|
||||
management-ip: 10.5.0.21
|
||||
os: UniFi AP 8.5.21.18681
|
||||
mac: 8c:ed:e1:8a:58:54
|
||||
serial: 8CEDE18A5854
|
||||
notes: |-
|
||||
UniFi device name: U7 Pro.
|
||||
Active in UniFi; management uplink reported on eth0.
|
||||
|
||||
- kind: Server
|
||||
name: unifi-u6-lr
|
||||
ports:
|
||||
- type: rj45
|
||||
speed: 1
|
||||
count: 1
|
||||
tags:
|
||||
- prod
|
||||
- network
|
||||
- unifi
|
||||
- ap
|
||||
- disconnected
|
||||
labels:
|
||||
ip: 10.5.0.20
|
||||
management-ip: 10.5.0.20
|
||||
os: UniFi AP 6.7.41.15623
|
||||
mac: d0:21:f9:69:a5:f1
|
||||
serial: D021F969A5F1
|
||||
notes: |-
|
||||
UniFi device name: U6 LR.
|
||||
Currently disconnected in UniFi as of 2026-05-21.
|
||||
|
||||
- kind: System
|
||||
type: Baremetal
|
||||
name: pd-truenas
|
||||
os: truenas-scale-25.10.2.1
|
||||
cores: 16
|
||||
ram: 32
|
||||
ip: 10.5.30.6
|
||||
runsOn:
|
||||
- plausibledeniability
|
||||
|
||||
- kind: System
|
||||
type: Baremetal
|
||||
name: serenity-unraid
|
||||
os: unraid-7.2.4
|
||||
cores: 12
|
||||
ram: 102
|
||||
ip: 10.5.30.5
|
||||
runsOn:
|
||||
- serenity
|
||||
|
||||
- kind: System
|
||||
type: Baremetal
|
||||
name: nomad-ubuntu
|
||||
os: ubuntu-25.10
|
||||
cores: 4
|
||||
ram: 32
|
||||
ip: 10.5.30.7
|
||||
runsOn:
|
||||
- nomad
|
||||
|
||||
- kind: System
|
||||
type: Baremetal
|
||||
name: rocinante-inference
|
||||
os: baremetal
|
||||
ip: 10.5.30.112
|
||||
runsOn:
|
||||
- rocinante
|
||||
|
||||
- kind: System
|
||||
type: Embedded
|
||||
name: unifi-gateway
|
||||
os: unifi-os-5.1.11.33023
|
||||
ip: 10.5.0.1
|
||||
runsOn:
|
||||
- unifi-udm-pro
|
||||
|
||||
- kind: System
|
||||
type: Embedded
|
||||
name: unifi-usw-24-poe-firmware
|
||||
os: unifi-switch-7.4.1.16850
|
||||
ip: 10.5.0.10
|
||||
runsOn:
|
||||
- unifi-usw-24-poe
|
||||
|
||||
- kind: System
|
||||
type: Embedded
|
||||
name: unifi-usw-pro-hd-24-firmware
|
||||
os: unifi-switch-7.4.1.16850
|
||||
ip: 10.5.0.135
|
||||
runsOn:
|
||||
- unifi-usw-pro-hd-24
|
||||
|
||||
- kind: System
|
||||
type: Embedded
|
||||
name: unifi-u7-pro-firmware
|
||||
os: unifi-ap-8.5.21.18681
|
||||
ip: 10.5.0.21
|
||||
runsOn:
|
||||
- unifi-u7-pro
|
||||
|
||||
- kind: System
|
||||
type: Embedded
|
||||
name: unifi-u6-lr-firmware
|
||||
os: unifi-ap-6.7.41.15623
|
||||
ip: 10.5.0.20
|
||||
runsOn:
|
||||
- unifi-u6-lr
|
||||
|
||||
- kind: Service
|
||||
name: homepage
|
||||
network:
|
||||
ip: 10.5.30.6
|
||||
port: 3300
|
||||
protocol: TCP
|
||||
url: http://10.5.30.6:3300
|
||||
runsOn:
|
||||
- pd-truenas
|
||||
|
||||
- kind: Service
|
||||
name: dockhand
|
||||
network:
|
||||
ip: 10.5.30.6
|
||||
port: 3230
|
||||
protocol: TCP
|
||||
url: http://10.5.30.6:3230
|
||||
runsOn:
|
||||
- pd-truenas
|
||||
|
||||
- kind: Service
|
||||
name: uptime-kuma
|
||||
network:
|
||||
ip: 10.5.30.6
|
||||
port: 3001
|
||||
protocol: TCP
|
||||
url: http://10.5.30.6:3001
|
||||
runsOn:
|
||||
- pd-truenas
|
||||
|
||||
- kind: Service
|
||||
name: gotify
|
||||
network:
|
||||
ip: 10.5.30.6
|
||||
port: 8443
|
||||
protocol: TCP
|
||||
url: https://gotify.paccoco.com
|
||||
runsOn:
|
||||
- pd-truenas
|
||||
|
||||
- kind: Service
|
||||
name: gitea
|
||||
network:
|
||||
ip: 10.5.30.6
|
||||
port: 3002
|
||||
protocol: TCP
|
||||
url: http://10.5.30.6:3002
|
||||
runsOn:
|
||||
- pd-truenas
|
||||
|
||||
- kind: Service
|
||||
name: gitea-ssh
|
||||
network:
|
||||
ip: 10.5.30.6
|
||||
port: 2222
|
||||
protocol: TCP
|
||||
url: ssh://git@10.5.30.6:2222/
|
||||
runsOn:
|
||||
- pd-truenas
|
||||
|
||||
- kind: Service
|
||||
name: shlink
|
||||
network:
|
||||
ip: 10.5.30.6
|
||||
port: 8087
|
||||
protocol: TCP
|
||||
url: http://10.5.30.6:8087
|
||||
runsOn:
|
||||
- pd-truenas
|
||||
|
||||
- kind: Service
|
||||
name: shlink-web-client
|
||||
network:
|
||||
ip: 10.5.30.6
|
||||
port: 8088
|
||||
protocol: TCP
|
||||
url: http://10.5.30.6:8088
|
||||
runsOn:
|
||||
- pd-truenas
|
||||
|
||||
- kind: Service
|
||||
name: rackpeek
|
||||
network:
|
||||
ip: 10.5.30.6
|
||||
port: 8283
|
||||
protocol: TCP
|
||||
url: http://10.5.30.6:8283
|
||||
tags:
|
||||
- source-of-truth-target
|
||||
runsOn:
|
||||
- pd-truenas
|
||||
|
||||
- kind: Service
|
||||
name: unifi-network-controller
|
||||
network:
|
||||
ip: 10.5.0.1
|
||||
port: 443
|
||||
protocol: TCP
|
||||
url: https://10.5.0.1
|
||||
tags:
|
||||
- internal
|
||||
- network
|
||||
- unifi
|
||||
runsOn:
|
||||
- unifi-gateway
|
||||
|
||||
- kind: Service
|
||||
name: authelia
|
||||
network:
|
||||
ip: 10.5.30.6
|
||||
port: 9091
|
||||
protocol: TCP
|
||||
url: https://auth.paccoco.com
|
||||
runsOn:
|
||||
- pd-truenas
|
||||
|
||||
- kind: Service
|
||||
name: litellm
|
||||
network:
|
||||
ip: 10.5.30.6
|
||||
port: 4000
|
||||
protocol: TCP
|
||||
url: http://10.5.30.6:4000
|
||||
runsOn:
|
||||
- pd-truenas
|
||||
|
||||
- kind: Service
|
||||
name: openwebui
|
||||
network:
|
||||
ip: 10.5.30.6
|
||||
port: 8282
|
||||
protocol: TCP
|
||||
url: http://10.5.30.6:8282
|
||||
runsOn:
|
||||
- pd-truenas
|
||||
|
||||
- kind: Service
|
||||
name: qdrant
|
||||
network:
|
||||
ip: 10.5.30.6
|
||||
port: 6333
|
||||
protocol: TCP
|
||||
url: http://10.5.30.6:6333
|
||||
runsOn:
|
||||
- pd-truenas
|
||||
|
||||
- kind: Service
|
||||
name: searxng
|
||||
network:
|
||||
ip: 10.5.30.6
|
||||
port: 8888
|
||||
protocol: TCP
|
||||
url: http://10.5.30.6:8888
|
||||
runsOn:
|
||||
- pd-truenas
|
||||
|
||||
- kind: Service
|
||||
name: ollama-light-tier
|
||||
network:
|
||||
ip: 10.5.30.6
|
||||
port: 11434
|
||||
protocol: TCP
|
||||
url: http://10.5.30.6:11434
|
||||
runsOn:
|
||||
- pd-truenas
|
||||
|
||||
- kind: Service
|
||||
name: whisper-cpu
|
||||
network:
|
||||
ip: 10.5.30.7
|
||||
port: 8786
|
||||
protocol: TCP
|
||||
url: http://10.5.30.7:8786
|
||||
runsOn:
|
||||
- nomad-ubuntu
|
||||
|
||||
- kind: Service
|
||||
name: ollama-heavy-tier
|
||||
network:
|
||||
ip: 10.5.30.112
|
||||
port: 11434
|
||||
protocol: TCP
|
||||
url: http://10.5.30.112:11434
|
||||
tags:
|
||||
- degraded
|
||||
runsOn:
|
||||
- rocinante-inference
|
||||
|
||||
- kind: Service
|
||||
name: whisper-cuda
|
||||
network:
|
||||
ip: 10.5.30.112
|
||||
port: 8787
|
||||
protocol: TCP
|
||||
url: http://10.5.30.112:8787
|
||||
tags:
|
||||
- degraded
|
||||
runsOn:
|
||||
- rocinante-inference
|
||||
|
||||
- kind: Service
|
||||
name: reranker
|
||||
network:
|
||||
ip: 10.5.30.5
|
||||
port: 9787
|
||||
protocol: TCP
|
||||
url: http://10.5.30.5:9787
|
||||
runsOn:
|
||||
- serenity-unraid
|
||||
|
||||
- kind: Service
|
||||
name: plex
|
||||
network:
|
||||
ip: 10.5.30.6
|
||||
port: 32400
|
||||
protocol: TCP
|
||||
url: http://10.5.30.6:32400
|
||||
runsOn:
|
||||
- pd-truenas
|
||||
|
||||
- kind: Service
|
||||
name: tautulli
|
||||
network:
|
||||
ip: 10.5.30.6
|
||||
port: 8181
|
||||
protocol: TCP
|
||||
url: http://10.5.30.6:8181
|
||||
runsOn:
|
||||
- pd-truenas
|
||||
|
||||
- kind: Service
|
||||
name: audiobookshelf
|
||||
network:
|
||||
ip: 10.5.30.6
|
||||
port: 13358
|
||||
protocol: TCP
|
||||
url: http://10.5.30.6:13358
|
||||
runsOn:
|
||||
- pd-truenas
|
||||
|
||||
- kind: Service
|
||||
name: calibre-web
|
||||
network:
|
||||
ip: 10.5.30.6
|
||||
port: 8183
|
||||
protocol: TCP
|
||||
url: http://10.5.30.6:8183
|
||||
runsOn:
|
||||
- pd-truenas
|
||||
|
||||
- kind: Service
|
||||
name: seerr
|
||||
network:
|
||||
ip: 10.5.30.6
|
||||
port: 5055
|
||||
protocol: TCP
|
||||
url: http://10.5.30.6:5055
|
||||
runsOn:
|
||||
- pd-truenas
|
||||
|
||||
- kind: Service
|
||||
name: immich
|
||||
network:
|
||||
ip: 10.5.30.6
|
||||
port: 2283
|
||||
protocol: TCP
|
||||
url: http://10.5.30.6:2283
|
||||
runsOn:
|
||||
- pd-truenas
|
||||
|
||||
- kind: Service
|
||||
name: paperless-ngx
|
||||
network:
|
||||
ip: 10.5.30.6
|
||||
port: 8083
|
||||
protocol: TCP
|
||||
url: http://10.5.30.6:8083
|
||||
runsOn:
|
||||
- pd-truenas
|
||||
|
||||
- kind: Service
|
||||
name: karakeep
|
||||
network:
|
||||
ip: 10.5.30.6
|
||||
port: 3100
|
||||
protocol: TCP
|
||||
url: http://10.5.30.6:3100
|
||||
runsOn:
|
||||
- pd-truenas
|
||||
|
||||
- kind: Service
|
||||
name: n8n
|
||||
network:
|
||||
ip: 10.5.30.6
|
||||
port: 5678
|
||||
protocol: TCP
|
||||
url: http://10.5.30.6:5678
|
||||
runsOn:
|
||||
- pd-truenas
|
||||
|
||||
- kind: Service
|
||||
name: kitchenowl
|
||||
network:
|
||||
ip: 10.5.30.6
|
||||
port: 8086
|
||||
protocol: TCP
|
||||
url: http://10.5.30.6:8086
|
||||
runsOn:
|
||||
- pd-truenas
|
||||
|
||||
- kind: Service
|
||||
name: donetick
|
||||
network:
|
||||
ip: 10.5.30.6
|
||||
port: 2021
|
||||
protocol: TCP
|
||||
url: http://10.5.30.6:2021
|
||||
runsOn:
|
||||
- pd-truenas
|
||||
|
||||
- kind: Service
|
||||
name: dakboard-bridge
|
||||
network:
|
||||
ip: 10.5.30.6
|
||||
port: 5087
|
||||
protocol: TCP
|
||||
url: http://10.5.30.6:5087
|
||||
runsOn:
|
||||
- pd-truenas
|
||||
|
||||
- kind: Service
|
||||
name: qui
|
||||
network:
|
||||
ip: 10.5.30.6
|
||||
port: 7476
|
||||
protocol: TCP
|
||||
url: http://10.5.30.6:7476
|
||||
runsOn:
|
||||
- pd-truenas
|
||||
|
||||
- kind: Service
|
||||
name: home-assistant
|
||||
network:
|
||||
ip: 10.5.30.6
|
||||
port: 8123
|
||||
protocol: TCP
|
||||
url: http://10.5.30.6:8123
|
||||
runsOn:
|
||||
- pd-truenas
|
||||
|
||||
- kind: Service
|
||||
name: kima-hub
|
||||
network:
|
||||
ip: 10.5.30.6
|
||||
port: 3333
|
||||
protocol: TCP
|
||||
url: http://10.5.30.6:3333
|
||||
runsOn:
|
||||
- pd-truenas
|
||||
|
||||
- kind: Service
|
||||
name: grafana
|
||||
network:
|
||||
ip: 10.5.30.6
|
||||
port: 3000
|
||||
protocol: TCP
|
||||
url: http://10.5.30.6:3000
|
||||
runsOn:
|
||||
- pd-truenas
|
||||
|
||||
- kind: Service
|
||||
name: prometheus
|
||||
network:
|
||||
ip: 10.5.30.6
|
||||
port: 9090
|
||||
protocol: TCP
|
||||
url: http://10.5.30.6:9090
|
||||
runsOn:
|
||||
- pd-truenas
|
||||
|
||||
- kind: Service
|
||||
name: meshmonitor
|
||||
network:
|
||||
ip: 10.5.30.6
|
||||
port: 8081
|
||||
protocol: TCP
|
||||
url: http://10.5.30.6:8081
|
||||
runsOn:
|
||||
- pd-truenas
|
||||
|
||||
- kind: Service
|
||||
name: tileserver-gl
|
||||
network:
|
||||
ip: 10.5.30.6
|
||||
port: 8082
|
||||
protocol: TCP
|
||||
url: http://10.5.30.6:8082
|
||||
runsOn:
|
||||
- pd-truenas
|
||||
|
||||
- kind: Service
|
||||
name: pelican-panel
|
||||
network:
|
||||
ip: 10.5.30.7
|
||||
port: 80
|
||||
protocol: TCP
|
||||
url: https://panel.paccoco.com
|
||||
runsOn:
|
||||
- nomad-ubuntu
|
||||
|
||||
- kind: Service
|
||||
name: wings-node-api
|
||||
network:
|
||||
ip: 10.5.30.7
|
||||
port: 8443
|
||||
protocol: TCP
|
||||
url: https://node1.paccoco.com
|
||||
runsOn:
|
||||
- nomad-ubuntu
|
||||
2
dispatcharr/.env.example
Normal file
2
dispatcharr/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
TZ=America/Chicago
|
||||
DISPATCHARR_LOG_LEVEL=info
|
||||
25
dispatcharr/README.md
Normal file
25
dispatcharr/README.md
Normal 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).
|
||||
28
dispatcharr/docker-compose.yaml
Normal file
28
dispatcharr/docker-compose.yaml
Normal 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
|
||||
@@ -17,7 +17,8 @@ Central index for all homelab infrastructure documentation.
|
||||
|------|-------------|
|
||||
| [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 |
|
||||
| [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 |
|
||||
|
||||
## Stacks
|
||||
@@ -34,4 +35,18 @@ Central index for all homelab infrastructure documentation.
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| [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 |
|
||||
|
||||
## 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 |
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
# 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
|
||||
|
||||
| Server | OS | IP | Role |
|
||||
|--------|----|----|------|
|
||||
| **PlausibleDeniability (PD)** | TrueNAS Scale 25.10.2.1 | 10.5.1.6 | Primary Docker host — all compose stacks |
|
||||
| **Serenity** | Unraid 7.2.4 | 10.5.1.5 | NAS, ARR stack, CPU reranker |
|
||||
| **N.O.M.A.D.** | Ubuntu 25.10 | 10.5.1.16 | Light Ollama models, game servers |
|
||||
| **Rocinante** | (bare metal) | 10.5.1.112 | Heavy Ollama models (RTX 4090) |
|
||||
| **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 |
|
||||
| **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.30.112 | Heavy Ollama models (RTX 4090) |
|
||||
|
||||
## 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
|
||||
- **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`)
|
||||
@@ -24,14 +36,15 @@ Full homelab stack as of 2026-05-09. All 6 expansion phases are complete and dep
|
||||
- **Homepage** (3300) — dashboard / service index
|
||||
- **Uptime Kuma** (3001) — monitoring and alerting
|
||||
- **Gotify** (8443) — push notifications
|
||||
- **Gitea** (3002) — self-hosted git
|
||||
- **Gitea** (3002 web / 2222 SSH) — self-hosted git; internal SSH remote uses `ssh://git@10.5.30.6:2222/<owner>/<repo>.git`
|
||||
- **Dockhand** (3230) — Docker management UI
|
||||
|
||||
### AI Layer
|
||||
- **LiteLLM** (4000) — unified LLM proxy; routes to Ollama on N.O.M.A.D. (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
|
||||
- **Qdrant** (6333/6334) — vector database for RAG
|
||||
- **Whisper** (8786) — CUDA speech-to-text on PD's RTX 2080 Ti
|
||||
- **Whisper** (8786 / 8787) — CPU speech-to-text on N.O.M.A.D. plus CUDA speech-to-text on Rocinante
|
||||
- **SearXNG** (8888) — private web search backend
|
||||
- **Reranker** (Serenity:9787) — TEI bge-reranker-v2-m3, CPU-only
|
||||
|
||||
@@ -41,7 +54,7 @@ Full homelab stack as of 2026-05-09. All 6 expansion phases are complete and dep
|
||||
|
||||
### Productivity Layer
|
||||
- **Immich** (2283) — photo management; library on Serenity NFS
|
||||
- **Paperless-NGX** (8083) — document management
|
||||
- **Paperless-NGX** (8083) — document management; uses internal Tika + Gotenberg sidecars for Office/OpenDocument text extraction and conversion
|
||||
- **Karakeep** (3100) — bookmarks / read-later
|
||||
- **n8n** (5678) — workflow automation
|
||||
- **KitchenOwl** (8086), **DoneTick** (2021) — household and task management
|
||||
@@ -57,8 +70,17 @@ Full homelab stack as of 2026-05-09. All 6 expansion phases are complete and dep
|
||||
## Key Architectural Decisions
|
||||
|
||||
1. **LiteLLM as unified proxy** — all AI clients target LiteLLM; model routing is transparent to clients
|
||||
2. **Reranker on Serenity (CPU)** — frees RTX 2080 Ti on PD exclusively for Whisper CUDA; CPU latency acceptable for RAG reranking
|
||||
2. **Reranker on Serenity (CPU)** — keeps reranking off the primary inference GPUs; CPU latency is acceptable for RAG reranking
|
||||
3. **External Docker networks** — `ai-services` bridges all AI containers across compose files; `ix-databases_shared-databases` provides shared DB access
|
||||
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
|
||||
6. **Credential hygiene** — Gitea remote uses HTTPS + credential store; 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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Serenity (NAS / Media Ingestion)
|
||||
- **OS:** Unraid 7.2.4
|
||||
- **IP:** 10.5.1.5 | Tailscale: 100.94.87.79
|
||||
- **IP:** 10.5.30.5 | Tailscale: 100.94.87.79
|
||||
- **CPU:** Dual Xeon X5690 (6c/12t each — 24 threads total)
|
||||
- **RAM:** 102GB
|
||||
- **GPU:** None (Matrox IPMI only)
|
||||
@@ -47,11 +47,23 @@
|
||||
### NFS Mounts
|
||||
- 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)
|
||||
- **OS:** Ubuntu 25.10 (bare metal)
|
||||
- **IP:** 10.5.1.16
|
||||
- **IP:** 10.5.30.7
|
||||
- **CPU:** Intel Core i7-4770K @ 3.50GHz (4c/8t, up to 3.9GHz)
|
||||
- **RAM:** 32GB DDR3 1600MHz (4×8GB)
|
||||
- **GPU:** NVIDIA GeForce GTX 1080 (8GB VRAM)
|
||||
|
||||
@@ -1,12 +1,42 @@
|
||||
# Homelab Networking Model
|
||||
|
||||
## Physical Network
|
||||
- **Gear:** Unifi throughout
|
||||
- **Main LAN:** 10.5.1.x (Trusted VLAN)
|
||||
- **Other VLANs:** Cameras, Guest, IoT
|
||||
- **Serenity IP:** 10.5.1.5
|
||||
- **N.O.M.A.D. IP:** 10.5.1.16
|
||||
- **PlausibleDeniability:** DHCP on LAN
|
||||
- **Gear:** UniFi throughout
|
||||
- **Gateway / controller:** UDM Pro (`10.5.0.1`, WAN `69.166.182.157`)
|
||||
- **Core / access switching:**
|
||||
- USW-24-PoE (`10.5.0.10`)
|
||||
- USW Pro HD 24 (`10.5.0.135`)
|
||||
- **Wi-Fi access points:**
|
||||
- U7 Pro (`10.5.0.21`) — active
|
||||
- U6 LR (`10.5.0.20`) — currently disconnected in UniFi as of 2026-05-21
|
||||
- **Management subnet:** `10.5.0.0/24`
|
||||
- **Trusted subnet:** `10.5.1.0/24` (VLAN 51)
|
||||
- **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
|
||||
- **N.O.M.A.D. IP:** 10.5.30.7
|
||||
- **PlausibleDeniability:** 10.5.30.6 (Servers VLAN)
|
||||
|
||||
## Remote Access
|
||||
- **Pangolin VPS + Newt:** Services exposed as subdomains via reverse proxy. Newt agents run on PD and N.O.M.A.D.
|
||||
@@ -45,6 +75,14 @@ networks:
|
||||
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 services run HTTP only behind the reverse proxy. Compose service names used for internal DNS between services in the same stack/network.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
All homelab services, their hosts, ports, and current status.
|
||||
|
||||
*Last updated: 2026-05-10*
|
||||
*Last updated: 2026-06-13*
|
||||
|
||||
## Shared Databases (PD)
|
||||
|
||||
@@ -19,11 +19,16 @@ All homelab services, their hosts, ports, and current status.
|
||||
| Homepage | 3300 | http://pd:3300 | ✅ Active |
|
||||
| Dockhand | 3230 | http://pd:3230 | ✅ Active |
|
||||
| Uptime Kuma | 3001 | http://pd:3001 | ✅ Active |
|
||||
| Gotify | 8443 | https://pd:8443 | ✅ Active |
|
||||
| Gitea | 3002 / 2222 | http://pd:3002 (SSH: 2222) | ✅ Active |
|
||||
| Traefik | internal only | Pangolin-targeted internal ingress on `pangolin` network | ✅ 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 |
|
||||
| Shlink | 8087 | http://pd:8087 | ✅ Active |
|
||||
| Shlink Web Client | 8088 | http://pd:8088 | ✅ Active |
|
||||
| RackPeek | 8283 | http://pd:8283 | ✅ Active |
|
||||
| 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
|
||||
|
||||
@@ -32,13 +37,21 @@ All homelab services, their hosts, ports, and current status.
|
||||
| LiteLLM | PD | 4000 | ✅ Active |
|
||||
| OpenWebUI | PD | 8282 | ✅ Active |
|
||||
| Qdrant | PD | 6333 (HTTP) / 6334 (gRPC) | ✅ Active |
|
||||
| Whisper (faster-whisper, CPU) | N.O.M.A.D. (10.5.1.16) | 8786 | ✅ Active |
|
||||
| Whisper (faster-whisper, CUDA large-v3) | Rocinante (10.5.1.112) | 8787 | ✅ 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.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 |
|
||||
| OpenClaw | PD | 18789 | ✅ Active |
|
||||
| Ollama — light tier | N.O.M.A.D. (10.5.1.16) | 11434 | ✅ Active |
|
||||
| Ollama — heavy tier | Rocinante (10.5.1.112) | 11434 | ✅ Active |
|
||||
| Reranker (TEI) | Serenity (10.5.1.5) | 9787 | ✅ 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.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 |
|
||||
|
||||
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)
|
||||
|
||||
@@ -55,19 +68,21 @@ All homelab services, their hosts, ports, and current status.
|
||||
| Service | Port | Status |
|
||||
|---------|------|--------|
|
||||
| Immich | 2283 | ✅ Active |
|
||||
| Paperless-NGX | 8083 | ✅ Active |
|
||||
| Paperless-NGX | 8083 | ✅ Active; DOCX/Office ingestion via internal Tika + Gotenberg sidecars |
|
||||
| Karakeep | 3100 | ✅ 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 |
|
||||
| DoneTick | 2021 | ✅ Active |
|
||||
| Dakboard Bridge | 5087 | ✅ Active |
|
||||
| Qui | 7476 | ✅ Active |
|
||||
| Dispatcharr | 9191 | ✅ Active |
|
||||
|
||||
## Smart Home (PD)
|
||||
|
||||
| Service | Port | Status |
|
||||
|---------|------|--------|
|
||||
| Home Assistant | 8123 | ✅ Active |
|
||||
| Home Assistant | 8123 | ✅ Active; Doris still treats this as manual until a stable operator-safe route exists from N.O.M.A.D. |
|
||||
| Kima Hub | 3333 | ✅ Active |
|
||||
|
||||
## Monitoring (PD)
|
||||
@@ -76,17 +91,34 @@ All homelab services, their hosts, ports, and current status.
|
||||
|---------|------|--------|
|
||||
| Grafana | 3000 | ✅ Active |
|
||||
| Prometheus | 9090 | ✅ Active |
|
||||
| Loki | internal | ✅ Active |
|
||||
| Promtail | internal | ✅ Active |
|
||||
| cAdvisor | internal | ✅ Active |
|
||||
| MeshMonitor | 8081 | ✅ Active |
|
||||
|
||||
## Radio / Mesh
|
||||
|
||||
| Service | Host | Port | Status |
|
||||
|---------|------|------|--------|
|
||||
| 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)
|
||||
|
||||
| Service | Port | Status |
|
||||
|---------|------|--------|
|
||||
| TileServer GL | 8082 | ✅ Active |
|
||||
|
||||
## Game Servers (N.O.M.A.D. — 10.5.1.16)
|
||||
## Game Servers (N.O.M.A.D. — 10.5.30.7)
|
||||
|
||||
| Service | Notes |
|
||||
|---------|-------|
|
||||
| Pelican / Wings | Game server management panel |
|
||||
| Minecraft | Managed via Pelican |
|
||||
| Service | Port | URL | Status | Notes |
|
||||
|---------|------|-----|--------|-------|
|
||||
| Pelican Panel | 80 / 443 | https://panel.paccoco.com | ✅ Active | For local health/probe use `http://10.5.30.7:8080`; local TLS path is self-signed and `panel.paccoco.com` resolves to `127.0.0.1` on N.O.M.A.D. |
|
||||
| Wings Node API | 8443 / 443 | https://node1.paccoco.com | ✅ Active | `401` from the public endpoint is expected and means the daemon is reachable/auth-protected |
|
||||
| Minecraft Server 25565 | 25565/tcp+udp | managed via Pelican | ✅ Active | Wings-managed Java server |
|
||||
| Minecraft Server 25566 | 25566/tcp+udp | managed via Pelican | ✅ Active | Wings-managed Java/NeoForge server |
|
||||
|
||||
358
docs/architecture/network-policy-lanes-2026-05-23.html
Normal file
358
docs/architecture/network-policy-lanes-2026-05-23.html
Normal 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>
|
||||
424
docs/architecture/network-topology-2026-05-23.html
Normal file
424
docs/architecture/network-topology-2026-05-23.html
Normal 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>
|
||||
210
docs/architecture/pd-future-state-topology.html
Normal file
210
docs/architecture/pd-future-state-topology.html
Normal 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>
|
||||
@@ -3,7 +3,7 @@
|
||||
## Current State (Known Gaps)
|
||||
- **No off-site backup** — this is a known gap, improvement pending
|
||||
- Serenity has local ZFS snapshots and replication
|
||||
- PlausibleDeniability has no automated backup of appdata or databases
|
||||
- PlausibleDeniability now has repo-documented automated backup scaffolding deployed to PD for Postgres dumps plus rsync replication to Serenity
|
||||
|
||||
## Serenity Backup Strategy
|
||||
- Sanoid snapshots cache/appdata: 7 daily, 4 weekly, 6 monthly
|
||||
@@ -26,6 +26,16 @@ Back up at minimum:
|
||||
|
||||
Recommended dump locations: `/mnt/tank/docker/backups/db-dumps/`
|
||||
|
||||
Repo-side scaffolding now lives under `automation/`:
|
||||
|
||||
- `automation/bin/pd_backup_postgres.sh`
|
||||
- `automation/bin/pd_backup_sync_to_serenity.sh`
|
||||
- `automation/bin/run_pd_backups.sh`
|
||||
|
||||
These helper scripts are now the live PD backup runner model, with deployment details tracked in `docs/operations/PD_BACKUP_DEPLOYMENT.md`.
|
||||
|
||||
Recommended live deployment model on PD: a plain **root cron** job invoking `/usr/bin/bash bin/run_pd_backups.sh` from `/mnt/docker-ssd/docker/compose/automation`.
|
||||
|
||||
## .env File Backup Options
|
||||
1. **Private Gitea repo** — short term, push all .env files to a private repo on your local Gitea
|
||||
2. **Encrypted restic** — include .env files in restic backup with encryption, push offsite (Backblaze B2)
|
||||
@@ -35,6 +45,10 @@ Recommended dump locations: `/mnt/tank/docker/backups/db-dumps/`
|
||||
- Test restores quarterly
|
||||
- Test at least one app DB restore and one full-config restore
|
||||
- Verify that restored stacks can start with current compose files
|
||||
- Repo-staged verification runner: automation/bin/run_pd_restore_verification.sh
|
||||
- Current default verification targets:
|
||||
- latest dumps from POSTGRES_DB_LIST
|
||||
- Serenity-backed tank-docker/appdata/grafana sample restore with required files present
|
||||
|
||||
## Operational Notes
|
||||
- Media libraries on Unraid are large and may not need the same backup frequency as configs/databases
|
||||
|
||||
129
docs/operations/DNS_RESILIENCE.md
Normal file
129
docs/operations/DNS_RESILIENCE.md
Normal 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`
|
||||
@@ -20,6 +20,7 @@ Examples:
|
||||
- `/mnt/docker-ssd/docker/appdata/seerr`
|
||||
- `/mnt/docker-ssd/docker/appdata/dockhand`
|
||||
- `/mnt/docker-ssd/docker/appdata/rackpeek`
|
||||
- `/mnt/docker-ssd/docker/compose/dev/rackpeek` (git-tracked RackPeek inventory source)
|
||||
- `/mnt/docker-ssd/docker/appdata/shlink`
|
||||
- `/mnt/docker-ssd/docker/appdata/gitea`
|
||||
- `/mnt/docker-ssd/docker/appdata/netdata`
|
||||
|
||||
64
docs/operations/GITEA_SSO_PREP.md
Normal file
64
docs/operations/GITEA_SSO_PREP.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Gitea SSO Prep
|
||||
|
||||
This is the staged cutover plan for moving the live PD Gitea instance to Authelia-backed sign-in without flipping it live yet.
|
||||
|
||||
## Current live shape
|
||||
|
||||
- stack path: `dev/`
|
||||
- public URL: `https://gitea.paccoco.com`
|
||||
- internal HTTP: `http://gitea:3000` on the `pangolin` network
|
||||
- shared Postgres backend already in use
|
||||
- Gitea version observed live on PD: `1.25.3`
|
||||
|
||||
## Safe hardening before SSO
|
||||
|
||||
- disable public self-registration
|
||||
- keep normal local admin login working until Authelia sign-in is proven
|
||||
- do not change `ROOT_URL` or SSH settings during the auth cutover
|
||||
|
||||
## Authelia side
|
||||
|
||||
Add a dedicated OIDC client for Gitea in the live Authelia `configuration.yml` when ready to test.
|
||||
|
||||
Recommended values:
|
||||
|
||||
- `client_id`: `gitea`
|
||||
- `client_name`: `Gitea`
|
||||
- `authorization_policy`: `one_factor`
|
||||
- `scopes`: `openid`, `profile`, `email`, `groups`
|
||||
- `grant_types`: `authorization_code`
|
||||
- `response_types`: `code`
|
||||
- `token_endpoint_auth_method`: `client_secret_basic`
|
||||
|
||||
Redirect URI guidance:
|
||||
|
||||
- use the callback URL shown by the Gitea authentication-source form
|
||||
- for standard Gitea OIDC sources this is typically under `/user/oauth2/<source-name>/callback`
|
||||
- validate the exact callback path against the live Gitea UI before committing the Authelia client entry
|
||||
|
||||
## Gitea side
|
||||
|
||||
In Gitea:
|
||||
|
||||
1. Sign in as a local admin.
|
||||
2. Go to `Site Administration` -> `Authentication Sources`.
|
||||
3. Add a new source of type `OpenID Connect`.
|
||||
4. Use Authelia discovery if the form supports it, otherwise enter the manual endpoints from `https://auth.paccoco.com/.well-known/openid-configuration`.
|
||||
|
||||
Recommended source values:
|
||||
|
||||
- name: `Authelia`
|
||||
- scopes: `openid profile email groups`
|
||||
- required claim mapping priority: email first, then username/login
|
||||
- keep the source enabled only after confirming the callback URL matches the Authelia client entry
|
||||
|
||||
## Rollout notes
|
||||
|
||||
- keep local admin auth available for the first pass
|
||||
- test with one admin account first
|
||||
- after successful sign-in, decide whether to keep local auth as break-glass only
|
||||
- do not disable Pangolin auth in front of Gitea until Authelia login is actually working end-to-end
|
||||
|
||||
## Related weak spot
|
||||
|
||||
The live Gitea app.ini observed on PD had public self-registration enabled. That should stay off for this homelab unless there is a deliberate reason to reopen it.
|
||||
82
docs/operations/IDENTITY_AND_SSO.md
Normal file
82
docs/operations/IDENTITY_AND_SSO.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# Identity and SSO
|
||||
|
||||
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 Authentik
|
||||
|
||||
- centralizes auth for the growing pile of web apps
|
||||
- works with shared Postgres/Redis already in the lab
|
||||
- supports OIDC for apps that speak it properly
|
||||
- avoids inventing separate local auth for every exposed service
|
||||
|
||||
## Current scope
|
||||
|
||||
- stack path: identity/
|
||||
- host target: PlausibleDeniability
|
||||
- public entrypoint: `https://authentik.paccoco.com` (`https://auth.paccoco.com` is now just a compatibility redirect)
|
||||
- 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
|
||||
|
||||
1. Copy identity/.env.example to identity/.env and generate fresh secrets.
|
||||
2. Copy identity/authelia/configuration.yml.example to identity/authelia/configuration.yml.
|
||||
3. Render concrete secrets and passwords into identity/authelia/configuration.yml on PD; do not rely on runtime env interpolation inside the YAML.
|
||||
4. Copy identity/authelia/users_database.example.yml to identity/authelia/users_database.yml.
|
||||
5. Generate an Argon2 password hash with the Authelia image and replace the sample hash.
|
||||
6. Create the authelia Postgres database and user in the shared database stack.
|
||||
7. Set `AUTHELIA_SESSION_REDIS_PASSWORD` from the live shared Redis stack on PD.
|
||||
8. Add a Pangolin resource for auth.paccoco.com pointing at container authelia:9091.
|
||||
9. Validate with docker compose --env-file .env config and docker exec authelia authelia config validate --config /config/configuration.yml.
|
||||
|
||||
## Rollout order
|
||||
|
||||
This repo stage is the control-plane bootstrap, not the full client cutover.
|
||||
|
||||
After Authelia is healthy on PD, wire OIDC or forward-auth clients one at a time.
|
||||
|
||||
Recommended first targets:
|
||||
|
||||
- Grafana
|
||||
- Gitea
|
||||
- any new public app before it gains yet another standalone login
|
||||
|
||||
## Grafana first-cutover notes
|
||||
|
||||
- Use Authelia OIDC as a Generic OAuth provider in Grafana.
|
||||
- Keep Grafana's local admin login enabled for the first rollout.
|
||||
- After OIDC is verified, disable Pangolin auth in front of Grafana to avoid double-auth.
|
||||
|
||||
## Gitea prep notes
|
||||
|
||||
- The live Gitea stack is the `dev/` compose on PD.
|
||||
- Disable public self-registration before or during the SSO prep pass.
|
||||
- Keep local admin login working for the first OIDC test.
|
||||
- Stage the exact Gitea auth-source values in `docs/operations/GITEA_SSO_PREP.md` before creating the live source.
|
||||
|
||||
## Notes
|
||||
|
||||
- File-backed users are intentional for the initial rollout; this is enough for the current household scale.
|
||||
- If identity sprawl grows later, migrate the authentication backend to LDAP instead of bolting extra auth stores onto apps ad hoc.
|
||||
- OIDC signing keys and per-app client secrets should be generated during deployment on PD, not committed to the repo.
|
||||
@@ -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
|
||||
126
docs/operations/INCIDENT_2026-05-28_PD_QDRANT_SEERR_DRIFT.md
Normal file
126
docs/operations/INCIDENT_2026-05-28_PD_QDRANT_SEERR_DRIFT.md
Normal 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.
|
||||
137
docs/operations/KITCHENOWL_RECIPE_IMPORT.md
Normal file
137
docs/operations/KITCHENOWL_RECIPE_IMPORT.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# KitchenOwl Recipe Import (Doris-managed)
|
||||
|
||||
## Why this exists
|
||||
|
||||
KitchenOwl's built-in import/scrape flow is unreliable for the way John and Leanne actually send recipe links around.
|
||||
|
||||
This repo-side helper gives Doris a safer path:
|
||||
|
||||
1. try KitchenOwl's native `/recipe/scrape` endpoint for sites it understands
|
||||
2. if that fails, fetch the page directly
|
||||
3. extract schema.org `Recipe` JSON-LD
|
||||
4. normalize ingredients/tags/timings into KitchenOwl's create-recipe payload
|
||||
5. optionally create the recipe through the KitchenOwl API
|
||||
|
||||
That means Doris can ingest recipe links from chat without depending on KitchenOwl's flaky native importer.
|
||||
|
||||
## Script
|
||||
|
||||
```bash
|
||||
automation/bin/kitchenowl_recipe_import.py
|
||||
```
|
||||
|
||||
Default mode is **dry-run**. It prints the normalized KitchenOwl payload and does **not** create anything unless `--create` is passed.
|
||||
|
||||
## Required env vars
|
||||
|
||||
Put these in a live `.env` file or export them ad hoc. Do **not** commit live secrets.
|
||||
|
||||
```env
|
||||
KITCHENOWL_BASE_URL=https://owl.paccoco.com
|
||||
KITCHENOWL_API_TOKEN=CHANGE_ME
|
||||
KITCHENOWL_HOUSEHOLD_ID=1
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `KITCHENOWL_API_TOKEN` should be a long-lived token for a user who can create recipes in the target household.
|
||||
- `KITCHENOWL_HOUSEHOLD_ID` for the current live instance is `1` (`Stafford's`) unless that changes later.
|
||||
|
||||
## Usage
|
||||
|
||||
### Dry-run a link
|
||||
|
||||
```bash
|
||||
cd /home/fizzlepoof/repos/truenas-stacks
|
||||
export KITCHENOWL_BASE_URL=https://owl.paccoco.com
|
||||
export KITCHENOWL_API_TOKEN=...redacted...
|
||||
export KITCHENOWL_HOUSEHOLD_ID=1
|
||||
|
||||
automation/bin/kitchenowl_recipe_import.py --pretty \
|
||||
"https://example.com/my-recipe"
|
||||
```
|
||||
|
||||
### Actually create the recipe
|
||||
|
||||
```bash
|
||||
automation/bin/kitchenowl_recipe_import.py --pretty --create \
|
||||
"https://example.com/my-recipe"
|
||||
```
|
||||
|
||||
### Import several links at once
|
||||
|
||||
```bash
|
||||
automation/bin/kitchenowl_recipe_import.py --create --pretty \
|
||||
"https://example.com/recipe-1" \
|
||||
"https://example.com/recipe-2"
|
||||
```
|
||||
|
||||
## Behavior details
|
||||
|
||||
### Strategy order
|
||||
|
||||
By default the helper tries:
|
||||
|
||||
1. `GET /api/household/<id>/recipe/scrape?url=...`
|
||||
2. if KitchenOwl returns `Unsupported website` or otherwise fails, Doris falls back to direct HTML fetch + JSON-LD parse
|
||||
|
||||
If you want to skip the built-in scrape and go straight to Doris mode:
|
||||
|
||||
```bash
|
||||
automation/bin/kitchenowl_recipe_import.py --skip-kitchenowl-scrape --pretty URL
|
||||
```
|
||||
|
||||
### Duplicate protection
|
||||
|
||||
Before creating a recipe, the helper loads existing household recipes and skips creation if it finds:
|
||||
- an exact matching `source` URL, or
|
||||
- an exact matching recipe `name`
|
||||
|
||||
To force duplicates anyway:
|
||||
|
||||
```bash
|
||||
automation/bin/kitchenowl_recipe_import.py --create --allow-duplicates URL
|
||||
```
|
||||
|
||||
### Visibility
|
||||
|
||||
KitchenOwl visibility enum:
|
||||
- `0` = private (default)
|
||||
- `1` = link
|
||||
- `2` = public
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
automation/bin/kitchenowl_recipe_import.py --create --visibility 0 URL
|
||||
```
|
||||
|
||||
## What parses well
|
||||
|
||||
The fallback parser works best on recipe pages that expose proper schema.org `Recipe` JSON-LD, which many decent recipe sites do.
|
||||
|
||||
It pulls:
|
||||
- name
|
||||
- description
|
||||
- instructions
|
||||
- ingredient list
|
||||
- total/prep/cook time
|
||||
- yield/servings
|
||||
- categories/cuisine/keywords → KitchenOwl tags
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Some sites block scraping aggressively; Doris first tries normal fetch, then falls back to `curl` headers for better odds.
|
||||
- Ingredient normalization is heuristic, not magic. It aims for a sane KitchenOwl item name plus a description/amount, but some weird ingredients may still need manual cleanup.
|
||||
- Recipe source images are now forwarded into KitchenOwl when the source exposes a usable image URL.
|
||||
- The helper expects structured JSON-LD. If a site hides the recipe entirely in client-side blobs or anti-bot nonsense, manual copy/paste may still be needed.
|
||||
- `GET /api/settings` on the live KitchenOwl instance currently returns `500`, but that does not block recipe import.
|
||||
|
||||
## Doris workflow expectation
|
||||
|
||||
Preferred operator flow when John or Leanne sends recipe links:
|
||||
|
||||
1. Doris runs a dry-run first when the site is unfamiliar or suspicious.
|
||||
2. If the normalized payload looks sane, Doris reruns with `--create`.
|
||||
3. If the page is too messy, Doris asks for the raw ingredients/steps and imports it manually.
|
||||
|
||||
That keeps KitchenOwl as the recipe store while making Doris the reliable intake layer.
|
||||
49
docs/operations/PAPERLESS.md
Normal file
49
docs/operations/PAPERLESS.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Paperless-NGX Operations
|
||||
|
||||
## Stack Location
|
||||
|
||||
- Compose path on PD: `/mnt/docker-ssd/docker/compose/documents/docker-compose.yaml`
|
||||
- Appdata: `/mnt/docker-ssd/docker/appdata/paperless/`
|
||||
- Consume source: `/mnt/unraid/data/paperless/consume`
|
||||
- Consume container path: `/usr/src/paperless/consume`
|
||||
|
||||
## Office Document Ingestion
|
||||
|
||||
Paperless-NGX uses internal-only Tika and Gotenberg sidecars:
|
||||
|
||||
- Tika endpoint: `http://tika:9998`
|
||||
- Gotenberg endpoint: `http://gotenberg:3000`
|
||||
- Network: `documents_paperless_internal`
|
||||
- Host ports: none
|
||||
- Resource limits: Tika `512m`, Gotenberg `256m`
|
||||
|
||||
This enables ingestion/extraction for Office and OpenDocument files such as:
|
||||
|
||||
- `.docx`, `.doc`
|
||||
- `.pptx`, `.xlsx`
|
||||
- `.odt`, `.ods`, `.odp`
|
||||
|
||||
## Deploy / Restart Procedure
|
||||
|
||||
Validate before changing running containers:
|
||||
|
||||
```bash
|
||||
cd /mnt/docker-ssd/docker/compose/documents
|
||||
docker compose --env-file .env config
|
||||
```
|
||||
|
||||
Deploy with compose only:
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env down && docker compose --env-file .env up -d
|
||||
```
|
||||
|
||||
Never use `systemctl restart docker` on PD for this stack.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env ps
|
||||
docker logs paperless --tail 200 | grep -Ei 'tika|gotenberg'
|
||||
docker exec paperless sh -lc 'echo "$PAPERLESS_TIKA_ENABLED $PAPERLESS_TIKA_ENDPOINT $PAPERLESS_TIKA_GOTENBERG_ENDPOINT"; mount | grep /usr/src/paperless/consume'
|
||||
```
|
||||
110
docs/operations/PD_BACKUP_DEPLOYMENT.md
Normal file
110
docs/operations/PD_BACKUP_DEPLOYMENT.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# PD Backup Deployment
|
||||
|
||||
Deploy the PD → Serenity backup runner from the real compose path on PD:
|
||||
|
||||
```bash
|
||||
/mnt/docker-ssd/docker/compose/automation
|
||||
```
|
||||
|
||||
## Why this model
|
||||
|
||||
- PD is the compose/data host, so backups should originate there.
|
||||
- TrueNAS SCALE is happier with plain cron + shell than extra appliance-fighting service glue.
|
||||
- Root cron on PD avoids the common `sudo`/Docker socket permission mess.
|
||||
- Backups should still run even if n8n is down.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Repo content present on PD at `/mnt/docker-ssd/docker/compose/automation`
|
||||
2. Writable dump directory:
|
||||
```bash
|
||||
mkdir -p /mnt/tank/docker/backups/db-dumps
|
||||
```
|
||||
3. SSH trust from PD to Serenity for the selected account
|
||||
4. Real `.env` created from `.env.example`
|
||||
5. Preferred: install the cron job in root's crontab on PD
|
||||
6. If using a non-root PD account instead, `sudo -n /usr/bin/docker` must work
|
||||
|
||||
## Suggested `.env` values to review
|
||||
|
||||
```dotenv
|
||||
SERENITY_BACKUP_HOST=root@10.5.30.5
|
||||
SERENITY_BACKUP_ROOT=/mnt/user/backups/plausible-deniability
|
||||
PD_APPDATA_ROOT=/mnt/docker-ssd/docker/appdata
|
||||
PD_DATABASES_ROOT=/mnt/docker-ssd/docker/databases
|
||||
PD_TANK_DOCKER_ROOT=/mnt/tank/docker
|
||||
PD_DB_DUMP_ROOT=/mnt/tank/docker/backups/db-dumps
|
||||
POSTGRES_CONTAINER=shared-postgres
|
||||
POSTGRES_DB_LIST="gitea n8n paperless"
|
||||
BACKUP_SSH_KEY=/home/truenas_admin/.ssh/serenity_backup_ed25519
|
||||
DOCKER_BIN=/usr/bin/docker
|
||||
SUDO_BIN=/usr/bin/sudo
|
||||
USE_SUDO_FOR_DOCKER=true
|
||||
```
|
||||
|
||||
## Manual first run
|
||||
|
||||
```bash
|
||||
cd /mnt/docker-ssd/docker/compose/automation
|
||||
cp .env.example .env
|
||||
chmod 600 .env
|
||||
mkdir -p /mnt/tank/docker/backups/db-dumps
|
||||
/usr/bin/bash bin/run_pd_backups.sh
|
||||
```
|
||||
|
||||
Confirm:
|
||||
|
||||
- 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
|
||||
- no sudo prompt appeared during `docker exec`
|
||||
|
||||
## Cron install
|
||||
|
||||
Preferred: install in **root's crontab** on PD.
|
||||
|
||||
```cron
|
||||
# BEGIN PD BACKUPS
|
||||
15 2 * * * cd /mnt/docker-ssd/docker/compose/automation && /usr/bin/bash bin/run_pd_backups.sh >> /mnt/tank/docker/backups/pd-backups.log 2>&1
|
||||
# END PD BACKUPS
|
||||
```
|
||||
|
||||
Quarterly restore verification:
|
||||
|
||||
```cron
|
||||
30 3 1 */3 * cd /mnt/docker-ssd/docker/compose/automation && /usr/bin/bash bin/run_pd_restore_verification.sh >> /mnt/tank/docker/backups/pd-restore-verify.log 2>&1
|
||||
```
|
||||
|
||||
## Post-deploy checks
|
||||
|
||||
```bash
|
||||
tail -100 /mnt/tank/docker/backups/pd-backups.log
|
||||
ls -lh /mnt/tank/docker/backups/db-dumps
|
||||
ssh root@10.5.30.5 'find /mnt/user/backups/plausible-deniability -maxdepth 2 -type d | sort | head -40'
|
||||
```
|
||||
|
||||
## Restore verification
|
||||
|
||||
After the backup path is stable, run the staged restore verifier from PD:
|
||||
|
||||
```bash
|
||||
cd /mnt/docker-ssd/docker/compose/automation
|
||||
/usr/bin/bash bin/run_pd_restore_verification.sh
|
||||
```
|
||||
|
||||
What it proves:
|
||||
|
||||
- the latest configured Postgres dumps can restore into a temporary container
|
||||
- a real appdata/config sample can be staged back from Serenity
|
||||
- expected files still exist inside the restored sample
|
||||
- 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
|
||||
|
||||
- Do **not** use `systemctl restart docker` on PD.
|
||||
- Keep source/destination paths under `/mnt/...`.
|
||||
- `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.
|
||||
- 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.
|
||||
130
docs/operations/SCHOOL_PAPERLESS_INTAKE.md
Normal file
130
docs/operations/SCHOOL_PAPERLESS_INTAKE.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# School Paperless Intake Workflow
|
||||
|
||||
Current workflow versions:
|
||||
- `n8n-workflows/18-school-paperless-intake-v1.2.json`
|
||||
- `n8n-workflows/19-paperless-school-metadata-enrichment-v1.1.json`
|
||||
|
||||
This workflow set is the Telegram chat-driven upload pipeline that sends schoolwork into Paperless-NGX with deterministic intake IDs and follow-up metadata enrichment.
|
||||
|
||||
## Versioning rule
|
||||
|
||||
When these workflow artifacts change, the version must change too:
|
||||
- the filename keeps a `-vX.Y` suffix
|
||||
- the internal n8n workflow `name` also carries the same version
|
||||
- docs and testing notes should reference the current version explicitly
|
||||
|
||||
## What it does
|
||||
|
||||
### Workflow 18 — v1.2
|
||||
|
||||
1. Accepts a multipart upload at `POST /webhook/school/intake/upload`
|
||||
2. Requires form fields:
|
||||
- `class_name`
|
||||
- `assignment_name`
|
||||
- `submission_kind`
|
||||
3. Accepts optional fields:
|
||||
- `semester`
|
||||
- `course_code`
|
||||
- `paper_kind`
|
||||
- `notes`
|
||||
- `source` (defaults to `telegram`)
|
||||
- `telegram_chat_id`
|
||||
- `telegram_message_id`
|
||||
4. Generates a deterministic `intake_id` from date + class + assignment + file checksum
|
||||
5. Builds a deterministic intake envelope from the request metadata and upload details
|
||||
6. Uploads the file to Paperless using the deterministic intake ID in the filename
|
||||
7. Marks the row as `uploaded` after Paperless accepts the upload
|
||||
|
||||
## Current correlation model
|
||||
|
||||
- Earlier drafts stored intake rows in shared Postgres.
|
||||
- The current live-tested `v1.2` + `v1.1` chain does **not** require Postgres in the webhook path.
|
||||
- Deterministic filename/title are the current source of correlation between upload and enrichment.
|
||||
- `docs/reference/SCHOOL_INTAKE_POSTGRES_SCHEMA.sql` is retained for a future DB-backed intake ledger if that gets reinstated.
|
||||
|
||||
## Required environment variables
|
||||
|
||||
Add to `automation/.env`:
|
||||
|
||||
```env
|
||||
PAPERLESS_BASE_URL=https://paperless.paccoco.com
|
||||
PAPERLESS_API_TOKEN=CHANGE_ME
|
||||
LITELLM_API_KEY=CHANGE_ME
|
||||
PAPERLESS_CORRESPONDENT_SCHOOL=
|
||||
PAPERLESS_DOCUMENT_TYPE_SCHOOL=
|
||||
PAPERLESS_TAG_SCHOOL=
|
||||
PAPERLESS_TAG_ASSIGNMENTS=
|
||||
PAPERLESS_TAG_TELEGRAM=
|
||||
PAPERLESS_TAG_CLASS_MAP_JSON={}
|
||||
PAPERLESS_TAG_SUBMISSION_KIND_MAP_JSON={}
|
||||
PAPERLESS_DOCUMENT_TYPE_PAPER_KIND_MAP_JSON={}
|
||||
PAPERLESS_CORRESPONDENT_CLASS_MAP_JSON={}
|
||||
NODE_FUNCTION_ALLOW_BUILTIN=crypto
|
||||
```
|
||||
|
||||
Add to the `n8n` service `environment:` block in `automation/docker-compose.yaml`:
|
||||
|
||||
```yaml
|
||||
PAPERLESS_BASE_URL: ${PAPERLESS_BASE_URL}
|
||||
PAPERLESS_API_TOKEN: ${PAPERLESS_API_TOKEN}
|
||||
PAPERLESS_CORRESPONDENT_SCHOOL: ${PAPERLESS_CORRESPONDENT_SCHOOL}
|
||||
PAPERLESS_DOCUMENT_TYPE_SCHOOL: ${PAPERLESS_DOCUMENT_TYPE_SCHOOL}
|
||||
PAPERLESS_TAG_SCHOOL: ${PAPERLESS_TAG_SCHOOL}
|
||||
PAPERLESS_TAG_ASSIGNMENTS: ${PAPERLESS_TAG_ASSIGNMENTS}
|
||||
PAPERLESS_TAG_TELEGRAM: ${PAPERLESS_TAG_TELEGRAM}
|
||||
PAPERLESS_TAG_CLASS_MAP_JSON: ${PAPERLESS_TAG_CLASS_MAP_JSON}
|
||||
PAPERLESS_TAG_SUBMISSION_KIND_MAP_JSON: ${PAPERLESS_TAG_SUBMISSION_KIND_MAP_JSON}
|
||||
PAPERLESS_DOCUMENT_TYPE_PAPER_KIND_MAP_JSON: ${PAPERLESS_DOCUMENT_TYPE_PAPER_KIND_MAP_JSON}
|
||||
PAPERLESS_CORRESPONDENT_CLASS_MAP_JSON: ${PAPERLESS_CORRESPONDENT_CLASS_MAP_JSON}
|
||||
NODE_FUNCTION_ALLOW_BUILTIN: crypto
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `PAPERLESS_CORRESPONDENT_SCHOOL` and `PAPERLESS_DOCUMENT_TYPE_SCHOOL` should be numeric IDs if you want those fields auto-assigned.
|
||||
- `PAPERLESS_TAG_CLASS_MAP_JSON`, `PAPERLESS_TAG_SUBMISSION_KIND_MAP_JSON`, `PAPERLESS_DOCUMENT_TYPE_PAPER_KIND_MAP_JSON`, and `PAPERLESS_CORRESPONDENT_CLASS_MAP_JSON` are optional JSON maps used by workflow 19.
|
||||
- Leave optional Paperless IDs and JSON maps blank/default if you prefer to start with deterministic title + generic tags only.
|
||||
- Workflow 18 v1.1 no longer requires `crypto`, so `NODE_FUNCTION_ALLOW_BUILTIN=crypto` is optional instead of required.
|
||||
- Workflow 18 v1.2 fixes the Paperless multipart upload by using `inputDataFieldName` for the binary form field.
|
||||
- `N8N_BLOCK_ENV_ACCESS_IN_NODE` must remain `false`.
|
||||
|
||||
## Paperless metadata behavior
|
||||
|
||||
The upload uses:
|
||||
- filename: `<intake_id>.<ext>`
|
||||
- title: `<class_name> — <assignment_name> — <submission_kind>`
|
||||
|
||||
Workflow 18 deliberately keeps the Paperless upload minimal and safe:
|
||||
- multipart `document`
|
||||
- deterministic title
|
||||
|
||||
The deterministic filename/title preserve the `intake_id` for later correlation.
|
||||
|
||||
### Workflow 19 — v1.1
|
||||
|
||||
Workflow 19 handles the follow-up metadata pass after Paperless finishes processing:
|
||||
- fetch Paperless document by webhook document id
|
||||
- resolve `intake_id` from the stored filename
|
||||
- recover class/assignment/submission metadata from the deterministic filename and title
|
||||
- re-apply deterministic title
|
||||
- merge generic school tags plus optional class/submission-kind mappings
|
||||
- optionally set document type / correspondent from JSON env mappings
|
||||
|
||||
## Testing
|
||||
|
||||
Use the example curl request in:
|
||||
- `n8n-workflows/fixtures/school-paperless-intake-webhook.curl.example.txt`
|
||||
|
||||
Minimum test checklist:
|
||||
1. Import the workflow into n8n.
|
||||
2. Import workflow 19 alongside workflow 18.
|
||||
3. Send the sample multipart request.
|
||||
4. Verify the uploaded Paperless document title and stored filename both include the deterministic intake ID.
|
||||
5. Trigger workflow 19 with the Paperless document id and verify title/tags enrichment succeeds.
|
||||
6. Re-submit the same file and metadata to confirm the same deterministic `intake_id` pattern is reused.
|
||||
|
||||
## Known assumptions / caveats
|
||||
|
||||
- Workflow 18 `v1.2` assignment intake was live smoke-tested successfully on 2026-05-15.
|
||||
- Workflow 19 `v1.1` is designed around the deterministic filename/title model rather than a Postgres lookup.
|
||||
- Workflow 18 v1.0 needed `require('crypto')`; workflow 18 v1.1 removed that dependency for easier live rollout; workflow 18 v1.2 replaced it with an internal stable hash.
|
||||
- If Paperless needs tags as repeated `tags[]` fields instead of a comma-separated `tags` field, adjust the HTTP Request node after import.
|
||||
@@ -1,11 +1,25 @@
|
||||
# Secrets Management
|
||||
|
||||
## Overview
|
||||
|
||||
Secrets are managed in two layers:
|
||||
|
||||
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** — those live `.env` files are backed up into the private git-crypt encrypted Gitea repo at `/mnt/docker-ssd/docker/secrets/` on PD
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Principles
|
||||
- `.env` files are gitignored and never committed to any public repo
|
||||
- `.env.example` files are committed with placeholder values
|
||||
- Real `.env` files live only on the host at the stack directory
|
||||
|
||||
- `.env` files are gitignored in the main repo — never committed there
|
||||
- `.env.example` files with placeholder values are committed instead
|
||||
- The secrets repo encrypts everything at rest using git-crypt (symmetric key)
|
||||
- Secrets are generated with standard tools, never reused across services
|
||||
|
||||
---
|
||||
|
||||
## Generating Secrets
|
||||
|
||||
```bash
|
||||
@@ -19,46 +33,313 @@ openssl rand -hex 32
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
## .env File Locations
|
||||
All `.env` files live alongside their `docker-compose.yaml`:
|
||||
---
|
||||
|
||||
## Live .env File Locations
|
||||
|
||||
### PD compose tree
|
||||
|
||||
PD stack `.env` files live alongside their `docker-compose.yaml`:
|
||||
|
||||
```
|
||||
/mnt/docker-ssd/docker/compose/<stack>/.env
|
||||
/mnt/docker-ssd/docker/compose/
|
||||
ai/.env
|
||||
automation/.env
|
||||
databases/.env
|
||||
dev/.env
|
||||
documents/.env
|
||||
home/.env
|
||||
infrastructure/.env
|
||||
media/.env
|
||||
media/kima-hub/.env
|
||||
mesh-mqtt-observer/.env
|
||||
meshtastic/.env
|
||||
monitoring/.env
|
||||
photos/.env
|
||||
```
|
||||
|
||||
## Backing Up .env Files
|
||||
### 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)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Gitea repo** | `https://gitea.paccoco.com/fizzlepoof/homelab-secrets` (private) |
|
||||
| **SSH remote** | `ssh://git@10.5.30.6:2222/fizzlepoof/homelab-secrets.git` |
|
||||
| **Local path on PD** | `/mnt/docker-ssd/docker/secrets/` |
|
||||
| **Symmetric key location** | `/mnt/docker-ssd/docker/secrets/.git-crypt-secrets.key` (if copied there) or retrieve from password manager |
|
||||
| **Key backup** | Password manager + `C:\Users\Fizzlepoof\Downloads\.git-crypt-secrets.key` |
|
||||
|
||||
### Repo structure
|
||||
|
||||
| Directory | Contents |
|
||||
|-----------|----------|
|
||||
| `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 |
|
||||
| `certs/` | TLS certificates |
|
||||
| `tokens/` | Service tokens (Paperless, LiteLLM, etc.) |
|
||||
| `ssh/` | SSH private keys |
|
||||
|
||||
All files except `README.md` and `.gitattributes` are encrypted by git-crypt on commit.
|
||||
|
||||
---
|
||||
|
||||
## Syncing .env Files to the Secrets Repo
|
||||
|
||||
### 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`:
|
||||
|
||||
### Short term: Private Gitea repo
|
||||
Create a private repo on Gitea (not GitHub) and push all .env files there:
|
||||
```bash
|
||||
# In a separate directory, not the main stacks repo
|
||||
git init homelab-secrets
|
||||
cd homelab-secrets
|
||||
# Copy and add .env files
|
||||
git remote add origin https://gitea.paccoco.com/fizzlepoof/homelab-secrets.git
|
||||
git push -u origin main
|
||||
export PATH="/mnt/docker-ssd/bin:/root/bin:$PATH"
|
||||
bash /mnt/docker-ssd/docker/compose/scripts/sync-envs-to-secrets.sh
|
||||
```
|
||||
|
||||
### Long term: Restic + offsite
|
||||
Include the compose directory (with .env files) in a restic backup:
|
||||
What it does:
|
||||
- Scans all stack directories under `/mnt/docker-ssd/docker/compose/` for `.env` files
|
||||
- Copies changed/new files to `/mnt/docker-ssd/docker/secrets/env/` as `<stack>.env`
|
||||
- Skips unchanged files
|
||||
- Commits and pushes to Gitea automatically
|
||||
|
||||
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.
|
||||
|
||||
### 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
|
||||
|
||||
apt is blocked on TrueNAS Scale. git-crypt is installed via Docker.
|
||||
|
||||
### Preferred installation location
|
||||
|
||||
```
|
||||
/mnt/docker-ssd/bin/git-crypt
|
||||
```
|
||||
|
||||
### Compatibility note
|
||||
|
||||
The original bootstrap script installs to `/root/bin/git-crypt`, while the newer runbook expects `/mnt/docker-ssd/bin/git-crypt`. Standardize on `/mnt/docker-ssd/bin/git-crypt` and make sure PATH includes that directory for both `root` and `truenas_admin`.
|
||||
|
||||
Recommended PATH entry:
|
||||
|
||||
```bash
|
||||
restic -r <repo> backup /mnt/docker-ssd/docker/compose --exclude="*.example"
|
||||
export PATH="/mnt/docker-ssd/bin:$PATH"
|
||||
```
|
||||
|
||||
### Best long term: Vaultwarden
|
||||
Once deployed, store each service's .env as a secure note in Vaultwarden. Single source of truth, accessible from anywhere.
|
||||
### Re-installing after a full rebuild
|
||||
|
||||
**Option 1 — one-liner:**
|
||||
```bash
|
||||
sudo -i
|
||||
docker run --rm -v /tmp:/out debian:bookworm-slim \
|
||||
bash -c "apt-get update -qq && apt-get install -y -qq git-crypt && cp /usr/bin/git-crypt /out/git-crypt"
|
||||
mkdir -p /mnt/docker-ssd/bin
|
||||
cp /tmp/git-crypt /mnt/docker-ssd/bin/git-crypt
|
||||
chmod +x /mnt/docker-ssd/bin/git-crypt
|
||||
echo 'export PATH="/mnt/docker-ssd/bin:$PATH"' >> /root/.bashrc
|
||||
echo 'export PATH="/mnt/docker-ssd/bin:$PATH"' >> /home/truenas_admin/.bashrc
|
||||
```
|
||||
|
||||
**Option 2 — via dev stack compose:**
|
||||
```bash
|
||||
cd /mnt/docker-ssd/docker/compose/dev
|
||||
sudo docker compose --profile setup up git-crypt-init
|
||||
# copies git-crypt to /root/bin — then move to persistent location:
|
||||
sudo cp /root/bin/git-crypt /mnt/docker-ssd/bin/git-crypt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Unlocking the Secrets Repo on a New Machine
|
||||
|
||||
```bash
|
||||
# Install git-crypt first (see above), then:
|
||||
git clone ssh://git@10.5.30.6:2222/fizzlepoof/homelab-secrets.git /mnt/docker-ssd/docker/secrets
|
||||
cd /mnt/docker-ssd/docker/secrets
|
||||
git-crypt unlock /path/to/.git-crypt-secrets.key
|
||||
```
|
||||
|
||||
After unlock, the `env/` directory contains plaintext `.env` files ready to copy back into the stack directories.
|
||||
|
||||
---
|
||||
|
||||
## Full Rebuild Runbook
|
||||
|
||||
On a fresh PD after reinstalling TrueNAS:
|
||||
|
||||
1. **Install git-crypt** (Option 1 above — docker is available immediately after TrueNAS install)
|
||||
2. **Restore secrets repo:**
|
||||
```bash
|
||||
git clone ssh://git@10.5.30.6:2222/fizzlepoof/homelab-secrets.git /mnt/docker-ssd/docker/secrets
|
||||
cd /mnt/docker-ssd/docker/secrets
|
||||
git-crypt unlock /path/to/.git-crypt-secrets.key
|
||||
```
|
||||
3. **Restore .env files** from `secrets/env/` back to their stack directories:
|
||||
```bash
|
||||
cp /mnt/docker-ssd/docker/secrets/env/ai.env /mnt/docker-ssd/docker/compose/ai/.env
|
||||
cp /mnt/docker-ssd/docker/secrets/env/databases.env /mnt/docker-ssd/docker/compose/databases/.env
|
||||
# ... etc for each stack
|
||||
```
|
||||
4. **Clone the main stacks repo:**
|
||||
```bash
|
||||
git clone ssh://git@10.5.30.6:2222/fizzlepoof/truenas-stacks.git /mnt/docker-ssd/docker/compose
|
||||
```
|
||||
5. **Redeploy stacks** per `DEPLOYMENT_CHECKLIST.md`
|
||||
6. **After any future live `.env` edit**, immediately run:
|
||||
```bash
|
||||
export PATH="/mnt/docker-ssd/bin:$PATH"
|
||||
bash /mnt/docker-ssd/docker/compose/scripts/sync-envs-to-secrets.sh
|
||||
```
|
||||
Then verify the secrets repo is either updated and pushed or already unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Known Credentials Locations
|
||||
|
||||
| Service | Where stored |
|
||||
|---------|-------------|
|
||||
| Service | File |
|
||||
|---------|------|
|
||||
| shared-postgres | `databases/.env` |
|
||||
| shared-mariadb | `databases/.env` |
|
||||
| OpenWebUI DB | `ai/.env` — user: openwebui, db: openwebui |
|
||||
| Donetick | `home/.env` + `selfhosted.yaml` |
|
||||
| Meshmonitor | `meshtastic/.env` |
|
||||
| Gitea | `dev/.env` |
|
||||
| LiteLLM | `ai/.env` |
|
||||
| Paperless | `documents/.env` |
|
||||
| UniFi Doris operator | `automation/.env` |
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
- **Key backup**: `C:\Users\Fizzlepoof\Downloads\.git-crypt-secrets.key` — also store in password manager
|
||||
- Regenerate Wings token on N.O.M.A.D. (was exposed in chat — TODO)
|
||||
- Regenerate N.O.M.A.D. Newt secret (was exposed in chat — TODO)
|
||||
- Disable signups in OpenWebUI after creating initial account
|
||||
- The Gitea token in `setup-secrets-repo.sh` should be rotated after initial setup
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
# Homelab Stack Standards
|
||||
|
||||
## Repository Root
|
||||
`/mnt/docker-ssd/docker/compose`
|
||||
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.
|
||||
|
||||
Remotes:
|
||||
- GitHub: `git@github.com:Paccoco/truenas-stacks.git`
|
||||
- Gitea: `https://gitea.paccoco.com/fizzlepoof/truenas-stacks.git`
|
||||
- GitHub: `git@github.com:fizzlepoof/truenas-stacks.git`
|
||||
- Gitea web: `http://10.5.30.6:3002/fizzlepoof/truenas-stacks`
|
||||
- Gitea SSH: `ssh://git@10.5.30.6:2222/fizzlepoof/truenas-stacks.git`
|
||||
|
||||
Use the internal Gitea SSH remote from PD/NOMAD. Port `2222` is SSH, not HTTP.
|
||||
|
||||
## Stack Layout
|
||||
Each production stack lives in a subdirectory of the repo root.
|
||||
@@ -16,6 +21,24 @@ Each stack contains:
|
||||
- `.env.example` — committed with placeholder values
|
||||
- optional init scripts (e.g. `initdb/`, `initdb-mariadb/`)
|
||||
|
||||
## NOMAD Live Services
|
||||
On NOMAD, live service roots belong under `/opt/<service>`.
|
||||
|
||||
This includes:
|
||||
- host-run services
|
||||
- standalone Docker Compose stacks
|
||||
- small service-local runtimes that should stay self-contained
|
||||
|
||||
Examples:
|
||||
- `/opt/project-nomad`
|
||||
- `/opt/newt`
|
||||
- `/opt/doris-dashboard`
|
||||
- `/opt/pihole-nomad`
|
||||
|
||||
Default rule on NOMAD: keep runtime data with the service under `/opt/<service>/data` unless a service has a documented reason to use another storage tier.
|
||||
|
||||
Use the repo for source/staging, but do not run production services from an agent workspace or random checkout under `/home/fizzlepoof/.openclaw/workspace`.
|
||||
|
||||
## Deployment Order
|
||||
1. `databases/` — must be up before anything else
|
||||
2. `infrastructure/` — newt creates the pangolin network
|
||||
@@ -25,8 +48,13 @@ Each stack contains:
|
||||
|
||||
Validate before deploying:
|
||||
```bash
|
||||
# PD-style shared compose root
|
||||
cd /mnt/docker-ssd/docker/compose/<stack>
|
||||
docker compose --env-file .env config
|
||||
|
||||
# NOMAD-style service root
|
||||
cd /opt/<service>
|
||||
docker compose --env-file .env config
|
||||
```
|
||||
|
||||
Deploy:
|
||||
@@ -116,7 +144,7 @@ After changing a healthcheck, must `down` then `up` (not just restart).
|
||||
cd /mnt/docker-ssd/docker/compose
|
||||
git add .
|
||||
git commit -m "description"
|
||||
git push # pushes to both GitHub and Gitea
|
||||
git push ssh://git@10.5.30.6:2222/fizzlepoof/truenas-stacks.git main
|
||||
```
|
||||
|
||||
All `.env` files are gitignored. Only compose files, `.env.example`, and init scripts are committed.
|
||||
|
||||
106
docs/operations/UNIFI_NETWORK_ACCESS.md
Normal file
106
docs/operations/UNIFI_NETWORK_ACCESS.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# UniFi network operator access for Doris
|
||||
|
||||
## Goal
|
||||
|
||||
Give Doris safe, durable read access to the UniFi Network controller at `https://10.5.0.1` so she can:
|
||||
|
||||
- verify gateway / switch / AP inventory
|
||||
- refresh RackPeek network device data
|
||||
- check client/device state during troubleshooting
|
||||
- avoid using your full owner account for routine reads
|
||||
|
||||
## Preferred access model
|
||||
|
||||
Use a **dedicated local Doris account** on UniFi OS with the **least privilege that still allows Network inventory reads**.
|
||||
|
||||
Target shape:
|
||||
|
||||
- Account name: `doris` or `doris-unifi`
|
||||
- Scope: **Network application only**
|
||||
- Permission: **view-only / read-only** if UniFi exposes that role cleanly
|
||||
- Local account: yes
|
||||
- Cloud account sharing: no
|
||||
|
||||
If UniFi's UI forces a broader role for API readability, use the narrowest app-scoped permission available and document the final role here.
|
||||
|
||||
## Secrets placement
|
||||
|
||||
Do **not** commit credentials to git.
|
||||
|
||||
When the Doris UniFi account exists, store the live credential in:
|
||||
|
||||
- PD live env: `/mnt/docker-ssd/docker/compose/automation/.env`
|
||||
- synced encrypted backup: `/mnt/docker-ssd/docker/secrets/env/automation.env`
|
||||
|
||||
Expected vars:
|
||||
|
||||
```bash
|
||||
UNIFI_BASE_URL=https://10.5.0.1
|
||||
UNIFI_USERNAME=doris
|
||||
UNIFI_PASSWORD=REPLACE_ME
|
||||
UNIFI_SITE=default
|
||||
UNIFI_VERIFY_TLS=false
|
||||
```
|
||||
|
||||
After editing the live `.env` on PD, run the normal secrets sync:
|
||||
|
||||
```bash
|
||||
export PATH="/mnt/docker-ssd/bin:$PATH"
|
||||
bash /mnt/docker-ssd/docker/compose/scripts/sync-envs-to-secrets.sh
|
||||
```
|
||||
|
||||
## Doris helper
|
||||
|
||||
Repo helper:
|
||||
|
||||
- `automation/bin/unifi_network.py`
|
||||
|
||||
Default behavior:
|
||||
|
||||
- loads `automation/.env`
|
||||
- logs into UniFi OS with cookie auth
|
||||
- queries Network app endpoints through `/proxy/network/api/s/<site>/...`
|
||||
- prints a JSON inventory summary without exposing the password
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
cd /mnt/docker-ssd/docker/compose
|
||||
python3 automation/bin/unifi_network.py
|
||||
python3 automation/bin/unifi_network.py --raw
|
||||
```
|
||||
|
||||
## Validation checklist
|
||||
|
||||
1. Confirm `https://10.5.0.1` is reachable from Doris's host.
|
||||
2. Create the dedicated UniFi operator account.
|
||||
3. Add the `UNIFI_*` vars to live `automation/.env` on PD.
|
||||
4. Sync secrets repo.
|
||||
5. Run:
|
||||
|
||||
```bash
|
||||
cd /mnt/docker-ssd/docker/compose
|
||||
python3 automation/bin/unifi_network.py
|
||||
```
|
||||
|
||||
Success means Doris can read:
|
||||
|
||||
- device inventory
|
||||
- controller health summary
|
||||
- connected client list
|
||||
|
||||
## Future integration
|
||||
|
||||
Once access is verified, use the helper as an input for:
|
||||
|
||||
- RackPeek network device refreshes
|
||||
- dashboard network summaries
|
||||
- targeted checks like "what AP is this client on" or "is switch X offline"
|
||||
|
||||
## Current status
|
||||
|
||||
- UniFi OS front door verified reachable at `https://10.5.0.1`
|
||||
- unauthenticated API requests correctly return `401 Unauthorized`
|
||||
- Doris helper scaffold exists in the repo
|
||||
- dedicated Doris account validated end-to-end against the `default` site from N.O.M.A.D.
|
||||
- current verified read scope is sufficient to read device inventory, controller health, and connected clients
|
||||
@@ -1,91 +1,125 @@
|
||||
# Kima-Hub Deployment Plan
|
||||
# Kima-Hub Deployment / Repair Reference
|
||||
|
||||
**Host:** PlausibleDeniability (PD)
|
||||
**Port:** 3333
|
||||
**Stack dir:** `/mnt/docker-ssd/docker/compose/media-extra/`
|
||||
**Status:** Pending
|
||||
**Stack dir in repo:** `media/kima-hub/`
|
||||
**Live stack dir on host:** `/mnt/docker-ssd/docker/compose/media/kima-hub/`
|
||||
**Status:** Repo stack corrected to match upstream all-in-one Kima layout and live service verified healthy on PD
|
||||
|
||||
## What It Is
|
||||
## Current canonical stack
|
||||
|
||||
Kima-Hub is a self-hosted music streaming platform — think personal Spotify. Point it at your music library and it handles cataloging, artist discovery, AI-powered playlists, podcast subscriptions, and seamless integration with Lidarr and Audiobookshelf.
|
||||
The upstream all-in-one Kima image expects this shape:
|
||||
|
||||
## Pre-Deploy Checklist
|
||||
- **Image:** `ghcr.io/chevron7locked/kima:latest`
|
||||
- **Container name:** `kima`
|
||||
- **Host port:** `3333`
|
||||
- **Container port:** `3030`
|
||||
- **Persistent data:**
|
||||
- `/mnt/docker-ssd/docker/appdata/kima-hub/data:/data`
|
||||
- **Media mount:**
|
||||
- `/mnt/unraid/data/media/music:/music`
|
||||
- **Network:** `pangolin`
|
||||
- **Host gateway alias:** `host.docker.internal:host-gateway`
|
||||
|
||||
- [ ] Confirm music library path on Unraid NFS (`/mnt/unraid/data/media/music` or similar)
|
||||
- [ ] Create appdata directory: `sudo mkdir -p /mnt/tank/docker/appdata/kima-hub`
|
||||
- [ ] Create stack directory: `sudo mkdir -p /mnt/docker-ssd/docker/compose/media-extra`
|
||||
## Canonical live `.env` shape
|
||||
|
||||
## docker-compose.yaml
|
||||
```env
|
||||
TZ=America/New_York
|
||||
SESSION_SECRET=
|
||||
SETTINGS_ENCRYPTION_KEY=
|
||||
KIMA_CALLBACK_URL=http://host.docker.internal:3333
|
||||
DISABLE_CLAP=
|
||||
LIDARR_API_KEY=
|
||||
FANART_API_KEY=
|
||||
LASTFM_API_KEY=
|
||||
OPENAI_API_KEY=
|
||||
SOULSEEK_USERNAME=
|
||||
SOULSEEK_PASSWORD=
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `SESSION_SECRET` and `SETTINGS_ENCRYPTION_KEY` can be left blank; current upstream all-in-one image will generate and persist them under `/data/secrets` on first start.
|
||||
- `DISABLE_CLAP=true` is the low-memory / no-AI-vibe fallback if the analyzer proves too heavy.
|
||||
- `KIMA_CALLBACK_URL` should point at the host-facing Kima URL Lidarr can reach.
|
||||
|
||||
## Canonical compose file
|
||||
|
||||
```yaml
|
||||
services:
|
||||
kima-hub:
|
||||
image: chevron7locked/kima:latest
|
||||
container_name: kima-hub
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3333:3030"
|
||||
volumes:
|
||||
- /mnt/tank/docker/appdata/kima-hub:/data
|
||||
- /mnt/unraid/data/media/music:/music:ro
|
||||
kima:
|
||||
image: ghcr.io/chevron7locked/kima:latest
|
||||
container_name: kima
|
||||
environment:
|
||||
- TZ=${TZ}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "cat /proc/net/tcp6 | grep -q 0BD6 || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
- SESSION_SECRET=${SESSION_SECRET:-}
|
||||
- SETTINGS_ENCRYPTION_KEY=${SETTINGS_ENCRYPTION_KEY:-}
|
||||
- KIMA_CALLBACK_URL=${KIMA_CALLBACK_URL:-http://host.docker.internal:3333}
|
||||
- DISABLE_CLAP=${DISABLE_CLAP:-}
|
||||
- LIDARR_API_KEY=${LIDARR_API_KEY:-}
|
||||
- FANART_API_KEY=${FANART_API_KEY:-}
|
||||
- LASTFM_API_KEY=${LASTFM_API_KEY:-}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||
- SOULSEEK_USERNAME=${SOULSEEK_USERNAME:-}
|
||||
- SOULSEEK_PASSWORD=${SOULSEEK_PASSWORD:-}
|
||||
volumes:
|
||||
- /mnt/docker-ssd/docker/appdata/kima-hub/data:/data
|
||||
- /mnt/unraid/data/media/music:/music
|
||||
ports:
|
||||
- "3333:3030"
|
||||
networks:
|
||||
- pangolin
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
pangolin:
|
||||
external: true
|
||||
```
|
||||
|
||||
> **Note:** Internal port is `3030`, mapped to `3333` on the host. Hex `0BD6` = port 3030 for healthcheck.
|
||||
> **Music path:** Adjust `/mnt/unraid/data/media/music` to match your actual Unraid music library path.
|
||||
> **Read-only mount:** Music library is mounted `:ro` — Kima-Hub should not need write access to source files.
|
||||
|
||||
## .env.example
|
||||
|
||||
```env
|
||||
TZ=America/New_York
|
||||
```
|
||||
|
||||
## Deploy
|
||||
## Validate before deploy
|
||||
|
||||
```bash
|
||||
cd /mnt/docker-ssd/docker/compose/media-extra
|
||||
sudo docker compose --env-file .env config # validate
|
||||
sudo docker compose --env-file .env up -d
|
||||
sudo docker logs kima-hub --tail 30
|
||||
cd /mnt/docker-ssd/docker/compose/media/kima-hub
|
||||
sudo docker compose --env-file .env config
|
||||
```
|
||||
|
||||
## Post-Deploy
|
||||
## Deploy / repair
|
||||
|
||||
1. Open `http://10.5.1.6:3333` and create your account
|
||||
2. Point Kima at your music directory (`/music`)
|
||||
3. In Lidarr: Settings → Connect → add Kima-Hub webhook
|
||||
4. In Audiobookshelf: verify Kima can reach it at `http://10.5.1.6:13358`
|
||||
5. Add Pangolin resource: `kima.paccoco.com` → port 3333
|
||||
```bash
|
||||
cd /mnt/docker-ssd/docker/compose/media/kima-hub
|
||||
sudo docker compose --env-file .env up -d
|
||||
sudo docker compose --env-file .env ps
|
||||
sudo docker logs --tail 60 kima
|
||||
```
|
||||
|
||||
## Pangolin Resource
|
||||
## Post-deploy checks
|
||||
|
||||
Add in Pangolin dashboard:
|
||||
- **Subdomain:** `kima.paccoco.com`
|
||||
- **Target:** `http://10.5.1.6:3333`
|
||||
- **Auth:** Enable (personal use only)
|
||||
1. Open `http://10.5.30.6:3333`
|
||||
2. Confirm the app starts and initial setup loads
|
||||
3. Confirm `/music` is visible to Kima
|
||||
4. Confirm Kima creates and uses `/data` persistence correctly
|
||||
5. Add Pangolin resource for `kima.paccoco.com` if desired
|
||||
6. Confirm the NFS-backed Serenity media mount at `/mnt/unraid/data/media` is present on PD and contains the music library Kima should scan
|
||||
|
||||
## Storage Decision
|
||||
Verified on 2026-05-15:
|
||||
- container `kima` running and healthy
|
||||
- `http://10.5.30.6:3333/api/health` returned status OK
|
||||
- live mount narrowed to `/mnt/unraid/data/media/music:/music`
|
||||
|
||||
| Path | Tier | Reason |
|
||||
|------|------|--------|
|
||||
| `/mnt/tank/docker/appdata/kima-hub` | tank | Config, DB, playlists — not write-heavy |
|
||||
| `/mnt/unraid/data/media/music` | Unraid NFS (read-only) | Source library stays on Serenity |
|
||||
## Optional integrations / secrets
|
||||
|
||||
Not required just to boot:
|
||||
|
||||
- **Lidarr API key** — for Lidarr integration
|
||||
- **Fanart API key** — for artist images
|
||||
- **Last.fm API key** — for enrichment/recommendations
|
||||
- **OpenAI API key** — optional AI features documented upstream; current upstream docs only explicitly mention OpenAI for that part
|
||||
- **Soulseek username/password** — if Soulseek features are used
|
||||
|
||||
Do **not** commit live secrets into the repo copy of `.env`.
|
||||
|
||||
## Notes
|
||||
|
||||
- NFS mount must be up before starting (`sudo bash /mnt/tank/docker/scripts/mount-unraid-nfs.sh`)
|
||||
- If you transcode on the fly, CPU load on PD will spike; Kima-Hub is CPU-bound for transcoding
|
||||
- Kima-Hub has no GPU acceleration — pure CPU transcoding
|
||||
- The previous repo/live shape using `/app/config`, `/app/data`, `PUID`, `PGID`, `KIMA_MUSIC_PATH`, and `3333:3333` did not match current upstream all-in-one docs.
|
||||
- KitchenOwl already lives in the `home/` stack; Kima lives in its own `media/kima-hub/` stack.
|
||||
- Keep compose-managed runtime under `/mnt/docker-ssd/docker/compose/...` per SOP.
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
# Pi-hole Deployment Plan
|
||||
|
||||
**Status:** Pending
|
||||
**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
|
||||
|
||||
> **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.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
[All LAN clients]
|
||||
↓ DNS: 10.5.1.53 (Virtual IP — floats to whichever node is MASTER)
|
||||
↓ DNS: 10.5.30.53 (Virtual IP — floats to whichever node is MASTER)
|
||||
↓
|
||||
┌─────┴──────────────────────────────────┐
|
||||
│ Keepalived VIP │
|
||||
│ 10.5.1.53/24 │
|
||||
│ 10.5.30.53/24 │
|
||||
└──────┬──────────────┬──────────────────┘
|
||||
│ │ │
|
||||
PD (MASTER) Serenity (BACKUP1) RPi4 (BACKUP2)
|
||||
10.5.1.6 10.5.1.5 10.5.1.X
|
||||
PD (MASTER) N.O.M.A.D. (BACKUP1) RPi4 (BACKUP2)
|
||||
10.5.30.6 10.5.30.7 10.5.1.X
|
||||
priority 100 priority 90 priority 80
|
||||
Pi-hole :53 Pi-hole :53 Pi-hole :53
|
||||
Unbound :5335 Unbound :5335 Unbound :5335
|
||||
@@ -28,10 +30,11 @@
|
||||
(recursive — no third-party upstream)
|
||||
```
|
||||
|
||||
- **Keepalived** floats the VIP `10.5.1.53` to whichever node is alive with the highest priority
|
||||
- **Keepalived** floats the VIP `10.5.30.53` to whichever node is alive with the highest priority
|
||||
- **Unbound** on each node acts as the local recursive resolver (replaces Cloudflare/Quad9)
|
||||
- **Pi-hole** on each node forwards upstream queries to its local Unbound (`127.0.0.1#5335`)
|
||||
- **Nebula Sync** (running on PD) syncs all Pi-hole configs from primary → replicas daily
|
||||
- Current live replica target is NOMAD at `10.5.30.7:8954`
|
||||
|
||||
All nodes use `network_mode: host` for Pi-hole, Unbound, and Keepalived — this is required for Keepalived to manipulate the VIP and for Pi-hole to bind cleanly to port 53.
|
||||
|
||||
@@ -39,7 +42,7 @@ All nodes use `network_mode: host` for Pi-hole, Unbound, and Keepalived — this
|
||||
|
||||
## Pre-Deploy: Network Planning
|
||||
|
||||
1. **Pick a free static IP for the VIP** — `10.5.1.53` is recommended (memorable for DNS). Reserve it in UniFi so DHCP never assigns it.
|
||||
1. **Pick a free static IP for the VIP** — `10.5.30.53` is recommended (memorable for DNS). Reserve it in UniFi so DHCP never assigns it.
|
||||
2. **Find the LAN interface name** on each node:
|
||||
```bash
|
||||
# PD / RPi4:
|
||||
@@ -108,7 +111,8 @@ mkdir -p /path/to/appdata/unbound/var
|
||||
|
||||
## Node 1: PlausibleDeniability (MASTER)
|
||||
|
||||
**Stack dir:** `/mnt/docker-ssd/docker/compose/pihole/`
|
||||
**Repo stack dir:** `/home/fizzlepoof/repos/truenas-stacks/pihole/`
|
||||
**Live stack dir:** `/mnt/docker-ssd/docker/compose/pihole/`
|
||||
**Web UI port:** `8953`
|
||||
|
||||
### Pre-Deploy
|
||||
@@ -169,7 +173,7 @@ services:
|
||||
volumes:
|
||||
- ./keepalived.conf:/container/environment/01-custom/keepalived.conf
|
||||
environment:
|
||||
KEEPALIVED_VIRTUAL_IPS: "#PYTHON2BASH:['10.5.1.53/24']"
|
||||
KEEPALIVED_VIRTUAL_IPS: "#PYTHON2BASH:['10.5.30.53/24']"
|
||||
```
|
||||
|
||||
### keepalived.conf (PD — MASTER)
|
||||
@@ -193,7 +197,7 @@ vrrp_instance VI_DNS {
|
||||
}
|
||||
|
||||
virtual_ipaddress {
|
||||
10.5.1.53/24
|
||||
10.5.30.53/24
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -210,23 +214,26 @@ LAN_INTERFACE=eno1
|
||||
|
||||
### Deploy
|
||||
|
||||
Preferred PD-first flow now that the repo-side stack exists:
|
||||
|
||||
```bash
|
||||
cd /mnt/docker-ssd/docker/compose/pihole
|
||||
# Copy unbound.conf to appdata
|
||||
sudo cp unbound.conf /mnt/tank/docker/appdata/unbound-primary/
|
||||
sudo docker compose --env-file .env config
|
||||
sudo docker compose --env-file .env up -d
|
||||
sudo docker logs pihole --tail 20
|
||||
sudo docker logs unbound --tail 20
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
/usr/bin/bash bin/prepare_pd.sh
|
||||
/usr/bin/bash bin/prepare_pd.sh --up
|
||||
sudo docker logs pihole-primary --tail 20
|
||||
sudo docker logs unbound-pihole-primary --tail 20
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Node 2: Serenity (BACKUP1)
|
||||
## Node 2: N.O.M.A.D. (BACKUP1)
|
||||
|
||||
Serenity runs Unraid. Deploy via Docker Compose in Unraid's **User Scripts** or the **Compose Manager** plugin.
|
||||
N.O.M.A.D. runs Ubuntu bare metal. Deploy via Docker Compose on the host.
|
||||
|
||||
**Appdata:** `/mnt/user/appdata/`
|
||||
**Live service root:** `/opt/pihole-nomad`
|
||||
**Service-local data root:** `/opt/pihole-nomad/data/`
|
||||
**Web UI port:** `8954`
|
||||
|
||||
### docker-compose.yaml
|
||||
@@ -241,7 +248,7 @@ services:
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /mnt/user/appdata/unbound-pihole:/opt/unbound/etc/unbound/
|
||||
- /opt/pihole-nomad/data/unbound:/opt/unbound/etc/unbound/
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "drill @127.0.0.1 -p 5335 cloudflare.com || exit 1"]
|
||||
interval: 30s
|
||||
@@ -250,7 +257,7 @@ services:
|
||||
|
||||
pihole:
|
||||
image: pihole/pihole:latest
|
||||
container_name: pihole-secondary
|
||||
container_name: pihole-nomad
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
@@ -259,11 +266,15 @@ services:
|
||||
environment:
|
||||
TZ: ${TZ}
|
||||
FTLCONF_webserver_api_password: ${PIHOLE_PASSWORD}
|
||||
FTLCONF_dns_listeningMode: "LOCAL"
|
||||
FTLCONF_dns_interface: "enp5s0"
|
||||
FTLCONF_dns_listeningMode: "SINGLE"
|
||||
FTLCONF_dns_upstreams: "127.0.0.1#5335"
|
||||
FTLCONF_webserver_port: "8954"
|
||||
FTLCONF_ntp_ipv4_active: "false"
|
||||
FTLCONF_ntp_ipv6_active: "false"
|
||||
FTLCONF_ntp_sync_active: "false"
|
||||
volumes:
|
||||
- /mnt/user/appdata/pihole-secondary/etc-pihole:/etc/pihole
|
||||
- /opt/pihole-nomad/data/pihole/etc-pihole:/etc/pihole
|
||||
cap_add:
|
||||
- SYS_NICE
|
||||
|
||||
@@ -280,16 +291,30 @@ services:
|
||||
- ./keepalived.conf:/container/environment/01-custom/keepalived.conf
|
||||
```
|
||||
|
||||
### keepalived.conf (Serenity — BACKUP1)
|
||||
### NOMAD host prerequisite
|
||||
|
||||
If `systemd-resolved` is holding a local DNS stub on port 53, disable it before relying on NOMAD Pi-hole for LAN DNS:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /etc/systemd/resolved.conf.d
|
||||
cat <<'EOF' | sudo tee /etc/systemd/resolved.conf.d/99-pihole.conf
|
||||
[Resolve]
|
||||
DNSStubListener=no
|
||||
EOF
|
||||
sudo systemctl restart systemd-resolved
|
||||
sudo ln -sf /run/systemd/resolve/resolv.conf /etc/resolv.conf
|
||||
```
|
||||
|
||||
### keepalived.conf (N.O.M.A.D. — BACKUP1)
|
||||
|
||||
```
|
||||
global_defs {
|
||||
router_id pihole_serenity
|
||||
router_id pihole_nomad
|
||||
}
|
||||
|
||||
vrrp_instance VI_DNS {
|
||||
state BACKUP
|
||||
interface eth0
|
||||
interface enp5s0
|
||||
virtual_router_id 53
|
||||
priority 90
|
||||
advert_int 1
|
||||
@@ -300,7 +325,7 @@ vrrp_instance VI_DNS {
|
||||
}
|
||||
|
||||
virtual_ipaddress {
|
||||
10.5.1.53/24
|
||||
10.5.30.53/24
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -386,7 +411,7 @@ vrrp_instance VI_DNS {
|
||||
}
|
||||
|
||||
virtual_ipaddress {
|
||||
10.5.1.53/24
|
||||
10.5.30.53/24
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -411,7 +436,7 @@ sudo systemctl start keepalived
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PRIMARY: "http://127.0.0.1:8953|${PIHOLE_PASSWORD}"
|
||||
REPLICAS: "http://10.5.1.5:8954|${PIHOLE_PASSWORD_SERENITY},http://10.5.1.X:8955|${PIHOLE_PASSWORD_RPI}"
|
||||
REPLICAS: "http://10.5.30.7:8954|${PIHOLE_PASSWORD_NOMAD},http://10.5.1.X:8955|${PIHOLE_PASSWORD_RPI}"
|
||||
RUN_GRAVITY: "true"
|
||||
FULL_SYNC: "false"
|
||||
CRON: "0 3 * * *"
|
||||
@@ -423,7 +448,7 @@ sudo systemctl start keepalived
|
||||
> - `REPLICAS` — comma-separated list of secondary instances
|
||||
> - `CRON` — syncs daily at 3 AM; force a manual sync with `docker exec nebula-sync nebula-sync run`
|
||||
> - Each Pi-hole instance can (and should) have a **different** password — nebula-sync handles auth per-instance
|
||||
> - Add `PIHOLE_PASSWORD_SERENITY` and `PIHOLE_PASSWORD_RPI` to your `.env` file
|
||||
> - Add `PIHOLE_PASSWORD_NOMAD` and `PIHOLE_PASSWORD_RPI` to your `.env` file
|
||||
|
||||
### What Nebula Sync copies
|
||||
- Gravity database (blocklists, allowlists, group assignments)
|
||||
@@ -438,7 +463,7 @@ sudo systemctl start keepalived
|
||||
Point **only the VIP** as DNS:
|
||||
|
||||
1. UniFi Network → Settings → Networks → **LAN** → DHCP Name Server: **Manual**
|
||||
2. **DNS Server 1:** `10.5.1.53` (VIP — floats between nodes)
|
||||
2. **DNS Server 1:** `10.5.30.53` (VIP — floats between nodes)
|
||||
3. **DNS Server 2:** *(leave blank or set a public fallback like `1.1.1.1` only if you want internet DNS as last resort)*
|
||||
4. Save and renew DHCP leases
|
||||
|
||||
@@ -447,17 +472,23 @@ Point **only the VIP** as DNS:
|
||||
|
||||
---
|
||||
|
||||
## Current live endpoints
|
||||
|
||||
- **PD admin UI:** `http://10.5.30.6:8953/admin/`
|
||||
- **NOMAD admin UI:** `http://10.5.30.7:8954/admin/`
|
||||
- **VIP for DNS clients:** `10.5.30.53`
|
||||
|
||||
## Post-Deploy
|
||||
|
||||
### Verify VIP is working
|
||||
|
||||
```bash
|
||||
# Check which node holds the VIP
|
||||
ip addr show | grep 10.5.1.53 # Run on each node — only MASTER shows it
|
||||
ip addr show | grep 10.5.30.53 # Run on each node — only MASTER shows it
|
||||
|
||||
# Test DNS through VIP
|
||||
nslookup google.com 10.5.1.53
|
||||
nslookup doubleclick.net 10.5.1.53 # Should return 0.0.0.0 (blocked)
|
||||
nslookup google.com 10.5.30.53
|
||||
nslookup doubleclick.net 10.5.30.53 # Should return 0.0.0.0 (blocked)
|
||||
```
|
||||
|
||||
### Test failover
|
||||
@@ -466,7 +497,7 @@ nslookup doubleclick.net 10.5.1.53 # Should return 0.0.0.0 (blocked)
|
||||
# On PD, bring down keepalived
|
||||
sudo docker stop keepalived
|
||||
# Wait ~3 seconds, then check from another machine:
|
||||
nslookup google.com 10.5.1.53 # Should still work — Serenity took over VIP
|
||||
nslookup google.com 10.5.30.53 # Should still work — N.O.M.A.D. took over VIP
|
||||
# Restore
|
||||
sudo docker start keepalived
|
||||
```
|
||||
@@ -487,10 +518,9 @@ Add in Pi-hole (primary) → Local DNS → DNS Records:
|
||||
|
||||
| Domain | IP |
|
||||
|--------|----|
|
||||
| pd.lan | 10.5.1.6 |
|
||||
| serenity.lan | 10.5.1.5 |
|
||||
| nomad.lan | 10.5.1.16 |
|
||||
| rocinante.lan | 10.5.1.112 |
|
||||
| pd.lan | 10.5.30.6 |
|
||||
| nomad.lan | 10.5.30.7 |
|
||||
| rocinante.lan | 10.5.30.112 |
|
||||
| rpi4.lan | 10.5.1.X |
|
||||
|
||||
Nebula Sync will propagate these to all replicas.
|
||||
@@ -499,8 +529,10 @@ Nebula Sync will propagate these to all replicas.
|
||||
|
||||
| Subdomain | Target |
|
||||
|-----------|--------|
|
||||
| `pihole.paccoco.com` | `http://10.5.1.6:8953` (primary) |
|
||||
| `pihole2.paccoco.com` | `http://10.5.1.5:8954` (Serenity) |
|
||||
| `pihole.paccoco.com` | `http://10.5.30.6:8953` (primary) |
|
||||
| `pihole2.paccoco.com` | `http://10.5.30.7:8954` (N.O.M.A.D.) |
|
||||
|
||||
Use `/admin/` on the public hostname for the Pi-hole admin UI path.
|
||||
|
||||
Enable Pangolin auth — Pi-hole admin should not be publicly accessible.
|
||||
|
||||
@@ -512,7 +544,7 @@ Enable Pangolin auth — Pi-hole admin should not be publicly accessible.
|
||||
|------|------|------|--------|
|
||||
| PD | `/mnt/tank/docker/appdata/pihole-primary` | tank | Low write frequency |
|
||||
| PD | `/mnt/tank/docker/appdata/unbound-primary` | tank | Config + root hints only |
|
||||
| Serenity | `/mnt/user/appdata/pihole-secondary` | Unraid array | Low write frequency |
|
||||
| N.O.M.A.D. | `/opt/pihole-nomad/data` | Local service root | Self-contained NOMAD service data |
|
||||
| RPi4 | `~/pihole/` | SD card / SSD | Dedicated Pi, no other concerns |
|
||||
|
||||
---
|
||||
|
||||
@@ -88,7 +88,7 @@ Generated: 2026-05-09
|
||||
### 12 — Pi-hole Stats Digest
|
||||
**Trigger:** Schedule (daily at 8 AM)
|
||||
**What it does:**
|
||||
1. Query Pi-hole API on PD (`http://10.5.1.6:8953/api/stats/summary`)
|
||||
1. Query Pi-hole API on PD (`http://10.5.30.6:8953/api/stats/summary`)
|
||||
2. Query secondary Pi-hole on Serenity
|
||||
3. LiteLLM summarizes notable blocked domains and compares primary vs secondary query load
|
||||
4. Push digest to Gotify (Pi-hole app) with 24h block count, top blocked domains, and any unusual spikes
|
||||
@@ -136,7 +136,7 @@ Generated: 2026-05-09
|
||||
### 16 — NFS Mount Watchdog + Auto-Heal
|
||||
**Trigger:** Schedule (every 5 min)
|
||||
**What it does:**
|
||||
1. SSH into PD (10.5.1.6) and probe each expected NFS mount path with `mountpoint -q`:
|
||||
1. SSH into PD (10.5.30.6) and probe each expected NFS mount path with `mountpoint -q`:
|
||||
- `/mnt/unraid/data/media` (Plex, Audiobookshelf)
|
||||
- `/mnt/unraid/data/photos` (Immich)
|
||||
- Any other mounts defined in the mount script
|
||||
@@ -153,7 +153,7 @@ Generated: 2026-05-09
|
||||
- Escalation: priority 9, list which paths are still unavailable
|
||||
|
||||
**Implementation notes:**
|
||||
- Uses n8n SSH node connecting to `10.5.1.6` with an SSH credential (key-based auth from n8n container)
|
||||
- Uses n8n SSH node connecting to `10.5.30.6` with an SSH credential (key-based auth from n8n container)
|
||||
- SSH node runs a single compound command: `mountpoint -q /mnt/unraid/data/media && echo MEDIA_OK || echo MEDIA_MISSING` etc.
|
||||
- Parse output in a Code node to determine which mounts failed
|
||||
- Use separate SSH nodes for: (1) running the mount script, (2) restarting containers — keeps the flow readable
|
||||
|
||||
305
docs/planning/PD_FUTURE_STATE_ARCHITECTURE.md
Normal file
305
docs/planning/PD_FUTURE_STATE_ARCHITECTURE.md
Normal 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
|
||||
82
docs/planning/SCHOOL_INTAKE_PIPELINE.md
Normal file
82
docs/planning/SCHOOL_INTAKE_PIPELINE.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# School Work Intake Pipeline (Telegram → Postgres → Paperless → n8n)
|
||||
|
||||
## Goal
|
||||
|
||||
Give Doris a reliable way to ingest school work from Telegram while preserving explicit metadata:
|
||||
- class
|
||||
- assignment
|
||||
- submission kind (`draft`, `final`, etc.)
|
||||
- optional semester / instructor / due date / paper kind
|
||||
|
||||
The design must be expandable for new classes and future non-paper workflows.
|
||||
|
||||
## Recommended architecture
|
||||
|
||||
1. **Telegram chat-driven intake**
|
||||
- John sends file(s) to Doris on Telegram.
|
||||
- Doris asks follow-up questions until required metadata is complete.
|
||||
|
||||
2. **n8n intake webhook**
|
||||
- Doris submits the file + metadata to an intake webhook.
|
||||
- The webhook creates a durable intake record in shared Postgres.
|
||||
- The webhook stages/uploads the file into Paperless with a deterministic `intake_id` embedded in the filename.
|
||||
|
||||
3. **Paperless post-consumption webhook**
|
||||
- After Paperless finishes processing the file, a second workflow fetches the Paperless document.
|
||||
- That workflow extracts `intake_id`, looks up the Postgres intake record, and applies metadata.
|
||||
|
||||
4. **Metadata + AI enrichment**
|
||||
- Deterministic metadata first: class tags, version tags, title rules, optional correspondent/document type IDs.
|
||||
- AI second: document summary/note and any low-risk enrichment.
|
||||
- Ambiguous cases can be routed to a review queue.
|
||||
|
||||
## Why this is better than folder-based consume ingestion
|
||||
|
||||
Folder paths are a decent hint, but they are not durable enough to be the only source of truth.
|
||||
|
||||
Problems with folder-only inference:
|
||||
- folder context can disappear after import
|
||||
- mixed uploads are easy to mislabel
|
||||
- retries/reprocessing can grab the wrong document
|
||||
- future use-cases (draft review, assignment history, instructor-specific rules) need explicit metadata anyway
|
||||
|
||||
## Shared DB requirements
|
||||
|
||||
Use shared Postgres, not SQLite.
|
||||
|
||||
Schema artifact:
|
||||
- `docs/reference/SCHOOL_INTAKE_POSTGRES_SCHEMA.sql`
|
||||
|
||||
Core tables:
|
||||
- `school_paperless_intake`
|
||||
- `school_paperless_intake_events`
|
||||
|
||||
## Config-driven class profiles
|
||||
|
||||
Class/course behavior should live in config, not hardcoded workflow branches.
|
||||
|
||||
Suggested profile fields:
|
||||
- `class_key`
|
||||
- `display_name`
|
||||
- `course_code`
|
||||
- `default_tags`
|
||||
- Paperless IDs for correspondent/document type/storage path when needed
|
||||
- title template override
|
||||
|
||||
## Initial rollout target
|
||||
|
||||
- ENGL-1010
|
||||
- HIST-2020
|
||||
|
||||
## OpenClaw helper artifact
|
||||
|
||||
Local helper:
|
||||
- `school/intake/submit_to_n8n.js`
|
||||
|
||||
This gives Doris a simple handoff point after the Telegram conversation is complete.
|
||||
|
||||
## Live work still needed
|
||||
|
||||
- configure class profiles with real Paperless IDs / tag mappings
|
||||
- decide Telegram delivery wording/confirmation behavior
|
||||
- after any live `.env` changes for this pipeline, sync them into the encrypted homelab secrets repo (`docs/operations/SECRETS_MANAGEMENT.md`)
|
||||
559
docs/planning/SERENITY_CLEANUP_WAVE_1.md
Normal file
559
docs/planning/SERENITY_CLEANUP_WAVE_1.md
Normal 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
|
||||
336
docs/planning/SERENITY_DOCKER_AUDIT.md
Normal file
336
docs/planning/SERENITY_DOCKER_AUDIT.md
Normal 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.
|
||||
368
docs/planning/SERENITY_MAJORITY_MIGRATION_PLAN.md
Normal file
368
docs/planning/SERENITY_MAJORITY_MIGRATION_PLAN.md
Normal 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
|
||||
@@ -1,27 +1,33 @@
|
||||
# Homelab TODO
|
||||
|
||||
## Security (do ASAP)
|
||||
- [ ] Regenerate Wings token on N.O.M.A.D. (was exposed in chat)
|
||||
- [ ] Regenerate N.O.M.A.D. Newt secret (was exposed in chat)
|
||||
- [x] Regenerate Wings token on N.O.M.A.D. (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
|
||||
- [ ] Deploy Pi-hole 3-node HA cluster (PD + Serenity + RPi4) — see docs/planning/DEPLOY_PIHOLE.md
|
||||
- RPi4 IP still TBD — fill in before deploying
|
||||
- [ ] Deploy Kima-Hub — see docs/planning/DEPLOY_KIMA_HUB.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 retired 2026-05-27 after local resolver cutover to Technitium (`10.5.30.8/.9/.10` + `9.9.9.9`)
|
||||
- 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
|
||||
- [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)
|
||||
- [ ] 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)
|
||||
- [ ] Add Pangolin resource for `ai.paccoco.com` → OpenWebUI (port 8282)
|
||||
- [ ] Add Cloudflare DNS A record for `ai.paccoco.com`
|
||||
- [ ] 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 Cloudflare DNS record for `openwebui.paccoco.com` — completed 2026-05-15
|
||||
- [ ] Install fresh editor on PD
|
||||
- [ ] Set up private Gitea repo for .env file backups
|
||||
- [ ] Add a stable Doris/Home Assistant route and convert the Services page card from manual to live-probed
|
||||
- [x] Set up private Gitea repo for .env file backups — 2026-05-14
|
||||
|
||||
## AI Stack
|
||||
- [ ] Pull `qwen2.5:14b` on PD ollama: `sudo docker exec -it ollama ollama pull qwen2.5:14b`
|
||||
- [ ] Create OpenWebUI account and set system prompt from HOMELAB_AI_CONTEXT.md
|
||||
- [ ] Disable signups in OpenWebUI after creating account
|
||||
- [ ] Connect Rocinante Ollama to OpenWebUI (Admin → Settings → Connections)
|
||||
- [ ] Confirm `OLLAMA_HOST=0.0.0.0` set on Rocinante and Ollama service restarted
|
||||
- [x] Pull `qwen2.5:14b` on PD ollama — completed 2026-05-15
|
||||
- [x] Create OpenWebUI account and set system prompt from HOMELAB_AI_CONTEXT.md — completed 2026-05-15
|
||||
- [x] Disable signups in OpenWebUI after creating account — completed 2026-05-15
|
||||
- [x] Connect Rocinante Ollama to OpenWebUI (Admin → Settings → Connections) — completed 2026-05-15
|
||||
- [x] Confirm `OLLAMA_HOST=0.0.0.0` set on Rocinante and Ollama service restarted — completed 2026-05-15
|
||||
|
||||
## Healthchecks
|
||||
- [ ] Add healthcheck to ix-plex-plex-1
|
||||
@@ -32,15 +38,23 @@
|
||||
|
||||
## Documentation
|
||||
- [ ] Document meshtastic stack fully (see docs/reference/MESHTASTIC.md)
|
||||
- [ ] Document N.O.M.A.D. containers (start_nomad.sh services)
|
||||
- [x] Document N.O.M.A.D. Docker/container audit (compose-managed, Project N.O.M.A.D.-managed, unmanaged, and Pelican/Wings-managed containers) — 2026-05-14
|
||||
- [x] Decide which services should run on NOMAD, which should not, and whether each should live under raw compose, Project N.O.M.A.D.-managed state, systemd, or another more explicit deployment model — 2026-05-14
|
||||
- Project N.O.M.A.D.-managed app containers are explicitly hands-off and stay exactly as-is.
|
||||
- `whisper` and `node-exporter` may be removed; exact runtime was documented in `docs/servers/NOMAD.md` for emergency rebuild.
|
||||
- Pelican/Wings-managed game containers stay and are documented as permanent game-server infrastructure on NOMAD.
|
||||
|
||||
## n8n Workflows
|
||||
- [ ] Create Gotify apps and n8n credentials: Reminders, n8n Health, Uptime / Infra
|
||||
- [ ] Add remaining env vars to n8n docker-compose + .env: `PAPERLESS_API_TOKEN`, `PAPERLESS_TAG_TRANSCRIPTION`, `PAPERLESS_TAG_LECTURE_NOTES`, `N8N_API_KEY` (see n8n-workflows/README.md for docker-compose.yaml changes required)
|
||||
- [ ] Set up `PD SSH` credential in n8n for workflow 16 (see n8n-workflows/README.md)
|
||||
- [ ] Add sudoers NOPASSWD rule for n8n watchdog (see n8n-workflows/README.md)
|
||||
- [ ] Import and activate upgraded workflows: 04, 08 (01 done — v1.4 active and tested)
|
||||
- [ ] Import and activate new workflows: 14, 15 (16 already active)
|
||||
- [x] Create Gotify apps and n8n credentials: Reminders, n8n Health, Uptime / Infra — completed 2026-05-15
|
||||
- [x] Add remaining env vars to n8n docker-compose + .env: `PAPERLESS_API_TOKEN`, `PAPERLESS_TAG_TRANSCRIPTION`, `PAPERLESS_TAG_LECTURE_NOTES`, `N8N_API_KEY` — completed 2026-05-15
|
||||
- [x] Set up `PD SSH` credential in n8n for workflow 16 — completed 2026-05-15
|
||||
- [x] Add sudoers NOPASSWD rule for n8n watchdog — completed 2026-05-15
|
||||
- [x] Import and activate upgraded workflows: 04, 08 (01 done — v1.4 active and tested) — completed 2026-05-15
|
||||
- [x] Import and activate new workflows: 14, 15 (16 already active) — completed 2026-05-15
|
||||
- [x] Configure class profiles + Paperless metadata mapping for current courses (ENGL-1010, HIST-2020, future classes) — completed 2026-05-15
|
||||
- [x] Delete legacy duplicate Paperless notes for school docs 42–49 after approval / with proper token context — completed 2026-05-14; docs now each have one canonical note
|
||||
- [x] Set up DB and Docker backups to Serenity following homelab SOP — completed 2026-05-15
|
||||
- Live `.env`, SSH trust, scheduler deployment, first run, and verification completed on PD
|
||||
|
||||
## Completed
|
||||
- [x] Deploy Gotify (Phase 1) — 2026-05-05
|
||||
@@ -59,3 +73,5 @@
|
||||
- [x] Create Postgres dedup tables (grafana_alert_dedup, n8n_health_dedup) in n8n_rss DB on PD — 2026-05-09
|
||||
- [x] Workflow 01 fully tested end-to-end (dedup, LiteLLM, Gotify) — v1.4 active — 2026-05-09
|
||||
- [x] Add LITELLM_API_KEY + N8N_BLOCK_ENV_ACCESS_IN_NODE to n8n docker-compose — 2026-05-09
|
||||
- [x] Build Telegram chat-driven school-work intake for Paperless using shared Postgres, deterministic `intake_id`, and class/version metadata — 2026-05-14
|
||||
- [x] Build Doris-managed KitchenOwl recipe importer flow with native-scrape fallback, schema.org JSON-LD parsing, duplicate checks, and dry-run support — 2026-05-15
|
||||
|
||||
110
docs/planning/doris-barbell-v1-plan.md
Normal file
110
docs/planning/doris-barbell-v1-plan.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Doris Barbell V1 Implementation Plan
|
||||
|
||||
> For Hermes: use subagent-driven-development if this gets split into parallel implementation chunks later.
|
||||
|
||||
Goal: ship a private-first exercise tracking site on PD that lets John log structured workouts, reps, weight, body weight, BMI, and related body metrics, while leaving a clean path for Manndra as a second household user later.
|
||||
|
||||
Architecture: start with the proven Doris pattern already used elsewhere in the homelab: a small FastAPI + Jinja app with a JSON-backed store for fast delivery, then graduate the storage layer to SQLite or shared Postgres if the workflow proves sticky. Keep the initial data model structured enough that templates, charts, PR tracking, and multi-user auth can layer in without a rewrite.
|
||||
|
||||
Tech stack: FastAPI, Jinja2, JSON state store for V1 bootstrap, Docker Compose on PD, responsive CSS, pytest.
|
||||
|
||||
---
|
||||
|
||||
## Working assumptions locked in for now
|
||||
|
||||
- Audience: John now, Manndra stubbed for later.
|
||||
- Hosting: PD.
|
||||
- Device target: both phone and desktop, mobile-friendly first.
|
||||
- Access model default: private-first now, public route optional later.
|
||||
- Backend default: Python / FastAPI because it matches the existing Doris app pattern and is fastest to ship here.
|
||||
- Reporting default: build toward workout history, PR/1RM, and body-metric trends, but do not block V1 logging on all charts being perfect.
|
||||
- Measurement preference: imperial only.
|
||||
- Progress photos: out for V1.
|
||||
- Muscle-size tracking: not a priority.
|
||||
- Current height assumption for seed data: 68.5 inches unless John corrects it.
|
||||
- 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
|
||||
|
||||
- New repo source: `home/doris-barbell`
|
||||
- Initial FastAPI app skeleton exists.
|
||||
- JSON state seeds `john` plus stub `manndra`.
|
||||
- Implemented endpoints:
|
||||
- `GET /api/health`
|
||||
- `GET /api/dashboard/summary`
|
||||
- `PUT /api/profile/{user_id}`
|
||||
- `POST /api/profile/{user_id}/weight`
|
||||
- `GET /api/history/{user_id}/weights`
|
||||
- `POST /api/profile/{user_id}/measurements`
|
||||
- `GET /api/history/{user_id}/measurements`
|
||||
- `POST /api/exercises/templates`
|
||||
- `GET /api/exercises/templates`
|
||||
- `POST /api/routines`
|
||||
- `GET /api/routines`
|
||||
- `POST /api/workouts`
|
||||
- `GET /api/history/{user_id}/workouts`
|
||||
- `GET /api/progression/{user_id}/exercises`
|
||||
- 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.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
### Slice 1: stabilize the base data model
|
||||
1. Add lightweight migration/version handling inside the JSON state.
|
||||
2. Add validation rules around timestamps, duplicate entries, and bad user IDs.
|
||||
3. Add additional non-muscle health/body metrics only if John actually wants them surfaced.
|
||||
4. Keep every write user-scoped so Manndra can be added later without rework.
|
||||
|
||||
### Slice 2: make workout logging genuinely usable
|
||||
1. Add exercise template edit/delete.
|
||||
2. Add routine edit/delete and clone-from-template flow.
|
||||
3. Add support for warm-up vs working sets.
|
||||
4. Add notes per exercise and per set.
|
||||
5. Add estimated 1RM helpers for barbell lifts.
|
||||
|
||||
### Slice 3: give John the actual dashboards he will care about
|
||||
1. Current weight card and trend sparkline.
|
||||
2. BMI and body-fat trend history.
|
||||
3. Recent workouts table.
|
||||
4. Exercise-specific progression view.
|
||||
5. PR cards and recent milestone callouts.
|
||||
6. Body-measurement history cards for waist, hips, chest, and neck.
|
||||
7. UI screens that consume the new history endpoints.
|
||||
|
||||
### Slice 4: prepare for shared household use without forcing it now
|
||||
1. Add auth choice once John settles on private-only vs public exposure.
|
||||
2. Preserve seeded Manndra user but hide stub-only clutter in the UI.
|
||||
3. Keep imperial defaults while leaving room for future per-user units if needed.
|
||||
|
||||
### Slice 5: deploy on PD cleanly
|
||||
1. Add `.env` from `.env.example` in the live deploy path.
|
||||
2. Validate with `docker compose --env-file .env config`.
|
||||
3. Deploy under `/mnt/docker-ssd/docker/compose/doris-barbell`.
|
||||
4. Decide whether it stays LAN-only or gets a routed hostname.
|
||||
|
||||
## Biggest open product questions still waiting on John
|
||||
|
||||
1. Working weights for the imported split.
|
||||
2. Preferred auth mode for V1.
|
||||
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.
|
||||
5. Whether he wants one-step quick workout entry or a fuller workout-builder flow first.
|
||||
|
||||
## Verification commands
|
||||
|
||||
```bash
|
||||
cd /home/fizzlepoof/repos/truenas-stacks/home/doris-barbell
|
||||
. .venv/bin/activate
|
||||
pytest tests/test_app.py -q
|
||||
python3 -m py_compile app/main.py app/config.py app/models.py app/routes/api.py app/routes/ui.py app/services/store.py
|
||||
python3 - <<'PY'
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
print(yaml.safe_load(Path('docker-compose.yaml').read_text())['services'].keys())
|
||||
PY
|
||||
```
|
||||
391
docs/planning/doris-schoolhouse-technical-spec.md
Normal file
391
docs/planning/doris-schoolhouse-technical-spec.md
Normal file
@@ -0,0 +1,391 @@
|
||||
# Doris Schoolhouse — Technical Spec
|
||||
|
||||
## Goal
|
||||
|
||||
Build a desktop-first local web app on NOMAD for school intake and tracking.
|
||||
|
||||
Primary jobs:
|
||||
|
||||
- upload assignment turn-ins
|
||||
- upload class recordings
|
||||
- suggest metadata from D2L
|
||||
- confirm metadata before Paperless submission
|
||||
- transcribe and summarize recordings
|
||||
- track assignment lifecycle through grading
|
||||
- append grades and professor comments into Paperless notes
|
||||
|
||||
## Deployment shape
|
||||
|
||||
- **Source path:** `home/doris-schoolhouse`
|
||||
- **Live NOMAD runtime target:** `/opt/doris-schoolhouse`
|
||||
- **Access:** local + Pangolin-authenticated
|
||||
- **Networks:** `pangolin`, `ix-databases_shared-databases`
|
||||
- **DB:** shared Postgres
|
||||
- **Archive/document system:** Paperless-NGX
|
||||
- **Workflow helpers:** existing n8n workflows for school intake and class recording processing when appropriate
|
||||
|
||||
## Source of truth
|
||||
|
||||
| Domain | Source of truth |
|
||||
|---|---|
|
||||
| Course roster | D2L sync cache |
|
||||
| Assignment roster / due dates | D2L sync cache with manual override support |
|
||||
| Submission workflow state | Doris Schoolhouse DB |
|
||||
| Uploaded files / transcripts / summaries | Paperless |
|
||||
| Grades / professor comments | D2L on refresh, cached in Doris Schoolhouse DB |
|
||||
|
||||
The app DB owns workflow state and linkage. Paperless owns archived artifacts.
|
||||
|
||||
## Existing reusable assets
|
||||
|
||||
### D2L
|
||||
|
||||
Existing workspace assets already prove the login/scrape path:
|
||||
|
||||
- `~/.openclaw/workspace/school/d2l_scraper_final.js`
|
||||
- `~/.openclaw/workspace/school/d2l_school_watcher.js`
|
||||
- `~/.openclaw/workspace/school/d2l_current_snapshot.json`
|
||||
- `~/.openclaw/workspace/school/d2l_grades.json`
|
||||
|
||||
Current confirmed D2L coverage:
|
||||
|
||||
- courses
|
||||
- grades
|
||||
- grade items
|
||||
- some material/date counts
|
||||
- calendar/ICS-derived schedule
|
||||
|
||||
Gap to close for Schoolhouse:
|
||||
|
||||
- normalize D2L data into app DB tables
|
||||
- stronger assignment/date extraction
|
||||
- deterministic matching between D2L assignment rows and Paperless-backed submissions
|
||||
|
||||
### Paperless / n8n
|
||||
|
||||
Existing repo assets:
|
||||
|
||||
- `n8n-workflows/08-class-recording-rag-v1.2.json`
|
||||
- `n8n-workflows/18-school-paperless-intake-v1.2.json`
|
||||
- `n8n-workflows/19-paperless-school-metadata-enrichment-v1.1.json`
|
||||
- `docs/operations/SCHOOL_PAPERLESS_INTAKE.md`
|
||||
- `docs/reference/SCHOOL_INTAKE_POSTGRES_SCHEMA.sql`
|
||||
|
||||
Current confirmed behavior from docs/testing notes:
|
||||
|
||||
- school uploads can already be pushed to Paperless with deterministic filename/title correlation
|
||||
- class recording pipeline already has a webhook path and downstream Paperless/RAG behavior
|
||||
- workflow 19 already does deterministic post-consumption enrichment
|
||||
|
||||
## App architecture
|
||||
|
||||
Use a server-rendered Python app for the first build:
|
||||
|
||||
- **Backend:** FastAPI
|
||||
- **Templates:** Jinja2
|
||||
- **Frontend:** vanilla JS + server-rendered forms/pages
|
||||
- **DB access:** SQLAlchemy / psycopg
|
||||
- **Background work:** app jobs can hand off long-running audio processing to existing n8n recording/transcription pipeline
|
||||
|
||||
Why this split:
|
||||
|
||||
- easier large-upload handling
|
||||
- easy form-heavy UI
|
||||
- less frontend build nonsense
|
||||
- plays nicely with existing Python-heavy repo bits
|
||||
|
||||
## Core pages
|
||||
|
||||
### 1. Dashboard `/`
|
||||
|
||||
Shows:
|
||||
|
||||
- upcoming assignments
|
||||
- unsubmitted assignments
|
||||
- recent uploads pending confirmation
|
||||
- newly graded work
|
||||
- recording processing status
|
||||
- sync health
|
||||
|
||||
### 2. Assignment Upload `/assignments/upload`
|
||||
|
||||
Form fields:
|
||||
|
||||
- class
|
||||
- assignment
|
||||
- submitted date
|
||||
- version
|
||||
- notes
|
||||
- file upload / drag-drop / paste
|
||||
|
||||
Behavior:
|
||||
|
||||
1. choose file
|
||||
2. app suggests class + assignment from D2L cache
|
||||
3. user confirms/edits metadata
|
||||
4. upload goes to Paperless intake path
|
||||
5. app stores linkage row
|
||||
6. assignment state becomes `submitted`
|
||||
|
||||
### 3. Recording Upload `/recordings/upload`
|
||||
|
||||
Form fields:
|
||||
|
||||
- class
|
||||
- session date
|
||||
- optional related assignment
|
||||
- notes
|
||||
- `.wav` upload
|
||||
|
||||
Behavior:
|
||||
|
||||
1. accept large WAV
|
||||
2. persist upload job row
|
||||
3. convert WAV → MP3
|
||||
4. delete WAV only after MP3 success
|
||||
5. hand MP3 to transcription/summarization flow
|
||||
6. send transcript + summary to Paperless
|
||||
7. store resulting Paperless document links
|
||||
|
||||
### 4. Classes `/classes` and `/classes/{id}`
|
||||
|
||||
Shows:
|
||||
|
||||
- assignments
|
||||
- due dates
|
||||
- grades
|
||||
- comments
|
||||
- linked submissions
|
||||
- linked recordings
|
||||
|
||||
### 5. D2L Sync `/admin/d2l`
|
||||
|
||||
Shows:
|
||||
|
||||
- last sync time
|
||||
- sync status
|
||||
- changed courses/assignments/grades
|
||||
- unmatched rows requiring manual mapping
|
||||
|
||||
## API surface
|
||||
|
||||
### Health
|
||||
- `GET /api/health`
|
||||
|
||||
### D2L
|
||||
- `POST /api/d2l/sync`
|
||||
- `GET /api/d2l/courses`
|
||||
- `GET /api/d2l/assignments?course_id=`
|
||||
- `GET /api/d2l/grades?course_id=`
|
||||
|
||||
### Assignments
|
||||
- `GET /api/assignments`
|
||||
- `POST /api/assignments/intake`
|
||||
- `POST /api/assignments/{id}/confirm-paperless`
|
||||
- `POST /api/assignments/{id}/status`
|
||||
|
||||
### Recordings
|
||||
- `GET /api/recordings`
|
||||
- `POST /api/recordings/intake`
|
||||
- `POST /api/recordings/{id}/reprocess`
|
||||
|
||||
### Paperless linkage
|
||||
- `POST /api/paperless/webhook/intake-enriched`
|
||||
- `POST /api/paperless/webhook/recording-complete`
|
||||
- `POST /api/paperless/assignments/{id}/append-grade-note`
|
||||
|
||||
## Data model
|
||||
|
||||
### `schoolhouse_course`
|
||||
|
||||
- id
|
||||
- d2l_course_id
|
||||
- name
|
||||
- code
|
||||
- semester
|
||||
- instructor_name
|
||||
- active
|
||||
- first_seen_at
|
||||
- last_synced_at
|
||||
|
||||
### `schoolhouse_assignment`
|
||||
|
||||
- id
|
||||
- course_id
|
||||
- d2l_assignment_key
|
||||
- title
|
||||
- category
|
||||
- due_at
|
||||
- status (`assigned|in_progress|submitted|graded|late|missing`)
|
||||
- grade_text
|
||||
- grade_numeric
|
||||
- professor_comments
|
||||
- source (`d2l|manual|template`)
|
||||
- created_at
|
||||
- updated_at
|
||||
|
||||
### `schoolhouse_submission`
|
||||
|
||||
- id
|
||||
- assignment_id
|
||||
- version_label
|
||||
- original_filename
|
||||
- mime_type
|
||||
- checksum_sha256
|
||||
- submitted_at
|
||||
- suggested_metadata_json
|
||||
- confirmed_metadata_json
|
||||
- paperless_document_id
|
||||
- paperless_title
|
||||
- paperless_note_appended_at
|
||||
- status (`draft|uploaded|confirmed|failed`)
|
||||
|
||||
### `schoolhouse_recording`
|
||||
|
||||
- id
|
||||
- course_id
|
||||
- assignment_id nullable
|
||||
- session_date
|
||||
- original_filename
|
||||
- mp3_filename
|
||||
- duration_seconds
|
||||
- transcription_status (`queued|processing|ready|failed`)
|
||||
- summary_status (`queued|processing|ready|failed`)
|
||||
- transcript_paperless_document_id
|
||||
- summary_paperless_document_id
|
||||
- recording_paperless_document_id nullable
|
||||
- notes
|
||||
|
||||
### `schoolhouse_d2l_sync_run`
|
||||
|
||||
- id
|
||||
- started_at
|
||||
- completed_at
|
||||
- status
|
||||
- raw_snapshot_path
|
||||
- changes_summary_json
|
||||
- error_text
|
||||
|
||||
### `schoolhouse_assignment_mapping`
|
||||
|
||||
- id
|
||||
- course_id
|
||||
- d2l_grade_item_name
|
||||
- normalized_key
|
||||
- assignment_id
|
||||
- confidence
|
||||
- mapping_source (`auto|manual`)
|
||||
|
||||
This table is the bastard that keeps D2L grade items tied to internal assignments cleanly.
|
||||
|
||||
## Matching rules
|
||||
|
||||
### Assignment matching priority
|
||||
|
||||
1. exact D2L assignment key when available
|
||||
2. normalized title within course
|
||||
3. filename heuristics
|
||||
4. latest due date proximity
|
||||
5. manual confirmation
|
||||
|
||||
### Grade/comment update policy
|
||||
|
||||
- D2L refresh updates cached grade/comment fields for mapped assignments
|
||||
- if a submission has a Paperless document ID and grade/comment changed, append note once per distinct change hash
|
||||
- never overwrite user notes in Paperless; append a dated Schoolhouse block
|
||||
|
||||
## Recording pipeline contract
|
||||
|
||||
### Input limits
|
||||
|
||||
- accept WAV up to at least 1 GB
|
||||
- support up to 90 minutes
|
||||
- reject unsupported formats with clear message
|
||||
|
||||
### Processing steps
|
||||
|
||||
1. store upload metadata row
|
||||
2. convert WAV → MP3
|
||||
3. delete WAV only after MP3 success
|
||||
4. submit MP3 to recording workflow
|
||||
5. receive transcript/summary callback
|
||||
6. archive transcript + summary into Paperless
|
||||
|
||||
## Secrets/config
|
||||
|
||||
Required env:
|
||||
|
||||
- `SCHOOLHOUSE_HOST`
|
||||
- `SCHOOLHOUSE_PORT`
|
||||
- `DATABASE_URL`
|
||||
- `PAPERLESS_BASE_URL`
|
||||
- `PAPERLESS_API_TOKEN`
|
||||
- `N8N_BASE_URL`
|
||||
- `SCHOOLHOUSE_N8N_ASSIGNMENT_WEBHOOK`
|
||||
- `SCHOOLHOUSE_N8N_RECORDING_WEBHOOK`
|
||||
- `D2L_SCRAPER_WORKDIR`
|
||||
- `D2L_SCRAPER_COMMAND`
|
||||
- `UPLOAD_TMP_DIR`
|
||||
- `MAX_UPLOAD_MB`
|
||||
- `SESSION_SECRET`
|
||||
|
||||
Do not commit live values. Use `.env.example` only.
|
||||
|
||||
## Repo / deployment plan
|
||||
|
||||
Repo source path:
|
||||
|
||||
- `home/doris-schoolhouse`
|
||||
|
||||
Expected live NOMAD runtime path later:
|
||||
|
||||
- `/opt/doris-schoolhouse`
|
||||
|
||||
Expected live data path later:
|
||||
|
||||
- `/opt/doris-schoolhouse/data`
|
||||
|
||||
Expected compose validation before deploy:
|
||||
|
||||
```bash
|
||||
cd /opt/doris-schoolhouse
|
||||
docker compose --env-file .env config
|
||||
```
|
||||
|
||||
## Build phases
|
||||
|
||||
### Phase 1
|
||||
|
||||
- app scaffold
|
||||
- schema
|
||||
- D2L import command/service
|
||||
- assignment upload UI
|
||||
- Paperless confirmation flow
|
||||
|
||||
### Phase 2
|
||||
|
||||
- recording upload flow
|
||||
- MP3 conversion
|
||||
- recording job tracking
|
||||
- transcript/summary callbacks
|
||||
|
||||
### Phase 3
|
||||
|
||||
- grade sync
|
||||
- Paperless note append logic
|
||||
- dashboard views
|
||||
|
||||
### Phase 4
|
||||
|
||||
- manual mappings UI
|
||||
- recurring assignment templates
|
||||
- mobile cleanup
|
||||
|
||||
## Verification checklist
|
||||
|
||||
- Python modules compile
|
||||
- schema file present and readable
|
||||
- compose file parses
|
||||
- route map loads without import failure
|
||||
- README explains runtime/deploy expectations
|
||||
- no secrets committed
|
||||
@@ -34,9 +34,9 @@ See the individual docs in this repo for detailed information:
|
||||
### Three Servers
|
||||
| Server | Role | OS | IP |
|
||||
|--------|------|----|----|
|
||||
| Serenity | NAS / Media ingestion | Unraid 7.2.4 | 10.5.1.5 |
|
||||
| PlausibleDeniability | Primary Docker host | TrueNAS Scale 25.10.2.1 | LAN DHCP |
|
||||
| N.O.M.A.D. | Offline knowledge + game servers | Ubuntu 25.10 | 10.5.1.16 |
|
||||
| Serenity | NAS / Media ingestion | Unraid 7.2.4 | 10.5.30.5 |
|
||||
| PlausibleDeniability | Primary Docker host | TrueNAS Scale 25.10.2.1 | 10.5.30.6 |
|
||||
| N.O.M.A.D. | Offline knowledge + game servers | Ubuntu 25.10 | 10.5.30.7 |
|
||||
|
||||
### Deployment Pattern (PD)
|
||||
```bash
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
# Meshtastic Stack
|
||||
|
||||
## 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: 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
|
||||
`/mnt/docker-ssd/docker/compose/meshtastic/`
|
||||
@@ -10,29 +17,43 @@ Self-hosted Meshtastic monitoring and management stack running on PlausibleDenia
|
||||
|
||||
| Container | Image | Port | Purpose |
|
||||
|-----------|-------|------|---------|
|
||||
| meshmonitor | ghcr.io/yeraze/meshmonitor:4.2.0 | 8081→3001 | Main UI + backend |
|
||||
| meshmonitor (virtual node) | — | 4404 | TCP virtual node server |
|
||||
| meshmonitor-tileserver | maptiler/tileserver-gl-light | 8082→8080 | Offline map tiles |
|
||||
| meshmonitor-mqtt-proxy | ghcr.io/ln4cy/mqtt-proxy:master | — | MQTT→TCP bridge |
|
||||
| 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 exposed directly by the meshmonitor container |
|
||||
| meshmonitor (embedded MQTT broker) | — | 1883 | Embedded Aedes-based MQTT broker exposed directly by the meshmonitor container |
|
||||
| meshmonitor-tileserver | maptiler/tileserver-gl-light:latest | 8082→8080 | Offline map tiles |
|
||||
| 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
|
||||
```
|
||||
Meshtastic Node (10.5.1.172)
|
||||
↓ MQTT
|
||||
meshmonitor-mqtt-proxy
|
||||
↓ TCP (port 4404)
|
||||
meshmonitor virtual node server
|
||||
↓
|
||||
meshmonitor backend
|
||||
Trusted-LAN mesh node (10.5.1.120)
|
||||
↓ direct node connection configured by MESHTASTIC_NODE_IP
|
||||
meshmonitor backend on PD
|
||||
↓
|
||||
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 IP: `10.5.1.172`
|
||||
- Connection type: MQTT via mqtt-proxy (not direct TCP)
|
||||
- mqtt-proxy connects to meshmonitor's virtual node server on port 4404
|
||||
- PD MeshMonitor compose is currently configured with `MESHTASTIC_NODE_IP=10.5.1.120`
|
||||
- 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
|
||||
- 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
|
||||
- Appdata: `/mnt/tank/docker/appdata/meshmonitor/data` → `/data`
|
||||
@@ -58,7 +79,7 @@ mkdir -p /mnt/docker-ssd/docker/compose/meshtastic/scripts
|
||||
|
||||
## Known Warnings
|
||||
- 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
|
||||
Map tiles stored in `./tiles/`. Currently includes `zurich_switzerland.mbtiles` (34MB).
|
||||
@@ -66,5 +87,4 @@ Tiles directory is gitignored. To add new tiles, download `.mbtiles` files and p
|
||||
|
||||
## TODO
|
||||
- [ ] Document all running meshtastic-map container config
|
||||
- [ ] Document meshtastic-map-mosquitto and meshtastic-mqtt containers
|
||||
- [ ] Add healthcheck to meshmonitor container
|
||||
|
||||
47
docs/reference/SCHOOL_INTAKE_POSTGRES_SCHEMA.sql
Normal file
47
docs/reference/SCHOOL_INTAKE_POSTGRES_SCHEMA.sql
Normal file
@@ -0,0 +1,47 @@
|
||||
-- Shared Postgres schema for Telegram-driven school-work intake.
|
||||
-- Apply in a dedicated `school_intake` database or adapt to an existing shared DB.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS school_paperless_intake (
|
||||
intake_id TEXT PRIMARY KEY,
|
||||
class_name TEXT NOT NULL,
|
||||
assignment_name TEXT NOT NULL,
|
||||
submission_kind TEXT NOT NULL,
|
||||
semester TEXT,
|
||||
course_code TEXT,
|
||||
paper_kind TEXT,
|
||||
notes TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'telegram',
|
||||
telegram_chat_id TEXT,
|
||||
telegram_message_id TEXT,
|
||||
original_filename TEXT NOT NULL,
|
||||
stored_filename TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
file_size_bytes BIGINT NOT NULL,
|
||||
checksum_sha256 TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'received',
|
||||
paperless_task_id TEXT,
|
||||
paperless_document_id BIGINT,
|
||||
paperless_title TEXT,
|
||||
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
uploaded_at TIMESTAMPTZ,
|
||||
enriched_at TIMESTAMPTZ,
|
||||
error_text TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_school_paperless_intake_status ON school_paperless_intake(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_school_paperless_intake_doc_id ON school_paperless_intake(paperless_document_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_school_paperless_intake_assignment ON school_paperless_intake(class_name, assignment_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_school_paperless_intake_checksum ON school_paperless_intake(checksum_sha256);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS school_paperless_intake_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
intake_id TEXT NOT NULL REFERENCES school_paperless_intake(intake_id) ON DELETE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
event_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_school_paperless_intake_events_intake_id
|
||||
ON school_paperless_intake_events(intake_id);
|
||||
@@ -4,7 +4,7 @@ Offline survival/knowledge server and game server host.
|
||||
|
||||
## Specs
|
||||
- **OS:** Ubuntu 25.10 (bare metal)
|
||||
- **IP:** 10.5.1.16
|
||||
- **IP:** 10.5.30.7
|
||||
- **CPU:** Intel Core i7-4770K @ 3.50GHz (4c/8t)
|
||||
- **RAM:** 32GB DDR3 1600MHz
|
||||
- **GPU:** NVIDIA GeForce GTX 1080 (8GB VRAM)
|
||||
@@ -12,6 +12,11 @@ Offline survival/knowledge server and game server host.
|
||||
- **Motherboard:** ASRock H97M Pro4
|
||||
- **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
|
||||
| Device | Size | Mount | Purpose |
|
||||
|--------|------|-------|---------|
|
||||
@@ -21,10 +26,261 @@ Offline survival/knowledge server and game server host.
|
||||
| Seagate SSD (sdd) | 223.6GB | `/mnt/ssd-1` | Fast scratch |
|
||||
|
||||
## Project N.O.M.A.D.
|
||||
- 10 containers running via `start_nomad.sh` / `stop_nomad.sh`
|
||||
- `/opt/project-nomad` → symlinked from `/mnt/hdd-1/project-nomad`
|
||||
- Core compose project lives at `/opt/project-nomad` → symlinked from `/mnt/hdd-1/project-nomad`
|
||||
- Main compose file: `/mnt/hdd-1/project-nomad/compose.yml`
|
||||
- Docker data-root: `/mnt/hdd-2/docker`
|
||||
- Based on Crosstalk Solutions offline survival/knowledge server
|
||||
- Current runtime is split between:
|
||||
- the base `project-nomad` compose stack
|
||||
- `project-nomad-managed` app containers launched/managed by the N.O.M.A.D. admin layer
|
||||
- a few extra non-compose / non-NOMAD containers on the host
|
||||
|
||||
## Docker Audit — 2026-05-14
|
||||
|
||||
### Compose files found on NOMAD
|
||||
- `/mnt/hdd-1/project-nomad/compose.yml` (same project as `/opt/project-nomad/compose.yml` via symlink)
|
||||
- `/opt/newt/docker-compose.yaml`
|
||||
|
||||
### Running containers tied directly to compose files
|
||||
**`project-nomad` compose (`/mnt/hdd-1/project-nomad/compose.yml`)**
|
||||
- `nomad_admin`
|
||||
- `nomad_mysql`
|
||||
- `nomad_redis`
|
||||
- `nomad_dozzle`
|
||||
- `nomad_disk_collector`
|
||||
- `nomad_updater`
|
||||
|
||||
**`newt` compose (`/opt/newt/docker-compose.yaml`)**
|
||||
- `newt`
|
||||
|
||||
### Running containers managed by Project N.O.M.A.D. itself
|
||||
These are labeled `com.docker.compose.project=project-nomad-managed` and `io.project-nomad.managed=true`, but no standalone compose files for them were found on disk during this audit.
|
||||
- `nomad_cyberchef`
|
||||
- `nomad_flatnotes`
|
||||
- `nomad_kiwix_server`
|
||||
- `nomad_kolibri`
|
||||
- `nomad_ollama`
|
||||
- `nomad_qdrant`
|
||||
|
||||
**Operator decision (John, 2026-05-14):** this layer stays exactly as-is. It is self-maintained by Project N.O.M.A.D. Document it, but **never manually touch, rebuild, migrate, or “clean up” these containers** unless John explicitly overrides that rule.
|
||||
|
||||
Known storage paths discovered from mounts / project storage:
|
||||
- Flatnotes data: `/opt/project-nomad/storage/flatnotes`
|
||||
- Kiwix ZIM library: `/opt/project-nomad/storage/zim`
|
||||
- Kolibri data: `/opt/project-nomad/storage/kolibri`
|
||||
- Ollama data/models: `/opt/project-nomad/storage/ollama`
|
||||
- Qdrant storage: `/opt/project-nomad/storage/qdrant`
|
||||
|
||||
### Running containers not tied to discovered compose files
|
||||
**Likely host-managed / manually launched / app-managed elsewhere**
|
||||
|
||||
#### `whisper` — `fedirz/faster-whisper-server:latest-cpu`
|
||||
**Operator decision (John, 2026-05-14):** this can go away. Do not preserve it as a required service, but keep enough detail here to rebuild it if removing it causes issues.
|
||||
- Purpose: ad-hoc Faster Whisper API endpoint
|
||||
- Network mode: `bridge`
|
||||
- Container IP during audit: `172.17.0.2`
|
||||
- Port publish: host `8786/tcp` → container `8000/tcp` (IPv4 and IPv6)
|
||||
- Restart policy: `unless-stopped`
|
||||
- Entrypoint: image default
|
||||
- Command:
|
||||
- `uv run uvicorn --factory faster_whisper_server.main:create_app`
|
||||
- Environment:
|
||||
- `WHISPER__MODEL=Systran/faster-distil-whisper-small.en`
|
||||
- `WHISPER__INFERENCE_DEVICE=cpu`
|
||||
- `UVICORN_HOST=0.0.0.0`
|
||||
- `UVICORN_PORT=8000`
|
||||
- Mounts: none
|
||||
- Compose labels: none found
|
||||
- Rebuild-equivalent `docker run`:
|
||||
```bash
|
||||
docker run -d \
|
||||
--name whisper \
|
||||
--restart unless-stopped \
|
||||
-p 8786:8000 \
|
||||
-e WHISPER__MODEL=Systran/faster-distil-whisper-small.en \
|
||||
-e WHISPER__INFERENCE_DEVICE=cpu \
|
||||
-e UVICORN_HOST=0.0.0.0 \
|
||||
-e UVICORN_PORT=8000 \
|
||||
fedirz/faster-whisper-server:latest-cpu \
|
||||
uv run uvicorn --factory faster_whisper_server.main:create_app
|
||||
```
|
||||
|
||||
#### `node-exporter` — `prom/node-exporter:v1.9.0`
|
||||
**Operator decision (John, 2026-05-14):** this can go away. Do not preserve it as a required service, but keep enough detail here to rebuild it if removing it causes issues.
|
||||
- Purpose: host Prometheus metrics exporter
|
||||
- Network mode: `host`
|
||||
- Restart policy: `unless-stopped`
|
||||
- Entrypoint: `/bin/node_exporter`
|
||||
- Command:
|
||||
- `--path.procfs=/host/proc`
|
||||
- `--path.sysfs=/host/sys`
|
||||
- `--path.rootfs=/rootfs`
|
||||
- `--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)`
|
||||
- Read-only bind mounts:
|
||||
- `/proc` → `/host/proc`
|
||||
- `/sys` → `/host/sys`
|
||||
- `/` → `/rootfs`
|
||||
- Compose labels: none found
|
||||
- Rebuild-equivalent `docker run`:
|
||||
```bash
|
||||
docker run -d \
|
||||
--name node-exporter \
|
||||
--restart unless-stopped \
|
||||
--network host \
|
||||
-v /proc:/host/proc:ro \
|
||||
-v /sys:/host/sys:ro \
|
||||
-v /:/rootfs:ro \
|
||||
prom/node-exporter:v1.9.0 \
|
||||
--path.procfs=/host/proc \
|
||||
--path.sysfs=/host/sys \
|
||||
--path.rootfs=/rootfs \
|
||||
'--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
|
||||
```
|
||||
|
||||
**Pelican/Wings-managed game containers**
|
||||
**Operator decision (John, 2026-05-14):** this is the game server stack. It stays. Document it; do not remove or migrate it unless John explicitly says so.
|
||||
|
||||
- `11d799cb-487a-416a-b6f9-9bf28f0e57e7`
|
||||
- Image: `ghcr.io/pelican-eggs/yolks:java_25`
|
||||
- Network: `pelican_nw` (`172.20.0.2` during audit)
|
||||
- Ports: `25565/tcp` and `25565/udp` exposed on host `0.0.0.0`
|
||||
- Restart policy: `no` (Wings owns lifecycle)
|
||||
- Entrypoint: `/usr/bin/tini -g --`
|
||||
- Command: `/entrypoint.sh`
|
||||
- Important env:
|
||||
- `SERVER_IP=0.0.0.0`
|
||||
- `SERVER_PORT=25565`
|
||||
- `SERVER_JARFILE=server.jar`
|
||||
- `STARTUP=java --add-modules=jdk.incubator.vector -Xms128M -XX:MaxRAMPercentage=95.0 -Dterminal.jline=false -Dterminal.ansi=true -jar server.jar`
|
||||
- `P_SERVER_UUID=11d799cb-487a-416a-b6f9-9bf28f0e57e7`
|
||||
- Mounts:
|
||||
- `/var/lib/pelican/volumes/11d799cb-487a-416a-b6f9-9bf28f0e57e7` → `/home/container` (rw)
|
||||
- `/etc/pelican/passwd` → `/etc/passwd` (ro)
|
||||
- `/etc/pelican/group` → `/etc/group` (ro)
|
||||
- `/etc/pelican/machine-id/11d799cb-487a-416a-b6f9-9bf28f0e57e7` → `/etc/machine-id` (ro)
|
||||
|
||||
- `d2aca31e-0d4d-433a-8295-cfad397dab0c`
|
||||
- Image: `ghcr.io/pelican-eggs/yolks:java_21`
|
||||
- Network: `pelican_nw` (`172.20.0.3` during audit)
|
||||
- Ports: `25566/tcp` and `25566/udp` exposed on host `0.0.0.0`
|
||||
- Restart policy: `no` (Wings owns lifecycle)
|
||||
- Entrypoint: `/usr/bin/tini -g --`
|
||||
- Command: `/entrypoint.sh`
|
||||
- Important env:
|
||||
- `SERVER_IP=0.0.0.0`
|
||||
- `SERVER_PORT=25566`
|
||||
- `SERVER_MEMORY=8192`
|
||||
- `MC_VERSION=1.21.1`
|
||||
- `NEOFORGE_VERSION=21.1.229`
|
||||
- `STARTUP=java -Xms128M -XX:MaxRAMPercentage=95.0 -Dterminal.jline=false -Dterminal.ansi=true @unix_args.txt`
|
||||
- `P_SERVER_UUID=d2aca31e-0d4d-433a-8295-cfad397dab0c`
|
||||
- Mounts:
|
||||
- `/var/lib/pelican/volumes/d2aca31e-0d4d-433a-8295-cfad397dab0c` → `/home/container` (rw)
|
||||
- `/etc/pelican/passwd` → `/etc/passwd` (ro)
|
||||
- `/etc/pelican/group` → `/etc/group` (ro)
|
||||
- `/etc/pelican/machine-id/d2aca31e-0d4d-433a-8295-cfad397dab0c` → `/etc/machine-id` (ro)
|
||||
|
||||
These are expected to be managed by Wings/Pelican rather than a local compose file.
|
||||
|
||||
### Dormant compose files found but not currently represented in running containers
|
||||
- None found during this audit in searched paths.
|
||||
- Search scope: `/opt`, `/srv`, `/home/fizzlepoof`, and `/mnt` for `docker-compose.yml`, `docker-compose.yaml`, `compose.yml`, and `compose.yaml`.
|
||||
|
||||
### Decisions captured from this audit
|
||||
- Project N.O.M.A.D.-managed app containers stay exactly as-is and are **hands-off** unless John explicitly says otherwise.
|
||||
- `whisper` and `node-exporter` are not protected services; they can be removed, but their exact runtime has been documented above for rebuild/recovery.
|
||||
- Pelican/Wings-managed game containers are part of the permanent game-server stack on NOMAD and stay in place.
|
||||
- Doris Dashboard source belongs in the homelab repo, and the live NOMAD runtime belongs at `/opt/doris-dashboard`.
|
||||
|
||||
### Doris Dashboard (local NOMAD homepage)
|
||||
- Live runtime path: `/opt/doris-dashboard`
|
||||
- Repo source path: `/home/fizzlepoof/repos/truenas-stacks/home/doris-dashboard`
|
||||
- Purpose: local-only dashboard for briefing/news/weather/homelab pulse/Paperless review on NOMAD
|
||||
- Generator: `home/doris-dashboard/bin/generate_dashboard.py`
|
||||
- Interactive LAN server: `home/doris-dashboard/bin/dashboard_server.py`
|
||||
- Service file source: `home/doris-dashboard/doris-dashboard.service`
|
||||
- Runtime notes:
|
||||
- Generated output and queue snapshots are runtime data; source/config lives in git, generated JSON/HTML does not need to be treated as canonical.
|
||||
- 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.
|
||||
|
||||
### Technitium Backup Resolver
|
||||
- Live stack path: `/opt/technitium-nomad`
|
||||
- Live secrets file: `/opt/technitium-nomad/.env`
|
||||
- Resolver bind IP: `10.5.30.9`
|
||||
- Role: active DHCP-advertised backup Technitium resolver for the homelab
|
||||
- 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-nomad.env`
|
||||
- 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
|
||||
|
||||
### LocalSend Trusted inbox
|
||||
- Live stack path: `/opt/localsend-nomad`
|
||||
- Repo source path: `/home/fizzlepoof/repos/truenas-stacks/localsend-nomad`
|
||||
- Trusted bind IP: `10.5.1.16`
|
||||
- Advertised device name: `Doris Trusted Inbox`
|
||||
- Role: headless LocalSend receiver for Trusted-LAN handoff into `/home/fizzlepoof/private/inbox-secrets`
|
||||
- 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`)
|
||||
- LocalSend listener ports: `53317/tcp`, `53317/udp`
|
||||
- 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
|
||||
- Inbox watcher for Minerva staging: `minerva-localsend-autosort.path`
|
||||
- Sorter service: `minerva-localsend-autosort.service`
|
||||
- Sorter script: `/home/fizzlepoof/.local/bin/minerva-localsend-autosort.py`
|
||||
|
||||
### MeshCore companion observer
|
||||
- Current runtime: `meshcore-capture.service` (systemd)
|
||||
- Install root: `/home/fizzlepoof/.meshcore-packet-capture`
|
||||
- Service unit: `/etc/systemd/system/meshcore-capture.service`
|
||||
- Runtime command: `/home/fizzlepoof/.meshcore-packet-capture/venv/bin/python3 /home/fizzlepoof/.meshcore-packet-capture/packet_capture.py`
|
||||
- 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`
|
||||
- Observer role:
|
||||
- NOMAD now uses the LetsMesh companion observer flow instead of the legacy `mctomqtt` relay
|
||||
- The companion process connects to the Heltec locally, signs JWTs on-device, and publishes packet capture data upstream to MQTT brokers
|
||||
- Service health observed on 2026-07-03:
|
||||
- `systemctl status meshcore-capture` shows the service active/running
|
||||
- Logs confirm successful serial connection to device name `Peachtree Mesh Monitor`
|
||||
- Logs confirm MQTT connectivity to LetsMesh US, LetsMesh EU, and NashMe
|
||||
- After removing the temporary PD embedded-broker test target, startup logs now show `Connected to 3 MQTT broker(s)`
|
||||
- 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:
|
||||
- Related mesh stack notes live in [MESHTASTIC.md](../reference/MESHTASTIC.md)
|
||||
|
||||
### Live deployment path rule
|
||||
- On NOMAD, live service roots belong under `/opt/<service>`.
|
||||
- That includes host-run services **and** standalone Docker Compose deployments that are not part of the core Project N.O.M.A.D. stack.
|
||||
- Keep service-local runtime data with the service under `/opt/<service>/data` unless there is a clear reason to place heavy state elsewhere.
|
||||
- Examples: `/opt/newt`, `/opt/doris-dashboard`, `/opt/pihole-nomad`.
|
||||
- Do not run production services from any agent workspace or ad-hoc checkout under `/home/fizzlepoof/.openclaw/workspace`.
|
||||
|
||||
### Safe to remove / how to restore
|
||||
#### Safe to remove now
|
||||
- `whisper`
|
||||
- `node-exporter`
|
||||
|
||||
Removing them should not be treated as a Project N.O.M.A.D. change or a Wings/Pelican change. They are standalone host-level containers from this audit’s point of view.
|
||||
|
||||
#### Restore checklist if removal causes trouble
|
||||
1. Confirm what broke:
|
||||
- speech/transcription endpoint dependency → restore `whisper`
|
||||
- Prometheus scraping / host metrics gap → restore `node-exporter`
|
||||
2. Recreate the container using the documented `docker run` command in this file.
|
||||
3. Verify it came back cleanly:
|
||||
- `docker ps --filter name=whisper`
|
||||
- `docker ps --filter name=node-exporter`
|
||||
- `docker logs --tail 50 whisper`
|
||||
- `docker logs --tail 50 node-exporter`
|
||||
4. Re-check any dependent dashboards, scrapers, or automations.
|
||||
5. If the recreated container needs to persist long-term again, document *why* before leaving it in place.
|
||||
|
||||
#### Restore intent
|
||||
- Restore `whisper` only if something still expects a local Faster Whisper HTTP endpoint on port `8786`.
|
||||
- Restore `node-exporter` only if something still depends on host Prometheus metrics from this box.
|
||||
- Do **not** fold either one into Project N.O.M.A.D. or Wings/Pelican casually; if they come back, they come back as deliberate standalone services unless John says otherwise.
|
||||
|
||||
## Pelican Panel (Game Server Management)
|
||||
- **URL:** `https://panel.paccoco.com`
|
||||
@@ -49,12 +305,22 @@ Offline survival/knowledge server and game server host.
|
||||
- **Docker network:** `pelican_nw` (172.20.0.0/16)
|
||||
|
||||
## Active Game Servers
|
||||
- **Minecraft:** Purpur, Java 21, port 25565 TCP/UDP (direct host port forward)
|
||||
- **Container `11d799cb-487a-416a-b6f9-9bf28f0e57e7`**
|
||||
- Workload: Minecraft / Purpur-style Java server
|
||||
- Java runtime image: `ghcr.io/pelican-eggs/yolks:java_25`
|
||||
- Public port: `25565` TCP/UDP
|
||||
- Data path: `/var/lib/pelican/volumes/11d799cb-487a-416a-b6f9-9bf28f0e57e7`
|
||||
- **Container `d2aca31e-0d4d-433a-8295-cfad397dab0c`**
|
||||
- Workload: modded Minecraft-style server (NeoForge-related env present)
|
||||
- Java runtime image: `ghcr.io/pelican-eggs/yolks:java_21`
|
||||
- Public port: `25566` TCP/UDP
|
||||
- Data path: `/var/lib/pelican/volumes/d2aca31e-0d4d-433a-8295-cfad397dab0c`
|
||||
|
||||
## Networking Notes
|
||||
- `panel.paccoco.com` → `127.0.0.1` in `/etc/hosts` for NAT loopback
|
||||
- nginx on 80 and 443 (self-signed cert) for local Wings → Panel
|
||||
- Wings uses `--ignore-certificate-errors` for self-signed cert
|
||||
- For local operator-safe Pelican health checks, use `http://10.5.30.7:8080` instead of the self-signed TLS path; it redirects cleanly to `/home` without tripping certificate validation.
|
||||
- Pangolin resource `node1.paccoco.com` has auth disabled
|
||||
|
||||
## Known Quirks
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
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
|
||||
- **OS:** TrueNAS Scale 25.10.2.1
|
||||
- **CPU:** Ryzen 9 3950X
|
||||
@@ -31,22 +33,35 @@ Primary Docker host running all production workloads.
|
||||
RTX 2080 Ti used by:
|
||||
- Plex (hardware transcoding)
|
||||
- immich-ml (CUDA face recognition)
|
||||
- Ollama light-tier inference (qwen2.5:14b and smaller)
|
||||
- Ollama light-tier inference for the main AI stack and local Honcho backend
|
||||
|
||||
> **Note:** Whisper moved to N.O.M.A.D. (10.5.1.16:8786) as CPU inference — avoids VRAM conflict with Ollama. See [NOMAD.md](NOMAD.md).
|
||||
> **Note:** Whisper moved to N.O.M.A.D. (10.5.30.7:8786) as CPU inference — avoids VRAM conflict with Ollama. See [NOMAD.md](NOMAD.md).
|
||||
|
||||
Recommended models for 11GB VRAM:
|
||||
| Model | VRAM | Best for |
|
||||
|-------|------|---------|
|
||||
| `qwen2.5:14b` | ~8GB | General use, coding, reasoning |
|
||||
| `qwen2.5:7b` | ~5GB | Cheap/default reasoning, summaries, derivation |
|
||||
| `qwen2.5:14b` | ~8GB | Heavier reasoning / high-max paths |
|
||||
| `qwen2.5-coder:7b` | ~5GB | Code tasks |
|
||||
| `deepseek-r1:8b` | ~5GB | Math, logic |
|
||||
| `phi4` | ~8GB | Analytical tasks |
|
||||
|
||||
Current light-tier notes:
|
||||
- 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 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 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
|
||||
- Pangolin VPS + Newt for external services
|
||||
- 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)
|
||||
- Serenity `/mnt/user/data` → `/mnt/unraid/data` (media libraries)
|
||||
- Serenity `/mnt/user/immich` → `/mnt/unraid/immich` (Immich photo library)
|
||||
@@ -55,6 +70,8 @@ Recommended models for 11GB VRAM:
|
||||
> ```bash
|
||||
> 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
|
||||
- immich-ml intermittently unhealthy — investigate GPU/CUDA health
|
||||
@@ -64,4 +81,12 @@ Recommended models for 11GB VRAM:
|
||||
## Pending
|
||||
- Add Pangolin resource + DNS for `ai.paccoco.com`
|
||||
- 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
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
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
|
||||
|
||||
| Component | Details |
|
||||
|-----------|---------|
|
||||
| **IP** | 10.5.1.112 |
|
||||
| **IP** | 10.5.30.112 |
|
||||
| **GPU** | NVIDIA RTX 4090 (24 GB VRAM) |
|
||||
| **Role** | Heavy AI inference (Ollama) |
|
||||
|
||||
@@ -22,8 +24,15 @@ Ollama runs on port `11434` and hosts the large-model ("heavy") tier.
|
||||
| `qwen2.5:32b` | Large-context tasks |
|
||||
| `gemma3:27b` | Multimodal / vision |
|
||||
|
||||
> **LiteLLM tier:** Rocinante is the **"heavy"** tier in LiteLLM `config.yaml`.
|
||||
> Heavy-tier requests from OpenWebUI and n8n route to `http://10.5.1.112:11434`.
|
||||
> **LiteLLM tier:** Rocinante may serve as an **opportunistic heavy/manual tier** when the PC is online.
|
||||
> 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
|
||||
|
||||
- Rocinante is John's personal PC and is not a dependable always-on homelab tier.
|
||||
- Treat Ollama/Whisper on Rocinante as opportunistic capacity when the machine is online, not as a required background dependency.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# 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
|
||||
|
||||
| Component | Details |
|
||||
|-----------|---------|
|
||||
| **IP (LAN)** | 10.5.1.5 |
|
||||
| **IP (LAN)** | 10.5.30.5 |
|
||||
| **IP (Tailscale)** | 100.94.87.79 |
|
||||
| **OS** | Unraid 7.2.4 |
|
||||
| **CPU** | Dual Intel Xeon X5690 (6c/12t each — 24 threads total) |
|
||||
@@ -18,6 +18,7 @@ NAS, media ingestion, and CPU-based AI inference.
|
||||
- **NAS** — primary storage for Docker volumes, media, and backups via NFS
|
||||
- **Media ingestion** — full ARR stack (Sonarr, Radarr, Prowlarr, qBittorrent, etc.)
|
||||
- **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
|
||||
|
||||
@@ -32,6 +33,8 @@ NAS, media ingestion, and CPU-based AI inference.
|
||||
> ```bash
|
||||
> 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)
|
||||
|
||||
@@ -42,18 +45,41 @@ Text Embeddings Inference reranker for OpenWebUI RAG pipeline.
|
||||
| **Model** | `BAAI/bge-reranker-v2-m3` |
|
||||
| **Image** | `ghcr.io/huggingface/text-embeddings-inference:cpu-latest` |
|
||||
| **Port** | `9787` |
|
||||
| **Volume** | `/mnt/user/appdata/reranker` |
|
||||
| **Volume** | `/mnt/user/appdate/reranker` |
|
||||
| **LiteLLM model name** | `reranker` |
|
||||
| **API base** | `http://10.5.1.5:9787` |
|
||||
| **API base** | `http://10.5.30.5:9787` |
|
||||
|
||||
> Moved from PlausibleDeniability (port 8787) to Serenity (port 9787) on 2026-05-09.
|
||||
> 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
|
||||
|
||||
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
|
||||
|
||||
- No GPU; reranker uses CPU-only TEI image
|
||||
- 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.
|
||||
|
||||
@@ -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
|
||||
- Requires `/mnt/tank/docker/appdata/donetick/config/selfhosted.yaml` with full DB config
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
@@ -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
|
||||
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@ PAPERLESS_DBUSER=paperless
|
||||
PAPERLESS_DBPASS=changeme
|
||||
PAPERLESS_SECRET_KEY=generate_with_openssl_rand_hex_32
|
||||
PAPERLESS_REDIS=redis://:URL_ENCODED_REDIS_PASSWORD@shared-redis:6379
|
||||
PAPERLESS_TIKA_ENABLED=1
|
||||
PAPERLESS_TIKA_ENDPOINT=http://tika:9998
|
||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT=http://gotenberg:3000
|
||||
|
||||
# -----------------------------
|
||||
# Karakeep
|
||||
@@ -15,7 +18,7 @@ KARAKEEP_DB_WAL_MODE=true
|
||||
KARAKEEP_LOG_LEVEL=notice
|
||||
|
||||
# Optional local AI later:
|
||||
# KARAKEEP_OLLAMA_BASE_URL=http://10.5.1.6:11434
|
||||
# KARAKEEP_OLLAMA_BASE_URL=http://10.5.30.6:11434
|
||||
# KARAKEEP_INFERENCE_TEXT_MODEL=llama3.2
|
||||
# KARAKEEP_INFERENCE_ENABLE_AUTO_TAGGING=true
|
||||
# KARAKEEP_INFERENCE_ENABLE_AUTO_SUMMARIZATION=false
|
||||
|
||||
39
documents/README.md
Normal file
39
documents/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Documents Stack
|
||||
|
||||
Docker Compose stack for document and knowledge-capture services on PD.
|
||||
|
||||
## Paperless-NGX
|
||||
|
||||
- URL: `https://paperless.paccoco.com` / `http://pd:8083`
|
||||
- Compose service: `paperless`
|
||||
- Appdata: `/mnt/docker-ssd/docker/appdata/paperless/`
|
||||
- Consume folder: `/mnt/unraid/data/paperless/consume` → `/usr/src/paperless/consume`
|
||||
- Database: shared Postgres on `ix-databases_shared-databases`
|
||||
- Redis: shared Redis via `PAPERLESS_REDIS`
|
||||
|
||||
Paperless has internal-only Tika and Gotenberg sidecars for Office/OpenDocument ingestion:
|
||||
|
||||
- `tika` (`apache/tika`) at `http://tika:9998`, memory limit `512m`
|
||||
- `gotenberg` (`gotenberg/gotenberg`) at `http://gotenberg:3000`, memory limit `256m`
|
||||
- Both sidecars live only on `documents_paperless_internal` and expose no host ports.
|
||||
|
||||
Required Paperless Tika env keys:
|
||||
|
||||
```env
|
||||
PAPERLESS_TIKA_ENABLED=1
|
||||
PAPERLESS_TIKA_ENDPOINT=http://tika:9998
|
||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT=http://gotenberg:3000
|
||||
```
|
||||
|
||||
Supported additional ingestion types include `.docx`, `.doc`, `.pptx`, `.xlsx`, and OpenDocument formats (`.odt`, `.ods`, `.odp`) in addition to the existing PDF/image OCR path.
|
||||
|
||||
## Deploy
|
||||
|
||||
From `/mnt/docker-ssd/docker/compose/documents` on PD:
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env config
|
||||
docker compose --env-file .env down && docker compose --env-file .env up -d
|
||||
```
|
||||
|
||||
Do not restart Docker on PD; use stack-level compose commands only.
|
||||
@@ -2,7 +2,7 @@ name: documents
|
||||
|
||||
services:
|
||||
paperless:
|
||||
image: ghcr.io/paperless-ngx/paperless-ngx:latest
|
||||
image: ghcr.io/paperless-ngx/paperless-ngx:2.16
|
||||
container_name: paperless
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -24,6 +24,12 @@ services:
|
||||
PAPERLESS_TIME_ZONE: America/Chicago
|
||||
PAPERLESS_OCR_LANGUAGE: eng
|
||||
PAPERLESS_URL: https://paperless.paccoco.com
|
||||
PAPERLESS_TIKA_ENABLED: ${PAPERLESS_TIKA_ENABLED}
|
||||
PAPERLESS_TIKA_ENDPOINT: ${PAPERLESS_TIKA_ENDPOINT}
|
||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: ${PAPERLESS_TIKA_GOTENBERG_ENDPOINT}
|
||||
depends_on:
|
||||
- tika
|
||||
- gotenberg
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8000/ || exit 1"]
|
||||
interval: 30s
|
||||
@@ -31,9 +37,26 @@ services:
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
networks:
|
||||
- paperless-internal
|
||||
- ix-databases_shared-databases
|
||||
- pangolin
|
||||
|
||||
tika:
|
||||
image: apache/tika:3.1.0.0
|
||||
container_name: paperless-tika
|
||||
restart: unless-stopped
|
||||
mem_limit: 512m
|
||||
networks:
|
||||
- paperless-internal
|
||||
|
||||
gotenberg:
|
||||
image: gotenberg/gotenberg:8.17
|
||||
container_name: paperless-gotenberg
|
||||
restart: unless-stopped
|
||||
mem_limit: 256m
|
||||
networks:
|
||||
- paperless-internal
|
||||
|
||||
karakeep:
|
||||
image: ghcr.io/karakeep-app/karakeep:${KARAKEEP_VERSION:-release}
|
||||
container_name: karakeep
|
||||
@@ -50,7 +73,9 @@ services:
|
||||
NEXTAUTH_SECRET: ${KARAKEEP_NEXTAUTH_SECRET}
|
||||
MEILI_ADDR: http://karakeep-meilisearch:7700
|
||||
MEILI_MASTER_KEY: ${KARAKEEP_MEILI_MASTER_KEY}
|
||||
BROWSER_WEB_URL: http://karakeep-chrome:9222
|
||||
# Chromium remote debugging now rejects non-local hostname Host headers here,
|
||||
# so Karakeep must reach Chrome over a stable bridge IP rather than service DNS.
|
||||
BROWSER_WEB_URL: http://172.16.16.3:9222
|
||||
DISABLE_SIGNUPS: ${KARAKEEP_DISABLE_SIGNUPS}
|
||||
DB_WAL_MODE: ${KARAKEEP_DB_WAL_MODE}
|
||||
LOG_LEVEL: ${KARAKEEP_LOG_LEVEL}
|
||||
@@ -79,7 +104,8 @@ services:
|
||||
- --remote-debugging-port=9222
|
||||
- --hide-scrollbars
|
||||
networks:
|
||||
- karakeep
|
||||
karakeep:
|
||||
ipv4_address: 172.16.16.3
|
||||
|
||||
karakeep-meilisearch:
|
||||
image: getmeili/meilisearch:v1.41.0
|
||||
@@ -95,9 +121,18 @@ services:
|
||||
- karakeep
|
||||
|
||||
networks:
|
||||
paperless-internal:
|
||||
name: documents_paperless_internal
|
||||
ix-databases_shared-databases:
|
||||
external: true
|
||||
pangolin:
|
||||
external: true
|
||||
karakeep:
|
||||
name: documents_karakeep
|
||||
# Pin the subnet so karakeep-chrome keeps the IP Karakeep uses for DevTools.
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.16.16.0/24
|
||||
gateway: 172.16.16.1
|
||||
- subnet: fdd0:0:0:10::/64
|
||||
gateway: fdd0:0:0:10::1
|
||||
|
||||
13
headscale/.env.example
Normal file
13
headscale/.env.example
Normal 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
317
headscale/README.md
Normal 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.
|
||||
28
headscale/config/headplane/config.yaml
Normal file
28
headscale/config/headplane/config.yaml
Normal 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
|
||||
99
headscale/config/headscale/config.yaml
Normal file
99
headscale/config/headscale/config.yaml
Normal 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
|
||||
49
headscale/config/headscale/policy.hujson
Normal file
49
headscale/config/headscale/policy.hujson
Normal 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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
55
headscale/docker-compose.yaml
Normal file
55
headscale/docker-compose.yaml
Normal 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
|
||||
26
headscale/examples/traefik-routes.yml
Normal file
26
headscale/examples/traefik-routes.yml
Normal 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
25
home/donetick/README.md
Normal 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.
|
||||
48
home/donetick/selfhosted.yaml.example
Normal file
48
home/donetick/selfhosted.yaml.example
Normal 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
|
||||
5
home/doris-barbell/.env.example
Normal file
5
home/doris-barbell/.env.example
Normal file
@@ -0,0 +1,5 @@
|
||||
TZ=America/Chicago
|
||||
BARBELL_HOST=0.0.0.0
|
||||
BARBELL_PORT=8093
|
||||
BARBELL_STATE_DIR=/data/state
|
||||
BARBELL_SHARED_PASSWORD=
|
||||
6
home/doris-barbell/.gitignore
vendored
Normal file
6
home/doris-barbell/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
.env
|
||||
.venv/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
/data/
|
||||
*.pyc
|
||||
19
home/doris-barbell/Dockerfile
Normal file
19
home/doris-barbell/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app ./app
|
||||
|
||||
EXPOSE 8093
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8093"]
|
||||
115
home/doris-barbell/README.md
Normal file
115
home/doris-barbell/README.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Doris Barbell
|
||||
|
||||
Private-first exercise tracking app for John, with a clean path to add Manndra later.
|
||||
|
||||
## Purpose
|
||||
|
||||
Doris Barbell gives John one place to track:
|
||||
|
||||
- workout sessions
|
||||
- structured exercise templates and routines
|
||||
- sets with reps, weight, notes, rest, and RPE
|
||||
- body weight and BMI over time
|
||||
- broader body metrics in imperial units
|
||||
- progress trends and simple PR tracking
|
||||
|
||||
## Runtime shape
|
||||
|
||||
- **Repo source:** `home/doris-barbell`
|
||||
- **Expected live PD runtime:** `/mnt/docker-ssd/docker/compose/doris-barbell`
|
||||
- **Expected first exposure:** private-first on PD, with public routing optional later
|
||||
- **Published app port:** `8093`
|
||||
|
||||
Do not run production from an agent workspace.
|
||||
|
||||
## V1 defaults in flight
|
||||
|
||||
Unless John overrides them later, V1 is being built with these defaults:
|
||||
|
||||
- mobile-friendly but usable on desktop
|
||||
- private-first deployment on PD
|
||||
- John is the active user
|
||||
- Manndra is stubbed as a second household member for future expansion
|
||||
- JSON-backed state first for speed of delivery
|
||||
- imperial measurement system only
|
||||
- no progress photos
|
||||
- no muscle-size tracking focus
|
||||
- structured workout logging first, richer planning/reporting layered in after
|
||||
|
||||
## Current scaffold
|
||||
|
||||
The current scaffold already supports:
|
||||
|
||||
- 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
|
||||
- weight logging with BMI calculation
|
||||
- imperial body measurements for waist, hips, chest, and neck
|
||||
- exercise template creation/listing
|
||||
- routine creation/listing on top of John's imported base split
|
||||
- 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
|
||||
- exercise progression summaries with max weight and estimated 1RM per exercise
|
||||
- mobile-friendly homepage with HTML forms for:
|
||||
- weight logging
|
||||
- body-measurement logging
|
||||
- exercise template creation
|
||||
- routine draft creation
|
||||
- quick workout logging
|
||||
- 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.
|
||||
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
|
||||
|
||||
- `GET /api/health`
|
||||
- `GET /api/dashboard/summary`
|
||||
- `PUT /api/profile/{user_id}`
|
||||
- `POST /api/profile/{user_id}/weight`
|
||||
- `GET /api/history/{user_id}/weights`
|
||||
- `POST /api/profile/{user_id}/measurements`
|
||||
- `GET /api/history/{user_id}/measurements`
|
||||
- `POST /api/exercises/templates`
|
||||
- `GET /api/exercises/templates`
|
||||
- `POST /api/routines`
|
||||
- `GET /api/routines`
|
||||
- `POST /api/workouts`
|
||||
- `GET /api/history/{user_id}/workouts`
|
||||
- `GET /api/progression/{user_id}/exercises`
|
||||
- `POST /log/weight`
|
||||
- `POST /log/measurements`
|
||||
- `POST /templates`
|
||||
- `POST /routines`
|
||||
- `POST /workouts/quick-log`
|
||||
|
||||
## Local run
|
||||
|
||||
```bash
|
||||
cd /home/fizzlepoof/repos/truenas-stacks/home/doris-barbell
|
||||
python3 -m venv .venv
|
||||
. .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --reload --port 8093
|
||||
```
|
||||
|
||||
Or:
|
||||
|
||||
```bash
|
||||
cd /home/fizzlepoof/repos/truenas-stacks/home/doris-barbell
|
||||
bin/run-local.sh
|
||||
```
|
||||
|
||||
## Verification targets
|
||||
|
||||
```bash
|
||||
curl -fsS http://127.0.0.1:8093/api/health
|
||||
curl -fsS http://127.0.0.1:8093/api/dashboard/summary
|
||||
```
|
||||
0
home/doris-barbell/app/__init__.py
Normal file
0
home/doris-barbell/app/__init__.py
Normal file
12
home/doris-barbell/app/config.py
Normal file
12
home/doris-barbell/app/config.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Settings:
|
||||
app_name: str = 'Doris Barbell'
|
||||
default_state_dir: Path = Path(os.environ.get('BARBELL_STATE_DIR', '/tmp/doris-barbell-state'))
|
||||
|
||||
28
home/doris-barbell/app/main.py
Normal file
28
home/doris-barbell/app/main.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.config import Settings
|
||||
from app.routes.api import router as api_router
|
||||
from app.routes.ui import router as ui_router
|
||||
from app.services.store import JSONStore
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def create_app(state_dir: Path | str | None = None) -> FastAPI:
|
||||
settings = Settings()
|
||||
app = FastAPI(title=settings.app_name)
|
||||
resolved_state_dir = Path(state_dir) if state_dir is not None else settings.default_state_dir
|
||||
app.state.store = JSONStore(resolved_state_dir)
|
||||
app.mount('/static', StaticFiles(directory=BASE_DIR / 'static'), name='static')
|
||||
app.include_router(ui_router)
|
||||
app.include_router(api_router)
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
84
home/doris-barbell/app/models.py
Normal file
84
home/doris-barbell/app/models.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
class ProfileUpdate(BaseModel):
|
||||
display_name: str | None = None
|
||||
height_inches: float | None = Field(default=None, gt=0)
|
||||
goal_weight_lbs: float | None = Field(default=None, gt=0)
|
||||
measurement_system: Literal['imperial'] | None = 'imperial'
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class WeightEntryCreate(BaseModel):
|
||||
recorded_at: str
|
||||
weight_lbs: float = Field(gt=0)
|
||||
body_fat_percent: float | None = Field(default=None, ge=0, le=100)
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class MeasurementEntryCreate(BaseModel):
|
||||
recorded_at: str
|
||||
waist_inches: float | None = Field(default=None, gt=0)
|
||||
hip_inches: float | None = Field(default=None, gt=0)
|
||||
chest_inches: float | None = Field(default=None, gt=0)
|
||||
neck_inches: float | None = Field(default=None, gt=0)
|
||||
notes: str | None = None
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_at_least_one_measurement(self) -> 'MeasurementEntryCreate':
|
||||
if not any(
|
||||
value is not None
|
||||
for value in [self.waist_inches, self.hip_inches, self.chest_inches, self.neck_inches]
|
||||
):
|
||||
raise ValueError('At least one body measurement is required.')
|
||||
return self
|
||||
|
||||
|
||||
class ExerciseTemplateCreate(BaseModel):
|
||||
name: str
|
||||
primary_muscle: str | None = None
|
||||
equipment: str | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class RoutineExerciseCreate(BaseModel):
|
||||
exercise_name: str
|
||||
target_sets: int = Field(ge=1)
|
||||
target_reps: str
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class RoutineCreate(BaseModel):
|
||||
user_id: str
|
||||
name: str
|
||||
notes: str | None = None
|
||||
exercises: list[RoutineExerciseCreate]
|
||||
|
||||
|
||||
class WorkoutSetCreate(BaseModel):
|
||||
reps: int = Field(ge=1)
|
||||
weight_lbs: float | None = Field(default=None, ge=0)
|
||||
rpe: float | None = Field(default=None, ge=0)
|
||||
rest_seconds: int | None = Field(default=None, ge=0)
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class WorkoutExerciseCreate(BaseModel):
|
||||
exercise_name: str
|
||||
notes: str | None = None
|
||||
sets: list[WorkoutSetCreate]
|
||||
|
||||
|
||||
class WorkoutCreate(BaseModel):
|
||||
user_id: str
|
||||
performed_at: str
|
||||
routine_name: str
|
||||
notes: str | None = None
|
||||
exercises: list[WorkoutExerciseCreate]
|
||||
|
||||
|
||||
JSONDict = dict[str, Any]
|
||||
111
home/doris-barbell/app/routes/api.py
Normal file
111
home/doris-barbell/app/routes/api.py
Normal file
@@ -0,0 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.models import (
|
||||
ExerciseTemplateCreate,
|
||||
MeasurementEntryCreate,
|
||||
ProfileUpdate,
|
||||
RoutineCreate,
|
||||
WeightEntryCreate,
|
||||
WorkoutCreate,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix='/api')
|
||||
|
||||
|
||||
@router.get('/health')
|
||||
def health() -> dict[str, str]:
|
||||
return {'status': 'ok'}
|
||||
|
||||
|
||||
@router.get('/dashboard/summary')
|
||||
def dashboard_summary(request: Request) -> dict:
|
||||
return request.app.state.store.dashboard_summary()
|
||||
|
||||
|
||||
@router.put('/profile/{user_id}')
|
||||
def update_profile(user_id: str, payload: ProfileUpdate, request: Request) -> dict:
|
||||
try:
|
||||
return request.app.state.store.update_profile(user_id, payload.model_dump())
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail='Unknown user') from exc
|
||||
|
||||
|
||||
@router.post('/profile/{user_id}/weight', status_code=201)
|
||||
def add_weight_entry(user_id: str, payload: WeightEntryCreate, request: Request) -> dict:
|
||||
try:
|
||||
return request.app.state.store.add_weight_entry(user_id, payload.model_dump())
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail='Unknown user') from exc
|
||||
|
||||
|
||||
@router.get('/history/{user_id}/weights')
|
||||
def weight_history(user_id: str, request: Request) -> list[dict]:
|
||||
try:
|
||||
return request.app.state.store.weight_history(user_id)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail='Unknown user') from exc
|
||||
|
||||
|
||||
@router.post('/profile/{user_id}/measurements', status_code=201)
|
||||
def add_measurement_entry(user_id: str, payload: MeasurementEntryCreate, request: Request) -> dict:
|
||||
try:
|
||||
return request.app.state.store.add_measurement_entry(user_id, payload.model_dump())
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail='Unknown user') from exc
|
||||
|
||||
|
||||
@router.get('/history/{user_id}/measurements')
|
||||
def measurement_history(user_id: str, request: Request) -> list[dict]:
|
||||
try:
|
||||
return request.app.state.store.measurement_history(user_id)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail='Unknown user') from exc
|
||||
|
||||
|
||||
@router.post('/exercises/templates', status_code=201)
|
||||
def create_exercise_template(payload: ExerciseTemplateCreate, request: Request) -> dict:
|
||||
return request.app.state.store.add_exercise_template(payload.model_dump())
|
||||
|
||||
|
||||
@router.get('/exercises/templates')
|
||||
def list_exercise_templates(request: Request) -> list[dict]:
|
||||
return request.app.state.store.list_exercise_templates()
|
||||
|
||||
|
||||
@router.post('/routines', status_code=201)
|
||||
def create_routine(payload: RoutineCreate, request: Request) -> dict:
|
||||
try:
|
||||
return request.app.state.store.add_routine(payload.model_dump())
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail='Unknown user') from exc
|
||||
|
||||
|
||||
@router.get('/routines')
|
||||
def list_routines(request: Request) -> list[dict]:
|
||||
return request.app.state.store.list_routines()
|
||||
|
||||
|
||||
@router.post('/workouts', status_code=201)
|
||||
def add_workout(payload: WorkoutCreate, request: Request) -> dict:
|
||||
try:
|
||||
return request.app.state.store.add_workout(payload.model_dump())
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail='Unknown user') from exc
|
||||
|
||||
|
||||
@router.get('/history/{user_id}/workouts')
|
||||
def workout_history(user_id: str, request: Request) -> list[dict]:
|
||||
try:
|
||||
return request.app.state.store.workout_history(user_id)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail='Unknown user') from exc
|
||||
|
||||
|
||||
@router.get('/progression/{user_id}/exercises')
|
||||
def exercise_progression(user_id: str, request: Request) -> list[dict]:
|
||||
try:
|
||||
return request.app.state.store.exercise_progression(user_id)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail='Unknown user') from exc
|
||||
582
home/doris-barbell/app/routes/ui.py
Normal file
582
home/doris-barbell/app/routes/ui.py
Normal file
@@ -0,0 +1,582 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Form, HTTPException, Request, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory='app/templates')
|
||||
|
||||
|
||||
@router.get('/')
|
||||
def home(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name='dashboard.html',
|
||||
context=_dashboard_context(request),
|
||||
)
|
||||
|
||||
|
||||
@router.post('/log/weight', status_code=303)
|
||||
def log_weight(
|
||||
request: Request,
|
||||
user_id: str = Form(...),
|
||||
recorded_at: str = Form(...),
|
||||
weight_lbs: float = Form(...),
|
||||
body_fat_percent: str = Form(''),
|
||||
notes: str = Form(''),
|
||||
):
|
||||
payload = {
|
||||
'recorded_at': recorded_at,
|
||||
'weight_lbs': weight_lbs,
|
||||
'body_fat_percent': _optional_float(body_fat_percent),
|
||||
'notes': notes.strip() or None,
|
||||
}
|
||||
request.app.state.store.add_weight_entry(user_id, payload)
|
||||
return RedirectResponse(url='/', status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post('/log/measurements', status_code=303)
|
||||
def log_measurements(
|
||||
request: Request,
|
||||
user_id: str = Form(...),
|
||||
recorded_at: str = Form(...),
|
||||
waist_inches: str = Form(''),
|
||||
hip_inches: str = Form(''),
|
||||
chest_inches: str = Form(''),
|
||||
neck_inches: str = Form(''),
|
||||
notes: str = Form(''),
|
||||
):
|
||||
payload = {
|
||||
'recorded_at': recorded_at,
|
||||
'waist_inches': _optional_float(waist_inches),
|
||||
'hip_inches': _optional_float(hip_inches),
|
||||
'chest_inches': _optional_float(chest_inches),
|
||||
'neck_inches': _optional_float(neck_inches),
|
||||
'notes': notes.strip() or None,
|
||||
}
|
||||
if not any(
|
||||
payload[key] is not None
|
||||
for key in ('waist_inches', 'hip_inches', 'chest_inches', 'neck_inches')
|
||||
):
|
||||
raise HTTPException(status_code=400, detail='At least one body measurement is required.')
|
||||
request.app.state.store.add_measurement_entry(user_id, payload)
|
||||
return RedirectResponse(url='/', status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post('/templates', status_code=303)
|
||||
def create_template(
|
||||
request: Request,
|
||||
name: str = Form(...),
|
||||
primary_muscle: str = Form(''),
|
||||
equipment: str = Form(''),
|
||||
notes: str = Form(''),
|
||||
):
|
||||
request.app.state.store.add_exercise_template(
|
||||
{
|
||||
'name': name.strip(),
|
||||
'primary_muscle': primary_muscle.strip() or None,
|
||||
'equipment': equipment.strip() or None,
|
||||
'notes': notes.strip() or None,
|
||||
}
|
||||
)
|
||||
return RedirectResponse(url='/', status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post('/routines', status_code=303)
|
||||
def create_routine(
|
||||
request: Request,
|
||||
user_id: str = Form(...),
|
||||
name: str = Form(...),
|
||||
exercise_lines: str = Form(...),
|
||||
notes: str = Form(''),
|
||||
):
|
||||
payload = {
|
||||
'user_id': user_id,
|
||||
'name': name.strip(),
|
||||
'notes': notes.strip() or None,
|
||||
'exercises': _parse_routine_exercises(exercise_lines),
|
||||
}
|
||||
request.app.state.store.add_routine(payload)
|
||||
return RedirectResponse(url='/', status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post('/workouts/quick-log', status_code=303)
|
||||
def quick_log_workout(
|
||||
request: Request,
|
||||
user_id: str = Form(...),
|
||||
performed_at: str = Form(...),
|
||||
routine_name: str = Form(...),
|
||||
exercise_lines: str = Form(...),
|
||||
notes: str = Form(''),
|
||||
):
|
||||
payload = {
|
||||
'user_id': user_id,
|
||||
'performed_at': performed_at,
|
||||
'routine_name': routine_name.strip(),
|
||||
'notes': notes.strip() or None,
|
||||
'exercises': _parse_workout_exercises(exercise_lines),
|
||||
}
|
||||
request.app.state.store.add_workout(payload)
|
||||
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:
|
||||
store = request.app.state.store
|
||||
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 {
|
||||
'title': 'Doris Barbell',
|
||||
'summary': summary,
|
||||
'john_weight_history': store.weight_history('john')[:5],
|
||||
'john_measurement_history': store.measurement_history('john')[:5],
|
||||
'john_workout_history': store.workout_history('john')[:5],
|
||||
'john_progression': store.exercise_progression('john')[:5],
|
||||
}
|
||||
|
||||
|
||||
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:
|
||||
cleaned = value.strip()
|
||||
if not cleaned:
|
||||
return None
|
||||
try:
|
||||
return float(cleaned)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=f'Invalid numeric value: {value}') from exc
|
||||
|
||||
|
||||
def _parse_routine_exercises(raw_lines: str) -> list[dict]:
|
||||
exercises = []
|
||||
for line in raw_lines.splitlines():
|
||||
cleaned = line.strip()
|
||||
if not cleaned:
|
||||
continue
|
||||
parts = [part.strip() for part in cleaned.split('|')]
|
||||
if len(parts) < 3:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail='Routine lines must use exercise|sets|reps|optional notes format.',
|
||||
)
|
||||
exercise_name, target_sets_raw, target_reps = parts[:3]
|
||||
try:
|
||||
target_sets = int(target_sets_raw)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=f'Invalid target sets: {target_sets_raw}') from exc
|
||||
exercises.append(
|
||||
{
|
||||
'exercise_name': exercise_name,
|
||||
'target_sets': target_sets,
|
||||
'target_reps': target_reps,
|
||||
'notes': parts[3] if len(parts) > 3 and parts[3] else None,
|
||||
}
|
||||
)
|
||||
if not exercises:
|
||||
raise HTTPException(status_code=400, detail='At least one routine exercise is required.')
|
||||
return exercises
|
||||
|
||||
|
||||
def _parse_workout_exercises(raw_lines: str) -> list[dict]:
|
||||
exercises = []
|
||||
for line in raw_lines.splitlines():
|
||||
cleaned = line.strip()
|
||||
if not cleaned:
|
||||
continue
|
||||
parts = [part.strip() for part in cleaned.split('|')]
|
||||
if len(parts) < 2:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail='Workout lines must use exercise|reps@weight, reps@weight|optional notes format.',
|
||||
)
|
||||
exercise_name = parts[0]
|
||||
notes = parts[2] if len(parts) > 2 and parts[2] else None
|
||||
sets = []
|
||||
for set_chunk in parts[1].split(','):
|
||||
token = set_chunk.strip()
|
||||
if not token:
|
||||
continue
|
||||
if '@' not in token:
|
||||
raise HTTPException(status_code=400, detail=f'Invalid set token: {token}')
|
||||
reps_raw, weight_raw = [value.strip() for value in token.split('@', 1)]
|
||||
try:
|
||||
reps = int(reps_raw)
|
||||
weight_lbs = float(weight_raw)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=f'Invalid set token: {token}') from exc
|
||||
sets.append({'reps': reps, 'weight_lbs': weight_lbs})
|
||||
if not sets:
|
||||
raise HTTPException(status_code=400, detail='Each workout exercise needs at least one set.')
|
||||
exercises.append({'exercise_name': exercise_name, 'notes': notes, 'sets': sets})
|
||||
if not exercises:
|
||||
raise HTTPException(status_code=400, detail='At least one workout exercise is required.')
|
||||
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
|
||||
613
home/doris-barbell/app/services/store.py
Normal file
613
home/doris-barbell/app/services/store.py
Normal file
@@ -0,0 +1,613 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
DEFAULT_USERS = {
|
||||
'john': {
|
||||
'id': 'john',
|
||||
'display_name': 'John',
|
||||
'is_stub': False,
|
||||
'measurement_system': 'imperial',
|
||||
'height_inches': 68.5,
|
||||
'goal_weight_lbs': None,
|
||||
'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.',
|
||||
},
|
||||
}
|
||||
|
||||
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': [],
|
||||
'measurement_entries': [],
|
||||
'workout_sessions': [],
|
||||
'active_workout_sessions': [],
|
||||
}
|
||||
|
||||
LEGACY_TEMPLATE_ALIASES = {
|
||||
'resistance band punch-out holds': 'Resistance Band Punch-Out Training',
|
||||
}
|
||||
|
||||
|
||||
class JSONStore:
|
||||
def __init__(self, state_dir: Path) -> None:
|
||||
self.state_dir = Path(state_dir)
|
||||
self.state_path = self.state_dir / 'state.json'
|
||||
self.state_dir.mkdir(parents=True, exist_ok=True)
|
||||
if not self.state_path.exists():
|
||||
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]:
|
||||
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:
|
||||
self.state_path.write_text(json.dumps(payload, indent=2, sort_keys=True))
|
||||
|
||||
def update_profile(self, user_id: str, changes: dict[str, Any]) -> dict[str, Any]:
|
||||
state = self._read()
|
||||
user = state['users'].get(user_id)
|
||||
if user is None:
|
||||
raise KeyError(user_id)
|
||||
for key, value in changes.items():
|
||||
if value is not None:
|
||||
user[key] = value
|
||||
state['users'][user_id] = user
|
||||
self._write(state)
|
||||
return user
|
||||
|
||||
def add_weight_entry(self, user_id: str, entry: dict[str, Any]) -> dict[str, Any]:
|
||||
state = self._read()
|
||||
user = self._user_or_raise(state, user_id)
|
||||
saved = {
|
||||
'id': uuid4().hex,
|
||||
'user_id': user_id,
|
||||
'recorded_at': entry['recorded_at'],
|
||||
'weight_lbs': entry['weight_lbs'],
|
||||
'body_fat_percent': entry.get('body_fat_percent'),
|
||||
'notes': entry.get('notes'),
|
||||
'bmi': _calculate_bmi(entry['weight_lbs'], user.get('height_inches')),
|
||||
}
|
||||
state['weight_entries'].append(saved)
|
||||
self._write(state)
|
||||
return saved
|
||||
|
||||
def weight_history(self, user_id: str) -> list[dict[str, Any]]:
|
||||
state = self._read()
|
||||
self._user_or_raise(state, user_id)
|
||||
return _history_for_user(state['weight_entries'], user_id, 'recorded_at')
|
||||
|
||||
def add_measurement_entry(self, user_id: str, entry: dict[str, Any]) -> dict[str, Any]:
|
||||
state = self._read()
|
||||
self._user_or_raise(state, user_id)
|
||||
saved = {
|
||||
'id': uuid4().hex,
|
||||
'user_id': user_id,
|
||||
'recorded_at': entry['recorded_at'],
|
||||
'waist_inches': entry.get('waist_inches'),
|
||||
'hip_inches': entry.get('hip_inches'),
|
||||
'chest_inches': entry.get('chest_inches'),
|
||||
'neck_inches': entry.get('neck_inches'),
|
||||
'notes': entry.get('notes'),
|
||||
}
|
||||
state['measurement_entries'].append(saved)
|
||||
self._write(state)
|
||||
return saved
|
||||
|
||||
def measurement_history(self, user_id: str) -> list[dict[str, Any]]:
|
||||
state = self._read()
|
||||
self._user_or_raise(state, user_id)
|
||||
return _history_for_user(state['measurement_entries'], user_id, 'recorded_at')
|
||||
|
||||
def add_exercise_template(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
state = self._read()
|
||||
saved = {
|
||||
'id': uuid4().hex,
|
||||
'name': payload['name'],
|
||||
'primary_muscle': payload.get('primary_muscle'),
|
||||
'equipment': payload.get('equipment'),
|
||||
'notes': payload.get('notes'),
|
||||
}
|
||||
state['exercise_templates'].append(saved)
|
||||
self._write(state)
|
||||
return saved
|
||||
|
||||
def list_exercise_templates(self) -> list[dict[str, Any]]:
|
||||
state = self._read()
|
||||
return sorted(state['exercise_templates'], key=lambda item: item['name'].lower())
|
||||
|
||||
def add_routine(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
state = self._read()
|
||||
self._user_or_raise(state, payload['user_id'])
|
||||
saved = {
|
||||
'id': uuid4().hex,
|
||||
'user_id': payload['user_id'],
|
||||
'name': payload['name'],
|
||||
'notes': payload.get('notes'),
|
||||
'exercises': [dict(exercise) for exercise in payload['exercises']],
|
||||
}
|
||||
state['routines'].append(saved)
|
||||
self._write(state)
|
||||
return saved
|
||||
|
||||
def list_routines(self) -> list[dict[str, Any]]:
|
||||
state = self._read()
|
||||
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]:
|
||||
state = self._read()
|
||||
self._user_or_raise(state, payload['user_id'])
|
||||
|
||||
set_count = 0
|
||||
total_volume_lbs = 0.0
|
||||
exercises: list[dict[str, Any]] = []
|
||||
for exercise in payload['exercises']:
|
||||
normalized_sets = []
|
||||
for item in exercise['sets']:
|
||||
set_count += 1
|
||||
weight_lbs = item.get('weight_lbs')
|
||||
if weight_lbs is not None:
|
||||
total_volume_lbs += item['reps'] * weight_lbs
|
||||
normalized_sets.append(dict(item))
|
||||
exercises.append({
|
||||
'exercise_name': exercise['exercise_name'],
|
||||
'notes': exercise.get('notes'),
|
||||
'sets': normalized_sets,
|
||||
})
|
||||
|
||||
saved = {
|
||||
'id': uuid4().hex,
|
||||
'user_id': payload['user_id'],
|
||||
'performed_at': payload['performed_at'],
|
||||
'routine_name': payload['routine_name'],
|
||||
'notes': payload.get('notes'),
|
||||
'exercise_count': len(exercises),
|
||||
'set_count': set_count,
|
||||
'total_volume_lbs': int(total_volume_lbs) if total_volume_lbs.is_integer() else total_volume_lbs,
|
||||
'exercises': exercises,
|
||||
}
|
||||
state['workout_sessions'].append(saved)
|
||||
self._write(state)
|
||||
return saved
|
||||
|
||||
def workout_history(self, user_id: str) -> list[dict[str, Any]]:
|
||||
state = self._read()
|
||||
self._user_or_raise(state, user_id)
|
||||
return _history_for_user(state['workout_sessions'], user_id, 'performed_at')
|
||||
|
||||
def exercise_progression(self, user_id: str) -> list[dict[str, Any]]:
|
||||
state = self._read()
|
||||
self._user_or_raise(state, user_id)
|
||||
progression: dict[str, dict[str, Any]] = {}
|
||||
for workout in _history_for_user(state['workout_sessions'], user_id, 'performed_at'):
|
||||
for exercise in workout['exercises']:
|
||||
name = exercise['exercise_name']
|
||||
stats = progression.setdefault(
|
||||
name,
|
||||
{
|
||||
'exercise_name': name,
|
||||
'sessions': 0,
|
||||
'set_count': 0,
|
||||
'max_weight_lbs': 0.0,
|
||||
'best_set_estimated_1rm': 0.0,
|
||||
'last_performed_at': workout['performed_at'],
|
||||
},
|
||||
)
|
||||
stats['sessions'] += 1
|
||||
if workout['performed_at'] > stats['last_performed_at']:
|
||||
stats['last_performed_at'] = workout['performed_at']
|
||||
for item in exercise['sets']:
|
||||
stats['set_count'] += 1
|
||||
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'],
|
||||
_estimated_1rm(weight_lbs, item['reps']),
|
||||
)
|
||||
results = []
|
||||
for item in progression.values():
|
||||
results.append(
|
||||
{
|
||||
**item,
|
||||
'max_weight_lbs': _normalize_number(item['max_weight_lbs']),
|
||||
'best_set_estimated_1rm': _normalize_number(item['best_set_estimated_1rm']),
|
||||
}
|
||||
)
|
||||
return sorted(results, key=lambda item: item['exercise_name'].lower())
|
||||
|
||||
def dashboard_summary(self) -> dict[str, Any]:
|
||||
state = self._read()
|
||||
users = []
|
||||
weight_entries = state['weight_entries']
|
||||
measurement_entries = state['measurement_entries']
|
||||
for user in state['users'].values():
|
||||
latest_weight = _latest_entry_for_user(weight_entries, user['id'])
|
||||
latest_measurements = _latest_entry_for_user(measurement_entries, user['id'])
|
||||
users.append({
|
||||
'id': user['id'],
|
||||
'display_name': user['display_name'],
|
||||
'is_stub': user['is_stub'],
|
||||
'measurement_system': user.get('measurement_system', 'imperial'),
|
||||
'height_inches': user['height_inches'],
|
||||
'goal_weight_lbs': user['goal_weight_lbs'],
|
||||
'current_weight_lbs': latest_weight['weight_lbs'] if latest_weight else None,
|
||||
'current_bmi': latest_weight['bmi'] if latest_weight else None,
|
||||
'last_weight_recorded_at': latest_weight['recorded_at'] if latest_weight else None,
|
||||
'latest_measurements': _trim_measurement_entry(latest_measurements),
|
||||
})
|
||||
|
||||
recent_workouts = sorted(
|
||||
state['workout_sessions'],
|
||||
key=lambda item: item['performed_at'],
|
||||
reverse=True,
|
||||
)[:5]
|
||||
recent_templates = list(reversed(state['exercise_templates'][-5:]))
|
||||
routine_board = _routine_board(state['routines'])
|
||||
return {
|
||||
'users': users,
|
||||
'recent_workouts': recent_workouts,
|
||||
'exercise_templates': recent_templates,
|
||||
'routines': routine_board,
|
||||
'counts': {
|
||||
'exercise_templates': len(state['exercise_templates']),
|
||||
'measurement_entries': len(state['measurement_entries']),
|
||||
'routines': len(state['routines']),
|
||||
'weight_entries': len(state['weight_entries']),
|
||||
'workout_sessions': len(state['workout_sessions']),
|
||||
},
|
||||
}
|
||||
|
||||
def _user_or_raise(self, state: dict[str, Any], user_id: str) -> dict[str, Any]:
|
||||
user = state['users'].get(user_id)
|
||||
if user is None:
|
||||
raise KeyError(user_id)
|
||||
return user
|
||||
|
||||
|
||||
def _latest_entry_for_user(entries: list[dict[str, Any]], user_id: str) -> dict[str, Any] | None:
|
||||
matches = [entry for entry in entries if entry['user_id'] == user_id]
|
||||
if not matches:
|
||||
return None
|
||||
return max(matches, key=lambda item: item['recorded_at'])
|
||||
|
||||
|
||||
def _history_for_user(entries: list[dict[str, Any]], user_id: str, sort_key: str) -> list[dict[str, Any]]:
|
||||
matches = [entry for entry in entries if entry['user_id'] == user_id]
|
||||
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:
|
||||
if entry is None:
|
||||
return None
|
||||
return {
|
||||
'recorded_at': entry['recorded_at'],
|
||||
'waist_inches': entry['waist_inches'],
|
||||
'hip_inches': entry['hip_inches'],
|
||||
'chest_inches': entry['chest_inches'],
|
||||
'neck_inches': entry['neck_inches'],
|
||||
}
|
||||
|
||||
|
||||
def _normalize_number(value: float) -> int | float:
|
||||
return int(value) if float(value).is_integer() else round(value, 1)
|
||||
|
||||
|
||||
def _estimated_1rm(weight_lbs: float, reps: int) -> float:
|
||||
return weight_lbs * (1 + reps / 30)
|
||||
|
||||
|
||||
def _calculate_bmi(weight_lbs: float, height_inches: float | None) -> float | None:
|
||||
if not height_inches:
|
||||
return None
|
||||
return 703 * weight_lbs / (height_inches * height_inches)
|
||||
610
home/doris-barbell/app/static/app.css
Normal file
610
home/doris-barbell/app/static/app.css
Normal file
@@ -0,0 +1,610 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--hf-night: #090b10;
|
||||
--hf-panel: rgba(17, 21, 29, 0.94);
|
||||
--hf-panel-raise: rgba(25, 31, 43, 0.96);
|
||||
--hf-paper: #f2efdd;
|
||||
--hf-muted: #cbbda7;
|
||||
--hf-line: rgba(242, 239, 221, 0.12);
|
||||
--hf-shadow: 0 20px 48px rgba(0, 0, 0, 0.34);
|
||||
--hf-red: #d53600;
|
||||
--hf-red-deep: #910f3f;
|
||||
--hf-amber: #f2c14e;
|
||||
--hf-blue: #7b8fb2;
|
||||
--bg: #090b10;
|
||||
--panel: var(--hf-panel);
|
||||
--panel-border: var(--hf-line);
|
||||
--text: var(--hf-paper);
|
||||
--muted: var(--hf-muted);
|
||||
--accent: var(--hf-red);
|
||||
--accent-2: var(--hf-amber);
|
||||
--field: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Inter, system-ui, sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(213, 54, 0, 0.22), transparent 22rem),
|
||||
radial-gradient(circle at top right, rgba(123, 143, 178, 0.16), transparent 20rem),
|
||||
linear-gradient(180deg, #07090d 0%, #0d1017 52%, #131824 100%);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.incident-tape {
|
||||
height: 14px;
|
||||
background: repeating-linear-gradient(135deg, var(--hf-amber) 0 18px, #111 18px 36px);
|
||||
box-shadow: 0 3px 14px rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
|
||||
.page-header,
|
||||
.page-shell {
|
||||
width: min(1100px, calc(100% - 2rem));
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.casefile-header {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: 1.25rem 0 1rem;
|
||||
}
|
||||
|
||||
.brand-row,
|
||||
.casefile-title-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.nav-brand { display: grid; gap: 3px; }
|
||||
.nav-kicker,
|
||||
.eyebrow {
|
||||
margin: 0 0 0.45rem;
|
||||
color: var(--hf-amber);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.nav-links { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.nav-link,
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
padding: 0.55rem 0.85rem;
|
||||
border: 1px solid var(--panel-border);
|
||||
text-decoration: none;
|
||||
background: rgba(255,255,255,.04);
|
||||
color: var(--text);
|
||||
font-size: .88rem;
|
||||
}
|
||||
.nav-link:hover { border-color: rgba(242, 193, 78, 0.58); background: rgba(242, 193, 78, 0.12); }
|
||||
.brand-badges { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.hot-fuzz-art {
|
||||
position: relative;
|
||||
flex: 0 1 320px;
|
||||
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 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
filter: drop-shadow(0 24px 30px rgba(0, 0, 0, 0.35));
|
||||
}
|
||||
.poster-frame { fill: rgba(10, 12, 18, 0.88); stroke: rgba(242, 239, 221, 0.2); stroke-width: 2; }
|
||||
.poster-halo { fill: url(#barbell-sunset); opacity: 0.9; }
|
||||
.poster-tape { fill: rgba(242, 193, 78, 0.88); stroke: rgba(17, 17, 17, 0.8); stroke-width: 2; }
|
||||
.constable-silhouette { fill: rgba(8, 10, 15, 0.92); stroke: rgba(242, 239, 221, 0.12); stroke-width: 1.5; }
|
||||
.constable-silhouette.partner { fill: rgba(24, 30, 42, 0.94); }
|
||||
.swan-stamp { fill: rgba(123, 143, 178, 0.2); stroke: rgba(242, 239, 221, 0.42); stroke-width: 2; }
|
||||
.swan-mark { fill: rgba(242, 239, 221, 0.94); }
|
||||
.poster-callout { fill: rgba(242, 239, 221, 0.82); font-size: 18px; font-weight: 800; letter-spacing: 0.24em; text-anchor: middle; }
|
||||
.recipe-card,
|
||||
.folder-body,
|
||||
.folder-tab,
|
||||
.barbell-bar,
|
||||
.plate-outer,
|
||||
.plate-inner { fill: rgba(242, 239, 221, 0.9); }
|
||||
.plate-inner { fill: rgba(10, 12, 18, 0.72); }
|
||||
.film-grain,
|
||||
.cinematic-glow {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
border-radius: 28px;
|
||||
}
|
||||
.film-grain {
|
||||
background: repeating-linear-gradient(0deg, rgba(255,255,255,0.025) 0 2px, transparent 2px 5px), repeating-linear-gradient(90deg, rgba(255,255,255,0.018) 0 1px, transparent 1px 4px);
|
||||
mix-blend-mode: soft-light;
|
||||
opacity: 0.35;
|
||||
}
|
||||
.cinematic-glow {
|
||||
background: radial-gradient(circle at 16% 18%, rgba(123, 143, 178, 0.22), transparent 24%), radial-gradient(circle at 84% 24%, rgba(213, 54, 0, 0.24), transparent 26%), linear-gradient(115deg, rgba(242, 193, 78, 0.05), transparent 40%, rgba(145, 15, 63, 0.1) 78%, transparent 100%);
|
||||
animation: emergencyPulse 8s ease-in-out infinite alternate;
|
||||
}
|
||||
.app-casefile { position: relative; overflow: hidden; isolation: isolate; }
|
||||
.casefile-stamp {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin: 0 0 0.85rem;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px solid rgba(242, 193, 78, 0.45);
|
||||
border-radius: 999px;
|
||||
color: #ffe5b0;
|
||||
background: rgba(242, 193, 78, 0.1);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.app-casefile-barbell .casefile-stamp { box-shadow: 0 0 0 1px rgba(213, 54, 0, 0.12), 0 0 28px rgba(145, 15, 63, 0.2); }
|
||||
@keyframes emergencyPulse {
|
||||
0% { opacity: 0.72; transform: scale(1); }
|
||||
50% { opacity: 0.92; transform: scale(1.015); }
|
||||
100% { opacity: 0.78; transform: scale(1.03); }
|
||||
}
|
||||
.badge-hotfuzz { background: linear-gradient(135deg, var(--hf-red), var(--hf-red-deep)); color: #fff4ea; border-color: transparent; }
|
||||
|
||||
h1 { margin: 0; font-size: clamp(2.4rem, 5vw, 4rem); line-height: .94; letter-spacing: -.04em; }
|
||||
h2 { margin-bottom: 0.75rem; }
|
||||
h3 { margin-bottom: 0.5rem; }
|
||||
.lede,
|
||||
.muted,
|
||||
.hint { color: var(--muted); }
|
||||
|
||||
.grid { display: grid; gap: 1rem; }
|
||||
.grid.cols-2 { grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); }
|
||||
.compact-grid { gap: 0.75rem; }
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 20px;
|
||||
padding: 1rem;
|
||||
box-shadow: var(--hf-shadow);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.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));
|
||||
}
|
||||
.card h2,
|
||||
.card h3,
|
||||
.card p { margin-top: 0; }
|
||||
.metric-value { font-size: 2rem; font-weight: 700; }
|
||||
.list { list-style: none; padding: 0; margin: 0; }
|
||||
.list li + li { margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid rgba(255, 255, 255, 0.08); }
|
||||
.badge { display: inline-block; padding: 0.2rem 0.55rem; border-radius: 999px; background: rgba(242, 193, 78, 0.18); color: var(--hf-amber); font-size: 0.8rem; }
|
||||
.stack-form { display: grid; gap: 0.75rem; }
|
||||
.stack-form label { display: grid; gap: 0.35rem; font-size: 0.95rem; }
|
||||
input, select, textarea, button {
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: var(--field);
|
||||
color: var(--text);
|
||||
padding: 0.8rem 0.9rem;
|
||||
font: inherit;
|
||||
}
|
||||
textarea { resize: vertical; }
|
||||
button {
|
||||
background: linear-gradient(135deg, var(--hf-red) 0%, var(--hf-red-deep) 100%);
|
||||
color: #fff4ea;
|
||||
border: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
button:hover { filter: brightness(1.05); cursor: pointer; }
|
||||
button:focus, input:focus, select:focus, textarea:focus { outline: 2px solid rgba(242, 193, 78, 0.45); outline-offset: 1px; }
|
||||
.history-block + .history-block { margin-top: 1.25rem; }
|
||||
.hint { margin: -0.35rem 0 0; font-size: 0.9rem; }
|
||||
.evidence-board,
|
||||
.evidence-card,
|
||||
.evidence-slab,
|
||||
.intake-docket { position: relative; }
|
||||
.dossier-grid { align-items: stretch; }
|
||||
.case-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.case-legend::after {
|
||||
content: 'FOR THE GREATER GOOD';
|
||||
font-size: 0.67rem;
|
||||
letter-spacing: 0.16em;
|
||||
color: rgba(242, 193, 78, 0.7);
|
||||
}
|
||||
.evidence-board::after,
|
||||
.evidence-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 14px;
|
||||
border: 1px dashed rgba(242, 193, 78, 0.09);
|
||||
border-radius: 16px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.case-list li { padding-left: 18px; position: relative; }
|
||||
.case-list li::before { content: '•'; position: absolute; left: 0; color: var(--hf-amber); }
|
||||
.intake-docket {
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0.015));
|
||||
border: 1px solid rgba(242, 239, 221, 0.06);
|
||||
border-radius: 18px;
|
||||
padding: 14px;
|
||||
}
|
||||
.evidence-slab {
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 16px;
|
||||
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) {
|
||||
.page-header,
|
||||
.page-shell {
|
||||
width: min(100% - 1.25rem, 1100px);
|
||||
}
|
||||
.evidence-contact-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.brand-row,
|
||||
.casefile-title-row {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.hot-fuzz-art {
|
||||
flex-basis: 100%;
|
||||
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 |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user