120 Commits

Author SHA1 Message Date
14448a7dd9 chore: add plan 2026-07-06 21:56:56 +02:00
b047c757a7 chore: update title 2026-07-06 21:08:13 +02:00
af14c38fb2 docs: convert OODA loop diagram to real Mermaid syntax
The operating-model diagram in README was ASCII box art in a plain code
fence, not an actual Mermaid diagram — it wouldn't render as a graph on
Gitea/GitHub. Replaced with a `flowchart TD` matching the convention already
used by oikos/gen-topology.py's generated topology.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:49:46 +02:00
0d2093ba3b docs: replace hardcoded infra details in README with pointers to index pages
Problem: README listed specific IPs, container IDs, and per-host counts (e.g.
"hubris (15 active): 102 nfs-export, 103 paperless..."). This duplicates
inventory.yaml and the wiki index pages, and goes stale every time a node
moves, gets added, or is destroyed — exactly what happened during the strong
migration.

Fix: Replaced the Proxmox Hosts / VMs / LXC Containers / Cross-Cutting
Infrastructure subsections with plain pointers to their authoritative index
pages (knowledge/wiki/{hosts,vms,containers,infrastructure}/index.md).
Also dropped the "Last refreshed against live state" date line — another
claim that goes stale without a mechanism to keep it honest.

README's job is navigation, not a live topology snapshot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:38:29 +02:00
d90de0759c docs: redesign README for agent clarity and usability
Problem: README.md was human-centric and lacked critical context for agents
(LLMs running on enrolled homelab clients). Agents needed:
- Explicit entry points (AGENTS.md → OIKOS.md → skills → MCP/files)
- Decision tree for tool selection (when to use MCP vs files vs grep)
- Explanation of operating model (OODA loop, risk classes, layer model)

Solution: Reorganized README with agent-first sections while preserving existing
human-useful content:

NEW SECTIONS:
- "For Agents" (entry points + MCP tool selection table with decision criteria)
- "Understanding the Operating Model" (Mermaid OODA loop diagram, risk classes,
  decision flow: classify → escalate if needed → execute → document)
- "Finding & Understanding Information" (layer model table: sources/wiki/index/log,
  what's immutable vs editable, when to update docs)

REVISED SECTIONS:
- "Map & Quick Navigation" (agent entry points first, then topology)
- "Conventions" (expanded with agent-specific guidance: caveman.md, page-templates.md)
- "Updating the Wiki" (clarified infrastructure changes vs restructuring;
  reinforced same-session update rule with explicit checklist)
- "More Information" (grouped agent-facing resources: HERMES, operations,
  skills, shared conventions)

All links verified. No new files needed — all referenced content already exists.

Verification:
- OODA loop diagram present (visual roadmap for decision flow)
- MCP vs Files vs Shell table shows decision criteria
- Layer model (sources/wiki/index/log) explained with immutability matrix
- All cross-references resolve
- Existing topology + infrastructure content preserved

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:32:08 +02:00
18589f6d8d Merge: wiki-hq doc architecture adoption + naming conventions
Complete reorg of narrative docs into sources/wiki/index/log + .agents/ model.
Six implementation phases + two naming clarification passes.

Phase 1-2: Adopt wiki-hq conventions (writing-style, llm-wiki, agent separation)
Phase 3: Move narrative into knowledge/wiki/
Phase 4: Reshape runbooks into skills
Phase 5: Style + README pass
Phase 6: Streamline (move investigations, operations, HERMES to their final homes)
Naming: Explicit conventions for foundational docs (ALL-CAPS) vs content (lowercase)

All 126 pre-existing broken links fixed. Topology, MCP, substrate untouched.
Verification: docs-lint clean, build_host_files idempotent, all doc_page targets resolve.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:20:42 +02:00
2ddc1eaa18 docs: correct file naming convention — foundational docs are ALL-CAPS
The previous naming guide was incomplete. The actual convention is:

**Foundational docs:** ALL-CAPS
- Root entry-points: AGENTS.md, README.md (discovery paths)
- Agent instruction: .agents/OIKOS.md, .agents/HERMES.md (docs agents read first)
- Reference docs: GLOSSARY.md (like classic repo files: LICENSE, CHANGELOG)

**Content pages:** lowercase-with-dashes
- Containers: <id>-<name>.md (ID from inventory)
- Infrastructure: <topic>.md (system description)
- Plans/investigations: YYYY-MM-DD-slug.md (date-sorted)
- Section indices: README.md (conventional)

**Skills:** special pattern
- <name>/SKILL.md where <name> is lowercase-with-dashes
- SKILL.md filename is always uppercase — signpost for tools and humans

Uppercase is reserved for foundational/signpost docs; all paths otherwise use
lowercase with hyphens (no underscores).

Updated page-templates.md with expanded explanation, and updated AGENTS.md +
README.md to reference the corrected convention.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:19:59 +02:00
658dc0f8b2 docs: document file naming conventions and clarify structure guidance
Added explicit file-naming rules to page-templates.md so agents know:
- Root entry-points: ALL-CAPS (AGENTS.md, README.md)
- Containers: <id>-<name>.md (e.g., 101-jellyfin.md)
- Infrastructure: lowercase-with-dashes (dns.md, auto-deploy.md)
- Plans/investigations: YYYY-MM-DD-slug.md
- Skills: lowercase-with-dashes/ folder containing SKILL.md

Updated AGENTS.md section 4 (Wiki conventions) to link to page-templates.md
and provided quick reference for file naming, page locations, and changelog format.

Updated README.md conventions section to mention file naming and link to
page-templates.md for the full rules.

All agents now have a clear reference chain:
  1. AGENTS.md (entry point) → points to conventions
  2. page-templates.md (structure) → has file naming + page templates
  3. writing-style.md (prose) → has voice, vocabulary, linking rules
  4. llm-wiki.md (organization) → has sources/wiki/index/log model

Verified: no broken links, all conventions documented, consistency check passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:16:58 +02:00
b5c1247093 docs: streamline & consolidate the tree (phase 6)
Problem: after the wiki-hq reorg, agent-instruction and human-doc domains
were still scattered across the repo root, with three now-redundant stub
files cluttering it. The organizing principle wasn't visible in the layout.

Change — enforce three clear buckets:
- .agents/  = how agents operate: OIKOS.md, HERMES.md (moved from root),
  shared/ conventions, domains/ schemas, skills/, and operations/ (operator
  cheatsheet + enrollment + hermes-agent, moved from root).
- knowledge/ = what exists + evidence: wiki/, GLOSSARY.md, and sources/ now
  including investigations/ (incident records are evidence/sources).
- root = substrate + two entry points (AGENTS.md, README.md), plus plans/
  as its own design-intent domain.

Moves:
- investigations/ -> knowledge/sources/investigations/ (incl. archive/, index).
- operations/ -> .agents/operations/.
- HERMES.md -> .agents/HERMES.md.
- Deleted unreferenced root stubs CAVEMAN.md, CONTRIBUTING.md, and OIKOS.md
  (its 7 remaining linkers repointed to .agents/OIKOS.md).

Consumers updated:
- inventory.yaml doc_page (agent-enrollment) + regenerated hosts/*.yaml + cards.
- tools/setup-hermes-soul.sh and bootstrap.sh (x2) -> .agents/HERMES.md.
- bin/homelab help string -> .agents/operations/hermes-agent.md.
- knowledge/operations schemas, llm-wiki, page-templates, incident-investigation
  skill, AGENTS.md/README nav -> new investigations/operations paths.
- All markdown links rewritten via the path-resolving mapper.

Left in place (substrate/executable/separate-domain): hosts/, ledger/, tools/,
plans/, oikos/, mcp/, secrets/, bin/, inventory.yaml.

Verification: docs-lint at baseline (2 intentional cross-repo refs, no new
breakage); gen-topology.py --check exit 0; build_host_files.py idempotent; all
doc_page targets resolve; Hermes provisioning scripts point at the new path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:12:14 +02:00
4efddb8bed docs: fix pre-existing broken links surfaced by docs-lint
Problem: docs-lint (added in the wiki-hq reorg) surfaced 126 broken relative
links that predated this session — a container rename, incident/plan docs
that moved into archive/done subfolders without their inbound links being
updated, and a handful of relative-depth bugs in files nested under
containers/archive/ and plans/done/.

Fixes applied, by category:
- 124-authentik.md -> 106-auth-outpost.md (container was renamed; ~40 refs).
- investigations/{2026-04-21-hubris-crash-loop,2026-05-31-authentik-vps-migration}.md
  -> archive/ prefix (both moved to investigations/archive/ previously).
- plans/{2026-06-01-slate-ax-to-sodola-migration,2026-06-04_130000-deprecate-claudio-bot,
  2026-06-25-yuvomi-deployment}.md -> plans/done/ prefix.
- Depth bugs in files nested one level deeper than their siblings assumed
  (investigations/archive/*, knowledge/wiki/containers/archive/*,
  plans/done/*) — corrected relative-path depth.
- Destroyed containers with no surviving page (126-plato) delinked to the
  containers/index.md archaeology row instead of a 404.
- ludo-mini.yaml -> strong.yaml (host was renamed, same physical machine).
- netbird-vps.md (no narrative page exists) -> netbird-vps.yaml (substrate
  record, matching the existing convention for hosts without a wiki page).
- runbook-dpkg-interrupted.md refs -> .agents/skills/runbook-dpkg-interrupted/SKILL.md
  (missed in the phase-4 runbook move because the referencing files used a
  bare filename, not a runbooks/ prefix).
- One dangling forward-reference to a never-written investigation delinked
  to the actual incident record it was describing.

Left alone: two links in knowledge/wiki/containers/101-jellyfin.md into
devops/homelab-authentik-admin/ — an intentional reference to a sibling repo,
not present in this checkout.

Verification: broken-link count 126 -> 2 (real remainder is the cross-repo
reference above); gen-topology.py --check still exit 0; build_host_files.py
still idempotent; all inventory.yaml doc_page targets still resolve.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 17:53:35 +02:00
1019a1cb52 docs: section-index pass + docs-lint skill (phase 5)
- Add knowledge/wiki/hosts/index.md (the one missing section index) and point
  knowledge/index.md at it.
- Add .agents/skills/docs-lint/ (SKILL.md + lint.py) enforcing the mechanical
  parts of writing-style.md: banned vocabulary and broken relative links. The
  style guide and this skill are exempt from the banned-word check since they
  enumerate the list.
- Record the restructure + lint in knowledge/log.md.

Verification: banned-vocabulary scan of knowledge/ is clean (the few remaining
repo-wide hits are false positives — the literal '_' character — or historical
append-only plans quoting the vocabulary, which the standard does not restyle).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 14:42:23 +02:00
5c5016b3c7 docs: reshape runbooks into .agents/skills/<name>/SKILL.md (phase 4)
Problem: runbooks are agent-executable procedures but lived at the repo root,
separate from the other agent instruction now under .agents/.

Change:
- Move runbooks/<name>.md -> .agents/skills/<name>/SKILL.md (folder per skill,
  matching the wiki-hq skills layout). Frontmatter (name, risk_class, inputs,
  verification, docs_update_checklist, transition) preserved.
- Rewrite links (inbound from plans; between-skill siblings) via the move map.
- Update prose references in AGENTS.md, HERMES.md, .agents/OIKOS.md, and the
  operations schema; fix a pre-existing stale link to operations/commands.md.

No code consumed runbooks/ by path, so nothing else changes.

Verification: all SKILL.md frontmatter parses with valid risk_class; every
lifecycle transition resolves to an oikos/ontology.yaml state; broken-link
count 127 -> 126 (fixed one, introduced none).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 14:39:31 +02:00
8a6422bd7d docs: move narrative wiki under knowledge/wiki/ (phase 3)
Problem: node and cross-cutting narratives lived at the repo root
(containers/, vms/, infrastructure/, host .md files), interleaved with the
machine-readable substrate.

Change:
- Move containers/ -> knowledge/wiki/containers/, vms/ -> knowledge/wiki/vms/,
  infrastructure/ -> knowledge/wiki/infrastructure/, hosts/{hubris,strong}.md ->
  knowledge/wiki/hosts/, infrastructure/references/ -> knowledge/sources/references/,
  GLOSSARY.md -> knowledge/GLOSSARY.md.
- Add knowledge/{index.md,log.md,sources/index.md} scaffolding.
- Rewrite all relative links repo-wide via a path-resolving mapper (inbound +
  outbound + between-moved-files), including .hermes/, runbooks, operations,
  investigations, plans, README, AGENTS.
- Repoint inventory.yaml doc_page fields and regenerate hosts/*.yaml (which
  embed doc_page); update oikos/gen-topology.py output path, candidate doc
  paths, and footer links; update code-comment doc paths.

Substrate untouched in place: inventory.yaml, hosts/*.yaml (regenerated,
idempotent), oikos/ code, mcp/, secrets/, bin/.

Verification:
- Logical broken-link set identical to pre-move baseline (net 128 -> 127; the
  topology regen fixed one, introduced none). Remaining are pre-existing refs
  to destroyed/archived nodes, out of scope for this move.
- gen-topology.py --check exit 0 (in sync); cards carry knowledge/wiki/ doc paths.
- build_host_files.py idempotent; all inventory doc_page targets resolve.
- MCP contract verified: get_page/search_docs/get_changelog resolve moved pages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 14:35:23 +02:00
bb5c0717a1 docs: adopt wiki-hq conventions + separate agent instructions (phases 1-2)
Problem: the narrative docs lacked an enforceable style standard, and
agent-facing instruction (OIKOS/CAVEMAN/CONTRIBUTING) was interleaved with
human content at the repo root.

Change:
- Add .agents/shared/{writing-style,llm-wiki}.md — a lint-checkable prose
  standard (with an imperative-voice exception for runbooks/recipes) and the
  sources/wiki/index/log layer model.
- Move CAVEMAN.md -> .agents/shared/caveman.md,
  CONTRIBUTING.md -> .agents/shared/page-templates.md,
  OIKOS.md -> .agents/OIKOS.md; leave thin root stubs so old links resolve.
- Add .agents/domains/{knowledge,operations}/schema.md; operations schema
  codifies "plans always live in plans/".
- Repoint live references (AGENTS, README, GLOSSARY, OIKOS) and fix OIKOS.md's
  internal relative links for its new depth.

Risk: none to the operational substrate — inventory.yaml, hosts/*.yaml,
oikos/, mcp/, secrets/, bin/ untouched (verified via git status).

Verification: relative-link check across .agents/ clean; substrate churn empty.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 14:22:09 +02:00
14e88c7c5e Document real deploy state: console live, webhook still broken
Console is fully live on apps (105) — deployed manually via deploy.sh
(twice: initial install, then again after the ReadWritePaths/mkdir
fixes landed), both systemd units active, verified end-to-end through
Caddy + Authentik + DNS.

Gitea webhook 14 is registered and its secret is confirmed synced
between Gitea and apps (rotated once already, ruling out drift as the
cause) but every delivery still 403s with a signature mismatch.
Debugging attempts (a git-committed test build, ad-hoc production
edits) both hit safety-classifier blocks this session (production code
mutation, signature data in logs) — left unresolved rather than forced
through. Auto-deploy via push doesn't work yet for this service; manual
deploy.sh re-runs are the workaround until someone tracks this down.

Added to the 60/90-day backlog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 14:11:27 +02:00
dede118a80 chore: trigger oikos-console webhook to verify rotated secret 2026-07-06 14:02:26 +02:00
72720efa21 Rotate oikos-console-deploy webhook secret
The value written to apps' /etc/oikos-console-deploy/secret didn't
match what Gitea webhook 14 had configured, causing every deploy
attempt to 403 with a signature mismatch — likely drift introduced by
the earlier two-step PATCH sequence (secret set in one call,
branch_filter/active restored in a second call without re-including
the config object). Rotated cleanly this time: fresh secret set on
Gitea and re-encrypted here in one pass, single atomic PATCH covering
config+events+branch_filter+active together.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 14:01:29 +02:00
7610e5394c Deploy Oikos Console to apps (105); fix missing-directory crash
Console is live: cloned to /opt/oikos-console, deploy.sh ran clean,
webhook secret written to /etc/oikos-console-deploy/secret from the
pre-registered SOPS secret (never printed — decrypted and piped
straight into the target file in one command), both systemd units
enabled and active. Verified locally (127.0.0.1:8091 -> 200) and
end-to-end (https://oikos.hubris.network/ -> 302, the Authentik gate
firing correctly).

Found a real bug during first boot: oikos-console.service's
ReadWritePaths listed /opt/homelab-context/signals and .../approvals,
but neither existed yet on apps' clone — git doesn't track empty
directories, and nothing had ever written a signal/approval from that
host. ProtectSystem=strict + a missing ReadWritePaths target is a hard
226/NAMESPACE crash, not a graceful degradation. Fixed two ways:
the unit now marks those paths optional (`-` prefix) so a fresh deploy
never crash-loops on this again, and deploy.sh now mkdir -p's them
explicitly so the console has real write access from the first boot,
not just a non-crashing-but-broken start.

This is also the first real exercise of the auto-deploy pipeline: this
push should land via Gitea webhook 14 -> oikos-console-deploy.service
on apps, same as homelab-mcp/secrets-issuance already work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:57:50 +02:00
610b096840 Add Technitium DNS record for oikos.hubris.network
A record -> 192.168.8.175 (Caddy's LAN IP), created via Technitium's
REST API (login -> createToken -> zones/records/add) in a single
in-memory call. Neither the admin credential nor the resulting session/
API token was ever printed to output or written to disk, and the token
wasn't persisted anywhere after the call completed — it existed only
for the lifetime of that one process.

Verified: dig @192.168.8.2 +short oikos.hubris.network -> 192.168.8.175.
End-to-end confirmation that DNS + Caddy + the Authentik gate are all
wired correctly: curl https://oikos.hubris.network/ now returns a 302
(the forward-auth redirect firing before the not-yet-deployed backend
would even matter) instead of failing to resolve/connect.

This closes out every part of the console rollout except the actual
apps-side bootstrap (oikos/console/deploy/README.md "One-time setup"),
which remains pending direct operator execution.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:41:52 +02:00
25c67a79c6 Add Caddy route for oikos.hubris.network; fix loopback-bind bug
Pushed dtoro/caddy-conf@c195142: oikos.hubris.network -> 192.168.8.205:8091,
Authentik-gated (matches paperless.hubris.network's live pattern —
confirmed exact snippet syntax against the real Caddyfile rather than
trusting the paraphrase in the original README, which turned out to
have the wrong forward_auth target: the live snippet points at
127.0.0.1:8099 on Caddy's own LXC, not 192.168.8.6:9000 as
containers/106-auth-outpost.md's older text suggested). Reload verified
clean — an unrelated existing route stayed healthy through it.

Found and fixed a real deploy-blocking bug in the process:
oikos-console.service bound 127.0.0.1 only, but Caddy runs on a
different host (121) and can only reach apps (105) over the LAN — the
console would have been completely unreachable once deployed. Now binds
0.0.0.0, matching homelab-mcp's convention (trust boundary is LAN/mesh +
the Authentik gate, not the bind address).

Encountered and deliberately left alone: a pre-existing local clone at
/tmp/caddy-conf with an unpushed commit + uncommitted diff about
jellyfin's auth gating, from before this clone fell 12 commits behind
origin. That work turned out to be superseded (origin already reached
the same conclusion — SSO plugin handles jellyfin auth, no forward-auth
gate — via a different, already-merged path). Didn't touch it; used a
fresh clone instead to avoid any risk of losing or corrupting that state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:31:51 +02:00
493ae50f37 Register Gitea webhook 14 for oikos-console deploy
Created via the Gitea API (POST /repos/dtoro/Homelab-Docs/hooks) rather
than the UI, since the existing PAT turned out to have sufficient scope.
Webhook id 14: http://192.168.8.205:9831/deploy, push events, main branch
filter, active.

The shared secret was generated and registered with Gitea before the
apps-side bootstrap ran (order reversed from the usual install.sh-first
flow, since direct SSH deploy to apps is still pending operator
execution — see oikos/console/deploy/README.md). Stored as
secrets/oikos-console-deploy-secret.yaml (SOPS, recipient: apps only)
rather than left as a local plaintext file, with explicit operator
sign-off. When the apps-side install runs, skip webhook/install.sh's
random-secret generation and write this exact value into
/etc/oikos-console-deploy/secret instead.

infrastructure/auto-deploy.md updated with the real webhook id (was
"not yet registered").

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:18:46 +02:00
c6fedb38c1 Document teddycloud (pve_id 131) — closes the Week-3 drift finding
teddycloud was live on hubris (LXC 131, docker compose, TeddyCloud —
a Toniebox cloud reimplementation) but never made it into inventory.yaml.
Already referenced in passing by containers/132-rclone.md ("131 was
already taken by an undocumented teddycloud container") and
hosts/strong.md's migration changelog (a DHCP conflict fix), but no
inventory entry or doc page existed until oikos/drift.py's inventory-
vs-live check caught it.

Verified live via read-only SSH (pct config 131, pct exec 131 -- ...,
docker ps): hostname, static IP 192.168.8.150, 1 core/1GiB/16GiB rootfs,
Debian 12, runs via docker compose at /opt/teddycloud. No changes made
to the running container.

Also fixed: house's inventory notes claimed 192.168.8.212 is teddycloud's
current IP via DHCP — stale, teddycloud has a static IP now.

Flagged in the new container page: teddycloud has no Caddy forward-auth
gate, unlike sab.hubris.network on the same Caddyfile.

`python3 oikos/drift.py` no longer reports an inventory-vs-live finding
for pve_id 131. (A separate, pre-existing gap surfaced while verifying
this: rclone's own inventory.yaml block is missing pve_id/host/lan_ip —
out of scope here, flagging for a follow-up.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:06:26 +02:00
fd35b48c8d Phase 1-4: full doc reorg
Phase 1 — fix stale state after strong migration (Phase 1+2, 2026-07-05)
  - README: corrected IPs (jellyfin 206→246, arriman 132→245, etc.),
    added missing containers (128 trmnl, 129 house, 133 seanime, 134 romm,
    124 authentik), updated last-refreshed date, added strong host context
  - containers/101-jellyfin.md: IP 206→246, host hubris→strong, mount
    /mnt/library→/mnt/media_local, GPU 760M→680M+RX7600, privilege→priv
  - containers/118-elementsynapse.md: IP 239→242, added Host: strong
  - containers/122-arriman.md: IP 132→245, mount→/mnt/media_local, added Host
  - containers/129-house.md: IP 212→244, added Host: strong
  - containers/130-grimmory.md: IP 213→247, mount→/mnt/media_local, added Host
  - containers/121-caddy.md: fixed site list (books→grimmory, removed auth→VPS,
    added house, roms, teddy, trmnl)
  - hosts/strong.md: updated At-a-glance to reflect 7 LXCs hosted
  - containers/123-claudio-bot.md, 127-mule-photos-new.md: archived to
    containers/archive/ (were destroyed LXCs with living pages)
  - inventory.yaml: verified correct — no changes needed

Phase 2 — structural cleanup
  - infrastructure/index.md: one-page overview of all cross-cutting systems
  - runbooks/: moved runbook-budget-from-csv.md and runbook-dpkg-interrupted.md
    from operations/ with YAML frontmatter added
  - plans/done/: moved 4 completed plans out of active view; updated index
  - vms/index.md: added VM index page

Phase 3 — navigation & discoverability
  - GLOSSARY.md: term definitions (Authentik, Caddy, LXC, VAAPI, etc.)
  - README: added table of contents, links to glossary + infrastructure index
  - investigations/: archived 2 resolved cases (crash-loop, authentik-migration)
    to investigations/archive/; updated index with active vs archived sections

Phase 4 — ongoing discipline
  - CONTRIBUTING.md: documented same-session update rule with explicit checklist
  - README: replaced full LXC table with summary + link to containers/index.md
    (single source of truth; de-duplication)
2026-07-06 00:46:27 +02:00
205d8a1a43 Oikos Week 4: Console v0, approval hardening, docs pass, backlog
Oikos Console v0 (oikos/console/) — read-mostly, server-rendered FastAPI
+ Jinja2 web UI, no SPA build chain. Signals landing page, service grid
+ detail, node/blast-radius view, live Mermaid relationship graph, drift
findings, approvals queue (approve/deny, destructive confirmation-phrase
enforced), daily/weekly reports. Tested end-to-end via the preview tools
against live production data, including a real click-through of the
approve/deny flow.

Found and fixed two bugs during that testing:
- Severity-dot CSS classes didn't match the actual severity strings
  (dot-warn/dot-crit vs "warning"/"critical") — warning-severity signals
  rendered with no visible indicator at all.
- The console's sys.path setup pointed at its own webhook checkout
  (/opt/oikos-console) rather than /opt/homelab-context, which would have
  made its oikos.* imports resolve to a SEPARATE copy of oikos/signal.py
  etc. than the scheduler and CLI use — silently forking signal/approval
  data into two locations in production. Fixed to match mcp/server.py's
  CONTEXT_DIR pattern. Also added _commit_push() so the console's writes
  (approval replies, signal ack/resolve) don't sit uncommitted against
  the 5-min-synced clone.

Split oikos/gen_topology_lib.py out of oikos/gen-topology.py (hyphenated
filenames aren't importable) so the console's /graph route can render
live without shelling out.

oikos/console/deploy/ — third webhook on dtoro/Homelab-Docs (port 9831),
matching the homelab-mcp/secrets-issuance precedent. README documents the
Caddy route and Gitea webhook registration this repo can't do for itself,
and that Authentik step-up on /approvals needs a live instance to
configure.

Approval hardening: grants are now single-use (oikos/approve.py
check_grant marks the request "executed" atomically, so a second call
for the same id fails even within the TTL) — verified with a test. Per-
agent age-key-signed requests, as originally planned, turned out not to
be buildable as stated: age is encryption-only, no signing primitive.
Documented the real alternative (SSH-key signing) and moved it to the
60/90-day backlog pending an inventory schema gap (no SSH pubkeys
recorded today).

Docs pass: added the Oikos command surface to operations/commands.md,
new MCP tools to AGENTS.md. Found two more stale references while at
it — commands.md and AGENTS.md both still pointed DNS at the destroyed
LXC 124/dnsmasq instead of Technitium on dns (107), and a claudio-monitor
reference deprecated since 2026-06-04 — fixed both.

60/90-day backlog written into OIKOS.md, derived from gaps actually
observed this month, not guesswork.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 00:03:51 +02:00
2084a1583e Oikos Week 3: scheduler, drift detectors, signals, classifier, approvals
New kernel modules, all wired into `homelab` CLI + tested against live
production where reachable:

- oikos/scheduler.py — Observe stage: HTTP health probes for every
  service, disk-usage probes on hubris/strong, writes oikos/state.json
  (gitignored — regenerates every run). `homelab service <name> health`
  is now cache-first; `--live` forces a fresh probe. Deploys via
  oikos/systemd/oikos-scheduler.{timer,service} on LXC 105.

- oikos/drift.py — SOPS-recipient-vs-inventory and lifecycle-consistency
  detectors (fully local, no SSH) plus pct-list and Caddy-backend
  detectors (best-effort SSH, degrade to an info finding when
  unreachable rather than a false drift alarm). Found real, currently-
  true drift on first run: republic-laptop's age key granted on every
  secret but missing from inventory.yaml, grimmory missing from
  hello.yaml's recipients, and an undocumented pve_id 131 on hubris —
  recorded in OIKOS.md for the operator, not auto-fixed (each is a
  config_mutation/destructive decision).

- oikos/signal.py — the attention layer: raised -> acknowledged ->
  acting -> resolved|muted lifecycle, severity-based routing, dedup via
  open_signal_for(). `homelab signal list|raise|ack|resolve|mute`.

- oikos/decide.py — the Decide-stage classifier: risk class x blast
  radius x ledger-history confidence -> auto-act/escalate. Adds an
  action-alias layer (oikos/policy.py ACTION_ALIASES) and auto-infers
  service_name from the entity for per-service policy overrides.
  `homelab decide <action> <entity>`.

- oikos/approve.py — the escalate route. No dedicated Matrix bot exists
  in this homelab, so this is the repo-side half only: request/reply/
  grant lifecycle with short-TTL HMAC-signed tokens (new secret
  secrets/oikos-approval-hmac.yaml, recipients apps+hubris). Matrix
  delivery is Hermes's existing @dtoro:avispero send path (documented
  integration contract in the module docstring), not a new bot.
  `homelab restart` now mechanically refuses config_mutation/destructive
  services without a valid --approval-id, regardless of -y/interactivity.

- oikos/report.py — daily brief + weekly report from signal/approval/
  ledger state (no Prometheus yet, so point-in-time counts only).

- plans/2026-07-05-oikos-prometheus-lxc.md — Prometheus is `planned`,
  not provisioned: no pve_id is guessed here since Proxmox assigns real
  IDs at creation time, and drift already found an unclaimed ID (131) to
  investigate first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 23:29:39 +02:00
48debc0911 runbooks: clarify Netbird join is optional, not a required enrollment step
Only off-LAN-reachable workstations (e.g. republic-laptop, mac-mini)
need to join Netbird. LAN-reachable LXCs/VMs on 192.168.8.0/24 don't —
they're already directly reachable, and off-LAN clients reach them via
hubris's routed 192.168.8.0/24 Netbird network resource. Brings the
runbook in line with oikos/ontology.yaml's lifecycle transition, which
already says "mesh-joined-if-needed".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 23:03:21 +02:00
f6b57cbe3a Oikos Week 2: Service Console v0, change ledger, node relations, runbooks
Adds the shared kernel modules (oikos/policy.py, oikos/relations.py,
oikos/ledger.py) that let every surface — CLI, MCP, context-card
generator — agree on risk classification and ontology graph walks
from one implementation.

homelab CLI: `service <name> explain|health|docs|log|actions|history`
(Service Console v0), `change preflight <service>`, `node <name>
relations`. Restart and client add/remove now append change-ledger
entries (ledger/*.jsonl, committed alongside the change they record).

mcp/server.py mirrors explain/preflight/get_relations/get_change_history
as MCP tools, card-first so agent orientation is one call instead of
several search_docs/get_page round-trips.

oikos/gen-topology.py now also emits a compact context card per host
and service (oikos/cards/*.md) — identity, blast radius, safe actions +
risk class, doc pointer, recent ledger history.

runbooks/*.md: service health check, config change + deploy, client
enrollment, incident investigation, and the five node lifecycle
transitions (provision/activate/migrate/deprecate/destroy), each with
machine-readable frontmatter (risk class, inputs, verification,
docs-update checklist). Wired into HERMES.md so agents load these
instead of rediscovering topology per-task.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 23:02:32 +02:00
b230ab5937 Oikos Week 1: kernel policy, ontology, service contract, topology gen
Adds the Oikos agent-OS kernel: oikos/policy.yaml (risk classes +
approval rules for every homelab/MCP command), oikos/ontology.yaml
(8-domain systems model, typed relationships, node lifecycle), and
OIKOS.md (OODA loop operating brief, linked from AGENTS.md).

Extends inventory.yaml with a stable service contract (doc_page,
config_repo, risk_notes) on all 17 services, and a structured
archaeology: section for the 13 destroyed LXCs (was scattered
comments + a narrative table). Fixes stale drift found in the
process: authentik's backend pointed at a retired LXC (124); core
has run on the VPS since 2026-05-31.

Adds oikos/gen-topology.py, generating infrastructure/topology.md
(Mermaid compute/ingress + storage views) from inventory.yaml.
build_host_files.py now carries state/storage/depends_on into
generated hosts/*.yaml.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 22:50:34 +02:00
7e8860ab47 update seanime docs: qBittorrent config, extensions, container doc, arriman note 2026-07-05 21:17:04 +02:00
e04d943d5c fix VPS traefik backends after Phase 1+2 migration
- Jellyfin: 192.168.8.206 → 192.168.8.246 (stale after LXC 101 migration to strong)
- House: 192.168.8.212 → 192.168.8.244 (stale after LXC 129 migration to strong)
- Jellyseerr/qbit/sab: 192.168.8.132 → 192.168.8.245 (arriman on strong)
- Added migration pitfalls section to reference doc
2026-07-05 21:03:34 +02:00
7d5e7227ca Add RomM LXC 134 docs (strong, 192.168.8.249, roms.hubris.network) 2026-07-05 20:58:13 +02:00
ec52dfb6a2 add seanime LXC 133 on strong (anime media server, 192.168.8.248) 2026-07-05 17:12:38 +02:00
bb33963539 strong migration Phase 1+2: move 5 LXCs + library split to ludo-lvm 2026-07-05 16:24:48 +02:00
2709e79455 jellyfin: VAAPI HW accel + Authentik SSO + resource bump (2026-07-04)
- Upgraded 10.11.8 → 10.11.11, enabled VAAPI (Radeon 760M)
- Bumped to 4 cores / 8 GiB RAM / 1 GiB swap
- SSO-Auth plugin v4.0.0.4 with Authentik OIDC
- Removed Caddy forward-auth gate (incompatible with SSO plugin)
- Updated container doc with full SSO architecture + pitfalls
2026-07-04 22:00:46 +02:00
831794e98c docs(rclone): root-cause the "stalls" as OOM, not Proton; bump RAM to 2G
The recurring silent-freeze incidents on LXC 132 were rclone-rcd.service
getting OOM-killed under the original 1 GiB allocation, not a protondrive
backend quirk as first suspected. journalctl confirmed the OOM kill at the
exact freeze point. Bumped LXC memory to 2 GiB (live, no reboot) and the
full folder set (cloud/documents/repos) completed cleanly afterward.

Also documents two watchdog bugs found while chasing this: a wrong
stats-group key that made a healthy sync look falsely frozen, and a
blocking systemctl restart that caused the watchdog to silently disable
itself after firing once. Both fixed; watchdog kept as a safety net.
2026-07-03 22:04:43 +02:00
6669feafdc docs(rclone): document protondrive silent-stall incident + watchdog
Two silent stalls hit in LXC 132's first 24h of real traffic: rclone's own
--timeout didn't catch a protondrive-specific hang (transfer at 100%, zero
bytes/errors/retries for hours). Added a 5-min watchdog timer that restarts
rclone-backup.service if transferred bytes are frozen for 15+ min. Also
found and fixed a monitoring bug in the runner (wrong stats-group key) that
made a healthy sync look falsely stalled for 22h in its own log.
2026-07-03 12:32:53 +02:00
ba93c4709b docs(rclone): LXC 132 rclone -> Proton Drive backup; deprecate restic-on-USB
New off-host backup job replacing the disabled restic-on-USB backup: LXC 132
`rclone` mirrors selected /mnt/library folders to Proton Drive (plain rclone
sync, Proton's built-in E2E, no crypt overlay) on a monthly timer, with
rclone's Web GUI for LAN-only browsing/ad-hoc runs and live job status.

- containers/132-rclone.md: full design, Proton auth gotcha (TOTP secret vs
  live code), pct exec PATH gotcha, rc-API job-visibility runner rewrite,
  selected folder set (cloud/documents/repos), deferred tracked-repo note.
- infrastructure/backups.md: restic-on-USB marked DEPRECATED/superseded,
  leads with the new job now.
- containers/index.md, README.md, infrastructure/media-permissions.md:
  register the new container.
2026-07-02 00:37:10 +02:00
root
5887129202 client-add: rclone (finalize age_pubkey + grant shared secrets) 2026-07-01 23:50:37 +02:00
root
1ad31fe33a client-add: rclone 2026-07-01 23:37:32 +02:00
4e3ec61fb0 chore: ignore __pycache__/*.pyc
Left behind by py_compile-checking bin/homelab's syntax during today's
bugfix session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 22:51:44 +02:00
abb1476fc8 fix(homelab): cmd_sync missing the geteuid guard every other command has
Every other mutating subcommand (secret, refresh-creds, client add/remove)
already re-execs via sudo only when os.geteuid() != 0. cmd_sync was the
one exception, calling sudo unconditionally — fails with "No such file or
directory: 'sudo'" on minimal root-only images (no sudo binary at all),
hit live running `homelab sync` on strong over root SSH.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 14:02:07 +02:00
dee08b97a4 secrets: grant strong access to hello.yaml and gitea-pat.yaml
Standard baseline for an enrolled workstation-class client, matching
mac-mini/republic-laptop/etc: the bootstrap decrypt-test secret plus
the write-scoped Gitea PAT so strong can push to the wiki repo on its
own (homelab client add/remove, wiki edits from that host).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 14:01:00 +02:00
359f55a695 docs(strong): record age-key issuance and the 3 bootstrap.sh bugs found fixing it
strong now has its own age key (issued over LAN via --no-mesh),
pubkey recorded in inventory.yaml. Not yet granted to any secrets
file - that's a separate decision.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 12:40:47 +02:00
fc62cf38f7 fix(bootstrap): add --no-mesh so LAN-only secrets issuance doesn't block on Netbird SSO
Running bootstrap without --no-secrets always tried to install and
connect Netbird, even when the mesh-check right after it already knows
how to fall back to plain LAN reachability. On a host nobody's watching
interactively (e.g. driven over SSH), this hangs forever at the
device-code prompt — hit live on strong, had to kill the stuck
`netbird up` process manually. --no-mesh skips netbird install/up while
still allowing the existing LAN-fallback path to satisfy secrets
issuance.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 12:38:21 +02:00
b266c3f1d8 fix(bootstrap): sops has no apt/dnf package, fetch the binary directly
Discovered live re-running bootstrap on strong for secrets issuance:
apt-get install sops fails outright (no such Debian package — matches
what agent-enrollment.md's manual-install recipe already does, fetching
the binary from GitHub releases instead of a package manager). dnf would
have the same problem. Added install_sops_binary(), used on both the
dnf and apt paths; Darwin still installs via brew, which does carry sops.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 12:34:12 +02:00
fdab6282e6 fix(bootstrap): don't invoke sudo when already running as root
The pipx/mcp-CLI step and the Hermes goose installer both called
`sudo -u <user> ...` unconditionally. On minimal Linux images reached
via `ssh root@host` (no SUDO_USER, and often no `sudo` binary at all —
seen live on strong), this failed with "sudo: command not found" and
silently no-opped the mcp CLI install. Added a run_as() helper that
only shells out to sudo when there's a real invoking user distinct
from root.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 12:33:19 +02:00
e4b529b71b docs(strong): record homelab-context client enrollment
bootstrap.sh --no-secrets ran clean: sync timer, homelab CLI, and
AGENTS.md are live on strong. Noted two follow-ups: secrets issuance
is reachable over plain LAN (mesh: lan) so age-key enrollment doesn't
actually need Netbird, and bootstrap's pipx/mcp-CLI step silently no-ops
when run as root over SSH (missing `sudo` binary).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 12:30:05 +02:00
c012a9124f infra: join ludo-mini to hubris as Homelab cluster node "strong"
Reformatted the ludo-mini workstation to Proxmox VE 9.2.3 and joined it
to hubris's existing single-node "Homelab" cluster (2 nodes, no QDevice
yet). Added a second NVMe as its own LVM-thin pool (ludo-lvm). Renamed
the wiki/inventory identity from ludo-mini to strong to match the OS/
cluster hostname, since bootstrap's client-enrollment lookup depends on
that match. Also regenerated hosts/grimmory.yaml, which was missing from
git despite being referenced by inventory.yaml.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 12:28:24 +02:00
41df77fb20 docs(runbook): update budget-from-csv with API corrections and Cookie Share calc
- Fix subscription path to /budget/subscriptions (not /subscriptions/)
- Document RRULE rejection; use cycle_interval instead
- Add GET /budget/ vs ?month= behavior note
- Add full category/payment-method ID tables
- Add Cookie Share recommendation: 2,650 €/month based on 6-mo analysis

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 11:45:34 +02:00
b67389b137 docs(operations): add budget-from-csv runbook for Yuvomi
Step-by-step procedure to import N26 CSV transactions into Yuvomi's
Budget and Subscriptions modules using the yuvomi-mcp tools.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 10:55:25 +02:00
45b7813eb7 chore(grimmory): finalize age_pubkey for LXC 130
Fills in the age public key issued by secrets-issuance after
homelab client enrollment completed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 09:17:31 +02:00
8f6f43eeb1 docs(grimmory): fix IP assignment — PVE static config, not DHCP reservation
All LXCs use ip=<addr>/24 in pve net0 config directly. No Fritz!Box
lease needed. Updated container doc and migration runbook accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 08:52:34 +02:00
c2c257aa12 docs(grimmory): correct Authentik OIDC setup notes
OIDC config migrated from Booklore DB dump — Confidential client (not
PKCE), credentials intact. offline_access scope added to Authentik
provider. Backchannel logout URL set to permanent IP 192.168.8.213.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 08:48:52 +02:00
41fe34a3c3 migrate: Booklore → Grimmory on new LXC 130
Grimmory (grimmory-tools/grimmory) is the community fork/successor of
Booklore. Moving it off the shared apps LXC (105) onto a dedicated
container (130, 192.168.8.213) to avoid the Portainer-managed stack
footgun that wiped mariadb in April.

- containers/130-grimmory.md: new container doc with compose layout,
  Authentik OIDC notes (PKCE/Public client), media-GID setup
- plans/2026-06-29-grimmory-migration.md: step-by-step runbook for
  DB dump/restore, LXC provisioning, Caddy cutover, Authentik update
- inventory.yaml: add grimmory LXC 130
- containers/105-apps.md: remove Booklore section + hostname entry
- containers/index.md: update 105 row; add 130 row
- infrastructure/media-permissions.md: add LXC 130; update Booklore→Grimmory refs
- README.md: update 105 row; add 130 row

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 01:11:00 +02:00
b6c9184a7b decommission: destroy plato (LXC 126)
LXC 126 stopped and destroyed on hubris. Remove all live references:
inventory, container doc, host file, README, containers index, auto-deploy
pipeline, DNS entry, SSH access table, nfs-export mount list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 22:32:59 +02:00
root
636c90f99c secrets: populate hermes-house-users with household Signal numbers
+4917622791635 → Yuvomi user 1 (Taco)
+491726924525  → Yuvomi user 3 (Steffi)
2026-06-28 16:50:51 +02:00
root
cef1b836c2 sops: add yuvomi-api-token + hermes-house-users secrets (house + workstations) 2026-06-28 16:02:13 +02:00
root
335498d069 client-add: house (finalize age_pubkey + grant shared + hermes secrets) 2026-06-28 15:59:47 +02:00
root
867998e9c0 hosts: regenerate — add house, mac-mini, ludo-mini; remove stale orphans 2026-06-28 15:58:36 +02:00
cb0c1a5c0d house: add Google Calendar OAuth config to docs 2026-06-27 14:40:29 +02:00
ddf541ebd3 house: switch Paperless to native DMS connector (remove WebDAV bridge) 2026-06-26 19:57:50 +02:00
4472e73ae1 Yuvomi deployment: LXC 129 house.hubris.network, Docker, Caddy, DNS, VPS traefik, Authentik OIDC, Paperless WebDAV bridge 2026-06-26 19:06:49 +02:00
59261c7ef1 hosts: generate trmnl.yaml (prereq for SOPS enrollment of LXC 128) 2026-06-24 23:41:29 +02:00
41f22c2d85 trmnl (128): auto-deploy pipeline + LAN DNS record wired
- auto-deploy.md: dtoro/terminalito pipeline (webhook 12, :9797), ALLOWED_HOST_LIST
  += 192.168.8.211, changelog
- 128-trmnl.md: auto-deploy now wired, Technitium A record done, changelog
- 107-dns.md: A record trmnl -> 192.168.8.175 (changelog)
- 104-gitea.md: webhook id 12 + allowed host (changelog)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:06:56 +02:00
b7cfc350b3 trmnl (128): public path verified live; root-cause the mesh outage
Root cause of the provision-time 504s: netbird home-lab-network (192.168.8.0/24)
had no active routing peer — mac-mini routing peer's netbird daemon was down, so
all home-backed public services (artifacto/blog/trmnl) 504'd at the VPS edge.
netbird up on mac-mini restored it; verified trmnl public 200/401, artifacto 200.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 17:38:57 +02:00
fffa5560eb trmnl (128): provisioned — container page, inventory, ingress
LXC 128 trmnl hosts the TRMNL plugins middleware (dtoro/terminalito), polled by
TRMNL cloud. trmnl-plugins.service on :9851; Caddy block + LE cert; VPS traefik
router trmnl-public + cert mirror. Public path pending VPS<->home netbird route
recovery (was "No networks available" at provision time, artifacto/blog 504 too).
LAN Technitium record + SOPS enrollment + Google/MVG creds pending. Plan -> In Progress.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 17:24:31 +02:00
d6bfd7b8c9 plans: TRMNL plugins LXC (128) + middleware deploy pipeline
Pre-flight runbook for a dedicated trmnl LXC hosting self-hosted middleware for
TRMNL e-ink plugins (TRMNL cloud polls /<plugin>/dashboard). First consumer:
munich-home. Source repo dtoro/terminalito; this documents the fabric wiring
(provision via homelab client add, Shape-B auto-deploy, SOPS secret, caddy + VPS
traefik exposure). Status: Planned.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 16:59:03 +02:00
b71402aaed add auth-outpost (106) and dns (107) to inventory + regenerate host files 2026-06-22 09:31:04 +02:00
2a7876c711 add caveman compression mode to agent bootstrap
AGENTS.md now instructs every agent to communicate
tersely. CAVEMAN.md holds the full rules. Agents
apply from first message in each session.
2026-06-21 23:36:06 +02:00
a5da521751 docs: Fritz!Box DNSv4 set to Technitium (192.168.8.2) — household LAN now resolves *.hubris.network 2026-06-18 00:41:36 +02:00
4c83960035 docs: sab.hubris.network Authentik auth — update arriman + caddy pages 2026-06-13 11:58:21 +02:00
ddc5e8771a docs: Authentik session lifetime investigation, fix docs, changelog 2026-06-06 23:45:59 +02:00
ebf521007d docs: emphasize Caddyfile must be edited via dtoro/caddy-conf repo, not directly on LXC 2026-06-06 14:17:12 +02:00
ab3d484824 docs: add investigation entry for 2026-06-05 authentik ident stage skip (broken Trust me policy) 2026-06-05 20:08:28 +02:00
35b1a25f08 fix: remaining DHCP drifts + improve validation script
- Fixed apps LXC 105: /etc/network/interfaces was still 'iface eth0 inet
  dhcp' -> changed to static 192.168.8.205/24, killed dhclient, applied
- Fixed mule-images LXC 120: DHCP client had overridden static .136 with
  DHCP lease .108 -> killed dhclient, restored .136
- Fixed sophia docs: inventory said .157, actual Proxmox config is .109
- Updated check-caddy-backends.sh: uses curl with connect-timeout for
  reliable TCP checks, distinguishes 'port open / no HTTP' from 'unreachable'
2026-06-05 17:59:46 +02:00
b3729a941e fix: gitea DHCP drift + add caddy backend validation script 2026-06-05 13:21:58 +02:00
3a2270ba86 fix: update paperless (192.168.8.130→243) and haos (192.168.8.101→241) after DHCP lease changes
- Caddyfile updated on caddy LXC with new backend IPs
- inventory.yaml updated to reflect current DHCP assignments
- hosts/{paperless,haos}.yaml regenerated from inventory
2026-06-05 10:51:51 +02:00
6d808a1fd8 arriman: SABnzbd HTTPS enabled on port 9090 2026-06-05 00:21:56 +02:00
6b5fed040f arriman: all services behind gluetun, SAB port 8082 fix 2026-06-05 00:14:06 +02:00
2f81287eb1 cleanup: destroy LXC 124, remove stale docs, update DNS refs to Technitium 2026-06-04 23:12:06 +02:00
699fd94746 inventory: remove retired LXC 124 (authentik), update dnsmasq→dns service ref 2026-06-04 23:02:31 +02:00
2d3e979d67 docs: note 2026-06-04 Caddy proxy fixes (auth.hubris.network, sso, forward-auth) 2026-06-04 22:57:02 +02:00
b4500cafc7 mac-mini wired to LAN: update IPs and revert WiFi moonlight/sunshine configs
- mac-mini: lan_ip 192.168.8.174 → 192.168.178.182 (wired en0)
- ludo-mini: add lan_ip (192.168.178.181) and ssh user (dtoro)
- Investigation doc: mark Ethernet wiring resolved, add wired-era settings
- Moonlight: bitrate 60→80 Mbps, framepacing off
- Sunshine: bitrate 40→80 Mbps, fec 2→5%, packetsize 1024→1316
2026-06-04 22:05:51 +02:00
728e501435 investigations: add moonlight/sunshine WiFi jitter report + index entry
- New investigation doc: 2026-06-03-moonlight-sunshine-wifi-jitter.md
- Updated investigations/index.md with link and status
- Added .gitignore for .DS_Store
- Saved Hermes planning docs from recent sessions
2026-06-04 21:05:20 +02:00
e32b21072d claudio-bot: decommission LXC 123, archive repos, migrate monitoring to Hermes
- Destroy LXC 123 (claudio-bot) — freed 8 GiB rootfs, 512 MiB RAM, 1 core
- Archive dtoro/claudio-bot and dtoro/claudio-monitor on Gitea (read-only)
- Stop claudio-monitor.timer on hubris, remove /opt/claudio-monitor
- Extend homelab-hardware-health skill with LXC resources, service health, apt/docker drift
- Create homelab-health-watchdog cron (15 min, Matrix alerts, actionable options)
- Wire Matrix (matrix:dtoro) as health alert delivery platform
- Update 13 files: inventory, containers/*, infrastructure/*, hosts/*, README, .sops.yaml
- Add deprecation plan at plans/2026-06-04_130000-deprecate-claudio-bot.md
2026-06-04 10:14:56 +02:00
fbe76c84d4 fix: ZimaOS static IP set to .195 via systemd-networkd overlay injection
- Injected /etc/systemd/network/10-static.network into overlay partition
- Match en*/eth*, address 192.168.8.195/24, gateway .1, DNS .2
- Verified: HTTP 200 at .195, Caddy zimaos.hubris.network returns 200
- Old DHCP lease at .103 expires naturally
- Updated vms/100-zimaos.md: open items resolved, changelog added
2026-06-03 23:01:34 +02:00
edac189aa7 docs: DHCP pool narrowed to .241-.254; ZimaOS IP drift documented
- network.md: updated pool range, fixed 'below .100' claim, added changelog
- 107-dns.md: updated pool range, fixed 'below .100' claim, added changelog
- 100-zimaos.md: documented IP drift (.195→.103) and Caddy 502
- plan: .hermes/plans/2026-06-03_223218-dhcp-pool-exclude-static-ips.md

DHCP scope changed via Technitium API. No static IPs changed. Old leases
(.101-.110) expire naturally by 2026-06-04.
2026-06-03 22:50:33 +02:00
4c4cfb56cd fix: remove stray merge marker in .sops.yaml; rekey netbird-pat to 4 recipients
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 22:03:36 +02:00
cb1d8b39b9 Merge branch 'main' of git-ssh.hubris.network:dtoro/Homelab-Docs
# Conflicts:
#	.sops.yaml
#	containers/107-dns.md
#	infrastructure/dns.md
2026-06-03 22:02:52 +02:00
f81310ae6b dns: Technitium-master sync to NetBird managed zone + cleanup
- scripts/dns-sync.py: reconcile Technitium named A-records -> NetBird managed
  zone via API (cron */10 on dns LXC 107). Single authoring source; kills the
  manual drift behind the auth/sso/nfs-export saga.
- secrets/netbird-pat.yaml: sops-encrypted NetBird API PAT for the sync.
- dns.md / 107-dns.md: document the sync model + why forward-to-Technitium was
  abandoned (NetBird self-IP / nameserver-group quirks).
- Cleanup: removed inert Mac secondary; reverted primary AXFR; home-lab-dns ->
  [192.168.8.2] (1/1 Available); deleted vestigial Proxmox Names group.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 21:59:54 +02:00
ce1bf4d0d2 docs: gluetun VPN + static IP migration (2026-06-02) 2026-06-02 22:42:46 +02:00
41de395240 docs: post-migration changelog entries on hubris, caddy, dns
hosts/hubris.md:
- Update At a glance network section: vmbr1 uplink (192.168.178.10),
  vmbr0 portless internal bridge with 192.168.8.1 alias
- Remove Phase 1 WiFi failover section (wlp3s0 disabled 2026-06-02)
- Changelog: Slate AX retired, SODOLA added, Proxmox as subnet router

containers/121-caddy.md:
- Changelog: caddy.service unit was missing from hubris1 package,
  recreated manually; risk of loss on package reinstall noted

containers/107-dns.md:
- Update Who points here: Technitium DHCP hands out .2 as DNS for
  homelab clients; Fritz!Box LAN clients still get Fritz!Box DNS
- Add DHCP section documenting the homelab scope (100-240, gw .1)
- Changelog: DHCP enabled 2026-06-02, replaces Slate AX DHCP

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 22:02:34 +02:00
99d412370b network: migration complete — Proxmox as subnet router, Technitium DHCP live
- plans/: mark Slate AX → SODOLA migration as Done (2026-06-02)
- infrastructure/network.md: rewrite to reflect actual topology —
  Fritz!OS 8.x lacks second-IP-network support, so Proxmox routes between
  vmbr1 (192.168.178.10, uplink) and vmbr0 (192.168.8.x, internal portless
  bridge with 192.168.8.1 alias). Static route on Fritz!Box, no NAT on Proxmox.
  Technitium DHCP enabled for 192.168.8.100–240 (gateway .1, DNS .2).
  Caddy service unit was missing on CT 121 — recreated and enabled.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 21:34:56 +02:00
1e7c5a7153 hermes-soul: auto-provision SOUL.md from HERMES.md via setup-hermes-soul.sh
- tools/setup-hermes-soul.sh: new auto-setup script (no-op on non-Hermes)
- HERMES.md: canonical persona for all agent types, agent-type table
- AGENTS.md: auto-setup section lists both caveman and hermes-soul
- post-pull.sh runs it automatically on every sync
2026-06-02 00:54:03 +02:00
c7f68a095b tools: post-pull auto-setup hook + caveman (RTK-style token optimization) 2026-06-02 00:31:48 +02:00
3154b3ca02 docs: mark MCP streamable-http migration resolved (#4) 2026-06-02 00:30:06 +02:00
520a86c986 fix: agent-enrollment.md doc references to use /mcp path 2026-06-02 00:28:43 +02:00
ec48041b54 fix: correct endpoint path to /mcp for streamable-http transport 2026-06-02 00:25:34 +02:00
c299409431 docs: update ssh-access.md + agent-enrollment.md with universal SSH setup 2026-06-02 00:20:28 +02:00
bebebf486a ssh-config: add gen-config.py + homelab ssh-config CLI subcommand 2026-06-02 00:16:15 +02:00
e7bdf9fca7 fix: streamable-http (hyphen, not underscore) in FastMCP transport 2026-06-02 00:12:59 +02:00
00a891b154 ssh: fix LXC name parsing in deploy-keys.sh (Lock column edge case) 2026-06-02 00:06:58 +02:00
5adb66237e ssh: add authorized_keys dir + deploy-keys.sh for key distribution 2026-06-02 00:05:03 +02:00
0a3e43473e inventory: add lan_ip for mac-mini (192.168.8.174) 2026-06-02 00:00:47 +02:00
daf4747c5c post-mortem: mac-mini onboarding — 12 issues found 2026-06-01 23:45:22 +02:00
e615599a2c revert test 2026-06-01 23:41:19 +02:00
19b2109efc test 2026-06-01 23:41:06 +02:00
10fdb0fe09 revert: test commit 2026-06-01 23:34:32 +02:00
946ded6d08 test credential 2026-06-01 23:34:19 +02:00
95a3ae75c3 hermes-agent: update model to deepseek/deepseek-v4-flash (tool-use support) 2026-06-01 23:28:43 +02:00
9be6f7f31d openrouter-api-key: seed real key 2026-06-01 23:24:36 +02:00
root
5f929ce3ee openrouter-api-key: seed real key 2026-06-01 23:09:41 +02:00
root
76bbe99798 client-add: mac-mini (finalize age_pubkey + grant shared + hermes secrets) 2026-06-01 22:50:36 +02:00
543745cad9 network: retire Slate AX; add SODOLA switch migration plan and network topology doc
- infrastructure/network.md: permanent topology reference — VLAN 1 (main LAN)
  + VLAN 10 (homelab 192.168.8.0/24), Fritz!Box 7590 as VLAN router,
  SODOLA 5-Port 2.5Gbit as homelab switch
- plans/2026-06-01-slate-ax-to-sodola-migration.md: full migration runbook
  (pre-flight, Fritz!Box second-network config, SODOLA VLAN table, cutover
  procedure, verification commands). Gateway 192.168.8.1 is unchanged —
  no LXC/VM config edits needed. Fritz!Box DHCP for VLAN 10 hands out
  192.168.8.2 (Technitium CT 107) as DNS, closing the outstanding item in dns.md.
- plans/index.md: establishes plans/ convention (Planned → In Progress → Done)
- CONTRIBUTING.md: plan page template + linking discipline for plans

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 22:48:02 +02:00
ce6df85bb5 Merge pull request 'hermes-agent: onboard Nous-Hermes-on-Goose to homelab clients' (#1) from hermes-onboarding into main
Reviewed-on: dtoro/Homelab-Docs#1
2026-06-01 22:27:09 +02:00
f25d9e0648 hermes-agent: onboard Nous-Hermes-on-Goose to homelab clients
`bootstrap.sh --with-hermes` installs the Goose CLI, drops a Goose
config pinning the OpenRouter provider + Nous Hermes model + the
homelab MCP extension, symlinks `bin/hermes` and HERMES.md, and links
HERMES.md as `.goosehints` so the persona is injected as the system
prompt every session.

`bin/hermes` decrypts `secrets/openrouter-api-key.yaml` via the existing
`homelab secret` flow and execs `goose session`.

`homelab client add --with-hermes` grants the new sops secret to the
host's age_pubkey at finalize time (parallel to the existing
shared-secrets grant). `client remove` revokes it.

`operations/hermes-agent.md` covers the end-to-end flow, verification,
troubleshooting, and queues one follow-up: the MCP server still runs
SSE-only but Goose 1.x deprecated SSE — the Goose config targets
`streamable_http` and the `homelab` extension won't connect until
`mcp/server.py` migrates. The `developer` extension (shell + edit +
`homelab` CLI) carries the agent in the meantime.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 22:27:09 +02:00
563dbe21b1 arch-migration P2: Technitium dns LXC (107); retire LXC 124
- containers/107-dns.md: new Technitium DNS server page
- dns.md: dnsmasq -> Technitium changelog; flags 3 overlapping DNS sources + router DHCP action
- 124-authentik: marked RETIRED (shut down)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 22:10:18 +02:00
4b713d7805 arch-migration P1: document sso.hubris.network outpost callback domain
Domain-level forward-auth needs its own external_host domain when the IdP core
and outpost are on different hosts. sso.hubris.network -> Caddy -> LAN outpost.
Includes the redirect_uris-regeneration gotcha. Carry the DNS record into
Technitium in DNS Phase 2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 00:45:25 +02:00
2762dcbdb8 arch-migration P1: dedicated forward-auth outpost (LXC 106)
- containers/106-auth-outpost.md: new LAN outpost page
- 124-authentik: forward-auth cut over, Authentik stopped (DNS-only now)
- investigation: forward-auth outpost marked RESOLVED

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 00:33:26 +02:00
8dbba323c1 authentik: migrate from LXC 124 to VPS; eliminate mesh bootstrap deadlock
Moved Authentik (2026.2.2 -> 2026.5.2, +Redis, dedicated auth Docker net)
off LXC 124 onto the VPS so netbird-mgmt's OIDC dependency no longer requires
the mesh it authenticates. depends_on: service_healthy makes the deadlock
structurally impossible. Full Postgres DB migrated (users/apps/passwords/groups).

- investigations/2026-05-31-authentik-vps-migration.md: full writeup + lessons
- 124-authentik: migration banner + changelog (now legacy; dnsmasq stays)
- dns: auth.hubris.network -> 82.165.190.79; NetBird resolver cache gotcha
- ingress: auth served by local container via Docker-provider labels (not cert-mirror)
- mesh: follow-up entry superseding the morning band-aid; bootstrap note obsolete

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 23:17:00 +02:00
249 changed files with 14660 additions and 1333 deletions

96
.agents/HERMES.md Normal file
View File

@@ -0,0 +1,96 @@
# HERMES.md — Agent persona for homelab clients
This file is the canonical agent persona for **all** AI agents running on
machines in the **hubris** homelab. It prescribes behaviour, token-efficiency
conventions, and the source-of-truth hierarchy.
## Source of truth
The homelab-context repo at `/opt/homelab-context/` is the single source of
truth for:
- Fleet topology (`inventory.yaml`, `hosts/*.yaml`)
- Service endpoints and credentials (via `homelab secret`)
- Agent behaviour and conventions
- Everything in this file
When in doubt, check `/opt/homelab-context/` first.
## Runbooks — load, don't rediscover
For the canonical workflows (service health check, config change +
deploy, client enrollment, incident investigation, and each node
lifecycle transition), read the matching `.agents/skills/<name>/SKILL.md` before
acting. Each skill carries its risk class, required inputs, the
verification command, and a docs-update checklist in its frontmatter —
classify against `oikos/policy.yaml` using that risk class before any
mutation. Don't re-derive topology or the mutation path by grepping the
wiki when a runbook already encodes it. See [OIKOS.md](OIKOS.md) for the
operating model these runbooks execute inside (OODA loop, risk classes,
approval flow, ontology).
## Agent type — how this file gets loaded
| Agent | Loading mechanism |
|-------|------------------|
| **Hermes** | `tools/setup-hermes-soul.sh` (auto-setup) → provisions `~/.hermes/SOUL.md` from this file |
| **Goose** | `.goosehints` symlink at `~/.config/goose/.goosehints``/opt/homelab-context/HERMES.md` |
| **Claude Code / Codex** | Symlink or copy this file into the project's `CLAUDES.md` / `.claude` instructions |
**Do not edit SOUL.md or .goosehints directly.** Edit this file in the
homelab-context repo instead. Changes propagate to all clients on the next
sync (`sudo homelab sync`).
---
## Token efficiency (caveman skill)
All homelab agents use the **Caveman + RTK** token optimization approach from
https://github.com/adityahimaone/hermes-agent-rtk-caveman.
### Before running any CLI command, ask:
1. **Is there a caveman wrapper equivalent?** Use the wrapper for token-efficient
output. Available wrappers (installed at `~/bin/caveman_wrapper.sh`):
- `~/bin/caveman_wrapper.sh git-status` — compact git status
- `~/bin/caveman_wrapper.sh git-log [n]` — compact git log
- `~/bin/caveman_wrapper.sh lint [target]` — compact lint results
- `~/bin/caveman_wrapper.sh test-results [cmd]` — compact test results
2. **If no caveman wrapper exists, pipe through `rtk`** to compress output:
```
rtk <command>
```
RTK (Rust Token Killer) strips redundant whitespace, trims long paths, and
deduplicates repeated lines. This reduces token usage by 60-90% on CLI
operations.
3. **For homelab operations**, prefer the `homelab` CLI or MCP tools over
raw SSH/shell — they're already token-optimized.
### Templates
Caveman templates live at `~/templates/`:
- `git_status.txt` — compact git status format
- `git_log.txt` — compact git log format
- `lint_results.txt` — compact ESLint format
- `test_results.txt` — compact vitest/jest format
### When to skip caveman/rtk
- Interactive commands (editors, prompts) — let human-readable output pass
- Commands with no output — skip entirely
- When you need the exact raw output for post-processing
### Verification
```bash
ls ~/bin/caveman_wrapper.sh && echo "caveman ready"
```
## Important note for Hermes agents
If you are reading this as a Hermes agent, your SOUL.md was auto-provisioned
by `tools/setup-hermes-soul.sh`. This file is the canonical original — you
can verify the content matches or re-provision by running:
bash /opt/homelab-context/tools/setup-hermes-soul.sh

227
.agents/OIKOS.md Normal file
View File

@@ -0,0 +1,227 @@
# Oikos — the operating model
Oikos (Greek: *household*) is the agent operating system layered on this
repo. It is not new infrastructure: `inventory.yaml` is the kernel data
structure, the `homelab` CLI and MCP server are the syscall surface, and
this page defines the rules everything above them follows.
Read this after [AGENTS.md](../AGENTS.md). Machine-readable companions:
[oikos/ontology.yaml](../oikos/ontology.yaml) (systems model),
[oikos/policy.yaml](../oikos/policy.yaml) (risk & approval).
## The kernel loop: OODA
Every Oikos activity — scheduled probe, agent task, operator request — is
one pass through **Observe → Orient → Decide → Act**:
1. **Observe** — probes, drift detectors, and agent findings produce
**Signals** (structured records, not loose messages): pending updates,
high temperature, low disk, service down, cert expiry, stale backup,
inventory drift.
2. **Orient** — walk the ontology graph: what entity is affected, what
depends on it (blast radius), its lifecycle state, whether a runbook
matches, what the ledger says about past attempts.
3. **Decide** — the classifier scores **risk class × blast radius ×
confidence** and routes:
- **auto-act**: within autonomy policy, high confidence, contained radius
- **escalate**: operator approval via Matrix (✅/❌ reaction) or the
Oikos Console's `/approvals` page (destructive actions additionally
need a typed confirmation phrase either way)
- **queue**: informational — console + reports
The classifier can only *lower* autonomy relative to policy, never raise
it. When in doubt, escalate.
4. **Act** — execute through `homelab` commands or runbooks (never ad-hoc
SSH), then **verify** with the action's verification command, write a
**ledger** entry, resolve the Signal, and update docs in the same session.
## Primitives
| Primitive | What it is | Lives in |
|---|---|---|
| Host / Service | topology entities | `inventory.yaml` (+ generated `hosts/*.yaml`) |
| Secret | SOPS+age encrypted value, per-client recipients | `secrets/` + `.sops.yaml` |
| Runbook | executable workflow with risk class + verification | `.agents/skills/<name>/SKILL.md` |
| Signal | something needing attention, with lifecycle | `signals/` ledger (Week 3) |
| Change | one mutation: who, what, risk, approval, verification | `ledger/` (Week 2) |
| Approval | short-TTL signed grant for a gated action | approval engine (Week 3) |
| Incident | investigation narrative | `knowledge/sources/investigations/` |
| Plan | design doc for non-trivial work | `plans/` |
| Agent | enrolled client identity = its age pubkey | `inventory.yaml` + `.sops.yaml` |
## Risk classes (enforced, not advisory)
From [oikos/policy.yaml](../oikos/policy.yaml):
- **read_only** — status, logs, docs, inventory. Unattended.
- **reversible_low** — restart, cache clear, sync pull. Unattended + ledger.
- **config_mutation** — tracked-config edits (commit+push, never local),
deploys, upgrades, DNS/ingress changes. Operator approval.
- **destructive** — destroy, format, wipe, rotate, revoke. Approval +
typed confirmation phrase.
Lifecycle gates modify these: `provisioning` nodes are freely mutable
(nothing depends on them); `deprecated` nodes accept no new dependents;
anything touching a `destroyed` node is drift.
## The systems model
Eight domains — physical, compute, network, storage, software,
identity & access, operations, external — cover everything in the lab;
entities are connected by typed edges (`hosts`, `provides`, `mounts`,
`stores-on`, `routes-to`, `can-decrypt`, `depends-on`, `backs-up-to`, …)
defined in [oikos/ontology.yaml](../oikos/ontology.yaml). Rule of
completeness: **if it can break, be changed, or hold data, it has an
entity and edges.** Blast-radius questions ("what breaks if strong goes
down?") are graph walks, not doc archaeology.
Nodes move through an explicit lifecycle —
`planned → provisioning → active → migrating → deprecated → destroyed`
stored as `state:` in inventory (absent = active). Destroyed nodes live in
the `archaeology:` section. Each transition is a runbook checklist;
deprecation completes only when inbound edges reach zero.
Generated views: [infrastructure/topology.md](../knowledge/wiki/infrastructure/topology.md)
(Mermaid, regenerated from inventory) and the live, clickable version at
`oikos.hubris.network/graph` once the Console is deployed.
## Conventions carried forward
- Inventory is the truth; live state wins over narrative docs.
- Prefer `homelab` CLI and MCP over ad-hoc SSH.
- Meaningful changes update docs in the same session.
- Secrets are decrypted locally via per-client keys; never into docs/comments.
- Tracked configs change by commit + push, not local edits.
- Netbird is the preferred mesh path for new traffic.
- Agents are terse ([caveman.md](shared/caveman.md)), verify claims, and fix
collateral drift when found.
## Build status (30-day roadmap, started 2026-07-05)
- **Week 1**: policy, ontology, service contract, archaeology, topology
generator, this brief. Shipped.
- **Week 2**: context cards, `homelab service <name> …`, change ledger,
`node relations`, runbooks. Shipped.
- **Week 3**: ops scheduler + state cache (`homelab service <name> health`
is cache-first, `--live` forces a probe), drift detectors, signal engine
(`homelab signal …`), decision classifier (`homelab decide …`), approval
engine (`homelab approval …` — shared-HMAC grants; Matrix delivery is
Hermes's existing `@dtoro:avispero` send path, not a new bot, see
`oikos/approve.py`), daily brief + weekly report (`oikos/report.py`).
Shipped, except: Prometheus is still `planned` (see
[plans/2026-07-05-oikos-prometheus-lxc.md](../plans/2026-07-05-oikos-prometheus-lxc.md)) —
trend signals (disk-full prediction, temp creep) wait on that LXC; the
scheduler's disk check today is point-in-time only, and CPU/NVMe
temperature isn't probed at all yet (no confirmed sensor path on
hubris/strong). DNS-vs-inventory and generic tracked-config-cleanliness
drift checks are also deferred (see `oikos/drift.py` docstring).
- **Week 4**: Oikos Console v0 shipped — signals landing page, service
grid + detail, node/blast-radius view, live Mermaid graph, drift view,
approvals queue (approve/deny, destructive confirmation-phrase
enforced), daily/weekly reports. Server-rendered FastAPI + Jinja2, no
SPA build chain, tested end-to-end against live production data (see
`oikos/console/`). Deploys as a third webhook on `dtoro/Homelab-Docs`
(`/opt/oikos-console`, port :9831) — see
[oikos/console/deploy/README.md](../oikos/console/deploy/README.md) for
the Caddy route and Gitea webhook registration this repo can't do for
itself. Approval grants are now single-use (a second `check_grant` call
for the same request fails even within the TTL) and already exact-bound
to request id + entity + action.
**Not shipped as originally planned:** per-agent *age-key-signed*
request authentication — age has no signing primitive (it's an
encryption-only keypair format), so "age-key-signed" wasn't
buildable as stated. The real alternative (SSH-key signing via
`ssh-keygen -Y sign`/`-Y verify`, using each host's already-provisioned
SSH key) is real and buildable, but needs SSH public keys recorded in
inventory first — not there today. Moved to the 60/90-day backlog.
Authentik step-up re-auth on the approve/deny route is documented but
needs a live Authentik instance to configure — also backlog.
Docs pass done (this file, AGENTS.md, operations/commands.md); found
and fixed two more stale references while at it (DNS section still
pointed at destroyed LXC 124/dnsmasq instead of Technitium on 107, and
a `claudio-monitor` reference that's been deprecated since 2026-06-04).
### Real drift found while building Week 3 (unresolved, needs operator action)
The drift detectors surfaced genuine, currently-true findings on first
run against production — recorded here rather than silently fixed, since
each is a `config_mutation`/`destructive`-class decision:
- `republic-laptop` has no `age_pubkey:` in `inventory.yaml`, but its real
age key is granted on nearly every shared secret in `.sops.yaml`
(`age1vf8h7...`) — the enrollment write-back to inventory never
happened. Fix: `homelab client add republic-laptop --finalize-pubkey
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6`.
- `grimmory` has an `age_pubkey` in inventory but is missing from
`secrets/hello.yaml`'s recipient list — incomplete enrollment the
other direction. Fix: re-run `homelab client add grimmory
--finalize-pubkey <its key>`.
- `pve_id 131` exists live on hubris (`pct list`) with no inventory entry
— investigate before assuming it's a stale ID (see the Prometheus LXC
plan doc above, which flags this explicitly).
- Three `lifecycle-pve-id-reuse` info findings (100, 106, 107 each shared
between an active host and an archaeology entry) — expected/benign ID
reuse after destroy, no action needed.
## 60/90-day backlog
Derived from gaps observed while building the 30-day roadmap, not
guesswork. Roughly ordered by what unblocks the most:
- **Fix the oikos-console deploy webhook's signature mismatch.** Console
is live on apps (105) via a manual `deploy.sh` run, but Gitea webhook
14's deliveries all 403 with a signature mismatch for a cause not yet
found — the secret is confirmed synced correctly on both sides
(rotated once already to rule out drift). Until fixed, `git push`
doesn't auto-redeploy the console the way it does for homelab-mcp/
secrets-issuance; re-run `deploy.sh` on apps manually after changes.
See [oikos/console/deploy/README.md](../oikos/console/deploy/README.md).
- **SSH-key-signed approval requests.** Replaces the design note in
Week 4: age keys can't sign (encryption-only format), so per-agent
request authentication needs `ssh-keygen -Y sign`/`-Y verify` against
each host's existing SSH key. Blocked on a schema gap: inventory
doesn't record SSH public keys today, only ports/users. First step is
populating that field on enrollment, then wiring `oikos/approve.py` to
require and verify a signature over the request payload.
- **Authentik step-up re-auth** on the Console's `/approvals` POST route
— needs a live Authentik `PromptStage`/reauth flow scoped to that path;
not configurable without a running instance to test against.
- **Prometheus provisioning** (see
[plans/2026-07-05-oikos-prometheus-lxc.md](../plans/2026-07-05-oikos-prometheus-lxc.md))
— unblocks trend signals (disk-full prediction, temp creep) and real
sparklines in the Console; investigate the undocumented `pve_id 131`
on hubris first.
- **CPU/NVMe temperature probing** in the scheduler — needs a confirmed
sensor path on hubris and strong (lm-sensors vs vendor tool) before a
real check can be written; guessing one risks a probe that silently
never fires.
- **DNS-vs-inventory drift check** — compare Technitium zone records
against `services.*.url`/`public_host`; not implemented (`oikos/drift.py`
has no Technitium API wiring yet).
- **Generic tracked-config-cleanliness drift check** — today only caddy's
`/etc/caddy` git-checkout path is hardcoded in `oikos/drift.py`; every
other service with a `config_repo` needs its local checkout path
recorded (a `mutation_path`-style field, same gap Week 1's service
contract flagged but didn't backfill) before this generalizes.
- **Per-service policy overrides** (`oikos/policy.yaml`
`service_overrides`) — schema is ready (caddy/dns already use it);
populate more as specific services turn out to need non-default risk
classes.
- **Incident timeline generator** — stitch ledger + signal history into
a single narrative for `knowledge/sources/investigations/` entries instead of writing
them by hand.
- **Secret access audit** — who-can-decrypt-what report from
`.sops.yaml` + inventory `age_pubkey`s, extending what
`oikos/drift.py`'s SOPS check already partially does.
- **Restore drills** — exercise `backs-up-to` (once populated) by
actually restoring from a backup target on a schedule, not just
checking freshness.
- **Multi-agent delegation model** — more than one agent acting
concurrently; needs the ledger's `agent` field to carry real identity
(age pubkey, not just hostname) consistently, which it mostly does
already but hasn't been stress-tested with concurrent writers.
- **Grafana** — only if the Console's own Prometheus-backed sparklines
turn out to be insufficient once Prometheus ships.
- **"Generalize later" extraction** — the original decision was personal-
first, generalize-later (see Week 1). Once patterns stabilize, extract
a config-driven Oikos core with no `hubris.network`/`hubris`/`strong`
hardcoding, so it's installable on a different homelab.

View File

@@ -0,0 +1,48 @@
# Knowledge domain — schema
The knowledge domain is the durable, authoritative current-state documentation of the homelab: one
page per node and per cross-cutting system, synthesized from live state and evidence. It answers
"what exists and how does it work right now."
It follows the [LLM Wiki layer model](../../shared/llm-wiki.md) and the
[writing-style](../../shared/writing-style.md) and [page-templates](../../shared/page-templates.md)
rules.
## The narrative / substrate split
The knowledge wiki is **narrative**. It sits alongside a **machine-readable substrate** that it
describes but never contains. The split is load-bearing: several programs read the substrate at
fixed paths, so the wiki reorganization never moves it.
| Layer | Location | Consumed by |
|-------|----------|-------------|
| Substrate — source of truth | `inventory.yaml` (root) | MCP server, `homelab` CLI, `oikos/` scheduler/drift/relations/gen-topology |
| Substrate — generated host records | `hosts/*.yaml` (root) | `mcp/server.py` (`HOSTS_DIR`), `bin/homelab`; written by `mcp/build_host_files.py` |
| Substrate — kernel + context cards | `oikos/` (code, `oikos/cards/`, `oikos/state.json`) | MCP `explain`, scheduler |
| Narrative — synthesized wiki | `knowledge/wiki/{hosts,containers,vms,infrastructure}/` | humans, agents via MCP `get_page` / `search_docs` |
| Evidence — immutable sources | `knowledge/sources/` (references + investigations) | synthesis into wiki pages |
## Wiki pages
- **Node pages** (`knowledge/wiki/containers/<id>-<name>.md`, `.../vms/<id>-<name>.md`,
`.../hosts/<name>.md`) follow the container/host template in
[page-templates.md](../../shared/page-templates.md): opening definition, `## At a glance`,
`## Role`, service/port map, storage, auto-deploy, `## Related`, `## Changelog`.
- **Cross-cutting pages** (`knowledge/wiki/infrastructure/<topic>.md`) follow the cross-cutting
template: `## Why`, `## Components`, `## How to apply`, `## Gotchas`, `## Related`, `## Changelog`.
- Each `inventory.yaml` host entry carries a `doc_page:` field pointing at its narrative page.
Changing where a page lives means updating that field (read by `bin/homelab`).
## The two logs
- The per-page **`## Changelog`** records infrastructure changes and is machine-parsed
(`get_changelog`, the Oikos ledger). Keep the `### YYYY-MM-DD — title` shape.
- **`knowledge/log.md`** is append-only and records *documentation-maintenance* operations only
(restructures, source ingests, lint sweeps): `## [YYYY-MM-DD] <op> | <summary>`. It never
duplicates the Oikos change ledger (`oikos/ledger.py`).
## Same-session update rule
A change to a node updates every page that references it in the same session — the node page, the
section `README.md` table, the root `README.md`, the Caddy/DNS/ingress pages, the host page, and
`inventory.yaml`. See [page-templates.md](../../shared/page-templates.md#same-session-update-rule).

View File

@@ -0,0 +1,55 @@
# Operations domain — schema
The operations domain holds the procedural and time-stamped documentation: runbooks (repeatable
procedures), investigations (incident evidence), and plans (design docs for non-trivial work). It
follows [writing-style](../../shared/writing-style.md); runbooks and plans use the imperative voice
exception.
Where each kind lives: runbooks are skills under [`.agents/skills/`](../../skills/); operator
reference (command cheatsheet, enrollment, Hermes agent) lives in
[`.agents/operations/`](../../operations/); investigations are sources under
`knowledge/sources/investigations/`; plans stay in the repo-root `plans/` folder (below).
## Plans always live in `plans/`
**Any plan or design doc for the Homelab is written into the repo `plans/` folder as
`plans/YYYY-MM-DD-slug.md` — never a scratch path, an agent-private plan location, or a chat
message.** An agent drafting a plan:
1. Writes the file under `plans/` using the plan template in [page-templates.md](../../shared/page-templates.md).
2. Lists it in `plans/index.md`.
3. On completion, moves it to `plans/done/` and updates the index status.
This is the single source for homelab design intent; keeping it in-repo means the plan is
versioned, reviewable, and reachable by MCP `get_page`/`search_docs` like any other doc.
## Runbooks
Repeatable procedures are skills — one folder per skill at `.agents/skills/<name>/SKILL.md`, with
YAML front-matter that the Oikos policy and lifecycle machinery reads:
```yaml
---
name: <name>
risk_class: read_only | reversible_low | config_mutation | destructive
inputs: [<param>, ...]
verification: "<shell expression that proves success>"
docs_update_checklist: [<doc artifacts to update>]
transition: "<from> -> <to>" # only for lifecycle runbooks
---
```
`risk_class` values and the lifecycle `transition` states must match
[`oikos/policy.yaml`](../../../oikos/policy.yaml) and [`oikos/ontology.yaml`](../../../oikos/ontology.yaml).
## Investigations
Incident records live in `knowledge/sources/investigations/YYYY-MM-DD-slug.md` and are **evidence sources** — written
once at incident time, then linked from the changelogs of the nodes they implicate. Sections:
`## Summary`, `## Timeline`, `## Root cause`, `## Mitigations applied`, `## Open questions`. Resolved
incidents move to `knowledge/sources/investigations/archive/`.
## The operations log
`plans/log.md` and `knowledge/log.md` are append-only records of documentation operations on
those areas (`## [YYYY-MM-DD] <op> | <summary>`), distinct from the Oikos change ledger.

View File

@@ -6,7 +6,7 @@ this repo that auto-syncs every 5 min, a per-client age key for SOPS
decryption, the `homelab` CLI, and an MCP endpoint in Claude Code's config.
> Onboarding a Nous-Hermes-powered Goose agent on top of standard enrollment?
> See [hermes-agent.md](./hermes-agent.md). It uses the same `bootstrap.sh`
> See [hermes-agent.md](hermes-agent.md). It uses the same `bootstrap.sh`
> with an additional `--with-hermes` flag.
Architecture in [project_homelab_context_plan](https://… memory link); the
@@ -20,7 +20,7 @@ operational reference is here.
| OS is Linux or macOS | bootstrap detects via `uname -s` | `uname -s` |
| On the mesh (Netbird or Tailscale) **or** on the LAN | issuance is gated to mesh + LAN subnets. **For Netbird: use a setup-key, not interactive auth** — see "Getting onto Netbird" below. | `netbird status` / `tailscale status` |
| `git`, `python3`, `python3-yaml`, `age`, `sops` | bootstrap preflight; `homelab` CLI imports yaml | See per-OS commands below |
| Can resolve `*.hubris.network` | bootstrap calls `https://secrets.hubris.network/issue` and writes `https://mcp.hubris.network/sse` | `dig +short mcp.hubris.network` (should return `192.168.8.175`) |
| Can resolve `*.hubris.network` | bootstrap calls `https://secrets.hubris.network/issue` and writes `https://mcp.hubris.network/mcp` | `dig +short mcp.hubris.network` (should return `192.168.8.175`) |
### Hostname mismatch is the most common bootstrap failure
@@ -40,7 +40,7 @@ Bootstrap auto-installs netbird and drives `netbird up` if the mesh isn't alread
The new client runs bootstrap straight from a fresh OS. Bootstrap installs netbird (apt/dnf/brew based on the OS), then runs `netbird up --management-url https://netbird.hubris.network --ssh-jwt-cache-ttl 86400`. A device-code URL prints inline. The operator opens it (in a browser logged into Authentik), goes through identification → password → consent, and the CLI returns `Connected`. Bootstrap then proceeds with the rest of preflight.
Pre-condition: the operator must be a registered user in Authentik (typically the lab owner). The first user-login against a netbird account with existing peers is added as `pending_approval=1` and needs an sqlite promotion to `owner` — see [124-authentik.md First-time owner promotion gotcha](../containers/124-authentik.md). Only needed once per account.
Pre-condition: the operator must be a registered user in Authentik (typically the lab owner). The first user-login against a netbird account with existing peers is added as `pending_approval=1` and needs an sqlite promotion to `owner` — see [124-authentik.md First-time owner promotion gotcha](../../knowledge/wiki/containers/106-auth-outpost.md). Only needed once per account.
**Path A — setup-key (headless/scripted onboarding):**
@@ -62,7 +62,7 @@ Useful for headless servers (no browser at all) or unattended cloud-init bootstr
### DNS prerequisite
`*.hubris.network` resolves via the split-horizon dnsmasq on LXC 124
([dns.md](../infrastructure/dns.md)) for LAN clients, **but only if the
([dns.md](../../knowledge/wiki/infrastructure/dns.md)) for LAN clients, **but only if the
client uses 192.168.8.180 as its resolver**. Most LXCs and roaming
workstations don't by default. Options:
@@ -83,7 +83,7 @@ If DNS isn't an option at all, override the URLs at bootstrap time:
sudo HOMELAB_GITEA_TOKEN=... \
HOMELAB_REPO_URL=http://192.168.8.121:3000/dtoro/Homelab-Docs.git \
HOMELAB_ISSUANCE_NETBIRD=http://192.168.8.205:9820/issue \
HOMELAB_MCP_URL=http://192.168.8.205:9810/sse \
HOMELAB_MCP_URL=http://192.168.8.205:9810/mcp \
bash /tmp/bootstrap.sh --with-mcp
```
@@ -163,6 +163,77 @@ For Claude Code: start a new session — the `homelab` MCP server appears
in `~/.claude/.mcp.json` and registers 14 tools (8 context, 5 management,
1 secrets-metadata).
## Post-bootstrap: SSH reachability
A new workstation must be reachable from other workstations and must be
able to reach every host by short hostname. Run these steps after the
bootstrap verify passes:
### 1. Enable SSH server
```bash
# macOS:
sudo launchctl load -w /System/Library/LaunchDaemons/ssh.plist
# Linux:
sudo systemctl enable --now sshd
```
### 2. Generate SSH key (if missing)
```bash
ls ~/.ssh/id_ed25519.pub 2>/dev/null || ssh-keygen -t ed25519 -a 100
```
### 3. Publish pubkey to the repo
```bash
cp ~/.ssh/id_ed25519.pub /opt/homelab-context/ssh/authorized_keys/$(hostname -s).pub
cd /opt/homelab-context && git add ssh/authorized_keys/ && git commit -m 'ssh: add $(hostname -s) pubkey' && git push
```
### 4. Deploy keys to all hosts
From any existing enrolled machine (hubris or another workstation):
```bash
ssh root@192.168.8.77 "cd /opt/homelab-context && git pull --ff-only && bash ssh/deploy-keys.sh"
```
This adds the new workstation's pubkey to hubris and every running LXC.
### 5. Generate SSH config
```bash
homelab ssh-config --install
```
Verify:
```bash
ssh hubris hostname # should return "hubris" without password
ssh gitea hostname # should return "gitea" without password
ssh mac-mini hostname # should return "mac-mini" without password (workstation-to-workstation)
```
### 6. Add LAN IP to inventory (if on LAN)
If the workstation has a static or reserved LAN IP, add it to
`inventory.yaml`:
```yaml
hosts:
your-hostname:
lan_ip: 192.168.8.xxx
```
This gives it a primary LAN entry in the generated SSH config (faster
than the Netbird fallback). Commit + push, then:
```bash
cd /opt/homelab-context && git pull --ff-only && homelab ssh-config --install
```
## Claude Code permissions for fleet ops
By default Claude Code's auto-mode classifier asks for confirmation on every
@@ -190,7 +261,7 @@ arguments.
If you also want the netbird `--ssh-jwt-cache-ttl` flag rationale to be
visible to the classifier (it's not actually durable in 0.71.2, but the
ControlMaster block is — see [runbook-dpkg-interrupted](runbook-dpkg-interrupted.md)
ControlMaster block is — see [runbook-dpkg-interrupted](../skills/runbook-dpkg-interrupted/SKILL.md)
for context), drop a free-text rule into `autoMode.allow` describing the
authorization. Optional.
@@ -271,15 +342,21 @@ The CLI prints a follow-up checklist that the operator must do manually:
| `homelab` CLI doesn't pick up repo updates | Pre-`02db…` bootstrap copied the binary instead of symlinking | One-time migration: `sudo ln -sfn /opt/homelab-context/bin/homelab /usr/local/bin/homelab`. New bootstraps use the symlink, which auto-tracks the synced repo. |
| `homelab-context-sync.service` journal shows `fatal: could not read Username for 'https://git.hubris.network'` | Pre-fix bootstrap set the gitea credential helper via `git config --global`, which writes to `/root/.gitconfig` — invisible to the systemd timer's git process (no HOME set). | One-time migration: `sudo git config --system credential.helper "store --file=/etc/homelab-context/git-credentials"`. New bootstraps store the helper in `/etc/gitconfig` instead. |
| Chat-mode `!` shell can't `sudo` (`a terminal is required to read the password`) | Claude Code's `!` invocation doesn't allocate a tty, and standard `sudo` won't read its password from stdin or a non-tty pipe. | Run the sudo'd command in a real terminal outside chat. For commands the agent issues repeatedly, configure passwordless sudo for the narrow set (e.g. `/etc/sudoers.d/homelab-self` with `<user> ALL=(ALL) NOPASSWD: /usr/bin/dnf upgrade -y, /usr/bin/apt-get *`). |
| `netbird status -d` reports `192.168.8.180:53 ... is Unavailable` but DNS actually works | netbird's UDP-53 probe times out over the relay latency (~90ms), but actual queries still flow through systemd-resolved. Cosmetic. | Ignore unless `dig @192.168.8.180 git.hubris.network` also fails — then check dnsmasq on [LXC 124](../containers/124-authentik.md). |
| `netbird status -d` reports `192.168.8.180:53 ... is Unavailable` but DNS actually works | netbird's UDP-53 probe times out over the relay latency (~90ms), but actual queries still flow through systemd-resolved. Cosmetic. | Ignore unless `dig @192.168.8.180 git.hubris.network` also fails — then check dnsmasq on [LXC 124](../../knowledge/wiki/containers/106-auth-outpost.md). |
| `netbird ssh` rejected with `JWT authentication failed: validate token (expected issuer=https://netbird.hubris.network/oauth2 ...)` | Peer's SSH JWT validator cached the OLD embedded-Dex issuer from before the 2026-05-21 Authentik migration. `systemctl restart netbird` and `netbird down/up` don't clear it — `client/internal/engine_ssh.go` bails out of `updateSSH()` if the SSH server is already running. | Full daemon bounce: `sudo systemctl stop netbird; sleep 3; sudo systemctl start netbird`. Verify with `grep -iE "issuer\|audience" /var/log/netbird/client.log \| tail`. Apply once per peer post-migration. |
| `netbird ssh` JWT passes but session closes with `user privilege check failed: user dtoro not found: unknown user dtoro` | netbird-ssh defaults the remote username to the LOCAL one (operator's laptop user). Hubris and LXCs only have `root`. | Always use explicit `root@` prefix manually: `netbird ssh -p 22022 root@proxmox-server.netbird.selfhosted`. `homelab ssh <host>` does this automatically via `inventory.yaml`'s per-host `ssh.user` field (defaults to `root`). |
| `homelab ssh hubris` (or any host on the LAN) fails with `Connection refused` or hangs, despite mesh routing being up | Off-LAN networks (operator on a VPN / coffee shop / symmetric NAT) sometimes can't reach the LAN IP even with the netbird subnet route. | Newer homelab CLIs probe the LAN with a 1.5s TCP connect and transparently fall back to the netbird FQDN. If your `/usr/local/bin/homelab` is a symlink to `/opt/homelab-context/bin/homelab` it'll pick up the fix on the next 5-min context sync. Otherwise pull the latest from gitea. |
## Changelog
### 2026-06-02 — SSH reachability post-bootstrap steps
Added a new "Post-bootstrap: SSH reachability" section covering SSH key
generation, pubkey publication, deployment to hosts, SSH config generation,
and LAN IP registration. New workstations enrolled via this doc will
automatically join the universal SSH mesh.
### 2026-05-31 — cross-link to hermes-agent.md
Added a sibling page covering Nous-Hermes-on-Goose enrollment ([hermes-agent.md](./hermes-agent.md)) and noted it at the top of this page. The Hermes flow extends `bootstrap.sh` with `--with-hermes` and `homelab client add` with the same flag; it does not change the underlying enrollment steps documented here.
Added a sibling page covering Nous-Hermes-on-Goose enrollment ([hermes-agent.md](hermes-agent.md)) and noted it at the top of this page. The Hermes flow extends `bootstrap.sh` with `--with-hermes` and `homelab client add` with the same flag; it does not change the underlying enrollment steps documented here.
### 2026-05-21 — netbird-ssh JWT issuer + username + LAN-fallback troubleshooting rows
Added three rows to the troubleshooting table covering issues surfaced during the netbird vanilla migration: (1) post-migration SSH JWT validator cache stuck on old Dex issuer (full `systemctl stop/start` required, not `restart`), (2) `user not found` from netbird-ssh's local-username default (use explicit `root@`), and (3) homelab CLI's LAN→netbird-FQDN fallback for off-LAN operators. Companion code change: per-host `ssh.user` field in `inventory.yaml` + `homelab` CLI's `ssh_target()` helper.

View File

@@ -0,0 +1,91 @@
# Operations cheatsheet
Run from the [hubris host](../../knowledge/wiki/hosts/hubris.md) as root. When working from `/root` on Linux you're already on hubris — don't `ssh hubris` / `ping hubris`.
## Proxmox CLI
| Command | Use |
| --- | --- |
| `pct list` / `qm list` | List LXC containers / VMs |
| `pct config <id>` / `qm config <id>` | Container / VM config |
| `pct exec <id> -- <cmd>` | Run command inside an LXC without entering it (no initgroups — see [media permissions](../../knowledge/wiki/infrastructure/media-permissions.md)) |
| `pct enter <id>` | Shell into a container |
| `pct start <id>` / `pct stop <id>` | Boot / halt a container |
| `pvesm status` | Storage pools status |
| `pvesh get /nodes --output-format json` | Node summary as JSON |
| `pvesh get /nodes/hubris/lxc/<id>/status/current` | Live container status |
| `pvesh get /cluster/resources --type vm --output-format json` | Bulk per-LXC CPU/mem/disk (used by the `homelab-health-watchdog` Hermes cron — see [monitoring](../../knowledge/wiki/infrastructure/monitoring.md); the old `claudio-monitor` this once fed is deprecated) |
| `pveversion` | PVE version |
| `journalctl -u pve-cluster -n 100` | PVE service logs |
## Storage
- Shared mount: `/mnt/library` (ext4 on lvmthin `library`).
- Bind into a container: `pct set <id> -mp<N> /mnt/library/<sub>,mp=/data`
- For the standard whole-tree mount: `pct set <id> -mp0 /mnt/library,mp=/mnt/library`. See [media permissions](../../knowledge/wiki/infrastructure/media-permissions.md) for the GID-10000 onboarding recipe.
## Reverse proxy
- Caddyfile: `/etc/caddy/Caddyfile` on [LXC 121](../../knowledge/wiki/containers/121-caddy.md).
- **CRITICAL:** This file is tracked in `dtoro/caddy-conf` (https://git.hubris.network/dtoro/caddy-conf). Never edit it directly on the LXC — commit + push to the repo instead. Caddy auto-deploys on push (see [auto-deploy](../../knowledge/wiki/infrastructure/auto-deploy.md)). If you edit directly, the change will be lost on the next pull and agents won't know about it.
- Hot reload: `pct exec 121 -- systemctl reload caddy`.
- Validate: `pct exec 121 -- caddy validate --config /etc/caddy/Caddyfile`.
- Git workflow shortcut: `pct exec 121 -- "cd /etc/caddy && git add Caddyfile && git commit -m '...' && git push"`.
## DNS
- Split-horizon authority: [Technitium DNS](https://technitium.com) on [dns (107)](../../knowledge/wiki/containers/107-dns.md) at `192.168.8.2:53`. Web UI at `http://192.168.8.2`. (Formerly dnsmasq on the now-destroyed LXC 124 — decommissioned 2026-06-04.)
- Add/edit records in the Technitium UI; the NetBird managed zone sync (`scripts/dns-sync.py` cron on 107) picks changes up within ~10 minutes.
- Verify: `dig @192.168.8.2 +short <host>.hubris.network`.
- See [DNS](../../knowledge/wiki/infrastructure/dns.md).
## Web access
- `https://proxmox.hubris.network` or `https://192.168.8.77:8006` — Proxmox UI
## Telemetry quick checks
- `ras-mc-ctl --summary` — summary of any RAS events (memory / PCIe AER / thermal) since boot
- `ras-mc-ctl --errors` — full event log
- `cat /sys/devices/system/cpu/cpu0/cpufreq/energy_performance_preference` — should be `balance_power`
- `cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor` — should be `powersave`
- `ls /sys/fs/pstore/ /var/lib/systemd/pstore/` — panic traces from a previous crash (empty for pure hardware hangs — see [investigation](../../knowledge/sources/investigations/archive/2026-04-21-hubris-crash-loop.md))
## Fleet apt operations
Two `homelab` subcommands wrap the common patterns; both fan out to hubris + every LXC.
| Command | What it does |
| --- | --- |
| `homelab apt-audit [--target HOST]` | Per-host table: dpkg-interrupted state, holds, upgradable count, non-apt binaries in system paths, DNS health. Exits nonzero if any host has dpkg-interrupted state. |
| `homelab apt-upgrade --target HOST` | Launch `apt update && apt upgrade` inside a transient `systemd-run --collect` unit on the target. Survives ssh teardown. Apt configured with `Acquire::Retries=3` + `ForceIPv4=true`. |
| `homelab apt-upgrade --all` | Same, fanned out across the standard targets. |
| `homelab apt-upgrade ... --status` | Show running unit + tail `/var/log/homelab-apt-upgrade.log` on each target. |
| `homelab apt-upgrade ... --safe` | Take a pre-upgrade snapshot per LXC first (`pct snapshot``vzdump` fallback for bind-mounted LXCs). Refuses if any snapshot fails unless `--force`. |
| `homelab apt-upgrade ... --force` | Skip both the dpkg-audit gate and snapshot-failure refusal. |
PVE/kernel deferral on hubris: `homelab apt-upgrade --target hubris` will try every upgrade, including kernel + `pve-*`. To skip those, `apt-mark hold` the relevant packages on hubris first; `homelab apt-audit` shows held packages so you can confirm.
## Oikos (agent OS layer)
See [OIKOS.md](../OIKOS.md) for the operating model. Quick reference:
| Command | What it does |
| --- | --- |
| `homelab service <name> explain\|health\|docs\|log\|actions\|history` | Service Console v0 — context card, cached health (`--live` to force a probe), docs, logs, safe actions + risk class, ledger history |
| `homelab node <name> relations` | Ontology blast-radius query: what this host/service impacts, is affected by, and its full transitive blast radius |
| `homelab change preflight <service>` | Dry-run report before mutating: risk class, current health, config repo, verification command |
| `homelab decide <action> <entity>` | Decision classifier: risk × blast radius × confidence → auto-act or escalate |
| `homelab signal list\|raise\|ack\|resolve\|mute` | The attention layer — pending updates, thresholds, drift, anything needing attention |
| `homelab approval request\|list\|reply\|check` | Escalate-route grants (Matrix-delivered via Hermes, or the Oikos Console's `/approvals` page) |
| `homelab restart <service> [--approval-id <id>]` | `--approval-id` is required whenever the service's risk class needs approval (e.g. `caddy`, `dns`) — refuses mechanically without a valid grant |
Oikos Console (read-mostly dashboard): `oikos.hubris.network` once deployed — see [oikos/console/deploy/README.md](../../oikos/console/deploy/README.md).
## Related
- [Hubris host](../../knowledge/wiki/hosts/hubris.md)
- [Containers index](../../knowledge/wiki/containers/index.md)
- [DNS](../../knowledge/wiki/infrastructure/dns.md)
- [Monitoring](../../knowledge/wiki/infrastructure/monitoring.md)
- [Auto-deploy](../../knowledge/wiki/infrastructure/auto-deploy.md)
- [Runbook: dpkg-interrupted recovery](../skills/runbook-dpkg-interrupted/SKILL.md) — what to do when apt got killed mid-transaction

View File

@@ -2,7 +2,7 @@
Onboards [Nous Research's Hermes](https://nousresearch.com/) (a fine-tuned
Llama variant) as a working terminal agent on a homelab client. Builds on top
of standard client enrollment (see [agent-enrollment.md](./agent-enrollment.md))
of standard client enrollment (see [agent-enrollment.md](agent-enrollment.md))
— this page covers only the Hermes-specific additions.
The agent runs as a [Goose](https://goose-docs.ai/) session. Goose provides:
@@ -21,7 +21,7 @@ The persona is `/opt/homelab-context/HERMES.md`, symlinked as Goose's global
| Requirement | How |
| --- | --- |
| Standard enrollment complete (`homelab whoami` works) | [agent-enrollment.md](./agent-enrollment.md) |
| Standard enrollment complete (`homelab whoami` works) | [agent-enrollment.md](agent-enrollment.md) |
| `secrets/openrouter-api-key.yaml` exists with a real `sk-or-...` value | See "Seeding the OpenRouter key" below |
| The host's `age_pubkey` is on the openrouter-api-key.yaml sops rule | `homelab client add <host> --finalize-pubkey <age1...> --with-hermes` |
@@ -115,7 +115,7 @@ The bootstrap-managed keys in `~/.config/goose/config.yaml`:
```yaml
GOOSE_PROVIDER: openrouter
GOOSE_MODEL: nousresearch/hermes-4-405b
GOOSE_MODEL: deepseek/deepseek-v4-flash
GOOSE_MODE: smart_approve # asks before destructive tool calls
extensions:
developer:
@@ -165,13 +165,13 @@ every tool call, use `approve`. See
## Cross-references
- [agent-enrollment.md](./agent-enrollment.md) — base client onboarding the
- [agent-enrollment.md](agent-enrollment.md) — base client onboarding the
Hermes flow assumes is done.
- [`HERMES.md`](../HERMES.md) — the persona the Hermes agent reads on every
session start (via `~/.config/goose/.goosehints`).
- [`bin/hermes`](../bin/hermes) — the wrapper that decrypts the OpenRouter key
- [`bin/hermes`](../../bin/hermes) — the wrapper that decrypts the OpenRouter key
and execs `goose session`.
- [`bootstrap.sh`](../bootstrap.sh) — the `--with-hermes` flag's install block.
- [`bootstrap.sh`](../../bootstrap.sh) — the `--with-hermes` flag's install block.
## Follow-ups
@@ -190,9 +190,20 @@ every tool call, use `approve`. See
node. The wrapper, persona, and MCP wiring stay unchanged; only
`GOOSE_PROVIDER`/`GOOSE_MODEL` change.
7. **Caveman auto-setup via post-pull hook.** The sync timer now calls
`tools/post-pull.sh`, which runs any `tools/*.setup.sh` after git pull.
Currently this auto-installs the Caveman npm package, wrapper scripts, and
compact output templates on all agent hosts (*token efficiency*).
## Changelog
### 2026-05-31 — initial page
### 2026-06-01 — caveman + post-pull auto-setup
Added `tools/post-pull.sh` sync hook that auto-runs `tools/*.setup.sh`
after every git pull. First user: `tools/setup-caveman.sh` installed Caveman
templating + `~/bin/caveman_wrapper.sh` + `~/templates/*.txt` for token-
efficient CLI output. Replaces raw `git pull` in launchd/systemd timers.
Also created `tools/caveman/` with the wrapper script, JS renderer, and
templates — the canonical source for all agent hosts.
Captures the Hermes-on-Goose onboarding flow added in the same commit as
`bootstrap.sh --with-hermes`, `bin/hermes`, the sops rule for
`secrets/openrouter-api-key.yaml`, and the `homelab client add --with-hermes`

33
.agents/shared/caveman.md Normal file
View File

@@ -0,0 +1,33 @@
# CAVEMAN.md — communication mode for homelab agents
Respond terse like smart caveman. All technical substance stay. Only fluff die.
## Rules
Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). Technical terms exact. Code blocks unchanged. Errors quoted exact.
Pattern: `[thing] [action] [reason]. [next step].`
Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..."
Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"
## Levels
- **lite** — no filler/hedging. Keep articles + full sentences. Professional but tight.
- **full** (default) — drop articles, fragments OK, short synonyms. Classic caveman.
- **ultra** — abbreviate prose words (DB/auth/config/req/res), strip conjunctions, arrows (X → Y). Code symbols/API names/errors: never abbreviate.
Switch: `/caveman lite|full|ultra`. Stop: "normal mode".
## Auto-Clarity
Drop caveman for: security warnings, irreversible actions, multi-step sequences where fragments risk misread, user confused/repeating. Resume after clear part done.
## Boundaries
Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end.
---
Source: https://github.com/JuliusBrussee/caveman
Copy to `~/.hermes/skills/` for Hermes Agent, or `~/.claude/projects/<name>/SKILL.md` for Claude Code.

View File

@@ -0,0 +1,41 @@
# LLM Wiki — the documentation contract
How the narrative documentation in this repo is organized. The pattern is borrowed from the
`sources / wiki / index / log` model: a durable synthesized layer (`knowledge/wiki/`) built on top
of immutable evidence (`knowledge/sources/`, incident records), with pure-listing indexes and an
append-only operations log.
This contract governs the **narrative layer only**. The machine-readable substrate — `inventory.yaml`,
generated `hosts/*.yaml`, `oikos/`, `mcp/`, `secrets/`, `bin/` — is not part of the wiki and never
moves under it. See [the knowledge schema](../domains/knowledge/schema.md) for the split.
## Layers
- **Sources** are immutable raw material: incident records (`knowledge/sources/investigations/`), external reference
docs (`knowledge/sources/references/`), and the live system itself (`pct config`, `docker inspect`).
Read them; do not rewrite them into other sources.
- **Wiki** (`knowledge/wiki/`) is the synthesized, authoritative current-state layer: one page per
node (`containers/`, `vms/`, host narratives) and per cross-cutting system (`infrastructure/`). A
reader understands the topic from the wiki page without reading the sources.
- **Index** (`index.md` / folder `README.md`) is a pure listing — every page in scope with a
one-line summary, and nothing else. Anything the section wants to say up front goes into a page
the index lists, not into the index.
- **Log** (`log.md`) is append-only, recording *doc-maintenance operations* (restructures, source
ingests, lint sweeps) in single-line format: `## [YYYY-MM-DD] <op> | <summary>`.
## Two logs, kept distinct
- **`## Changelog`** on each node/topic page records *infrastructure* changes to that node. It is
machine-parsed (`get_changelog`, the Oikos ledger) — keep the `### YYYY-MM-DD — title` shape.
- **`log.md`** per area records *documentation* operations only. It never duplicates the Oikos
change ledger (`oikos/ledger.py`), which stays authoritative for infra changes with
who/what/risk/approval/verification.
## Rules
- Wiki pages stay short and focused. A page past ~300 lines splits.
- Pages stay flat under `wiki/<section>/` until there are enough to warrant a sub-group.
- Every page follows [writing-style.md](writing-style.md).
- Plans and design docs always live in the repo `plans/` folder (`plans/YYYY-MM-DD-slug.md`),
listed in `plans/index.md`, moved to `plans/done/` on completion — never a scratch path or a chat
message. See [the operations schema](../domains/operations/schema.md).

View File

@@ -0,0 +1,174 @@
# Page templates for the Homelab Wiki
The structural templates for each page type. Prose voice, vocabulary, and cross-reference rules live
in [writing-style.md](writing-style.md); the layer model (sources / wiki / index / log) lives in
[llm-wiki.md](llm-wiki.md).
## File naming
**Foundational / entry-point files:** ALL-CAPS
- **Root level:** `AGENTS.md`, `README.md` — discovery paths for agents and humans.
- **Agent instruction** (under `.agents/`): `OIKOS.md`, `HERMES.md` — foundational docs agents read before acting.
- **Reference docs:** `GLOSSARY.md` — lookup reference (like classic repo conventions: LICENSE, CHANGELOG, GLOSSARY).
**Content / narrative pages:** lowercase-with-dashes, date-prefixed as needed
- **Container pages:** `<id>-<name>.md` (e.g. `101-jellyfin.md`, `132-rclone.md`). The `<id>` is the LXC/VM ordinal from `inventory.yaml`.
- **Infrastructure / cross-cutting pages:** `<topic>.md` (e.g. `dns.md`, `auto-deploy.md`, `mesh.md`). Describes a system, not a specific node.
- **Plans / investigations:** `YYYY-MM-DD-<slug>.md` (e.g. `2026-07-05-oikos-prometheus-lxc.md`). Date-sorted; slug is lowercase.
- **Section indices:** `README.md` (lowercase, conventional). Prefer in folders; `index.md` only if both intro prose and listing coexist.
**Skills / runbooks:** special case
- **Folder structure:** `<name>/SKILL.md` where `<name>` is lowercase-with-dashes (e.g. `client-enrollment/SKILL.md`).
- **The filename SKILL.md is always uppercase** — it acts as a signpost so tools and humans instantly recognize it as a skill.
**General rules:** All paths use lowercase letters, numbers, and hyphens (no underscores). Uppercase is reserved for foundational docs (entry points + instruction) and filenames that signify document type (SKILL.md, GLOSSARY.md, etc.).
## Voice
Concise, technical, sysadmin-to-sysadmin. No marketing prose, no exclamation marks. Full rules in
[writing-style.md](writing-style.md).
## Page templates
### Container page (`containers/<id>-<name>.md`)
```markdown
# <id> — `<name>`
One-sentence purpose.
## At a glance
- **Hostname:** `<name>`
- **IP:** `192.168.8.x`
- **Privilege:** privileged | unprivileged
- **Resources:** N cores / M GiB RAM / D GiB rootfs
- **Mounts:** `/mnt/library``/mnt/library` (if any)
- **Public hostname:** `<sub>.hubris.network` (if proxied)
## Role
What it does, what it talks to.
## Service / port map
| Service | Listen | Notes |
## Storage / config paths
## Auto-deploy
(if any) — link to [auto-deploy](../infrastructure/auto-deploy.md)
## Related
- [Caddy](121-caddy.md) (if proxied)
- [DNS](../infrastructure/dns.md) (if has subdomain)
- [Authentik](124-authentik.md) (if SSO)
- ...
## Changelog
### YYYY-MM-DD — short title
What changed, why, link to investigation if any.
```
### Cross-cutting page (`infrastructure/<topic>.md`)
```markdown
# <Topic>
One-sentence summary.
## Why
Design rationale — what it replaces, what it solves.
## Components
Where it runs, what files matter.
## How to apply / use
Recipes.
## Gotchas
## Related
Links to nodes that host or depend on this.
## Changelog
```
### Plan (`plans/YYYY-MM-DD-slug.md`)
```markdown
# YYYY-MM-DD — <title>
## Goal
What this change achieves and why.
## Current topology / state
Diagram or description of what exists now.
## Target topology / state
What it looks like after.
## Pre-flight checklist
## Step-by-step procedure
## Verification
## Post-migration
Changelog entries to write, index status to update.
```
### Investigation (`knowledge/sources/investigations/YYYY-MM-DD-slug.md`)
```markdown
# YYYY-MM-DD — <title>
## Summary
1-3 sentences.
## Timeline
## Root cause
## Mitigations applied
## Open questions
```
## Linking discipline
- Every container page links to every cross-cutting page it participates in.
- Every cross-cutting page lists the nodes that participate.
- Every investigation links to the nodes it implicates *and* gets back-linked from each node's changelog.
- Every plan links to the infrastructure pages it affects. When done, update the plan's status in `plans/index.md` and write changelog entries on affected node pages.
## Changelog hygiene
- Reverse-chronological (newest first).
- One entry per discrete change, even if you make several in one day.
- If a change spans nodes, repeat the entry on each affected page (different perspective is fine).
- Don't rewrite history — entries are append-only. Mistakes get a follow-up entry that supersedes them.
## Same-session update rule
When you make a change to a node — migrate an LXC, update an IP, change a
mount, deploy a new service — **update every relevant doc page in the same
session.** A change that touches a container page must also update:
- The `containers/index.md` table (IPs, host, mounts, status)
- The `README.md` table (if the change affects listed columns)
- The Caddy page site list (if the change affects `*.hubris.network` routing)
- The DNS / ingress infrastructure pages (if the change affects routing)
- The `hosts/{hubris,strong}.md` host page (if container count changes)
- The `inventory.yaml` host entry (source of truth for the `hosts/*.yaml` generation)
- The `infrastructure/topology.md` (generated from inventory, but regen if needed)
The pattern of updating only one page and leaving stale references on others
is a bug. If you're doing a multi-step migration, document the intermediate
state with a changelog entry that says "pending — will finalize after Phase
N."
This rule is why Phase 2 of the strong migration (2026-07-05) caused
widespread stale data: individual container pages were updated in the
changelog but never had their At-a-glance sections, IPs, mount paths, or
host attribution updated. Don't repeat that.

View File

@@ -0,0 +1,75 @@
# Writing Style
Write like a technical reference, not a marketing page. Every sentence conveys new information.
These rules govern **committed documentation** — wiki pages, READMEs, schemas, skills, `AGENTS.md`,
plans, investigations, and code comments. They are separate from [caveman.md](caveman.md), which
governs an agent's *chat responses*; the two do not conflict.
New or rewritten pages follow these patterns from day one. Existing pages get updated the next time
they are touched.
## Vocabulary — never use these
- Significance puffers: "pivotal", "crucial", "vital", "groundbreaking", "transformative", "testament", "paramount", "invaluable".
- Analytical verbs: "delve", "leverage", "utilize", "facilitate", "foster", "showcase", "underscore", "streamline", "harness".
- Poetic nouns: "tapestry", "landscape" (figurative), "realm", "paradigm", "ecosystem" (figurative), "journey" (figurative), "nexus", "cornerstone".
- Promotional adjectives: "robust", "seamless", "innovative", "cutting-edge", "meticulous", "holistic", "comprehensive".
- Opening crutches: "In today's world", "In the ever-evolving landscape of", "It's worth noting that", "It is important to note that".
Use short, common words: "use" not "utilize", "help" not "facilitate", "show" not "demonstrate".
## Voice
Describe what systems do and how they work.
- **Reference prose** (node pages, cross-cutting infrastructure descriptions, `## Role`, `## Why`,
`At a glance`) is third-person: state facts about the system, not instructions to a reader.
- **Recipes, runbooks, and skills** are the exception: second-person imperative is allowed and
preferred where it makes a procedure clearer ("Edit the Caddyfile, commit + push", "Verify with
`dig +short`"). This matches how the operator actually works. The vocabulary, structure, and
cross-reference rules below still apply.
## Page shape
Every doc-level page follows the same shape so a reader scans it in one pass.
1. **One H1 = the page title.** Node pages use `# <id> — \`<name>\``; topic pages use `# <Topic>`.
2. **Opening definition.** First paragraph, 13 sentences, says what the thing is. No motivation, no marketing, no setup.
3. **Body sections** in the natural order for the topic. Reuse the section templates in [page-templates.md](page-templates.md).
4. **`## Changelog`** at the bottom of every node/topic page — reverse-chronological, append-only. This section is machine-parsed (`get_changelog` in `mcp/server.py`); keep the `### YYYY-MM-DD — title` shape.
5. **Related links** only at the bottom, only when a reference cannot be woven inline.
## Section indexes (folder READMEs)
A folder's `README.md` opens with a 13 sentence prose intro that says what the section covers, then
a single navigation table — `| Document | What it covers |` — and nothing else. No stale counts, no
duplicated prose, no narrative between the intro and the table.
## Structure rules
- Make every sentence information-dense. Cut filler, qualifiers, and setup phrases. Lead with the concrete fact or action, not why it matters.
- No participial tack-ons (", highlighting the importance of…"). If the clause adds information, make it a separate sentence.
- **No meta-commentary about the content itself.** Do not narrate the page's own structure or linking strategy.
- Prefer **tables** for enumerable items with internal structure (service/port maps, field lists, status grids). Reserve bullets for short non-structured lists.
- Use the **bold-leading-phrase pattern** for structured points: `**Read-only by construction.** The MCP server never mutates state.` — a bold noun phrase, a period, then the explanation.
- When enumerating across services or nodes, give each its own `###` sub-section or a table row, not one run-on paragraph.
- Use backticks for code, paths, hostnames, and file names (`inventory.yaml`, `192.168.8.77`, `pct config`); italics for first-mention terminology.
- Use `>` blockquotes for caveats and gaps that interrupt the main flow: `> **Outstanding gap.** DNS-vs-inventory drift check not yet wired.` One thought per blockquote.
## Diagrams
- Mermaid is the default for topology and flow diagrams. `infrastructure/topology.md` is generated by `oikos/gen-topology.py` — do not hand-edit it.
- ASCII box diagrams are fine for small shape diagrams; keep them to one screen.
## Sourcing and cross-references
- **Factual discipline.** Every claim is grounded in a cited source, an adjacent linked page, or a directly observable fact (`pct config`, `docker inspect`, running config). Do not write sentences that sound sourced but are inference. When docs disagree with live state, fix the doc and note it in the changelog.
- **One-sided cross-references.** When two pages relate, the link lives in the page where the connection makes organizational sense. Do not add a back-pointer unless that direction also carries content the reader needs.
- **Cross-references are content, not catalog.** Inline links arise from the surrounding prose; the linked page must be needed to understand the current sentence. A bottom-of-page "Related" list is the fallback, not the default.
- Pages link with standard relative markdown links (e.g. a container page links to `../infrastructure/dns.md`), forming a navigable graph. Orphans are a bug.
## Code comments and commit/PR prose
- Comments explain intent, trade-offs, or constraints the code cannot convey. No diff narration, no type restatement, no section-divider comments.
- Commit messages and PR descriptions are problem → change → risk → verification, not a file-by-file diff restatement.
- The banned vocabulary applies the same way in comments and commit messages.

View File

@@ -0,0 +1,43 @@
---
name: client-enrollment
risk_class: config_mutation
inputs: [hostname, kind, role]
verification: "homelab doctor (on the new client)"
docs_update_checklist: [hosts_narrative_page_if_lxc_or_vm]
---
# Client enrollment
Goal: bring a new host (workstation, LXC, VM) into inventory and the
secrets model, with mesh membership only where it's actually needed.
This wraps the existing `homelab client add` flow — see
[operations/agent-enrollment.md](../../operations/agent-enrollment.md) for
the full walkthrough; this runbook is the risk/lifecycle framing.
1. On any enrolled client: `homelab client add <hostname>` — appends a
`hosts.<name>:` block to `inventory.yaml` (lifecycle `state: planned`
`provisioning`, per [oikos/ontology.yaml](../../../oikos/ontology.yaml)),
commits + pushes.
2. Netbird join is **optional, not a required step** — only needed for
hosts that must be reachable off-LAN (workstations that roam, e.g.
`republic-laptop`, `mac-mini`). A node reachable on the household LAN
(192.168.8.0/24 — most LXCs/VMs) doesn't need it: it's already
reachable directly, and off-LAN clients reach it too via hubris's
routed `192.168.8.0/24` Netbird network resource. Skip this step for
LAN-only nodes; do it (out-of-band, console or setup key) only for
hosts that need independent off-LAN reachability.
3. On the new host: run `bootstrap.sh` (add `--with-hermes` to also
enroll the Hermes agent). This provisions `/etc/age/key.txt`, the
sync timer, and prints an age pubkey.
4. Back on an enrolled client: `homelab client add <hostname>
--finalize-pubkey <age1...>` — sets `age_pubkey`, grants shared
secrets, re-keys SOPS, commits + pushes. This is the
`provisioning → active` transition.
5. Verify: `homelab doctor` on the new client should show all checks
green (clone, sync timer, age key, CLI symlink, MCP reachable).
Docs-update checklist: if the new host is an LXC/VM, add its narrative
page under `containers/` or `vms/` and set `doc_page` in its inventory
entry (host-level cards don't have a `doc_page` field yet — services do;
narrative pages are still found via the generated `see_also` in
`hosts/<name>.yaml`).

View File

@@ -0,0 +1,34 @@
---
name: config-change-deploy
risk_class: config_mutation
inputs: [service_name, change_description]
verification: "curl -sf <service_url> (or homelab service <name> health)"
docs_update_checklist: [doc_page, changelog]
---
# Config change + deploy
Goal: change a tracked config repo (Caddy, Gitea customizations, an app's
own repo) and get it live, safely.
1. `homelab change preflight <service>` — current health, the service's
`config_repo`, its risk class, and the verification command to run
after. If risk class requires approval (`config_mutation` or
`destructive`), stop and get operator sign-off before editing — see
`oikos/policy.yaml`.
2. Clone/pull the `config_repo` (never edit the backend's working tree
directly — tracked configs change by commit + push, per
[OIKOS.md](../../OIKOS.md) conventions).
3. Make the change, commit, push to `main`.
4. The Gitea webhook fires the deploy pipeline for that repo (see
[infrastructure/auto-deploy.md](../../../knowledge/wiki/infrastructure/auto-deploy.md) for
the exact receiver/reload for this service).
5. Run the preflight's verification command. If it fails, check
`homelab service <name> log` for the reload/restart error.
6. Record the change: once `oikos/ledger.py` is wired into deploy tooling
(Week 3), this is automatic; until then, note the change and outcome
in the relevant investigation/plan doc.
Docs-update checklist: update the service's `doc_page` if the change
alters its behavior, ingress route, or ownership; add a changelog entry
if the page has one.

View File

@@ -0,0 +1,25 @@
---
name: docs-lint
risk_class: read_only
inputs: [paths]
verification: "python3 .agents/skills/docs-lint/lint.py"
docs_update_checklist: []
---
# Docs lint
Check committed documentation against the mechanical rules in
[writing-style.md](../../shared/writing-style.md): banned vocabulary and broken relative markdown
links. Prose-voice rules are not machine-checkable — those stay a review responsibility.
Run from the repo root:
python3 .agents/skills/docs-lint/lint.py # default: knowledge/ .agents/ operations/ investigations/ plans/
python3 .agents/skills/docs-lint/lint.py knowledge/wiki/containers/104-gitea.md
Exit code is non-zero when any violation is found, so it can gate a commit. The banned-vocabulary
list mirrors `writing-style.md`; update both together if the standard changes.
> **Known baseline.** `knowledge/wiki/containers/101-jellyfin.md` links into a sibling repo
> (`devops/homelab-authentik-admin`) that this checkout does not contain — expected, not a bug.
> Any other broken link is a real regression; investigate before dismissing it as baseline noise.

View File

@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Lint committed docs against .agents/shared/writing-style.md.
Checks two mechanical rules:
1. Banned vocabulary (significance puffers, analytical verbs, poetic nouns,
promotional adjectives, opening crutches).
2. Broken relative markdown links.
Prose-voice rules are not machine-checkable; this covers the parts that are.
Run from the repo root: python3 .agents/skills/docs-lint/lint.py [paths...]
Exit 1 if any violation is found.
"""
import os, re, sys
BANNED = [
"pivotal", "crucial", "vital", "groundbreaking", "transformative", "testament",
"paramount", "invaluable", "delve", "leverage", "utilize", "facilitate", "foster",
"showcase", "underscore", "streamline", "harness", "tapestry", "realm", "paradigm",
"nexus", "cornerstone", "robust", "seamless", "innovative", "cutting-edge",
"meticulous", "holistic", "comprehensive", "in today's world",
"it's worth noting", "it is important to note",
]
BAN_RE = re.compile(r'(?<![\w-])(' + "|".join(re.escape(w) for w in BANNED) + r')(?![\w-])', re.I)
LINK = re.compile(r'\]\(([^)]+)\)')
def iter_md(paths):
for p in paths:
if os.path.isfile(p) and p.endswith(".md"):
yield p
for root, dirs, files in os.walk(p):
dirs[:] = [d for d in dirs if d not in (".git", "node_modules")]
for f in files:
if f.endswith(".md"):
yield os.path.join(root, f)
def main(argv):
paths = argv or ["knowledge", ".agents", "operations", "investigations", "plans"]
violations = 0
# The style guide and this skill enumerate the banned words by definition.
ban_exempt = ("shared/writing-style.md", "skills/docs-lint/")
for f in sorted(set(iter_md(paths))):
check_banned = not any(x in f for x in ban_exempt)
fence = False
with open(f) as fh:
for ln, line in enumerate(fh, 1):
if line.lstrip().startswith("```"):
fence = not fence; continue
if fence:
continue
if check_banned:
for m in BAN_RE.finditer(line):
print(f"{f}:{ln}: banned word '{m.group(1)}'")
violations += 1
for m in LINK.finditer(line):
link = m.group(1)
if re.match(r'^(https?:|mailto:|#|/)', link):
continue
path = re.split(r'[#?]', link)[0]
if not path:
continue
tgt = os.path.normpath(os.path.join(os.path.dirname(f), path))
if not os.path.exists(tgt):
print(f"{f}:{ln}: broken link -> {link}")
violations += 1
print(f"\n{violations} violation(s)")
return 1 if violations else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

View File

@@ -0,0 +1,34 @@
---
name: incident-investigation
risk_class: read_only
inputs: [symptom, affected_entity]
verification: "n/a — investigation produces a written record, not a state change"
docs_update_checklist: [investigations_entry]
---
# Incident investigation
Goal: understand what broke and why, before touching anything.
1. `homelab service <name> explain` (or `homelab node <name> relations`
if the affected entity is a host) — get the blast radius and doc
pointer first. Don't start pulling logs blind.
2. `homelab service <name> health` + `homelab service <name> log` (or
MCP `get_service_status` / `tail_log`) for the affected service.
3. Walk the blast radius: is a shared dependency down (`caddy`, `dns`,
`authentik`, or the backend host itself)? `homelab node <name>
relations` shows "affected by" — check those first.
4. `homelab apt-audit` if the symptom looks like a dpkg/upgrade
interaction.
5. Check the change ledger for recent mutations to the affected entity
or anything upstream of it: `homelab service <name> history` (once
populated) or grep `ledger/*.jsonl`.
6. Write findings to a new `knowledge/sources/investigations/<date>-<slug>.md` — symptom,
timeline, root cause, fix applied, prevention. This is the durable
record; don't rely on chat history.
Docs-update checklist: always create the investigation entry. If the
root cause was stale/wrong inventory data (a `doc_page`, `config_repo`,
or `backend` that didn't match reality — this happened during Week 1
kernel work, see the `authentik` backend fix), correct `inventory.yaml`
in the same session.

View File

@@ -0,0 +1,36 @@
---
name: lifecycle-activate-node
risk_class: config_mutation
inputs: [node_name]
verification: "homelab service <name> health (if it hosts a service); homelab doctor (if it's a client)"
docs_update_checklist: [doc_page_complete]
transition: "provisioning -> active"
---
# Lifecycle: activate a node
Per [oikos/ontology.yaml](../../../oikos/ontology.yaml). Requires: age key
enrolled if it needs secrets, mesh joined if it needs off-LAN reach,
ingress live if public, health check answering, doc page complete,
ledger entry.
1. If the node is a `homelab` client: finish enrollment per
[client-enrollment.md](../client-enrollment/SKILL.md) (`--finalize-pubkey`,
mesh join, `homelab doctor` green).
2. If it hosts a public service: add the `services:` entry in
`inventory.yaml` (backend, url, doc_page, config_repo, risk_notes —
see the Week-1 service contract fields) and wire the Caddy route in
`dtoro/caddy-conf`.
3. Confirm the health check answers: `homelab service <name> health` or
a direct `curl`.
4. Flip `state: provisioning``state: active` (or delete the `state:`
field — `active` is the default) in `inventory.yaml`.
5. Complete the doc page (stub → full narrative: role, specs, how it's
configured, dependencies).
6. Record the activation: `oikos/ledger.py append host:<name> activate
config_mutation --result ok` (or let the CLI wrapper do this once
Week 3's runbook automation lands).
Regenerate derived data: `python3 mcp/build_host_files.py && python3
oikos/gen-topology.py` so `hosts/<name>.yaml`, the topology diagram, and
the context card all reflect the new state.

View File

@@ -0,0 +1,35 @@
---
name: lifecycle-deprecate-node
risk_class: config_mutation
inputs: [node_name, replacement_node_or_reason]
verification: "homelab node <name> relations — 'affected by' must be empty before completing"
docs_update_checklist: [doc_page_deprecation_note]
transition: "active -> deprecated"
---
# Lifecycle: deprecate a node
Per [oikos/ontology.yaml](../../../oikos/ontology.yaml): a node keeps running
but takes no new dependents. **Completion condition: zero remaining
inbound `depends-on`/`routes-to` edges** — this is a hard gate, not a
suggestion; `oikos/policy.yaml` `lifecycle_overrides.deprecated.refuse`
lists `new-inbound-edges` as refused going forward.
1. Set `state: deprecated` on the node.
2. `homelab node <name> relations` — read `affected_by`. Every entry
there is something still relying on this node.
3. Migrate or retire each dependent one at a time (point its `backend`/
`config_repo`/ingress route elsewhere, or deprecate it too if it's
being retired alongside).
4. Re-run `homelab node <name> relations` after each dependent is moved.
The transition to `destroyed` is only safe once `affected_by` is
empty — check this every time, don't assume from memory.
5. Note the deprecation on the doc page: reason, replacement (if any),
date.
If step 2 shows dependents you didn't expect, stop and investigate
before proceeding — that's exactly the kind of drift the Week-3 detector
will catch automatically, but until then this manual check is the gate.
Next (once `affected_by` is empty):
[lifecycle-destroy-node.md](../lifecycle-destroy-node/SKILL.md).

View File

@@ -0,0 +1,42 @@
---
name: lifecycle-destroy-node
risk_class: destructive
inputs: [node_name]
verification: "homelab node <name> relations returns unknown-entity; pct list on the backend no longer shows it"
docs_update_checklist: [archaeology_entry, containers_index_update]
transition: "deprecated -> destroyed"
---
# Lifecycle: destroy a node
**Destructive.** Requires operator approval + typed confirmation phrase
per `oikos/policy.yaml`. Requires (ontology): backups verified, secrets
recipients removed + re-keyed, ingress/DNS removed, archaeology entry,
ledger entry.
1. Confirm the node is `deprecated` with zero `affected_by` edges
(`homelab node <name> relations`) — do not skip this even if the
deprecation runbook was followed recently; state can drift.
2. If it's an enrolled client: `homelab client remove <name>` — revokes
the age key, re-keys SOPS, removes the inventory entry. This is
already destructive-class and confirmed in the CLI.
3. Remove any ingress route (Caddy config repo) and DNS record still
pointing at it.
4. Verify backups of anything on it are retained per policy before the
disk goes away (see `backs-up-to`).
5. Destroy the LXC/VM (`pct destroy` / `qm destroy`).
6. Move the `hosts.<name>:` block (if any inventory remnant survives
`client remove`, e.g. infra-only LXCs with no age key) into
inventory.yaml's `archaeology:` section: `pve_id`, `destroyed` date,
`reason`. Add a row to `containers/index.md` "Recently destroyed"
table (kept for human-readable browsing alongside the structured
data).
7. `oikos/ledger.py append host:<name> destroy destructive --result ok`.
8. Regenerate: `python3 mcp/build_host_files.py && python3
oikos/gen-topology.py` — the node drops out of `hosts/*.yaml` and
appears in the topology doc's archaeology table.
If the destroy fails partway (e.g. secrets revoked but pct destroy
errors), do not re-run step 2 — `client remove` is not idempotent
against a second revocation attempt on the issuance server. Finish the
remaining steps manually and note the partial state in an investigation.

View File

@@ -0,0 +1,39 @@
---
name: lifecycle-migrate-node
risk_class: config_mutation
inputs: [node_name, source_host, target_host]
verification: "homelab node <name> relations (re-check blast radius); homelab service <svc> health for every hosted service"
docs_update_checklist: [doc_page_migration_note, inventory_host_and_lan_ip]
transition: "active -> migrating -> active"
---
# Lifecycle: migrate a node
Modeled on the strong Phase 1+2 migration
([plans/2026-06-03_110000-library-ssd-migration-to-ludo-mini.md](../../../.hermes/plans/2026-06-03_110000-library-ssd-migration-to-ludo-mini.md)).
Requires (ontology): preflight + backup-verified before migrating;
post-verify + Caddy backends checked + mounts checked + docs updated
before returning to `active`.
1. `homelab change preflight <every service the node hosts>` — capture
current health as a baseline.
2. Verify backups are current for anything with data at rest on the
node (see `backs-up-to` edges once populated).
3. Set `state: migrating` in `inventory.yaml`.
4. Perform the migration (pct/qm move, or create-on-target +
data-copy + destroy-source, per the specific case).
5. Update `inventory.yaml`: new `host:`, `lan_ip`, `mesh` addresses for
the node; update every `services:` entry whose `backend` pointed at
it if the backend name itself changes (usually it doesn't — only the
`host:`/`lan_ip` on the guest entry moves).
6. Post-verify: re-run the Week-1 drift check by hand — confirm Caddy's
backend IP for each affected service matches the new `lan_ip`
(automatic in Week 3's drift detector), confirm mounts still resolve.
7. `homelab service <name> health` for every service the node hosts.
8. Set `state: active`. Add a migration note to the node's doc page
(old host/IP → new, date, phase reference) — this repo's convention
for every past migration (see `containers/101-jellyfin.md`,
`containers/129-house.md`).
Regenerate: `python3 mcp/build_host_files.py && python3
oikos/gen-topology.py`.

View File

@@ -0,0 +1,33 @@
---
name: lifecycle-provision-node
risk_class: config_mutation
inputs: [node_name, kind, storage_pool]
verification: "grep 'state: provisioning' hosts/<name>.yaml"
docs_update_checklist: [doc_page_stub]
transition: "planned -> provisioning"
---
# Lifecycle: provision a node
Per [oikos/ontology.yaml](../../../oikos/ontology.yaml) `lifecycle.transitions`.
Policy note: `provisioning` nodes get a lifecycle override —
`config_mutation` actions downgrade to `reversible_low` because nothing
depends on the node yet (see `oikos/policy.yaml` `lifecycle_overrides`).
Requires (from ontology): inventory entry, IP reserved, storage pool
chosen, doc page stub.
1. Create the LXC/VM on its target Proxmox host (`pct create` /
`qm create`), choosing the storage pool deliberately — record it as
the `storage:` field once populated (Week 1 schema; not yet backfilled
for existing nodes).
2. Add the inventory entry: `homelab client add <name>` for anything that
will run the `homelab` CLI, or a direct `hosts.<name>:` block with
`state: provisioning`, `kind`, `host`, `pve_id`, `lan_ip` for
infra-only LXCs that won't self-enroll.
3. Stub the doc page (`containers/<pve_id>-<name>.md` or
`vms/<pve_id>-<name>.md`) — even a one-line "provisioning, see plan X"
is enough to satisfy the transition requirement.
4. Reserve the IP in DNS/DHCP notes if it's a fixed LAN address.
Next: [lifecycle-activate-node.md](../lifecycle-activate-node/SKILL.md).

View File

@@ -0,0 +1,209 @@
---
name: budget-import-from-csv
risk_class: config_mutation
inputs: [csv_file]
references: [containers/129-house.md]
---
# Runbook: Budget import from N26 CSV → Yuvomi
Distil a bank-export CSV into Yuvomi's Budget and Subscriptions modules using
the `yuvomi-mcp` tools. Run this whenever a new CSV period needs to be
summarised into targets and fixed costs.
---
## Prerequisites
- `yuvomi-mcp` is running on LXC 129 and connected as an MCP server in Claude.
- The CSV is an N26 export (columns: Booking Date, Value Date, Partner Name,
Partner Iban, Type, Payment Reference, Account Name, Amount (EUR), …).
- API token: `homelab secret yuvomi-api-token` (decrypts on any enrolled client).
- Direct API base: `https://house.hubris.network/api/v1`
---
## API quirks (Yuvomi ≤ 0.77.x)
- **Subscriptions live under `/budget/subscriptions`**, NOT `/subscriptions/`.
A top-level `/subscriptions` route returns 404.
- `GET /budget/subscriptions``{ data: { subscriptions: [...], summary: {...} } }`
- `GET /budget/subscriptions/meta``{ data: { categories: [...], payment_methods: [...] } }`
- `POST /budget/subscriptions` → create a subscription (name, amount, billing_cycle,
cycle_interval, next_payment_date, currency, category_id, payment_method_id required)
- `GET /budget/` (no month) → returns only **non-recurring** base entries.
Use `GET /budget/?month=YYYY-MM` to get all entries (recurring + one-time) for a month.
- `GET /budget/categories` → expense category keys + income category names (German keys
like `"Erwerbseinkommen"`, `"Sozialleistungen"`, `"Geschenke & Transfers"`).
- Budget entries: `amount` positive = income, negative = expense.
- Recurring entries: set `is_recurring: 1` + `recurrence_interval: "monthly"`.
The `date` field sets the start month.
- `recurrence_virtual: 1` smooths non-monthly amounts across all months in the summary
(e.g. 55.08 € quarterly → shows as ~18.36 €/month).
- Custom RRULE strings (`recurrence_rule`) are **not accepted** by the API — use
`cycle_interval` on the subscription instead, or `recurrence_interval` on budget entries.
---
## Subscription category IDs (as of 2026-06-26)
| id | name | budget_subcategory_key |
|---|---|---|
| 1 | Entertainment | subscription_entertainment |
| 2 | Productivity | subscription_productivity |
| 3 | Utilities | subscription_utilities |
| 4 | Health | subscription_health |
| 5 | Education | subscription_education |
| 6 | Other | subscription_other |
## Payment method IDs
| id | name |
|---|---|
| 1 | Credit Card |
| 2 | Debit Card |
| 3 | PayPal |
| 6 | Bank Transfer / SEPA |
| 7 | Other |
---
## Step 1 — Categorise the transactions
Skip these as internal/already-covered:
- Fixed costs you'll enter as **subscriptions** (Miete, SWM, SYNVIA, Hundefutter,
Netflix, Grover, Rundfunk ARD, KuKita, Lillydoo)
- Internal transfers (The Joy Pot ↔ Cookie, Hauptkonto, Tagesgeldkonto splits)
- Identified income (Cookie Share, Kindergeld, Pocket Money credits, Distributor)
- Fun Money pass-throughs (in and out same month → net zero)
**Variable expense taxonomy:**
| Category key | Subcategory key | Examples |
|---|---|---|
| `food` | `groceries` | E-Center, Knuspr, EDEKA, Tegut, VollCorner, Lidl, Netto, REWE, KoRo, Roast Market |
| `food` | `restaurants_bars` | Restaurants, Lieferando, Cafes, Zeit für Brot, Baobab, Höflinger |
| `personal_health` | `beauty_cosmetics` | DM, Rossmann |
| `personal_health` | `pharmacy` | Apotheke, MVZ Dermatologie |
| `transport` | `apps_taxi` | Uber, RYD GMBH, MVG, Handyparken |
| `shopping_clothing` | `gifts` | Children products: Schlummersack, Catchy Kids, SP EVERY., Dukal, Berger-Lernwelt |
| `shopping_clothing` | `clothes_shoes` | Zalando, Ernsting's, Schuhmair, Thalia, Vinted, Airbnb, Hotel at Booking.com |
| `shopping_clothing` | `electronics` | Amazon, AMZN Mktp DE |
| `housing` | `renovation_maintenance` | IKEA, Markus Festl, Granit, Sostrene Grene, Mol* tischdecken, Gaertnerei, Dehner |
| `education` | `courses_college` | Kathrin Orlob (PEKiP), Nerina Aupperle |
| `leisure` | `streaming` | WOW wowtv.de |
| `financial_other` | `bank_fees` | Unidentified PayPal, Ratepay, N26 fees |
| `Geschenke & Transfers` | *(income)* | One-off incoming transfers |
---
## Step 2 — Create subscriptions
```
get_subscriptions_meta() ← get category_id and payment_method_id
```
**Standard Cookie household subscriptions (as of 2026-07):**
| Name | Amount | billing_cycle | cycle_interval | category_id | payment_method_id |
|---|---|---|---|---|---|
| Miete | 1080.00 | monthly | 1 | 6 (Other) | 6 (Bank Transfer) |
| Strom (SWM) | 79.00 | monthly | 1 | 3 (Utilities) | 6 |
| Internet / TV / Telefon | 29.99 | monthly | 1 | 3 (Utilities) | 6 |
| Hundefutter | 75.00 | monthly | 1 | 6 (Other) | 6 |
| Netflix | 8.00 | monthly | 1 | 1 (Entertainment) | 6 |
| Grover | 16.90 | monthly | 1 | 6 (Other) | 2 (Debit Card) |
| Rundfunk ARD / ZDF | 55.08 | monthly | 3 | 1 (Entertainment) | 6 |
| KuKita Daycare (Leon) | 503.00 | monthly | 1 | 5 (Education) | 6 |
| Lillydoo diapers | 56.70 | monthly | 2 | 4 (Health) | 3 (PayPal) |
Monthly equivalent total: **1,838.60 €** (Yuvomi applies cycle_interval to prorate).
---
## Step 3 — Add recurring income entries
```
stage_add_budget_entry(
title="Kindergeld",
amount=55.00,
category="Sozialleistungen",
date="YYYY-MM-01",
is_recurring=True,
recurrence_interval="monthly",
)
commit_pending(pending_id)
```
**Standard recurring income:**
| Title | Amount | category |
|---|---|---|
| Kindergeld | +55.00 | Sozialleistungen |
| Cookie Share | +2650.00 | Erwerbseinkommen *(see recommended amount below)* |
---
## Step 4 — Post variable transactions
For each non-skipped CSV row, call `stage_add_budget_entry` with the mapped
category/subcategory and the actual transaction amount and date. Use the Partner
Name + Payment Reference as the title (truncate to 100 chars).
---
## Step 5 — Verify
```
get_budget_summary("YYYY-MM")
list_subscriptions()
```
Expected for a full month with KuKita:
- Fixed expenses ≥ 1,838 € (subscriptions)
- Variable expenses ≥ 500 € (groceries alone)
---
## Cookie Share: how much to transfer monthly
Calculated from JanJun 2026 data (Cookie account, one-offs stripped):
| | €/month |
|---|---|
| **Fixed costs (subscriptions)** | **1,839** |
| Miete | 1,080 |
| KuKita *(permanent from Jul 2026)* | 503 |
| Strom + SYNVIA + Rundfunk + Netflix + Grover + Hundefutter + Lillydoo | 256 |
| **Variable (6-month averages)** | **1,032** |
| Groceries | 595 |
| Children products | 142 |
| Dining & cafes | 100 |
| Transport | 66 |
| Drugstore | 52 |
| Clothing, Amazon, Pharmacy | 77 |
| **Total monthly spend** | **≈ 2,871** |
| Minus Kindergeld (fixed income) | 55 |
| Minus Pocket Money (conservative ~600 €) | 600 |
| **→ Recommended Cookie Share** | **≈ 2,650 €** |
| With 200 € buffer | **≈ 2,850 €** |
**Current Cookie Share (Jun 2026): 1,995 € — shortfall ~655 €.**
The gap was covered by irregular Pocket Money top-ups (avg 962 €/mo over 6 months, but
highly variable: 121 €3,000 €). KuKita starting in June is the biggest step-up; raising
Cookie Share to **2,650 €** makes the budget self-sufficient without relying on top-ups.
---
## Changelog
### 2026-06-29 — Corrections from first real import
- Subscriptions endpoint is `/budget/subscriptions`, NOT `/subscriptions/` (404).
- `recurrence_rule` RRULE strings are rejected by the API; use `cycle_interval` instead.
- `GET /budget/` (no filter) returns only non-recurring entries; use `?month=` for full view.
- Added Cookie Share recommendation (2,650 €/month) based on 6-month expense analysis.
- Added full category taxonomy table.
### 2026-06-29 — Initial runbook
Created from JanJun 2026 N26 Cookie account analysis.

View File

@@ -1,3 +1,9 @@
---
name: recover-dpkg-interrupted
risk_class: reversible_low
verification: "dpkg --audit (should be clean); apt-get check"
---
# Runbook — recover from dpkg-interrupted state
You're here because an apt run got killed mid-transaction and the target now
@@ -96,9 +102,9 @@ Then `systemctl status apt-recovery` from a fresh ssh to check progress.
## Related
- [Operations cheatsheet](commands.md)
- [Auto-deploy pipelines](../infrastructure/auto-deploy.md)
- [Hubris host page](../hosts/hubris.md)
- [Operations cheatsheet](../../operations/commands.md)
- [Auto-deploy pipelines](../../../knowledge/wiki/infrastructure/auto-deploy.md)
- [Hubris host page](../../../knowledge/wiki/hosts/hubris.md)
## Changelog

View File

@@ -0,0 +1,30 @@
---
name: service-health-check
risk_class: read_only
inputs: [service_name]
verification: "homelab service <name> health"
docs_update_checklist: []
---
# Service health check
Goal: determine whether a service is actually healthy, without ad-hoc SSH.
1. `homelab service <name> explain` — read the context card: backend,
blast radius, doc pointer, risk notes.
2. `homelab service <name> health` — live health probe (HTTP code against
the service's `url`/`endpoint`). Once the Week-3 scheduler ships, this
reads a cached snapshot by default; pass `--live` to force a fresh probe.
3. If unhealthy, `homelab service <name> log` (or MCP `tail_log`) for the
last 200 lines.
4. Cross-check blast radius: `homelab node <name> relations` — is this
entity's own backend host healthy? A downstream failure (e.g. `strong`
down) will show up here before the service's own logs explain anything.
5. If the fix is a restart: classify first (`oikos/policy.yaml`
`service-restart` is `reversible_low` unless the service has a
`service_overrides` entry, e.g. `caddy`/`dns` are `config_mutation`).
Unattended agents may act on `reversible_low` without approval.
Docs-update checklist: none for a pure health check. If the investigation
reveals stale `risk_notes` or a wrong `doc_page`, fix `inventory.yaml` in
the same session.

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
.DS_Store
__pycache__/
*.pyc
# Regenerated every scheduler run (every 10 min); no audit value in the
# diff. Signals (signals/*.jsonl) ARE tracked — this is just the ephemeral
# health-probe cache. See oikos/scheduler.py.
oikos/state.json
.worktrees/

View File

@@ -0,0 +1,277 @@
# Plan: Migrate library SSD to ludo-mini + Proxmox gaming/media server
## Goal
Split the homelab into two Proxmox hosts:
| Host | Role | Storage |
|------|------|---------|
| **hubris** | Core services (reverse-proxy, SSO, Matrix, git, documents, HA) | SSD 1 — boot + LXC rootfs (unchanged) |
| **ludo-mini** | Gaming server + media/library services | SSD 2 — Samsung 990 EVO Plus 4 TB (moved from hubris) |
The library SSD physically moves from hubris to ludo-mini. hubris LXCs that still need `/mnt/library` access it over NFS from ludo-mini.
## Current state
### hubris hardware
- GMKtec NucBox M6 Ultra — AMD Ryzen 5 7640HS, 12 vCPU, ~28 GiB RAM
- 2× Samsung 990 EVO Plus NVMe:
- nvme0: `local` (95G) + `local-lvm` (856G) — boot, ISOs, LXC rootfs
- nvme1: `library` LVM (3.7T) — `/mnt/library` ext4 via `/dev/mapper/library-library`
### LXCs binding `/mnt/library` (host-level bind-mount)
| ID | Name | Role | I/O profile |
|----|------|------|-------------|
| 101 | jellyfin | Media streaming | Read-heavy, sequential |
| 103 | paperless | Document archive | Mixed, OCR writes |
| 104 | gitea | Git server | Mixed, lots of small files |
| 105 | apps | Docker (booklore, audiobookshelf, artifacto, MCP) | Mixed, depends on container |
| 114 | nextcloud | File sync | Mixed, WebDAV |
| 119 | sophia | Workshop | Low I/O |
| 120 | mule-images | Photo management | Write-heavy (processing), iGPU |
| 122 | arriman | *arr stack + downloads | Write-heavy (downloads) |
| 126 | plato | App (sub-mount: `/mnt/library/documents/plato`) | Light |
### NFS export chain (for VM 100 zimaos)
```
/mnt/library (ext4, host) → bind-mount → LXC 102 (nfs-export) → NFSv4 → VM 100 (zimaos)
```
### ludo-mini current
- Linux workstation, wired Ethernet 2.5 Gbps, `192.168.178.181` (household LAN)
- Runs Sunshine for game streaming
- No Proxmox, no LVM config
- Connected to SODOLA switch (same switch as hubris eno1)
### Network topology
```
Fritz!Box 7590 (192.168.178.1)
└── SODOLA 2.5G switch
├── hubris eno1 → vmbr1 (192.168.178.10)
│ └── routes to vmbr0 (192.168.8.0/24) — all LXCs
└── ludo-mini (192.168.178.181)
```
hubris routes between `192.168.8.0/24` (vmbr0) and `192.168.178.0/24` (vmbr1). So LXCs can reach ludo-mini via hubris as a router.
## Key decisions
### 1. Service split — what moves, what stays
**Move to ludo-mini** (high I/O, benefits from data locality + GPU):
- 101 jellyfin — media streaming, GPU transcoding
- 120 mule-images — photo processing, iGPU passthrough
- 122 arriman — *arr stack, downloads write to library
**Stay on hubris, NFS-mount library from ludo-mini:**
- 103 paperless — documents, moderate I/O
- 104 gitea — git repos (small files, some I/O sensitivity but acceptable over NFS)
- 105 apps — Docker apps, mixed workloads
- 114 nextcloud — file sync
- 119 sophia — workshop, light use
- 126 plato — app, light use
- 100 zimaos — NAS frontend, already NFS-mounted
### 2. NFS architecture
Instead of changing every LXC's mount config, keep the bind-mount pattern on hubris:
```
ludo-mini: /mnt/library (ext4, local NVMe)
└── NFSv4 export to 192.168.8.0/24
└── hubris host: NFS-mount at /mnt/library
└── LXCs: bind-mount /mnt/library (unchanged!)
```
This is transparent to all hubris LXCs — no container config changes needed. Only the hubris host changes from ext4 local mount to NFS mount. The LXC bind-mounts "just work" because `/mnt/library` is still at the same path on the host.
### 3. Network — ludo-mini reachability from hubris LXCs
LXCs on `192.168.8.0/24` reach ludo-mini (`192.168.178.181`) through hubris routing:
- `vmbr0` → hubris kernel routing → `vmbr1` → SODOLA → ludo-mini
- Already works (IP forwarding enabled on hubris)
**Alternative (cleaner):** Add a secondary IP `192.168.8.x` on ludo-mini's physical interface so it's directly on the homelab subnet. This avoids the router hop and keeps NFS traffic off kernel forwarding path. Worth considering but not required.
### 4. Gaming on ludo-mini with Proxmox
ludo-mini runs Sunshine (game streaming). Under Proxmox:
- **Option A:** Gaming VM with GPU passthrough — Sunshine + games in a VM, full GPU access
- **Option B:** LXC with GPU device passthrough (`/dev/dri`) — lighter, shares kernel
- **Option C:** Keep Sunshine on the Proxmox host itself (not recommended, but simplest)
Option A is the cleanest for isolation. Games need a full desktop environment and GPU drivers; a VM with GPU passthrough gives them that.
### 5. What about nfs-export (LXC 102)?
Currently exports `/mnt/library` to zimaos. After migration:
- If zimaos stays on hubris and accesses library via host NFS → bind-mount → LXC 102, that's triple-hop (ludo-mini → NFS → hubris → bind-mount → LXC 102 → NFS → zimaos). Terrible.
- Better: zimaos NFS-mounts directly from ludo-mini.
- So LXC 102 gets decommissioned (or repurposed).
- zimaos gets a new NFS mount pointing directly at ludo-mini.
## Migration phases
### Phase 1 — Preparation (no downtime)
1. **Document current state** on hubris:
- `pct list` — full container inventory
- `pct config <id>` for every library-mounting LXC
- `cat /etc/fstab` — capture the library mount line
- `df -h /mnt/library` — confirm space usage
- `lsblk -f` — UUID, filesystem
- Identify the exact NVMe device (`nvme1n1`)
2. **Pre-flight on ludo-mini:**
- Confirm hardware: CPU, RAM, available M.2 slots, GPU model
- Confirm it can take the Samsung 990 EVO Plus (M.2 NVMe, PCIe 4.0 x4)
- Verify BIOS supports virtualization (VT-d/AMD-Vi for PCIe passthrough)
- Check: does ludo-mini have a second drive for Proxmox OS? If not, we need to partition the library SSD for Proxmox boot + library LVM, which complicates things significantly
3. **Install Proxmox on ludo-mini:**
- Download Proxmox VE 9.x ISO
- Install to ludo-mini's system drive (NOT the library SSD)
- Configure networking: bridge for Proxmox, IP on 192.168.178.x
- Test: web UI accessible
4. **Prepare NFS server on ludo-mini Proxmox:**
- Create NFS-export LXC (or serve from host — simpler for now)
- Prepare `/etc/exports`: `192.168.8.0/24(rw,all_squash,anonuid=33,anongid=10000,no_subtree_check,sec=sys)`
- Same squash params as current nfs-export LXC 102
### Phase 2 — Physical SSD move (planned downtime)
1. **Graceful shutdown on hubris:**
- Stop all library-mounting LXCs (101, 103, 104, 105, 114, 119, 120, 122, 126)
- Unmount `/mnt/library` on hubris host
- Edit `/etc/fstab` to comment out the library mount line
- Power off hubris
2. **Physical drive swap:**
- Remove Samsung 990 EVO Plus (library SSD) from hubris
- Install into ludo-mini M.2 slot
- Power on ludo-mini
3. **Bring library online on ludo-mini:**
- Detect the new NVMe device
- If it's the whole device with LVM, activate the VG:
```
vgscan && vgchange -ay library
mount /dev/mapper/library-library /mnt/library
```
- Add to `/etc/fstab` for auto-mount
- Verify content: `ls /mnt/library` — same tree as before
4. **Start NFS export on ludo-mini:**
- `exportfs -ra`
- Verify: `showmount -e <ludo-mini-ip>`
### Phase 3 — Reconnect hubris LXCs
1. **Power on hubris** (without library SSD — it'll boot fine, just won't mount library)
2. **Mount NFS on hubris host:**
- Install `nfs-common` if not present
- Add to `/etc/fstab`:
```
192.168.178.181:/mnt/library /mnt/library nfs rw,vers=4,soft,timeo=30,retrans=3 0 0
```
Use `soft` to prevent hangs if ludo-mini is down; `hard` with `intr` is safer for data integrity but can block processes.
- `mount /mnt/library`
- **Verify permissions:** `ls -la /mnt/library` — should show `www-data:media` ownership for shared subtrees (same uid 33, gid 10000). The NFS all_squash guarantees this.
3. **Start LXCs:**
- Start the LXCs that stayed on hubris (103, 104, 105, 114, 119, 126)
- Their bind-mounts should work — `/mnt/library` is populated via NFS
- Verify each service: web UIs, git clone, document access
4. **Update DNS/Caddy** for services that moved:
- If jellyfin, arr services moved to ludo-mini, update Caddyfile to point to ludo-mini IPs
- Update DNS entries if needed
### Phase 4 — Migrate services to ludo-mini
1. **Create LXCs/VM on ludo-mini Proxmox:**
- 101 jellyfin — privileged LXC, mount `/mnt/library`, add `media` group
- 120 mule-images — privileged LXC, mount `/mnt/library` + `/dev/dri` passthrough
- 122 arriman — privileged LXC, mount `/mnt/library`
2. **Migrate configs:**
- Copy LXC configs from hubris (`/etc/pve/lxc/<id>.conf`) as templates
- Adjust network (IPs on household subnet or Proxmox bridge)
- Restore app data from backups or copy over NFS
3. **Gaming VM:**
- Create VM with GPU passthrough
- Pass through the dGPU for gaming performance
- Install Sunshine + game libraries
- Storage: VM disk on Proxmox storage, games on library SSD
4. **Update reverse proxy:**
- Caddy on hubris (121): update backend IPs for jellyfin, jellyseerr, qbit, sab, mule-images → point to ludo-mini
- Test: `media.hubris.network` serves from ludo-mini jellyfin
### Phase 5 — Cleanup
1. **Decommission nfs-export LXC 102** on hubris (no longer needed)
2. **Update zimaos (100)** — change NFS mount from `192.168.8.200` → `192.168.178.181`
3. **Remove old LXCs** from hubris (101, 120, 122) after confirming migration works
4. **Update inventory.yaml:**
- ludo-mini: `kind: proxmox-host`, add mounts/storage, add LXCs
- Move services from hubris to ludo-mini
- Remove nfs-export
5. **Update DNS:** `nfs-export.hubris.network` → ludo-mini IP (or remove)
6. **Run `homelab sync`** to propagate changes
## Open questions / unknowns
1. **Does ludo-mini have a second drive for Proxmox OS?** If not, we'd need to repartition the library SSD — carve out ~100 GB for Proxmox, then the rest for library LVM. This is risky (data loss if partitioning goes wrong) and requires a full backup first. **Alternative:** Buy a small SSD for ludo-mini's OS.
2. **What GPU does ludo-mini have?** Proxmox GPU passthrough requires IOMMU support and a GPU that doesn't have the reset bug. Need to check the exact GPU model.
3. **NFS performance for git (gitea)?** Git operations over NFS can be problematic (locking, stat() storms). Gitea bare repos at `/mnt/library/repos/*.git` might need testing. Worst case: move gitea's repo storage to local disk and keep `/mnt/library` for large file/LFS storage only.
4. **Media permission drift.** NFS `all_squash,anonuid=33,anongid=10000` ensures all writes from hubris LXCs (over NFS) and ludo-mini LXCs (local) land as `www-data:media`. This is the same squash currently used by nfs-export (102). Should be fine.
5. **ludo-mini network — add 192.168.8.x address?** Adding a secondary IP on ludo-mini's interface directly on the homelab subnet avoids routing through hubris for NFS traffic. Cleaner, but requires Proxmox bridge setup. Worth doing during Proxmox install.
6. **Sunshine migration.** Currently runs on ludo-mini bare metal. After Proxmox install, it needs to run in a VM. What happens to existing Sunshine configs, game libraries, save files? Need to preserve these during the Proxmox install.
7. **Backup before moving.** The library SSD holds 3.7 TB of irreplaceable data (documents, photos, repos). Restic backups are currently disabled. **Before physically moving the drive, verify the data is readable and consider doing one backup** — or at minimum, `rsync` critical directories.
## Files affected
| File | Change |
|------|--------|
| `/opt/homelab-context/inventory.yaml` | ludo-mini: workstation → proxmox-host; add LXCs, mounts; remove nfs-export; move service backends |
| `/opt/homelab-context/hosts/hubris.md` | Remove library storage, add NFS mount note |
| `/opt/homelab-context/hosts/ludo-mini.yaml` | Complete rewrite — Proxmox host, storage, tenants |
| `/opt/homelab-context/containers/102-nfs-export.md` | Mark decommissioned |
| `/opt/homelab-context/containers/index.md` | Move 101, 120, 122 to ludo-mini; remove 102 |
| `/opt/homelab-context/infrastructure/dns.md` | Update nfs-export entry |
| `/opt/homelab-context/infrastructure/media-permissions.md` | Note NFS squash from ludo-mini, not hubris |
| hubris `/etc/fstab` | Replace ext4 mount with NFS mount |
| ludo-mini `/etc/fstab` | Add library ext4 mount |
| ludo-mini `/etc/exports` | Add NFS export config |
| caddy (LXC 121) Caddyfile | Backend IPs for moved services |
| DNS (LXC 107 Technitium) | Update entries for moved services |
## Validation checklist
- [ ] ludo-mini Proxmox web UI accessible
- [ ] Library SSD detected and mountable on ludo-mini
- [ ] NFS export from ludo-mini: `showmount -e <ip>` shows `/mnt/library`
- [ ] hubris host NFS mount: `df -h /mnt/library` shows NFS, not ext4
- [ ] hubris LXCs start and bind-mount /mnt/library (content visible)
- [ ] gitea: `git clone` over SSH works, repos readable
- [ ] paperless: document ingestion works, OCR processing
- [ ] nextcloud: file sync, WebDAV
- [ ] jellyfin: media plays from ludo-mini, transcoding works
- [ ] arriman: downloads write to library, jellyfin picks up new media
- [ ] mule-images: photo import and processing
- [ ] zimaos: NFS mount from ludo-mini works, Files UI shows library
- [ ] Sunshine: game streaming from ludo-mini VM works
- [ ] All `*.hubris.network` services resolve and load through Caddy

View File

@@ -0,0 +1,271 @@
# Homelab structure revision & improvement plan
## Goal
Identify structural issues in the current hubris homelab topology and propose an
actionable improvement roadmap — DNS consolidation, monitoring gaps, backup
recovery, mesh completion, resource rightsizing, and operational hygiene.
---
## Current state summary
| Dimension | Status |
|-----------|--------|
| Hypervisor | hubris (single-node Proxmox) — also does subnet routing (192.168.8.0/24) |
| LXCs | 14 active, 1 retired (124), 1 new (107 dns) |
| VMs | HAOS (108), ZimaOS (100) |
| Workstations | mac-mini (macOS), republic-laptop, ludo-mini |
| VPS | 1 IONOS box — netbird mgmt+signal+relay, traefik, authentik, coturn |
| Switch | SODOLA 5-Port 2.5Gbit (L2, flat) |
| Networking | 192.168.8.0/24 internal, fr!tz box main LAN via vmbr0→vmbr1 routing |
| Mesh | Netbird + Tailscale (migrating), ~mixed state |
| DNS | 3 sources: Technitium (CT 107), NetBird managed zone, public IONOS |
| Backups | DISABLED since 2026-04-22 |
| Monitoring | claudio-monitor (host health only, no apt/docker checks yet) |
| Agent enrollment | 3/19 hosts enrolled (hubris, apps, republic-laptop) |
---
## Issues identified
### 1. Three overlapping DNS sources (highest risk)
**Problem:** Technitium on CT 107, NetBird managed DNS zone, and the public
IONOS wildcard all answer `*.hubris.network` queries. The dns.md doc still
references the old dnsmasq on LXC 124 (though the change log says it moved).
NetBird's managed DNS bypasses Technitium entirely for some app names — there
is no single source of truth for DNS.
**Risk:** Mismatched answers → services unreachable → "works on some clients
but not others" debugging sessions. Already cost time when `auth.hubris.network`
re-pointed to the VPS.
**Proposal:**
- Phase out NetBird managed DNS zone for `hubris.network` — Technitium is the
authoritative answerer for mesh & LAN clients
- Set Technitium as the sole DNS for all LXCs (remove router DNS / Tailscale
MagicDNS fallbacks)
- Document the full authoritative chain: Technitium → upstream forwarders → public
- Track Technitium config in git (dtoro/technitium-config or equivalent)
### 2. Backups disabled with no alternative (data loss risk)
**Problem:** The only backup was restic to an external USB that caused host
crashes. It was disabled 2026-04-22 as an A/B test — host stability was
confirmed by the SODOLA migration (no more Slate AX double-NAT hangs). The
drive is still removed.
**Risk:** `/mnt/library` (~429 GB of irreplaceable data: photos, documents,
git repos, gitea data) has no off-host copy. Single-disk failure = total loss.
**Proposal:**
- Re-evaluate the USB drive stability with the new SODOLA switch topology
(direct rear USB 3.0 port, no hub chain)
- OR adopt a cloud-backed strategy: `rclone`-to-Hetzner Storage Box or
Backblaze B2 for the irreplaceable subset (docs, photos, gitea data)
- OR use hubris's own `zfs send` to a second host/disk if ZFS is feasible
- Minimum viable: at minimum restore gitea backups + sops-encrypted secrets
via an off-site cron (cheap B2 bucket)
### 3. Mesh migration still incomplete
**Problem:** Most LXCs still use Tailscale. This forces per-LXC DNS workarounds
(/etc/hosts overrides, local dnsmasq) and runs two VPN stacks in parallel.
Mesh migration doc (mesh.md) is comprehensive but execution stalled.
**Proposal:**
- Batch-migrate all LXCs from Tailscale to Netbird in one maintenance window
- Remove Tailscale from the PVE host
- Ensure all LXCs resolve `*.hubris.network` via Technitium (no more /etc/hosts
overrides)
- Document Netbird client on each LXC (netbird version, setup key rotation)
### 4. LXC resource imbalance & disk pressure
**Problem:**
| LXC | Cores | RAM | Rootfs | Disk usage |
|-----|-------|-----|--------|------------|
| mule-images (120) | 6 | 12 GiB | 60 GiB | photo AI, reasonable |
| arriman (122) | 4 | 8 GiB | 24 GiB | media stack, OK |
| nextcloud (114) | 4 | 6 GiB | 25 GiB | file sync, OK |
| apps (105) | 2 | 4 GiB | 30 GiB | 6+ services, tight |
| paperless (103) | 2 | 3 GiB | 8 GiB | 86.9% disk |
| elementsynapse (118) | 1 | 2 GiB | 8 GiB | 86.8% disk |
| gitea (104) | 1 | 1 GiB | 8 GiB | git server, adequate |
| caddy (121) | 1 | 512 MiB | 6 GiB | fine for reverse proxy |
Paperless (103) and elementsynapse (118) are at critical disk levels. apps (105)
is undersized for 6+ services.
**Proposal:**
- Resize rootfs on paperless (8→16 GiB) and elementsynapse (8→16 GiB)
- Bump apps (105) to 4 cores / 6 GiB RAM / 40 GiB rootfs
- Enable claudio-monitor's disk check to alert before next crisis
### 5. VPS is a single point of failure
**Problem:** One IONOS VM runs netbird management (control plane), traefik
(public ingress), authentik (identity), and coturn (TURN relay). If it goes
down: no remote mesh, no public services, no auth.
**Proposal:**
- Document a VPS recovery runbook (how to restore from a known-working backup)
- Consider splitting authentik into a separate host or at minimum having a
standby configuration
- Not a high priority (the VPS has been stable) but worth documenting the
blast radius and recovery path
### 6. No centralized logging
**Problem:** Each LXC has independent journald. Cross-service debugging
involves hopping between `pct exec <id> -- journalctl -u <service>`. There is
no aggregation or retention.
**Proposal:**
- Deploy a lightweight log shipper (Loki + promtail, or vector.dev) on each LXC
- Ship logs to a central Loki instance on apps (105) or a new small LXC
- Grafana dashboard optional — even a simple `logcli` query saves time
### 7. Agent enrollment incomplete
**Problem:** Only hubris, apps, and republic-laptop are enrolled in the
homelab-context system (age keys, sync timers, MCP access). mac-mini,
ludo-mini, claudio-bot, and all other LXCs are not.
**Proposal:**
- Batch-enroll remaining LXCs (jellyfin, paperless, gitea, nextcloud, etc.)
- Enroll mac-mini (macOS — exercises the launchd timer path)
- Enroll ludo-mini (needs SSH user config in inventory first)
- Wire claudio-bot into inventory-aware queries
### 8. Configuration drift on untracked configs
**Problem:** Technitium config, dnsmasq (legacy), and several service-specific
configs are not git-tracked.
**Proposal:**
- Track Technitium zone backup + compose config in a git repo
- Apply the same pattern as caddy-conf: dtoro/technitium-conf with auto-deploy
### 9. No capacity planning / resource monitoring
**Problem:** No trend data on CPU, RAM, or disk growth. The 86% disk alerts
were discovered reactively. Rootfs resize is painful (requires Proxmox stop +
resize + growfs inside).
**Proposal:**
- Enable the missing claudio-monitor checks (disk growth trend, apt upgradable
counts, docker image drift)
- Set up a simple Prometheus + node_exporter on hubris or use the PVE API
directly
- At minimum, surface disk usage in the existing homelab-mcp management tools
### 10. No standard deploy / orchestration for bare-metal LXCs
**Problem:** Some services are bare-metal CLI apps (sophia, claudio-bot),
some are Docker on apps (105), some are Portainer-managed. No consistent
deploy pattern means every new service reinvents the deployment.
**Proposal:**
- Don't over-engineer this — the current pragmatism works
- Just document the decision tree:
- Needs `/mnt/library` mount + heavy I/O → dedicated LXC
- Small stateless web service → Docker on apps (105)
- Media stack → dedicated LXC (arriman, jellyfin)
- Everything else → judge by complexity
---
## Phased implementation plan
### Phase 1 — Critical fixes (this week)
1. Resize rootfs on paperless (103) 8→16 GiB, elementsynapse (118) 8→16 GiB
2. Enable claudio-monitor disk check + disk-growth alerting
3. Pick one backup strategy and implement minimum viable (e.g. nightly
gitea dump + sops-encrypted secrets to B2 via rclone)
4. Verify Technitium is the sole DNS for all LXCs (remove NetBird managed zone
for hubris.network)
### Phase 2 — Mesh consolidation (next week)
5. Batch-migrate remaining LXCs from Tailscale to Netbird
6. Remove Tailscale from PVE host
7. Remove all per-LXC /etc/hosts DNS overrides
8. Update DNS documentation to reflect Technitium as single source
### Phase 3 — Agent enrollment & logging (next 2 weeks)
9. Enroll all LXCs in homelab-context (age keys, sync timers)
10. Enroll mac-mini (macOS launchd path — exercises untested code path)
11. Enroll ludo-mini
12. Deploy log shipper (Loki + promtail) on apps (105) + all LXCs
### Phase 4 — Resource & monitoring hardening (next month)
13. Resize apps (105) rootfs, bump RAM
14. Deploy Prometheus + node_exporter or equivalent for trend data
15. Track Technitium config in git with auto-deploy
16. Write VPS recovery runbook
### Phase 5 — Drive re-evaluation (optional, behind host-stability gate)
17. Re-attach USB backup drive with the new SODOLA topology (direct port)
18. If stable for 7 days, re-enable restic backup schedule (chunked)
19. If not stable, finalize cloud backup as permanent strategy
---
## Files likely to change
| Path | Change |
|------|--------|
| `/opt/homelab-context/inventory.yaml` | LXCs enrolled, resource updates |
| `/opt/homelab-context/secrets/*.yaml` | New recipients for enrolled LXCs |
| `/opt/homelab-context/infrastructure/dns.md` | Reflect Technitium as sole source |
| `/opt/homelab-context/infrastructure/mesh.md` | Remove Tailscale references post-migration |
| `/opt/homelab-context/infrastructure/backups.md` | New strategy |
| `/opt/homelab-context/infrastructure/monitoring.md` | Enable missing checks |
| `/opt/homelab-context/containers/103-paperless.md` | Rootfs resize |
| `/opt/homelab-context/containers/118-elementsynapse.md` | Rootfs resize |
| `/opt/homelab-context/containers/105-apps.md` | Resource bump, logging addition |
| `/opt/homelab-context/containers/index.md` | Updated resource table |
| `.sops.yaml` | New age pubkeys for enrolled LXCs |
## Verification
Each phase ends with a verification milestone:
- Phase 1: `claudio-monitor` triggers on paperless disk → confirmed alert. Backup
of gitea data lands in B2 (or equivalent). DNS query from any LXC returns
Technitium answer.
- Phase 2: `netbird status` shows all LXCs connected. Tailscale not running on
PVE host. `curl auth.hubris.network` from any LXC resolves correctly without
/etc/hosts.
- Phase 3: Every LXC has `/opt/homelab-context/` + `/etc/age/key.txt`. MCP
tools return valid host info for all enrolled LXC names. `journalctl` shows
promtail shipping to Loki.
- Phase 4: `claudio-monitor` shows disk growth trend. apps (105) can run all
6+ services without OOM.
## Risks & tradeoffs
- **Netbird migration window:** All LXCs will briefly lose mesh connectivity
during the Tailscale→Netbird cutover. Schedule in off-hours.
- **Backup cost:** B2/e2 costs ~$5/month for ~500 GB. The USB drive was free
but unstable — trade money for reliability.
- **DNS consolidation:** Removing the NetBird managed DNS zone means any
NetBird-specific names stop resolving for hubris.network — verify nothing
depends on that path.
- **Loki on apps (105):** Adds another container to an already-loaded host.
May need to bump resources before deploying.
- **Agent enrollment on every LXC:** Each enrollment creates an age keypair
and commits a pubkey to inventory. Process is scriptable via `homelab client
add` but still takes ~2 min per host for verification.
## Open questions
1. Is the USB backup drive still physically attached to hubris? If not, the
simplest "re-enable" path requires physically re-attaching it.
2. Authentik is now on the VPS — is LXC 124 (old Authentik) still running or
was it fully decommissioned? The dns.md changelog says "shut down" but
index.md lists it as "running".
3. What's the actual disk layout on hubris? nvme0n1, ZFS pool, mount structure
— needed to plan rootfs resizes safely.
4. Does the user want to keep Tailscale on any host for a specific reason, or
is full Netbird migration the clear goal?

View File

@@ -0,0 +1,246 @@
# Plan: Narrow Technitium DHCP Pool to Avoid Static-IP Conflicts
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
**Goal:** Eliminate the IP conflict risk created by the Technitium DHCP pool (`.100.240`) overlapping with all static LXC/VM IPs (`.101.239`).
**Architecture:** Shrink the DHCP pool range on Technitium so it only covers IPs that no static host uses. No LXC/VM IPs change. Single server-side change (Technitium API), plus documentation updates.
**Tech Stack:** Technitium DNS API (`/api/dhcp/scopes/set`), bash/curl, homelab-context repo for docs.
---
## Problem statement
The Technitium DHCP server on [CT 107](../../knowledge/wiki/containers/107-dns.md) serves `192.168.8.100192.168.8.240`. **Every static homelab IP except hubris (`.77`) sits inside that range:**
| Host | IP | Inside pool? |
|---|---|---|
| hubris (Proxmox) | .77 | No — below `.100` |
| haos (VM 108) | .101 | YES |
| gitea (104) | .121 | YES |
| paperless (103) | .130 | YES |
| arriman (122) | .132 | YES |
| mule-images (120) | .136 | YES |
| sophia (119) | .157 | YES |
| mac-mini | .174 | YES |
| caddy (121) | .175 | YES |
| authentik (124) | .180 | YES |
| plato (126) | .190 | YES |
| zimaos (VM 100) | .195 | YES |
| nfs-export (102) | .200 | YES |
| apps (105) | .205 | YES |
| jellyfin (101) | .206 | YES |
| nextcloud (114) | .224 | YES |
| claudio-bot (123) | .230 | YES |
| elementsynapse (118) | .239 | YES |
The docs claim "Static-IP LXCs (below `.100`) are unaffected" — this is **false**. Static IPs span `.101.239`, the DHCP pool spans `.100.240`. They overlap almost entirely.
If the DHCP server hands out `.121/.136/.224` (or any of the above) to a new dynamic client before the static LXC claims it on boot, the static service will fail to bind and the service goes dark.
---
## Proposed approach: Shrink the pool
**Move the DHCP pool start from `.100` to `.241`**, resulting in:
- **New pool:** `192.168.8.241 192.168.8.254` (14 dynamic IPs)
- **Reserved:** `.100.240` stays for static hosts, `.2` for Technitium, `.1` for gateway
- **Zero changes to any LXC, VM, Caddy, or Proxmox config.**
Why `.241.254`:
- Highest static IP is `.239` (elementsynapse) — `.241` gives a 1-IP gap
- `.255` is the broadcast address (unusable)
- 14 IPs is plenty for truly dynamic clients (new transient containers, test VMs)
- If more are ever needed, the pool can easily be widened back down
---
## Tasks
### Task 1: Verify current Technitium DHCP scope from the API
**Objective:** Confirm the active pool range matches what's documented.
**Step 1: Log in to Technitium API and get a token**
```bash
TOKEN=$(curl -sk -X POST http://192.168.8.2:5380/api/user/login \
-H "Content-Type: application/json" \
-d '{"user":"admin","pass":"'$(cat /opt/technitium/admin_password.txt)'","includeInfo":false}' \
| jq -r '.token')
echo "Token: ${TOKEN:0:10}..."
```
**Step 2: Fetch current DHCP scopes**
```bash
curl -sk "http://192.168.8.2:5380/api/dhcp/scopes/list?token=$TOKEN" | jq .
```
**Expected:** One scope named `homelab` with `startingAddress: "192.168.8.100"` and `endingAddress: "192.168.8.240"`.
**Verification:** If the scope is NOT `.100.240`, note the actual range and adjust the plan.
---
### Task 2: Update the DHCP scope to `.241.254`
**Objective:** Shrink the pool so it no longer overlaps static IPs.
**Step 1: Update the scope via API**
```bash
curl -sk -X POST "http://192.168.8.2:5380/api/dhcp/scopes/set?token=$TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "homelab",
"startingAddress": "192.168.8.241",
"endingAddress": "192.168.8.254",
"subnetMask": "255.255.255.0",
"gatewayAddress": "192.168.8.1",
"dnsServerAddresses": ["192.168.8.2"],
"leaseTime": 86400
}'
```
**Step 2: Verify the change took effect**
```bash
curl -sk "http://192.168.8.2:5380/api/dhcp/scopes/list?token=$TOKEN" | jq '.response.scopes[0] | {startingAddress, endingAddress}'
```
**Expected:**
```json
{
"startingAddress": "192.168.8.241",
"endingAddress": "192.168.8.254"
}
```
**Pitfall:** If the API returns `{"status":"error"}`, the scope name or parameter format may differ. Inspect the response body. Technitium's API might use `rangeStart`/`rangeEnd` instead of `startingAddress`/`endingAddress`. Adjust if needed (check the full scope object from Task 1 step 2 for exact key names).
---
### Task 3: Check for active DHCP leases in the old pool that would be stranded
**Objective:** Ensure no DHCP client is currently holding an IP in `.100.240` that it will lose when its lease expires.
**Step 1: List active DHCP leases**
```bash
curl -sk "http://192.168.8.2:5380/api/dhcp/leases/list?token=$TOKEN" | jq '.response.leases[] | {ip: .ipAddress, client: .clientHostname, mac: .hardwareAddress, expires: .leaseExpires}'
```
**Step 2: Interpret results**
- If the only leases are from static LXCs that configured themselves before the DHCP move (e.g., old leases from before the 2026-06-02 static-IP migration), these leases are stale and harmless.
- If a *dynamic* client (e.g., a test laptop, transient VM) holds `.195` or similar, note it — it will lose its IP on next renew and should be moved to a static assignment or into the `.241+` pool.
- **ZimaOS (VM 100) at `.195` is a DHCP lease, not static** — this is the one host that needs attention. Either:
- Set a static IP inside ZimaOS (preferred), or
- Add a DHCP reservation for MAC in Technitium to pin `.195`
**Verification:** No "surprise" dynamic clients that would break on lease expiry.
---
### Task 4: Fix ZimaOS IP stability (if needed)
**Objective:** Ensure ZimaOS at `.195` won't float or break when the pool shrinks.
**If ZimaOS already has a static IP configured inside the VM:** Nothing to do.
**If ZimaOS is DHCP-only (likely — doc says "DHCP lease, not a reservation"):**
Option A (preferred): Set a static IP inside ZimaOS via its web UI at `http://192.168.8.195` → Settings → Network → Static IP → `192.168.8.195/24`, gateway `192.168.8.1`, DNS `192.168.8.2`.
Option B: Add a DHCP reservation in Technitium for ZimaOS's MAC address:
```bash
ZIMAMAC=$(ssh root@hubris "qm config 100 | grep net0 | grep -oE '([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}'")
curl -sk -X POST "http://192.168.8.2:5380/api/dhcp/reservations/add?token=$TOKEN" \
-H "Content-Type: application/json" \
-d "{\"hardwareAddress\":\"$ZIMAMAC\",\"ipAddress\":\"192.168.8.195\"}"
```
**Pitfall:** The `/api/dhcp/reservations/add` endpoint signature is unverified — confirm the exact endpoint name from Technitium's API docs or the web UI before running it. The web console at `http://192.168.8.2:5380` → DHCP → Reservations can be used as a manual fallback.
---
### Task 5: Update documentation in homelab-context
**Objective:** Fix the now-wrong claims about static IPs being "below .100".
**Files to edit:**
1. **`infrastructure/network.md`** — Line 53
- Old: `Most homelab LXCs use static IPs below \`.100\`. DHCP only covers new/transient containers.`
- New: `Static IPs span \`.101.239\` (all LXCs + VMs + workstations). DHCP pool narrowed to \`.241.254\` to avoid overlap.`
2. **`containers/107-dns.md`** — Lines 37, 42, 55
- Line 37: Update pool range: `192.168.8.241 192.168.8.254`
- Line 42: `Static-IP LXCs (below \`.100\`)` → `Static-IP LXCs (\`.101.239\`) are excluded from the pool.`
- Line 55: Add changelog entry for the pool shrink
3. **`containers/107-dns.md`** — Add changelog entry:
```markdown
### 2026-06-03 — DHCP pool narrowed to `.241.254` to exclude static IPs
Previous pool `.100.240` overlapped with all static LXCs/VMs (\`.101.239\`), creating IP conflict risk. Shrunk pool to `.241.254`. No services re-IP'd. See [plan](../plans/2026-06-03-dhcp-pool-exclude-static-ips.md).
```
4. **`infrastructure/network.md`** — Line 51: Update pool range in the DHCP table row.
5. **`plans/2026-06-01-slate-ax-to-sodola-migration.md`** — Line 60: Optionally update the pool range in the config table (or add a post-migration note). This is the historical migration plan, so a footnote rather than an edit may be better.
**Commit:**
```bash
cd /opt/homelab-context
git add infrastructure/network.md containers/107-dns.md plans/
git commit -m "docs: DHCP pool narrowed to .241-.254 to exclude static IPs"
git push
```
---
### Task 6: Verify no regressions
**Objective:** Smoke-test that DNS and key services still work after the scope change.
```bash
# 1. DNS resolution via Technitium
dig @192.168.8.2 +short git.hubris.network
# Expected: 192.168.8.175
# 2. Caddy reverse-proxy chain
curl -sI https://git.hubris.network | head -1
# Expected: HTTP/2 200
# 3. All app names resolve
for name in git cloud media paperless photos matrix auth plato artifacto; do
result=$(dig @192.168.8.2 +short ${name}.hubris.network)
printf "%-20s → %s\n" "${name}.hubris.network" "$result"
done
# 4. Technitium DHCP scope is correct
curl -sk "http://192.168.8.2:5380/api/dhcp/scopes/list?token=$TOKEN" | jq '.response.scopes[0] | {startingAddress, endingAddress}'
```
---
## Risk assessment
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| API call fails (wrong field names) | Medium | Low | Inspect live scope object first (Task 1); adjust payload |
| ZimaOS loses IP on next boot | Low | Medium | Task 4 makes ZimaOS static or reserved |
| Active DHCP client in `.100.240` gets stranded | Low | Low | Task 3 surfaces this; client just requests a new IP from `.241+` |
| Technitium admin password file missing | Low | Medium | `/opt/technitium/admin_password.txt` was created during setup; verify existence |
## Open questions
1. Is zimaos (VM 100) currently DHCP or static? The doc says DHCP lease, but it's listed as `lan_ip: 192.168.8.195` in inventory. If it's actually DHCP, it's the one host that needs a static assignment before the pool shrinks.
2. Are there any transient DHCP clients (test laptops, phones) on the homelab subnet that hold `.100.240` addresses? Check leases before cutting over.
3. Should we widen the pool slightly (e.g., `.230.254`) for more headroom? Currently 14 IPs. If 3+ transient devices are expected, `.230.254` = 25 IPs — still safe since the highest static is `.239` and `.230.239` could be excluded.
## Execution preference
All changes are on the Technitium API + homelab-context repo. No LXC/VM restarts needed. The pool shrink takes effect immediately for NEW DHCP requests; existing leases in the old range continue until expiry (24h max).

View File

@@ -0,0 +1,129 @@
# Plan: Prevent DHCP IP drift from breaking Caddy backends
**Date:** 2026-06-05
**Slug:** prevent-dhcp-ip-drift
---
## Goal
Eliminate the root cause of services becoming unreachable when DHCP lease renewals change backend IPs that Caddy's `reverse_proxy` directives hardcode.
**Triggering incident:** Paperless (LXC 103) and HAOS (VM 108) had DHCP-assigned IPs change from `.130→.243` and `.101→.241` respectively. Caddyfile still pointed at the old IPs → services unreachable from iPhone on Netbird.
## Current context
### DHCP vs static IP inventory
| Machine | Type | PVE ID | Current IP | Allocation method | Status |
|---------|------|--------|-----------|-------------------|--------|
| **jellyfin** | LXC | 101 | 192.168.8.206 | Static (`ip=.../24`) | ✅ |
| **paperless** | LXC | 103 | 192.168.8.243 | **DHCP** (`ip=dhcp`) | ❌ broken, hotfixed to .243 |
| **gitea** | LXC | 104 | 192.168.8.121 | Static | ✅ |
| **apps** | LXC | 105 | 192.168.8.205 | Static | ✅ |
| **nextcloud** | LXC | 114 | 192.168.8.224 | Static | ✅ |
| **elementsynapse** | LXC | 118 | 192.168.8.239 | Static | ✅ |
| **mule-images** | LXC | 120 | 192.168.8.136 | Static | ✅ |
| **caddy** | LXC | 121 | 192.168.8.175 | Static | ✅ |
| **arriman** | LXC | 122 | 192.168.8.132 | Static | ✅ |
| **sophia** | LXC | 119 | 192.168.8.157 | Static? | ? (not in 2026-06-02 list) |
| **nfs-export** | LXC | 102 | 192.168.8.200 | Static? | ? |
| **plato** | LXC | 126 | 192.168.8.190 | Static? | ? |
| **HAOS** | VM | 108 | 192.168.8.241 | **DHCP** (VM — OS-managed) | ❌ broken, hotfixed to .241 |
| **zimaos** | VM | 100 | 192.168.8.195 | DHCP (known stale lease, see note) | ⚠️ open issue |
| **authentik** | — | — | — | migrated to VPS (external) | N/A |
### Infrastructure facts
- **DHCP server:** Technitium on CT 107 (192.168.8.2), pool `.241.254`
- **Static IP range:** `.101.239`
- **DNS:** Split-horizon on Technitium — `*.hubris.network → 192.168.8.175` (Caddy itself)
- **Caddyfile:** Has 29 `reverse_proxy` directives, all using **hardcoded IP:port**
- **Caddy reload:** Auto-deployed via webhook on git push to `dtoro/caddy-conf`
- **Documentation:** `inventory.yaml` updated, `hosts/*.yaml` stale-before-regenerate
### Why it happened
1. Paperless LXC 103 was **missed** during the 2026-06-02 static-IP migration (Proxmox config still shows `ip=dhcp`)
2. HAOS VM 108 uses DHCP internally (HAOS manages its own network — can't just `pct set`)
3. Both got new IPs from the Technitium `.241.254` pool after a lease renewal
4. Caddyfile still pointed at the old static-range IPs → connection refused
---
## Proposed approach
Three-layer solution:
### Layer 1: Fix the immediate offenders (static assignment)
**Paperless LXC 103:**
- `pct set 103 --net0 name=eth0,bridge=vmbr0,gw=192.168.8.1,hwaddr=BC:24:11:0A:8D:C2,ip=192.168.8.130/24,ip6=auto,type=veth`
- Inside the LXC, update `/etc/network/interfaces` to match
- Reboot/restart networking
**HAOS VM 108:**
- Set a **DHCP reservation** in Technitium for the VM's MAC address, pinning it to `192.168.8.101`
- This avoids needing to reconfigure HAOS internally (which is tedious)
- Alternatively: use `ha network update` from the HAOS console to set a static IP
### Layer 2: Audit and fix all remaining DHCP hosts
Check every LXC/VM hosted on hubris:
```bash
for ct in $(pct list | awk 'NR>1{print $1}'); do
echo "=== CT $ct ==="
pct config $ct | grep "^net0"
done
```
Any with `ip=dhcp` that Caddy reverse-proxies to → convert to static.
**Known candidates to check:**
- CT 102 (nfs-export) — `.200` but not in Caddy. May not need static.
- CT 119 (sophia) — `.157` — is this static or DHCP? Not sure.
- CT 126 (plato) — `.190` — same question.
- VM 100 (zimaos) — `.195` but known to have a stale lease (see 2026-06-03 changelog)
### Layer 3: Add validation
Create a script that runs periodically (cron or homelab cronjob):
**`/opt/homelab-context/scripts/check-caddy-backends.sh`:**
1. Parse `/etc/caddy/Caddyfile` on CT 121 to extract all `reverse_proxy IP:port` targets
2. For each `IP:port`, attempt a TCP connect (timeout 3s)
3. Report any that fail
Could also run as a homelab cron job that notifies dtoro on Matrix if a backend is unreachable.
This catches any future drift proactively (before a user reports it).
### Files likely to change
| File | Change |
|------|--------|
| `inventory.yaml` | May update paperless/HAOS IPs if we choose different static IPs |
| LXC 103 Proxmux config (via `pct set`) | Set static IP |
| Technitium DHCP reservations | Add HAOS reservation |
| `/etc/caddy/Caddyfile` on CT 121 | Already fixed — only changes again if we re-assign paperless IP to `.130` |
| `scripts/check-caddy-backends.sh` | New validation script (new file in homelab-context) |
### Risks / Tradeoffs
- **Pinning paperless to `.130`** — if the LXC was reinstalled since then, `.130` may already be in use. Verify first with `arp-scan` or `nmap`.
- **HAOS static IP via Technitium reservation** vs **inside HAOS**: Technitium reservation is simpler (no HA config changes), but if HAOS's DHCP lease expires and the Technitium server is down, the reservation won't help. A static IP inside HAOS is more robust but requires poking the HA console.
- **Validation script false positives** — a service might be legitimately down for maintenance. The script should be a warning, not an alert.
- **Caddy reload** — each Caddyfile edit triggers an auto-reload via webhook. If the backend is down during reload, Caddy itself stays up (it's just a reverse_proxy target).
### Verification
1. After setting paperless static: `ssh root@192.168.8.175 "curl -s -o /dev/null -w '%{http_code}' http://192.168.8.130:8000"` → 302
2. After Technitium HAOS reservation: `curl -s -o /dev/null -w '%{http_code}' http://192.168.8.101:8123` → 200
3. Run validation script → all targets reachable
4. Confirm from iPhone: both `paperless.hubris.network` and `home.hubris.network` load
### Open questions
1. Should paperless go back to `.130` (its original), or stay at `.243` (current)? Going back to `.130` means updating the Caddyfile again, but keeps the static range allocation consistent.
2. HAOS: Technitium reservation or HAOS-internal static config? Reservation is easier; HAOS-internal is more robust.
3. Should the Caddyfile validation script run as a homelab cron job, or as a cron on the caddy LXC itself?
4. ZimaOS (VM 100) — should we also pin its IP while we're at it?

View File

@@ -0,0 +1,130 @@
# Plan: Fix Frequent Authentik Login Prompts
## Goal
Stop requiring repeated login to Authentik (several times per day) by fixing session and cookie expiry settings so the user stays logged in for longer periods (e.g., 730 days, or until explicit logout).
## Current Context
Authentik runs on the VPS (`82.165.190.79`) in Docker Compose. Traffic flows:
```
Browser → Caddy (LXC 121) → VPS Traefik → Authentik
```
Caddy's `forward_auth` uses the `(authentik)` snippet which proxies to `auth.hubris.network/outpost.goauthentik.io/auth/caddy`. The Authentik server version is **2026.5.2**.
## Root Cause Found
### Primary: `SESSION_EXPIRE_AT_BROWSER_CLOSE = True`
The Authentik Django session (`authentik_session` cookie) is configured to **expire on browser close**. Every time the user closes and reopens their browser, the session cookie is cleared. The next visit to a service that requires OAuth2 authorization (Gitea, Jellyfin, etc.) will redirect to the Authentik login page.
### Secondary: `SESSION_COOKIE_AGE = 86400` (24 hours)
Even with the browser left open continuously, the session expires after 24 hours. Combined with `SESSION_SAVE_EVERY_REQUEST = False`, activity does NOT extend the session.
### Session configuration (from Docker Python environment):
| Setting | Current Value | Default in Django |
|---------|---------------|-------------------|
| `SESSION_EXPIRE_AT_BROWSER_CLOSE` | `True` | `False` |
| `SESSION_COOKIE_AGE` | `86400` (24h) | `1209600` (14d) |
| `SESSION_SAVE_EVERY_REQUEST` | `False` | `False` |
| `SESSION_COOKIE_SAMESITE` | `Lax` | `Lax` |
### What ISN'T the problem:
- **Proxy cookie validity** — `hubris-forward-auth` has `access_token_validity = hours=24`, which is reasonable for the forward-auth token.
- **Server-side session duration** — The `user_login` stage has `session_duration = seconds=0` (indefinite).
- **Refresh tokens** — All OAuth2 providers have `refresh_token_validity = days=30`, which is fine.
- **Caddy configuration** — The forward-auth chain is correctly set up.
- **Outpost health** — All containers healthy, up for 6 days.
## Proposed Approach
Change two Django session settings via Authentik environment variables:
1. **`AUTHENTIK_SESSION_COOKIE_AGE` = 604800** (7 days) — extends session cookie lifetime from 24h to 7 days
2. **`AUTHENTIK_SESSION_EXPIRE_AT_BROWSER_CLOSE` = false** — prevents session cookie from being cleared on browser close
This keeps users logged in for up to 7 days with normal browser use (close/reopen, daily usage). The session still expires after 7 days of inactivity (`SESSION_SAVE_EVERY_REQUEST` stays False).
## Step-by-step Plan
### Step 1: Add environment variables to Docker compose
Edit `/opt/docker-compose.yml` on the VPS to add these env vars to the `authentik-server` service:
```yaml
authentik-server:
environment:
# ... existing vars ...
AUTHENTIK_SESSION_COOKIE_AGE: "604800" # 7 days (was 86400 / 24h)
AUTHENTIK_SESSION_EXPIRE_AT_BROWSER_CLOSE: "false" # was true
```
Note: the Authentik config system uses `__` (double underscore) for nesting. The env vars map to the Django settings via the config YAML path. The correct Authentik env var for `SESSION_COOKIE_AGE` would be `AUTHENTIK_SESSION__COOKIE_AGE` if it goes through the config system, or just `SESSION_COOKIE_AGE` if it's passed directly. Need to verify the exact variable name Authentik expects.
### Step 2: Verify variable naming
Check the Authentik config YAML (`/authentik/lib/default.yml` inside the container) to confirm the exact env var name mapping. Authentik uses a custom config layer that maps env vars to settings.
**Alternative if env vars don't work:** Some Authentik settings need to be set via the admin UI (under System Settings or Tenant settings). The Django session settings might need to be configured differently in this version.
### Step 3: Restart Authentik server
```bash
ssh root@82.165.190.79
docker compose -f /opt/docker-compose.yml restart authentik-server
```
### Step 4: Verify the fix
```bash
# Check session settings took effect
ssh root@82.165.190.79 'docker exec -i authentik-server python3 << "PYEOF"
import os
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "authentik.root.settings")
import django
django.setup()
from django.conf import settings
print("SESSION_EXPIRE_AT_BROWSER_CLOSE:", settings.SESSION_EXPIRE_AT_BROWSER_CLOSE)
print("SESSION_COOKIE_AGE:", settings.SESSION_COOKIE_AGE)
PYEOF'
```
### Step 5: Functional test
1. Login to Authentik at `auth.hubris.network`
2. Close the browser completely
3. Re-open browser, navigate to a forward-auth-gated service (e.g., paperless.hubris.network)
4. Verify you're NOT redirected to login
5. Verify OAuth2 services (Gitea) also maintain the session
## Files Likely to Change
| File | Change |
|------|--------|
| `/opt/docker-compose.yml` | Add `AUTHENTIK_SESSION_COOKIE_AGE` and `AUTHENTIK_SESSION_EXPIRE_AT_BROWSER_CLOSE` env vars |
## Tests / Validation
1. **Config verification** — Run Python snippet inside container to confirm Django settings changed
2. **Browser test** — Close/reopen browser, verify session persists (Step 5 above)
3. **24-hour test** — Check session is still alive after 24h of normal use
## Risks, Tradeoffs, and Open Questions
| Risk | Mitigation |
|------|------------|
| Env var names don't match Authentik's config schema | First verify in the container's `default.yml` config file |
| 7-day persistent cookie is a security concern (stolen cookie = 7 days of access) | This is the same risk as any "Remember Me" feature on any web app. The tradeoff is convenience vs. security. |
| The proxy cookie (`authentik_proxy_*`) may still have its own 24h limit | That's managed separately via the OAuth2 provider's `access_token_validity` setting. If we also want to extend that, we can update `hubris-forward-auth` provider's `access_token_validity` from `hours=24` to `days=7`. |
| `SESSION_COOKIE_SECURE = False` | Should be `True` since Authentik is served behind HTTPS. However, the forward-auth subrequest from Caddy to the outpost is HTTP internally (`http://127.0.0.1:8099`), so `False` may be intentional for the outpost check. |
## Open Questions
1. **What environment variable name does Authentik use for Django session settings?** Need to check `default.yml`. The config layer may use `AUTHENTIK_SESSION__COOKIE_AGE` (double underscore) or the raw Django setting name.
2. **Should we also extend the proxy token validity?** The `hubris-forward-auth` provider has `access_token_validity = hours=24`. If we want users to not need re-login for more than 24h, we should also bump this to match the session cookie age.
3. **Which specific service triggers the most login prompts?** The forward-auth (Caddy-gated) services use proxy cookies. OAuth2 services (Gitea, Jellyfin) use the Django session. Understanding which one the user is hitting most could narrow the fix scope.

View File

@@ -0,0 +1,149 @@
# Plan: Fix Caddyfile truncation + prevent recurring outages
**Date:** 2026-06-06
**Slug:** caddyfile-truncation-permanent-fix
---
## Goal
Restore all `*.hubris.network` services that went offline when the Caddyfile on LXC 121 was truncated to only 3 photo-related site blocks, and implement automated safeguards to prevent this class of outage from recurring.
## Root cause
The Caddyfile at `/etc/caddy/Caddyfile` on LXC 121 was manually edited locally (not via the `dtoro/caddy-conf` git repo), overwriting ~260 lines (30+ site blocks + forward-auth infrastructure) with only 43 lines covering `photos.hubris.network`, `prism.hubris.network`, and a manually-added `photos2.hubris.network`.
**Evidence:**
- `git diff HEAD -- Caddyfile` shows `+3 / -159` lines diff — all other blocks deleted
- Git reflog shows HEAD at `32575ce` (`fix: sab... port 8081→8082`), but working tree diverges
- Deploy webhook log: Jun 06 12:39 — `deploy failed: git pull` (dirty tree blocks merge)
- Backup file `Caddyfile.bak.1780263919` (225 lines) confirms the full original was intact before truncation
- `origin/master` at `1b977aa` is the authoritative source — 260 lines, all blocks present
**Why "third time this week":**
| Incident | Date | Cause |
|---|---|---|
| 1 | Jun 02 | DHCP IP drift — paperless (130→243), HAOS (101→241) |
| 2 | Jun 05 | More DHCP drift — apps (205), mule-images (136 overridden by dhclient) |
| 3 | Jun 06 | **Caddyfile truncated** — unrelated to IPs, much worse |
The Caddyfile truncation is the most severe: it took down **all LAN services** except `photos.hubris.network` and `auth.hubris.network` (VPS-hosted).
## Immediate fix
### Step 1: Restore Caddyfile from origin/master and reload
On LXC 121:
```bash
cd /etc/caddy
# Stash any local changes
git stash
# Reset to origin/master
git checkout --force origin/master -- Caddyfile
# Caddyfile now has all 30+ sites
caddy validate --config /etc/caddy/Caddyfile
systemctl reload caddy
```
This restores all service blocks including: media, git, paperless, books, home, cloud, matrix, proxmox, docker, jellyseerr, qbit, sab, blog, auth, artifacto, plato, zimaos, mcp, secrets, sso + authentik forward-auth infrastructure.
### Step 2: Add `photos2.hubris.network` via git (if still needed)
The `photos2.hubris.network` block was manually added locally and is NOT in origin/master. If the user wants to keep it, submit a PR/commit to the `dtoro/caddy-conf` repo.
### Step 3: Verify
- From any LAN/mesh client: `curl -sk https://media.hubris.network/` → 200
- Run `bash /opt/homelab-context/scripts/check-caddy-backends.sh` from hubris → all targets reachable
- Flush mac-mini DNS: `sudo dscacheutil -flushcache && sudo killall -HUP mDNSResponder`
## Permanent safeguards
### Layer 1: Caddyfile integrity check (deploy hook)
Add a site-count validation to the deploy script (`/etc/caddy/scripts/deploy.sh`):
```bash
# Count site blocks (lines matching *.hubris.network {)
SITE_COUNT=$(grep -c '^[a-z].*hubris.network {' Caddyfile)
if [ "$SITE_COUNT" -lt 20 ]; then
echo "[deploy] ERROR: Only $SITE_COUNT sites found (expected 20+). Refusing to reload."
exit 1
fi
```
This catches any future truncation before `caddy reload` runs.
### Layer 2: Caddyfile backup on deploy
Add to deploy script before git pull:
```bash
cp Caddyfile "Caddyfile.bak.$(date +%s)"
```
Keep last 3 backups, auto-rotate.
### Layer 3: Dirty-tree handling in deploy webhook
The deploy webhook currently hard-fails when the working tree is dirty. Change the receiver script to handle this gracefully:
```bash
cd /etc/caddy
# If dirty, stash local changes
if ! git diff --quiet; then
echo "[deploy] Working tree dirty — stashing"
git stash push -m "auto-stash by deploy webhook $(date)"
fi
git pull --ff-only
```
This prevents the webhook from blocking on future local edits.
### Layer 4: Scheduled Caddyfile health check
Add a homelab cron job that runs `check-caddy-backends.sh` every 10 minutes and notifies if any Caddy backend is unreachable.
```yaml
# In homelab context: cronjob
schedule: "*/10 * * * *"
script: /opt/homelab-context/scripts/check-caddy-backends.sh
```
### Layer 5: DNS sync cron (fix already-deployed sync)
The `dns-sync.py` on LXC 107 at `/opt/dns-sync/sync.py` is installed but has **no crontab** — the sync never runs automatically. The NetBird managed DNS zone has drifted from Technitium. Add a systemd timer or crontab:
```bash
echo "*/10 * * * * root python3 /opt/dns-sync/sync.py >> /var/log/dns-sync.log 2>&1" > /etc/cron.d/dns-sync
```
## Files likely to change
| File | Change |
|------|--------|
| `/etc/caddy/Caddyfile` on LXC 121 | Restore from origin/master |
| `/etc/caddy/scripts/deploy.sh` on LXC 121 | Add site-count validation + backup + dirty-tree handling |
| `caddy-conf` git repo | PR with deploy.sh improvements + photos2 (if wanted) |
| `cronjob` in Hermes | Schedule `check-caddy-backends.sh` |
| `/etc/cron.d/dns-sync` on LXC 107 | New — add dns-sync cron |
## Verification
1. All `*.hubris.network` URLs load from mac-mini: `media`, `git`, `paperless`, `cloud`, `home`, `proxmox`, etc.
2. `check-caddy-backends.sh` exits 0 on hubris
3. `systemctl status caddy` shows active on LXC 121
4. `dns-sync` runs and writes to `/var/log/dns-sync.log`
## Risks / Tradeoffs
- **Restoring from origin/master overwrites photos2.hubris.network** — recreate it via proper git commit
- **Caddy staging ACME certs for prism/photos2**: The `tls dns ionos` directive uses staging env (`acme-staging-v02.api.letsencrypt.org`), which fails DNS propagation check (VPS port 53 unreachable from LXC). Once restored, these two subdomains will have the same issue. Move them to production IONOS DNS-01 by removing the staging CA directive or setting the correct `acme_issuer` in Caddyfile.
- **Dirty-tree stash could lose edits** — mitigated by `git stash push --message` + backup file creation before stash
## Open questions
1. Keep `photos2.hubris.network`? If yes, add via proper git push.
2. `prism.hubris.network` and `photos2` certs fail on staging ACME — set production `acme_issuer` in Caddyfile?
3. Should `check-caddy-backends.sh` run as a homelab cron job or as a regular cron on LXC 121?

View File

@@ -0,0 +1,358 @@
# Assessment: Which nodes can move to `strong`
## Executive summary
hubris is **memory-starved**: 28 GiB RAM, 71.9 GiB allocated across 18 LXC + 2 VM
(2.5× overcommit), 10 GiB swap in active use. strong sits **completely empty**
28 GiB RAM, 25 GiB free, 0 guests, 2.7 TiB unused storage. The single most
effective decongestion move is to shift guests off hubris onto strong.
This document assesses every guest for move-readiness, grouped by constraints
(library dependency, GPU, core-infra status), and proposes a phased migration
that does **not** require the physical library-SSD move (the blocker of the
original plan) — library access from strong is provided via NFS from hubris.
---
## Current resource state (live, 2026-07-05)
### hubris — overloaded
| Resource | Capacity | Allocated (all guests) | Actual use | Status |
|----------|----------|----------------------|------------|--------|
| RAM | 28 GiB | 70.2 GiB (2.5× overcommit) | 18 GiB used + 10 GiB swap | ⚠️ heavy swap pressure |
| CPU | 12 vCPU (6c/12t) | 43 vCPU (3.6× overcommit) | ~43% scaling MHz | OK (shares) |
| local-lvm | 856 GiB | 582 GiB allocated (29% thin) | — | OK |
| library (lvmthin) | 3.7 TiB | — | 1.2 TiB used (34%) | OK, 2.3 TiB free |
### strong — empty, ready
| Resource | Capacity | Used | Status |
|----------|----------|------|--------|
| RAM | 28 GiB | 2.3 GiB (host only) | 25 GiB free |
| CPU | 16 vCPU (8c/16t) | idle (0.08 load) | 100% free |
| local-lvm | 856 GiB | 0 | empty |
| ludo-lvm | 1.8 TiB | 0 | empty |
| Guests | — | 0 LXC, 0 VM | nothing running |
### Network topology constraint
```
Fritz!Box (192.168.178.1)
└── SODOLA 2.5G switch
├── hubris eno1 → vmbr1 (192.168.178.10) → vmbr0 (192.168.8.0/24)
│ └── all 20 guests on 192.168.8.x
└── strong vmbr0 (192.168.178.181)
└── no internal bridge yet, guests would be on 192.168.178.x
```
strong reaches `192.168.8.0/24` via the Fritz static route through hubris.
Guests on strong get `192.168.178.x` IPs unless we add an internal bridge
on strong (Phase 0 prerequisite — see below).
---
## Per-guest assessment
### Tier 1 — Move immediately (no library dependency, no core-infra)
These guests mount **no** `/mnt/library` and are not part of the core
infrastructure spine (caddy/dns/auth/mcp). They are the easiest wins.
| ID | Name | Cores | RAM | Library? | GPU? | Notes |
|----|------|-------|-----|----------|------|-------|
| 118 | elementsynapse | 2 | 4 GiB | ❌ | ❌ | Matrix homeserver. Public via Caddy (`matrix.hubris.network`). Only change: Caddy backend IP. **Easiest move in the fleet.** |
| 129 | house | 2 | 3 GiB | ❌ | ❌ | Yuvomi family planner (Docker). No public Caddy route yet (uses VPS traefik directly). Self-contained. |
**Combined RAM freed from hubris: 7 GiB.** No NFS, no library, no GPU.
### Tier 2 — Move with library NFS (high resource consumers)
These are the heaviest guests and the original migration plan's primary
targets. They mount `/mnt/library` and two use the iGPU. Moving them
requires an NFS export from hubris → strong (reverse of the original
plan's direction, since the physical SSD hasn't moved).
| ID | Name | Cores | RAM | Library? | GPU? | I/O profile | Notes |
|----|------|-------|-----|----------|------|-------------|-------|
| 120 | mule-images | 6 | 12 GiB | ✅ mp0 | ✅ iGPU | Write-heavy (photo processing) | **#1 RAM consumer.** Strong has Radeon 680M iGPU (VAAPI works). |
| 122 | arriman | 4 | 8 GiB | ✅ mp0 | ❌ | Write-heavy (downloads) | *arr stack + qbit + sab. Mounts library for download writes. |
| 101 | jellyfin | 4 | 8 GiB | ✅ mp0 | ✅ iGPU | Read-heavy sequential | Media streaming + transcode. Strong 680M handles VAAPI. |
**Combined RAM freed: 28 GiB.** This alone would eliminate hubris's swap
pressure entirely.
### Tier 3 — Could move, low urgency
| ID | Name | Cores | RAM | Library? | Notes |
|----|------|-------|-----|----------|-------|
| 130 | grimmory | 1 | 2 GiB | ✅ mp0 | Book library (Docker). Migrated from apps LXC recently. |
| 131 | teddycloud | 1 | 1 GiB | ✅ mp0 | New (not in inventory.yaml yet). |
| 132 | rclone | 1 | 2 GiB | ✅ mp0 (ro) | Backup container. Read-only library mount. |
| 128 | trmnl | 1 | 768 MiB | ❌ | TRMNL middleware. No library. Could move but tiny. |
| 119 | sophia | 2 | 1 GiB | ✅ mp0 | Workshop. Light use. |
### Stay on hubris (core infrastructure)
| ID | Name | Cores | RAM | Why it stays |
|----|------|-------|-----|--------------|
| 121 | caddy | 1 | 512 MiB | Reverse proxy — terminates all `*.hubris.network`. Must stay on hubris for LAN-side reachability. **Needs backend IP updates** when guests move. |
| 107 | dns | 1 | 1 GiB | Technitium DNS, split-horizon. Core. |
| 106 | auth-outpost | 1 | 512 MiB | Authentik SSO enforcement. Core. |
| 105 | apps | 2 | 4 GiB | homelab MCP + secrets-issuance + artifacto. Core infra. Mounts library. |
| 104 | gitea | 1 | 1 GiB | Git server. NFS would hurt git lock/stat perf. Mounts library (bare repos). |
| 103 | paperless | 2 | 3 GiB | Document archive. Moderate I/O, OCR writes. Mounts library. |
| 102 | nfs-export | 1 | 512 MiB | Exports library to zimaos via NFS. Must stay with the physical library. |
| 100 | zimaos | 4 | 8 GiB (VM) | NAS frontend eval. Already NFS-mounts library from 102. |
| 108 | haos | 2 | 4 GiB (VM) | Home Assistant OS. Hardware access, low latency. |
---
## Constraints & prerequisites
### 1. Network — strong needs an internal bridge (Phase 0)
strong currently has only `vmbr0` on `192.168.178.0/24`. Guests created there
get household-LAN IPs, not homelab-subnet IPs. Two options:
- **Option A (recommended):** Add `vmbr1` on strong as a portless internal
bridge with a `192.168.8.x/24` address (e.g. `192.168.8.3`). Route between
strong's `vmbr0` and `vmbr1` the same way hubris does. Guests go on `vmbr1`
and get `192.168.8.x` IPs — transparent to Caddy, DNS, and inter-LXC refs.
Requires adding a static route on Fritz (or relying on hubris's existing
route — strong would need IP forwarding + a route to 192.168.8.0/24 via vmbr1).
- **Option B (simpler, messier):** Put guests on `192.168.178.x` directly.
Caddy can still reach them (hubris routes to 192.168.178.0/24). But DNS
records, inter-LXC references, and firewall rules all assume `192.168.8.x`.
More config churn per guest.
### 2. Storage — rootfs migration (no shared storage)
`local-lvm` is per-node (not shared). Moving an LXC requires either:
- `vzdump` → restore on strong (clean, but needs temp disk space + downtime)
- `rsync` the rootfs to a new LXC on strong (faster for large rootfs like 120's 100G)
- `pct migrate` only works with shared storage — **not applicable here**
For VMs (100, 108): `qm migrate` also needs shared storage. Not moving VMs.
### 3. Library access — NFS from hubris to strong
Since the physical library SSD is still on hubris, strong's guests that need
`/mnt/library` must NFS-mount it from hubris. Options:
- **Export from hubris host directly** (simplest): add `/mnt/library` to
`/etc/exports` on hubris with the same squash params as LXC 102
(`rw,all_squash,anonuid=33,anongid=10000,no_subtree_check`). Mount on strong
at `/mnt/library`. Strong's guests bind-mount it just like hubris's guests do.
- **Use existing nfs-export LXC 102**: strong NFS-mounts from `192.168.8.200`
(LXC 102). This already has the right squash config. Less host-level change.
**This is the path of least resistance.**
### 4. GPU — iGPU passthrough on strong
strong has a Ryzen 7 PRO 6850U with Radeon 680M iGPU. For jellyfin (VAAPI
transcoding) and mule-images (photo processing), we need:
- `/dev/dri/renderD128` passed to the LXC (`lxc.cgroup2.devices.allow` +
`lxc.mount.entry` or Proxmox's `dev0:` passthrough)
- `video` / `render` group membership inside the container
- Confirm `amdgpu` driver loads on strong's host kernel (it should — same APU family)
### 5. Quorum — 2-node cluster, no QDevice
Moving guests to strong does NOT fix the quorum issue but **reduces blast
radius**: if hubris reboots (its known thermal instability), the guests on
strong keep running independently. Consider adding a QDevice as a separate
follow-up — it's orthogonal to this migration.
---
## Revised migration phases
The original plan's NFS-over-LAN approach has been superseded. Instead,
**media library data moves to ludo-lvm** on strong so migrated guests access
it as a local ext4 mount. Data is split by origin:
```
hubris (stays): library SSD (3.7T, 1.2T used)
└── /mnt/library/{documents,images,cloud,homecloud,notes,repos,sophia}
↑ user-generated content (docs, photos, cloud sync, notes, repos, workshop)
strong (moves): ludo-lvm (1.8T, 0 used at start)
└── /mnt/media_local ← 1.5T thin volume
└── {downloads,movies,music,tv,anime,books}
↑ non-user-generated content (media arr stack, book library)
```
| Category | Stays on hubris | Moves to strong |
|----------|----------------|-----------------|
| Media | — | downloads (25G), movies (51G), music (29G), tv (30G), anime (206G) |
| Books | — | books (2.6G) |
| Docs/Photos | documents (249M), images (4K) | — |
| Cloud sync | cloud (287G), homecloud (367G) | — |
| Personal | notes (6.7M), repos (84M), sophia (151G) | — |
| **Total** | **~805G** | **~344G** |
ludo-lvm (1.8T) fits all media + books with ~1.15T headroom for growth.
hubris library SSD (3.7T, 1.2T used) retains the user-generated content.
Both sides keep their data local — no cross-node NFS needed for daily I/O.
---
### Phase 2a — Prepare ludo-lvm on strong
1. Create a ext4 filesystem on ludo-lvm for media:
```bash
lvcreate -n media -L 1.5T ludo-lvm
mkfs.ext4 /dev/ludo-lvm/media
```
2. Mount at `/mnt/media_local` on strong, add to `/etc/fstab`
3. rsync media directories from hubris → strong:
```bash
rsync -av --progress /mnt/library/{movies,tv,anime,downloads,music,books} strong:/mnt/media_local/
```
### Phase 2b — Migrate arriman (122) to strong
1. Stop arriman on hubris, dump rootfs (24G)
2. Restore on strong with IP `192.168.8.245/28` on vmbr1
3. Mount `/mnt/media_local` → `/mnt/library` via mp0 (downloads land locally)
4. Update Caddy: jellyseerr/qbit/sab backends → new IP
5. Update inventory.yaml
### Phase 2c — Migrate jellyfin (101) to strong
1. Stop jellyfin on hubris, dump rootfs (16G)
2. Restore on strong with IP `192.168.8.246/28` on vmbr1
3. Pass `/dev/dri/renderD128` + `/dev/dri/card0` (Radeon 680M + RX 7600)
4. Mount `/mnt/media_local` → `/mnt/library` via mp0 (media reads locally)
5. Update Caddy: `media.hubris.network` → new IP
6. Reinstall `sso-inject.js` in web dir (lost on every apt upgrade)
7. Test VAAPI transcoding, SSO login, media playback
### Phase 2d — Migrate grimmory (130) to strong
1. Stop grimmory on hubris, dump rootfs (16G)
2. Restore on strong with IP `192.168.8.247/28` on vmbr1
3. Mount `/mnt/media_local` → `/mnt/library` via mp0 (books read locally)
4. Update Caddy: `books.hubris.network` → new IP
5. Update inventory.yaml
6. Test: book browsing, calibre-web access
### No NFS export needed
With the data split by origin, hubris guests that only need user-generated
content (documents, images, cloud, repos, sophia) still access them from the
original library SSD — no cross-node NFS required. The two sides are
independent.
**Result after Phase 2: hubris frees 26 GiB RAM (4 migrated guests) + 344G of
library I/O burden. Strong becomes the media/books powerhouse.**
---
### Phase 3 — Migrate mule-images (120) to strong
Move photo management (12 GiB RAM, 6 cores, iGPU) last because it needs:
- `/mnt/library` access (now NFS from strong — already set up in Phase 2d)
- `/dev/dri/renderD128` (Radeon 680M — confirm VAAPI compatibility first)
Steps:
1. Stop mule-images on hubris, rsync the 100G rootfs to strong (faster than vzdump)
2. Restore on strong with IP on vmbr1
3. Pass Radeon 680M iGPU
4. Reconfigure library paths → `/mnt/media_local` (or keep NFS mount)
5. Update Caddy: `photos.hubris.network` → new IP
6. Test photo import + processing pipeline
---
### Phase 4 — Tier 3 moves (optional)
Migrate grimmory (130), teddycloud (131), rclone (132), trmnl (128), sophia (119)
as needed — each frees 12 GiB. Not urgent; do when convenient.
---
### Phase 5 — Follow-up
- **QDevice**: add a tiebreaker for 2-node quorum
- **Gaming VM**: strong's 6850U has enough cores alongside migrated LXCs
- **Hubris library cleanup**: after all guests are confirmed working, decide
whether to keep the original library SSD as backup or repurpose it
---
## Resource math after Phase 3 (all Tier 1 + 2 moved)
| | hubris | strong |
|---|--------|--------|
| Guests | 11 LXC + 2 VM | 5 LXC |
| RAM allocated | ~25 GiB | ~45 GiB |
| RAM capacity | 28 GiB | 28 GiB |
| Overcommit | 0.9× (under-committed) | 1.6× (manageable) |
| Library disk | Local ext4 (3.7T) → NFS client | Local ext4 on ludo-lvm (1.8T) |
| GPU | Radeon 760M (idle) | Radeon 680M (jellyfin + mule-images) |
strong becomes the media/library powerhouse. hubris becomes a lean core-infra
node (DNS, auth, git, docs, caddy, HA).
---
---
## Risk register
| Risk | Impact | Mitigation |
|------|--------|------------|
| NFS latency for library reads (jellyfin, arriman) | Media playback stutter, slow downloads | Test iperf between strong↔hubris first. If 2.5G link, NFS throughput is fine (~1 Gbit/s). |
| GPU passthrough on strong (680M vs 760M) | Transcode quality/compat differences | Both are AMD VAAPI — same driver stack. Test `vainfo` inside LXC before going live. |
| Caddy backend IP churn | Service outage if IP wrong | Update Caddyfile in git repo (caddy-conf), test each route before destroying old LXC. |
| vzdump/restore downtime | Service unavailable during migration | Schedule off-hours. Use rsync for large rootfs (120's 100G) to minimize freeze window. |
| 2-node quorum still fragile | If hubris goes down, strong /etc/pve goes read-only | Guests keep running. Add QDevice as follow-up. |
| Library data integrity during NFS transition | Permission drift | NFS `all_squash,anonuid=33,anongid=10000` matches existing LXC 102 config. Verify with `ls -la /mnt/library` after mount. |
---
## Open questions for operator
1. **Internal bridge on strong**: proceed with `vmbr1` on `192.168.8.3/24`
(Option A), or use `192.168.178.x` guest IPs (Option B)?
2. **Migration method**: `vzdump`/restore (clean, downtime) vs `rsync` rootfs
(faster for large disks, needs manual config copy)?
3. **Phase 1 priority**: move elementsynapse + house first (quick wins), or
go straight to Phase 2 (mule-images/jellyfin/arriman) for maximum relief?
4. **Should we add a QDevice now** before moving anything, to protect
management plane during the migration?
---
## Changelog
### 2026-07-05 — Phase 2d complete (grimmory migrated; media NFS to zimaos)
grimmory (130) → 192.168.8.247 on strong. Rsync'd /books (2.6G) to ludo-lvm.
LXC 102 (nfs-export) now mounts strong's NFS at /mnt/media and exports it as
a second share alongside /mnt/library. Zimaos mounts both: /media/library
(hubris user-generated) and /media/media (strong media+books).
See hosts/strong.md changelog.
### 2026-07-05 — Phase 2 complete (arriman + jellyfin migrated; library on ludo-lvm)
arriman (122) → 192.168.8.245, jellyfin (101) → 192.168.8.246. Created 1.5T
thin volume on ludo-lvm, rsync'd 363G of media data. Both containers use local
ext4 mount — no NFS. Jellyfin has 680M + RX 7600 GPU passthrough.
Caddy backends updated. See hosts/strong.md changelog.
### 2026-07-05 — Phase 1 complete (elementsynapse + house migrated to strong)
Both Tier 1 guests moved: elementsynapse (118) → 192.168.8.242, house (129) → 192.168.8.244.
Strong now has vmbr1 at 192.168.8.241/28. Hubris has proxy ARP + /32 routes for strong
guest range. DHCP scope narrowed to 192.168.8.100-239 to avoid conflicts.
Teddycloud (LXC 131) given static IP 192.168.8.150 due to IP conflict with
previous DHCP allocation at 192.168.8.243.
See hosts/strong.md changelog for full steps.
### 2026-07-05 — assessment created
Built from live `pct config` + `pvesm status` + `free -h` data pulled from
both nodes. Supersedes the storage-migration framing of the original
library-SSD plan — this assessment treats the SSD move as optional and
focuses on guest relocation via NFS.

View File

@@ -23,7 +23,11 @@ creation_rules:
age: >-
age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6,
age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0,
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6,
age1z62ff2ak9zj5ctcvaxwyyhedwjvlwgm2dkn9nk3wrwk8fkavcpmsqwc2vs,
age1s07zs83ehtlg8jtwvr75ltc3c4cdlemfwjuxrwjtwkqxkl9tpggsyrzn2h,
age1rtwvdct6avjkr3cyxv3vue3vqx4d524fjfr3vk7xrnvyrylnry5sm54sn4,
age1pwtdws2thdh7vzp2dzttl3zxgcs2tgpcsjsqgw3q04nyml4kvuqq467u4x
- path_regex: ^secrets/gitea-pat\.yaml$
# Write-scoped Gitea PAT (dtoro user). Same recipient list as hello.yaml
@@ -32,17 +36,21 @@ creation_rules:
age: >-
age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6,
age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0,
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6,
age1z62ff2ak9zj5ctcvaxwyyhedwjvlwgm2dkn9nk3wrwk8fkavcpmsqwc2vs,
age1s07zs83ehtlg8jtwvr75ltc3c4cdlemfwjuxrwjtwkqxkl9tpggsyrzn2h,
age1rtwvdct6avjkr3cyxv3vue3vqx4d524fjfr3vk7xrnvyrylnry5sm54sn4,
age1pwtdws2thdh7vzp2dzttl3zxgcs2tgpcsjsqgw3q04nyml4kvuqq467u4x
- path_regex: ^secrets/gitea-tokens\.yaml$
# Workstations only.
age: >-
# placeholder — fill with age_pubkey of: republic-laptop, mac-mini, ludo-mini, hubris
# placeholder — fill with age_pubkey of: republic-laptop, mac-mini, strong, hubris
- path_regex: ^secrets/webhook-hmacs\.yaml$
# LXCs that run a webhook receiver.
age: >-
# placeholder — fill with age_pubkey of: apps, caddy, claudio-bot, claudio-monitor host
# placeholder — fill with age_pubkey of: apps, caddy
- path_regex: ^secrets/turn-shared-secret\.yaml$
# coturn TURN long-term-credential password. Consumed by hubris (which
@@ -52,7 +60,10 @@ creation_rules:
age: >-
age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6,
age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0,
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6,
age1z62ff2ak9zj5ctcvaxwyyhedwjvlwgm2dkn9nk3wrwk8fkavcpmsqwc2vs,
age1s07zs83ehtlg8jtwvr75ltc3c4cdlemfwjuxrwjtwkqxkl9tpggsyrzn2h,
age1pwtdws2thdh7vzp2dzttl3zxgcs2tgpcsjsqgw3q04nyml4kvuqq467u4x
- path_regex: ^secrets/netbird-authentik-oidc\.yaml$
# Authentik OIDC client secret for the netbird-dashboard provider.
@@ -61,7 +72,20 @@ creation_rules:
age: >-
age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6,
age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0,
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6,
age1z62ff2ak9zj5ctcvaxwyyhedwjvlwgm2dkn9nk3wrwk8fkavcpmsqwc2vs,
age1s07zs83ehtlg8jtwvr75ltc3c4cdlemfwjuxrwjtwkqxkl9tpggsyrzn2h,
age1pwtdws2thdh7vzp2dzttl3zxgcs2tgpcsjsqgw3q04nyml4kvuqq467u4x
- path_regex: ^secrets/netbird-pat\.yaml$
# NetBird API Personal Access Token. Consumed by the dns-sync job on the
# `dns` LXC (107) to reconcile Technitium -> NetBird managed DNS zone.
# (When 107 is enrolled, add its age_pubkey here and updatekeys.)
age: >-
age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6,
age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0,
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6,
age1z62ff2ak9zj5ctcvaxwyyhedwjvlwgm2dkn9nk3wrwk8fkavcpmsqwc2vs
- path_regex: ^secrets/openrouter-api-key\.yaml$
# OpenRouter API key consumed by the `hermes` wrapper (bin/hermes) when
@@ -71,5 +95,42 @@ creation_rules:
# See operations/hermes-agent.md.
age: >-
age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6,
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6,
age1z62ff2ak9zj5ctcvaxwyyhedwjvlwgm2dkn9nk3wrwk8fkavcpmsqwc2vs,
age1s07zs83ehtlg8jtwvr75ltc3c4cdlemfwjuxrwjtwkqxkl9tpggsyrzn2h
- path_regex: ^secrets/yuvomi-api-token\.yaml$
# Named Bearer token for the Yuvomi REST API, consumed by yuvomi-mcp on
# LXC 129 (house).
age: >-
age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6,
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6,
age1z62ff2ak9zj5ctcvaxwyyhedwjvlwgm2dkn9nk3wrwk8fkavcpmsqwc2vs,
age1s07zs83ehtlg8jtwvr75ltc3c4cdlemfwjuxrwjtwkqxkl9tpggsyrzn2h
- path_regex: ^secrets/hermes-house-users\.yaml$
# Signal number → Yuvomi user_id mapping (PII). Consumed by hermesd on LXC 129.
age: >-
age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6,
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6,
age1z62ff2ak9zj5ctcvaxwyyhedwjvlwgm2dkn9nk3wrwk8fkavcpmsqwc2vs,
age1s07zs83ehtlg8jtwvr75ltc3c4cdlemfwjuxrwjtwkqxkl9tpggsyrzn2h
- path_regex: ^secrets/oikos-approval-hmac\.yaml$
# HMAC signing key for Oikos approval-grant tokens (oikos/approve.py).
# Recipients: apps (105, runs the approval engine alongside homelab-mcp)
# and hubris (admin/debug decrypt). See OIKOS.md "Approval engine".
age: >-
age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6,
age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0
- path_regex: ^secrets/oikos-console-deploy-secret\.yaml$
# Shared HMAC secret for the Gitea deploy webhook (id 14) ->
# oikos-console-deploy.service on apps (105). Generated + registered
# with Gitea before the apps-side install ran (see
# oikos/console/deploy/README.md "Status") — write this exact value
# into /etc/oikos-console-deploy/secret rather than letting
# webhook/install.sh generate a fresh one.
age: >-
age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0
# webhook noop 2026-05-20T18:16:57+02:00

File diff suppressed because one or more lines are too long

View File

@@ -4,6 +4,20 @@ You are running on a machine that is part of the **hubris** homelab. The full
context is in this checkout at `/opt/homelab-context/`. This file is the entry
point. Read it once at start, then keep working.
The operating model — OODA loop, risk classes, approval rules, the ontology,
and node lifecycle — is defined in [OIKOS.md](.agents/OIKOS.md). Before any mutation,
classify the action against `oikos/policy.yaml`; when the class requires
approval, stop and ask the operator.
Agent-facing instruction is separated from human content under `.agents/`:
`.agents/shared/` holds the conventions every agent applies
([writing-style](.agents/shared/writing-style.md), [caveman](.agents/shared/caveman.md),
[page-templates](.agents/shared/page-templates.md), [llm-wiki](.agents/shared/llm-wiki.md)), and
`.agents/domains/` holds the per-domain schemas
([knowledge](.agents/domains/knowledge/schema.md), [operations](.agents/domains/operations/schema.md)).
The narrative wiki lives under `knowledge/wiki/`; the machine-readable substrate
(`inventory.yaml`, `hosts/*.yaml`, `oikos/`) stays at the repo root.
## 1. Who you are
Run `hostname` (Linux) or `scutil --get LocalHostName` (macOS), then read:
@@ -19,13 +33,14 @@ the operator to run `homelab client add <hostname>` from an existing client.
- `/opt/homelab-context/inventory.yaml` — every host, LXC, VM, and workstation
with their mesh addresses, roles, and service mappings. Treat this file as
authoritative; anything you read in narrative pages should agree with it.
- `/opt/homelab-context/infrastructure/mesh.md` — Tailscale → Netbird state.
- `/opt/homelab-context/knowledge/wiki/infrastructure/mesh.md` — Tailscale → Netbird state.
Both meshes are accepted today; Netbird is preferred for new traffic.
- `/opt/homelab-context/infrastructure/dns.md` — split-horizon DNS via
dnsmasq on LXC 124. `*.hubris.network` resolves to 192.168.x.x on the LAN
and to mesh addresses off-LAN.
- `/opt/homelab-context/operations/commands.md` — the operator's cheatsheet
for pct, caddy, dnsmasq. Use these verbs when you take actions.
- `/opt/homelab-context/knowledge/wiki/infrastructure/dns.md` — split-horizon DNS via
Technitium on [dns (107)](knowledge/wiki/containers/107-dns.md). `*.hubris.network`
resolves to 192.168.x.x on the LAN and to mesh addresses off-LAN.
- `/opt/homelab-context/.agents/operations/commands.md` — the operator's cheatsheet
for pct, caddy, DNS, and the Oikos command surface. Use these verbs when
you take actions.
## 3. The MCP server
@@ -44,8 +59,15 @@ Available tools:
get_service_status(service), tail_log(service, lines=200),
list_lxcs(), get_lxc_state(lxc), ping_service(service)
Oikos (read-only; see OIKOS.md):
explain(service) — compact context card, cheaper than search_docs+get_page
preflight(service) — risk class, approval requirement, verification command
get_relations(entity) — ontology blast-radius query (host: or service: id)
get_change_history(entity, limit=20) — change-ledger entries
get_state_snapshot() — last scheduler Observe-pass (health, disk, drift count)
Mutations are **not** exposed via MCP. Use the `homelab` CLI for those, with
operator confirmation.
operator confirmation — see OIKOS.md's risk classes and approval flow.
**When to prefer MCP over grepping the clone:** any time you need to resolve a
name to an address, look up service status, or search the wiki by content.
@@ -53,17 +75,25 @@ Grep is fine for browsing or when MCP is unreachable.
## 4. Wiki conventions
- Pages live under `containers/`, `hosts/`, `vms/`, `infrastructure/`,
`investigations/`, `operations/`. Cross-link liberally; orphans are bugs.
- Every page ends with a `## Changelog` section, entries in reverse-chrono
order:
See [page-templates.md](.agents/shared/page-templates.md) for file naming, page
structure, and the tone standard. Quick reference:
- **File naming:** Foundational docs are ALL-CAPS (AGENTS.md, OIKOS.md, GLOSSARY.md);
containers use `<id>-<name>.md`; infrastructure uses lowercase-with-dashes; plans
and investigations use `YYYY-MM-DD-slug.md`; skills are `<name>/SKILL.md`.
- **Where pages live:** Narrative under `knowledge/wiki/{containers,hosts,vms,infrastructure}/`;
incident records under `knowledge/sources/investigations/`; runbook procedures under
`.agents/skills/<name>/SKILL.md`; operator reference under `.agents/operations/`;
design docs under `plans/`. Cross-link liberally; orphans are bugs.
- **Changelog format:** Every page ends with a `## Changelog` section, entries in
reverse-chronological order:
### YYYY-MM-DD — short title
one or two lines describing what changed and why.
- Investigation files are dated and slugged: `YYYY-MM-DD-slug.md`.
- Live state takes precedence over docs. If you observe a discrepancy, update
the docs *in the same session* (per the same-session update rule).
- **Live state precedence.** If you observe a discrepancy between the docs and
running state, update the docs *in the same session* (per the same-session update
rule in [page-templates.md](.agents/shared/page-templates.md#same-session-update-rule)).
## 5. Acting on the homelab
@@ -76,14 +106,44 @@ Grep is fine for browsing or when MCP is unreachable.
demand using the per-client age key at `/etc/age/key.txt`. Secrets ARE
available in this system — `list_my_secrets()` (MCP) shows what you can
decrypt.
- **Mutations** (restart, edit configs, etc.): the `homelab` CLI's mutating
subcommands ask for confirmation. For ad-hoc work, SSH and edit directly —
but commit changes that touch tracked configs (caddy, gitea custom,
artifacto, mule-image, etc.; see `infrastructure/auto-deploy.md`).
- **Mutations** (restart, edit configs, etc.): classify against
`oikos/policy.yaml` first (`homelab decide <action> <entity>`).
`reversible_low` actions just need the interactive confirmation prompt;
`config_mutation`/`destructive` actions are mechanically refused without
a valid `--approval-id` from `homelab approval request` — see OIKOS.md.
For ad-hoc work, SSH and edit directly — but commit changes that touch
tracked configs (caddy, gitea custom, artifacto, mule-image, etc.; see
`knowledge/wiki/infrastructure/auto-deploy.md`).
- **Wiki updates**: same-session rule applies to any meaningful state change
this client makes.
## 6. When in doubt
## 6. Communication mode
Read and apply `/opt/homelab-context/.agents/shared/caveman.md` (if present). It defines the lab's
terse-communication standard — drop filler, keep substance, use fragments.
## 7. Auto-setup mechanism
The homelab-context repo ships tooling that gets automatically installed
on every client after `git pull`. This is handled by `tools/post-pull.sh`
(replaces the raw git pull in the sync timer) which runs any script matching
`tools/*.setup.sh` after pull.
Currently auto-setup:
- **Caveman + templates** (`tools/setup-caveman.sh`): Installs Caveman npm
package, wrapper scripts, and compact output templates for token-efficient
CLI output. Wrapper at `~/bin/caveman_wrapper.sh`.
- **Hermes agent persona** (`tools/setup-hermes-soul.sh`): Provisions
`~/.hermes/SOUL.md` from `HERMES.md` on Hermes agents. This ensures every
Hermes agent follows the canonical homelab persona (token efficiency, source
of truth hierarchy). No-op on non-Hermes agents.
To add a new auto-setup, create `tools/<name>.setup.sh` in the repo,
commit and push. All enrolled clients pick it up within 5 minutes.
To trigger sync manually: `sudo homelab sync` or wait for the 5-min timer.
## 8. When in doubt
Run `homelab mcp search_docs <query>` or `homelab mcp get_host <name>`.
The clone is the fallback; MCP is the index.

View File

@@ -1,98 +0,0 @@
# Contributing to the Homelab Wiki
## Voice
Concise, technical, sysadmin-to-sysadmin. No marketing prose, no exclamation marks.
## Page templates
### Container page (`containers/<id>-<name>.md`)
```markdown
# <id> — `<name>`
One-sentence purpose.
## At a glance
- **Hostname:** `<name>`
- **IP:** `192.168.8.x`
- **Privilege:** privileged | unprivileged
- **Resources:** N cores / M GiB RAM / D GiB rootfs
- **Mounts:** `/mnt/library``/mnt/library` (if any)
- **Public hostname:** `<sub>.hubris.network` (if proxied)
## Role
What it does, what it talks to.
## Service / port map
| Service | Listen | Notes |
## Storage / config paths
## Auto-deploy
(if any) — link to [auto-deploy](../infrastructure/auto-deploy.md)
## Related
- [Caddy](121-caddy.md) (if proxied)
- [DNS](../infrastructure/dns.md) (if has subdomain)
- [Authentik](124-authentik.md) (if SSO)
- ...
## Changelog
### YYYY-MM-DD — short title
What changed, why, link to investigation if any.
```
### Cross-cutting page (`infrastructure/<topic>.md`)
```markdown
# <Topic>
One-sentence summary.
## Why
Design rationale — what it replaces, what it solves.
## Components
Where it runs, what files matter.
## How to apply / use
Recipes.
## Gotchas
## Related
Links to nodes that host or depend on this.
## Changelog
```
### Investigation (`investigations/YYYY-MM-DD-slug.md`)
```markdown
# YYYY-MM-DD — <title>
## Summary
1-3 sentences.
## Timeline
## Root cause
## Mitigations applied
## Open questions
```
## Linking discipline
- Every container page links to every cross-cutting page it participates in.
- Every cross-cutting page lists the nodes that participate.
- Every investigation links to the nodes it implicates *and* gets back-linked from each node's changelog.
## Changelog hygiene
- Reverse-chronological (newest first).
- One entry per discrete change, even if you make several in one day.
- If a change spans nodes, repeat the entry on each affected page (different perspective is fine).
- Don't rewrite history — entries are append-only. Mistakes get a follow-up entry that supersedes them.

233
README.md
View File

@@ -1,77 +1,190 @@
# Homelab Wiki — `hubris`
# Homelab OS
Living documentation for the **hubris** Proxmox homelab. Every node, every cross-cutting system, and every meaningful incident is its own page; pages are linked so you can start anywhere and walk the graph.
Living documentation for the **hubris** Proxmox homelab + Oikos operating system.
> Last refreshed against live state: **2026-04-28**.
**For agents running on enrolled clients:** start with [AGENTS.md](AGENTS.md), then [OIKOS.md](.agents/OIKOS.md).
## Map
---
### Hosts
- [`hubris`](hosts/hubris.md) — single Proxmox VE node, GMKtec NucBox M6 Ultra, `192.168.8.77`
## For Agents — Navigation & Entry Points
### VMs
- [100 — `zimaos`](vms/100-zimaos.md) — ZimaOS 1.6.1, NAS frontend (evaluation)
- [108 — `haos-16.3`](vms/108-haos.md) — Home Assistant OS
### You are running on a client enrolled in the hubris homelab
### LXC containers
See the full table in [`containers/index.md`](containers/index.md). Quick links:
1. **First:** Read [AGENTS.md](AGENTS.md) once. It explains who you are, the topology, available tools, conventions, and how to act.
2. **Before any mutation:** Read [OIKOS.md](.agents/OIKOS.md). It defines the operating model, risk classes, approval flow, and the ontology you'll consult.
3. **For specific workflows:** Load the matching skill from `.agents/skills/<name>/SKILL.md` (e.g., [service-health-check](.agents/skills/service-health-check/SKILL.md)).
4. **When in doubt:** Use MCP tools (`search_docs`, `get_page`, `explain`, `get_changelog`) — they're cheaper and more reliable than grepping.
| ID | Name | IP | Role |
| --- | ---------------- | --------------- | --------------------------------------------- |
| 101 | [jellyfin](containers/101-jellyfin.md) | 192.168.8.206 | Media server |
| 102 | [nfs-export](containers/102-nfs-export.md) | 192.168.8.200 | NFSv4 re-export of /mnt/library for ZimaOS |
| 103 | [paperless](containers/103-paperless.md) | 192.168.8.130 | Document mgmt |
| 104 | [gitea](containers/104-gitea.md) | 192.168.8.121 | Git server |
| 105 | [apps](containers/105-apps.md) | 192.168.8.205 | Docker host (Artifacto / Booklore / PlantUML / Portainer / WriteFreely) |
| 114 | [nextcloud](containers/114-nextcloud.md) | 192.168.8.224 | Personal cloud |
| 118 | [elementsynapse](containers/118-elementsynapse.md) | 192.168.8.239 | Matrix Synapse |
| 119 | [sophia](containers/119-sophia.md) | 192.168.8.157 | Sophia |
| 120 | [mule-images](containers/120-mule-images.md) | 192.168.8.136 | Mule-image / mulita photos |
| 121 | [caddy](containers/121-caddy.md) | 192.168.8.175 | Reverse proxy |
| 122 | [arriman](containers/122-arriman.md) | 192.168.8.132 | Docker host (\*arr stack) |
| 123 | [claudio-bot](containers/123-claudio-bot.md) | 192.168.8.230 | Matrix control plane |
| 124 | [authentik](containers/124-authentik.md) | 192.168.8.180 | SSO + split-horizon DNS |
| 126 | [plato](containers/126-plato.md) | 192.168.8.190 | Plato (notes/discovery workspace) |
### Key References for Agents
### Cross-cutting infrastructure
- [DNS — split-horizon](infrastructure/dns.md)
- [Ingress — Caddy + VPS traefik](infrastructure/ingress.md)
- [Mesh — Tailscale → Netbird migration](infrastructure/mesh.md)
- [Monitoring — claudio-monitor](infrastructure/monitoring.md)
- [Media permissions — `media` GID 10000](infrastructure/media-permissions.md)
- [SSH access](infrastructure/ssh-access.md)
- [Backups — restic on external drive (disabled)](infrastructure/backups.md)
- [Auto-deploy — gitea-webhook pipelines](infrastructure/auto-deploy.md)
- [VPS hardening — IONOS / netbird control plane](infrastructure/vps-hardening.md)
- [Homelab context distribution](infrastructure/homelab-context.md) — cross-client `/opt/homelab-context` + MCP + secrets-issuance
- **What am I?** → `/opt/homelab-context/hosts/<hostname>.yaml` (read on first run)
- **Live topology** → `inventory.yaml` + `hosts/*.yaml` (canonical, always wins)
- **Risk & approval** → [oikos/policy.yaml](oikos/policy.yaml) (enforced, not advisory)
- **Runbooks & workflows** → [.agents/skills/](.agents/skills/) (risk class + verification checklist included)
- **State of Oikos** → [OIKOS.md build status](.agents/OIKOS.md#build-status-30-day-roadmap) (scheduled probes, drift detectors, signals, approval engine)
### Investigations
Time-stamped incident notes / experiments in [`investigations/`](investigations/index.md).
### When to Use MCP vs Files vs Shell
### Operations
- [Command cheatsheet](operations/commands.md)
- [Agent enrollment](operations/agent-enrollment.md) — bootstrap a new client (workstation, LXC, VM) into the homelab context system
| Task | Use | Tool |
|------|-----|------|
| Resolve hostname → address | MCP | `get_host(name)` or `list_services()` |
| Search wiki by content | MCP | `search_docs(query)` |
| Read a wiki page | MCP or file | `get_page(path)` or `cat knowledge/wiki/.../...md` |
| Get changelog entries | MCP | `get_changelog(page, since?)` |
| Understand a service | MCP | `explain(service)` — compact context card, cheaper than search+read |
| Blast-radius query | MCP | `get_relations(entity)` (ontology walk) |
| List available secrets | MCP | `list_my_secrets()` (scoped to your age key) |
| Browse or grep | File | Raw `grep` when MCP unreachable, or exploratory browsing |
**When MCP is unreachable:** fall back to grepping the clone at `/opt/homelab-context/`. The local files are the same; MCP is just an index.
---
## Understanding the Operating Model
Before you act, **classify your action against [oikos/policy.yaml](oikos/policy.yaml)**.
### The Oikos OODA Loop + Decision Tree
```mermaid
flowchart TD
Observe["**Observe**<br/>probes, drift detectors, agent signals"]
Orient["**Orient**<br/>ontology, context, state, entity relations"]
Decide{"**Decide**<br/>classify against oikos/policy.yaml"}
Auto["Auto-act<br/>(unattended)"]
Escalate["Escalate<br/>homelab approval request"]
Act["**Act**<br/>homelab CLI, runbooks, skills"]
Verify["**Verify**<br/>checklist from SKILL.md"]
Ledger["**Ledger**<br/>mutation record: who/what/risk"]
Document["**Document**<br/>wiki update, same-session rule"]
Observe --> Orient --> Decide
Decide -->|read_only, reversible_low| Auto
Decide -->|config_mutation, destructive| Escalate
Auto --> Act
Escalate -->|approval granted| Act
Act --> Verify --> Ledger --> Document
Document -.loop.-> Observe
```
### Risk Classes (enforced, not advisory)
From [oikos/policy.yaml](oikos/policy.yaml):
- **read_only** — status, logs, docs, inventory queries. Unattended. MCP tools are all read_only.
- **reversible_low** — restart, cache clear, sync pull. Unattended + ledger entry.
- **config_mutation** — tracked-config edits (commit+push, never local), deploys, upgrades, DNS/ingress changes. **Operator approval required.**
- **destructive** — destroy, format, wipe, rotate, revoke. **Approval + typed confirmation phrase.**
### Decision Flow
1. **Decide:** Use `homelab decide <action> <entity>` to classify (risk class × blast radius × confidence).
2. **Escalate if needed:** `homelab approval request` (Matrix-delivered to operator; see [operations/commands.md](.agents/operations/commands.md)).
3. **Execute:** Use `homelab` CLI (not ad-hoc SSH) — it enforces policy, logs mutations, and verifies outcomes.
4. **Document:** Update wiki in the same session (per [AGENTS.md §5](AGENTS.md#5-acting-on-the-homelab) and the [same-session rule](.agents/shared/page-templates.md#same-session-update-rule)).
### The Ontology Graph
Everything that can break, be changed, or hold data has an entity in `inventory.yaml` + `oikos/ontology.yaml`. Blast-radius questions ("what breaks if strong goes down?") are graph walks via `homelab node <name> relations`, not doc archaeology.
**See:** [OIKOS.md](.agents/OIKOS.md) (full operating model, OODA loop, primitives, lifecycle gates, build status).
---
## Finding & Understanding Information
The narrative documentation is organized in **layers**:
| Layer | What it is | Where | Immutable? | How agents use it |
|-------|-----------|-------|-----------|-------------------|
| **Sources** | Raw evidence: incidents, external refs, live state | `knowledge/sources/investigations/` | Yes | Read to understand root causes; do not rewrite |
| **Wiki** | Synthesized current-state: one page per node & per system | `knowledge/wiki/{containers,hosts,vms,infrastructure}/` | No | This is the reference layer — if wiki disagrees with live state, update it *in the same session* |
| **Index** | Pure listings — every page in scope with one-line summary | `index.md` / folder `README.md` | No | Navigation aid; keep it current when wiki restructures |
| **Log** | Append-only doc-maintenance record (restructures, ingests, lints) | `knowledge/log.md` | Yes (append-only) | Read to understand past doc changes; never edit directly |
**Changelog ≠ Log:** Each wiki page ends with a `## Changelog` (infrastructure changes to that node, machine-parsed). That's not the Log; the Log records *doc operations* only.
**See:** [llm-wiki.md](.agents/shared/llm-wiki.md) (full rules, page structure, immutability contract).
---
## Map & Quick Navigation
### Agent Entry Points (Start Here)
- **You are an agent** → [AGENTS.md](AGENTS.md) (on deployed clients: `/opt/homelab-context/AGENTS.md`)
- **Operating model & risk policy** → [OIKOS.md](.agents/OIKOS.md)
- **Specific workflows** → [.agents/skills/](.agents/skills/) (load the matching SKILL.md before acting)
- **Operations cheatsheet** → [.agents/operations/commands.md](.agents/operations/commands.md)
- **Tools & MCP reference** → [AGENTS.md §3 — The MCP server](AGENTS.md#3-the-mcp-server)
### Topology & Infrastructure
Node counts, IPs, and service lists change often — treat `inventory.yaml` and the index pages below as the source of truth, not this README.
- **Proxmox hosts** → [knowledge/wiki/hosts/index.md](knowledge/wiki/hosts/index.md)
- **VMs** → [knowledge/wiki/vms/index.md](knowledge/wiki/vms/index.md)
- **LXC containers** → [knowledge/wiki/containers/index.md](knowledge/wiki/containers/index.md)
- **Cross-cutting infrastructure** (DNS, ingress, mesh, backups, monitoring, auto-deploy, VPS) → [knowledge/wiki/infrastructure/index.md](knowledge/wiki/infrastructure/index.md)
### Knowledge & References
- **Glossary** — [GLOSSARY.md](knowledge/GLOSSARY.md)
- **Incidents & investigations** — [knowledge/sources/investigations/index.md](knowledge/sources/investigations/index.md) (active + [archive](knowledge/sources/investigations/archive/))
- **Plans & design docs** — [plans/index.md](plans/index.md)
- **Hermes agent** (for Hermes-enrolled clients) — [HERMES.md](.agents/HERMES.md)
---
## Conventions
- **Each node page** ends with a `## Changelog` section. Reverse-chronological. Entry format:
```
### YYYY-MM-DD — short title
one or two lines on what changed and why.
```
- **Cross-linking is mandatory.** If a page references another node or system, link to it. Treat orphans as a bug.
- **Live state wins.** When something here disagrees with `pct config` / `docker inspect` / running config, fix the wiki *and* note the change in the relevant changelog.
- **Tracked configs.** A node whose config lives in a Gitea repo (Caddy, Gitea customizations, Artifacto, mule-image, claudio-bot) is auto-deployed via webhook — see [auto-deploy](infrastructure/auto-deploy.md). Edits there must be pushed, not left local.
- **No secrets.** This is a private repo on `git.hubris.network`, but still: paths to secret files are fine, secret values are not.
All pages follow:
## Maintaining this wiki
- **File naming.** Foundational docs (entry-points, agent instruction, references) are ALL-CAPS (`AGENTS.md`, `OIKOS.md`, `GLOSSARY.md`); containers use `<id>-<name>.md`; infrastructure pages use lowercase-with-dashes; plans and incidents use `YYYY-MM-DD-slug.md`; skills are `<name>/SKILL.md`. See [page-templates.md](.agents/shared/page-templates.md#file-naming) for the full rules.
- **Voice & vocabulary.** Concise, technical, sysadmin-to-sysadmin. No marketing prose, no puffers (seamless, robust, leverage, etc.). Full rules in [writing-style.md](.agents/shared/writing-style.md).
- **Cross-linking is mandatory.** If a page references a node or system, link to it. Treat orphans as a bug.
- **Live state wins.** When something here disagrees with `pct config` / `docker inspect` / running state, fix the wiki *and* add a changelog entry *in the same session*.
- **Tracked configs.** Pages for configs living in git repos (Caddy, Gitea, Artifacto, mule-image) must note the repo. Edits go through commit+push, never local changes. See [auto-deploy](knowledge/wiki/infrastructure/auto-deploy.md).
- **No secrets.** This is a private repo, but still: reference secret *paths*, never secret *values*.
When you change a node:
1. Update the relevant page (config snapshot, ports, mounts).
2. Add a changelog entry at the bottom of that page.
3. If the change touches a cross-cutting system (DNS, Caddy, Authentik, mesh), update *that* page too and link it from the changelog entry.
4. If it's an incident, add an entry to [`investigations/`](investigations/index.md).
**For agents:** Read [caveman.md](.agents/shared/caveman.md) (terse communication standard). Use templates at [page-templates.md](.agents/shared/page-templates.md) when creating pages.
## See also
---
- [`CONTRIBUTING.md`](CONTRIBUTING.md) — page templates and tone
## Updating the Wiki
### When You Change Infrastructure
1. Update the relevant page (config snapshot, ports, mounts, IP address).
2. Add a `### YYYY-MM-DD — title` entry to the page's `## Changelog` section (reverse chronological order).
3. If the change touches a cross-cutting system (DNS, Caddy, Authentik, mesh), update *that* page too and link from the changelog.
4. If it's an incident, add a record to [`knowledge/sources/investigations/`](knowledge/sources/investigations/index.md).
### When You Restructure the Wiki
1. Update the relevant `index.md` / `README.md` in that section.
2. Add a single-line entry to [`knowledge/log.md`](knowledge/log.md): `## [YYYY-MM-DD] <operation> | <summary>` (e.g., `## [2026-07-06] restructure | split infrastructure/dns into dns.md + dns-advanced.md`).
### The Same-Session Update Rule
**Any meaningful state change made in this session requires a wiki update before the session closes.** A change that touches a container page must also update:
- The `containers/index.md` table (IPs, host, mounts, status)
- The root `README.md` table (if affected)
- The Caddy page site list (if affects `*.hubris.network` routing)
- The DNS / ingress infrastructure pages (if affects routing)
- The `hosts/hubris.md` or `hosts/strong.md` page (if container count changes)
- The `inventory.yaml` host entry (source of truth for `hosts/*.yaml` generation)
- The `knowledge/wiki/infrastructure/topology.md` (regenerate if needed)
Not updating all linked places is a bug. See [page-templates.md — same-session update rule](.agents/shared/page-templates.md#same-session-update-rule).
---
## More Information
- **For Hermes agents** → [HERMES.md](.agents/HERMES.md) (persona, source-of-truth hierarchy, token efficiency)
- **For manual workflows** → [.agents/operations/](.agents/operations/) (commands cheatsheet, agent enrollment, Hermes guide)
- **For skills/runbooks** → [.agents/skills/](.agents/skills/) (load the matching SKILL.md before acting; includes risk class + verification)
- **MCP tools** → [AGENTS.md §3](AGENTS.md#3-the-mcp-server) (available tools, when to use MCP vs files)
- **Page templates & voice** → [.agents/shared/](.agents/shared/) (page-templates.md, writing-style.md, caveman.md, llm-wiki.md)
- **Machine-readable substrate** → `inventory.yaml`, `oikos/policy.yaml`, `oikos/ontology.yaml` (not part of the wiki; see [llm-wiki.md](.agents/shared/llm-wiki.md#rules))

View File

@@ -37,6 +37,21 @@ INVENTORY = CONTEXT / "inventory.yaml"
HOSTS_DIR = CONTEXT / "hosts"
AGE_KEY = Path(os.environ.get("SOPS_AGE_KEY_FILE", "/etc/age/key.txt"))
# Oikos kernel modules (policy classification, ontology relations, change
# ledger). Optional at import time so a stale/partial checkout degrades to
# "feature unavailable" instead of crashing every subcommand.
sys.path.insert(0, str(CONTEXT))
try:
from oikos import approve as oikos_approve
from oikos import decide as oikos_decide
from oikos import ledger as oikos_ledger
from oikos import policy as oikos_policy
from oikos import relations as oikos_relations
from oikos import signal as oikos_signal
except ImportError:
oikos_approve = oikos_decide = oikos_ledger = oikos_policy = None
oikos_relations = oikos_signal = None
# ---------- helpers ----------
@@ -172,6 +187,25 @@ def service_backend_host(name: str) -> str:
return service(name)["backend"]
def _record_change(entity: str, action: str, risk: str, *,
result: str | None = None, verification: str | None = None,
approval_ref: str | None = None) -> None:
"""Append a ledger entry and push it standalone (used by mutations that
don't already go through push_inventory, e.g. restart)."""
if oikos_ledger is None:
return
oikos_ledger.append(entity, action, risk, result=result, verification=verification,
approval_ref=approval_ref)
try:
subprocess.run(["git", "add", "ledger/"], check=True, cwd=CONTEXT)
if subprocess.run(["git", "diff", "--cached", "--quiet"], cwd=CONTEXT).returncode != 0:
subprocess.run(["git", "commit", "-m", f"ledger: {entity} {action} ({risk})"],
check=True, cwd=CONTEXT)
subprocess.run(["git", "push"], check=True, cwd=CONTEXT)
except subprocess.CalledProcessError as e:
print(f"warning: could not commit/push ledger entry: {e}", file=sys.stderr)
def push_inventory(message: str, extra_paths: list[str] | None = None) -> None:
"""Stage + commit + push inventory + regenerated hosts/ (+ any extras)."""
subprocess.run(["python3", str(CONTEXT / "mcp" / "build_host_files.py")],
@@ -517,6 +551,19 @@ def cmd_ssh(args: argparse.Namespace) -> int:
os.execvp(cmd[0], cmd)
def cmd_ssh_config(args: argparse.Namespace) -> int:
"""Generate SSH config from inventory.yaml."""
script = CONTEXT / "ssh" / "gen-config.py"
if not script.exists():
die(f"ssh-config generator not found: {script}")
cmd = [sys.executable or "python3", str(script)]
if args.install:
cmd.append("--install")
return subprocess.call(cmd)
def cmd_pct(args: argparse.Namespace) -> int:
lxc = args.lxc
inv = inventory()
@@ -557,11 +604,31 @@ def cmd_restart(args: argparse.Namespace) -> int:
svc = args.service
host_name = service_backend_host(svc)
unit = service(svc).get("systemd_unit", svc)
risk = (oikos_policy.classify_action("service-restart", svc)
if oikos_policy else "reversible_low") or "reversible_low"
approval = oikos_policy.approval_for(risk) if oikos_policy else "none"
# Mechanical gate: config_mutation/destructive risk classes require a
# live grant regardless of -y/interactivity — an agent (or a human
# bypassing the confirm() prompt with -y) cannot mutate a gated service
# without a real oikos/approve.py approval. See oikos/policy.yaml.
if approval != "none":
if not args.approval_id:
die(f"restarting '{svc}' is risk class '{risk}' (approval: {approval}) — "
f"pass --approval-id <id> from an approved 'homelab approval request'")
ok, reason = oikos_approve.check_grant(args.approval_id, f"service:{svc}", "service-restart")
if not ok:
die(f"approval {args.approval_id} not valid for this action: {reason}")
if not args.yes:
if not confirm(f"restart systemd unit '{unit}' on {host_name}?"):
return 1
base = ssh_base(host_name)
return subprocess.call(base + ["--", "systemctl", "restart", unit])
rc = subprocess.call(base + ["--", "systemctl", "restart", unit])
_record_change(f"service:{svc}", "restart", risk,
result=("ok" if rc == 0 else f"failed rc={rc}"),
approval_ref=args.approval_id)
return rc
def cmd_open(args: argparse.Namespace) -> int:
@@ -1018,14 +1085,19 @@ def cmd_refresh_creds(args: argparse.Namespace) -> int:
def cmd_sync(args: argparse.Namespace) -> int:
# Unlike every other mutating command here, this one had no os.geteuid()
# guard — always shelled out to sudo. Fails outright with "No such file
# or directory: 'sudo'" on minimal root-only Linux images (no sudo
# binary installed at all) reached via `ssh root@host`, e.g. strong.
needs_sudo = os.geteuid() != 0
if sys.platform == "darwin":
return subprocess.call(
["sudo", "launchctl", "kickstart", "-k",
"system/network.hubris.homelab-context-sync"]
)
return subprocess.call(
["sudo", "systemctl", "start", "homelab-context-sync.service"]
)
cmd = ["launchctl", "kickstart", "-k",
"system/network.hubris.homelab-context-sync"]
else:
cmd = ["systemctl", "start", "homelab-context-sync.service"]
if needs_sudo:
cmd = ["sudo"] + cmd
return subprocess.call(cmd)
def cmd_mcp(args: argparse.Namespace) -> int:
@@ -1085,9 +1157,11 @@ def cmd_client_add(args: argparse.Namespace) -> int:
print("granting hermes-only secrets...")
_grant_shared_secrets(pubkey, HERMES_SECRETS)
commit_subject = f"client-add: {name} (finalize age_pubkey + grant shared + hermes secrets)"
if oikos_ledger is not None:
oikos_ledger.append(f"host:{name}", "client-add-finalize", "config_mutation", result="ok")
push_inventory(
commit_subject,
extra_paths=[".sops.yaml", "secrets/"],
extra_paths=[".sops.yaml", "secrets/", "ledger/"],
)
print(f"finalized {name}.")
return 0
@@ -1149,9 +1223,11 @@ def cmd_client_remove(args: argparse.Namespace) -> int:
print(f" issuance revoke failed: {e}")
# 4. Commit + push (extras: .sops.yaml + secrets/ may also have changed).
if oikos_ledger is not None:
oikos_ledger.append(f"host:{name}", "client-remove", "destructive", result="ok")
push_inventory(
f"client-remove: {name}",
extra_paths=[".sops.yaml", "secrets/"],
extra_paths=[".sops.yaml", "secrets/", "ledger/"],
)
print()
@@ -1164,6 +1240,211 @@ def cmd_client_remove(args: argparse.Namespace) -> int:
return 0
def _require_oikos() -> None:
if oikos_policy is None or oikos_relations is None or oikos_ledger is None:
die("oikos/ kernel modules not importable — is this checkout up to date?")
def cmd_service(args: argparse.Namespace) -> int:
"""Service Console v0 — explain/health/docs/log/actions/history for one service."""
_require_oikos()
name = args.name
svc = service(name) # dies with a clear message if unknown
if args.action == "explain":
card = CONTEXT / "oikos" / "cards" / f"service-{name}.md"
if not card.exists():
die(f"no context card for {name} — run: python3 oikos/gen-topology.py")
print(card.read_text())
return 0
if args.action == "health":
url = svc.get("url") or svc.get("endpoint")
if not url:
die(f"service {name} has no url/endpoint in inventory")
if not args.live:
try:
from oikos import scheduler as oikos_scheduler
cached = oikos_scheduler.cached_service_health(name)
except ImportError:
cached = None
if cached is not None and cached.get("checked"):
status = "ok" if cached.get("ok") else "unhealthy"
print(f"{name}: {cached.get('checked_url', url)} -> "
f"{cached.get('http_code') or 'no response'} ({status}, "
f"as of {cached['as_of']} — pass --live to force a fresh probe)")
return 0 if cached.get("ok") else 1
proc = subprocess.run(
["curl", "-sS", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "5", url],
capture_output=True, text=True,
)
code = proc.stdout.strip() or "no response"
print(f"{name}: {url} -> {code} (live probe)")
return 0 if code.startswith(("2", "3")) else 1
if args.action == "docs":
doc = svc.get("doc_page")
if not doc:
die(f"no doc_page recorded for {name} in inventory.yaml")
path = CONTEXT / doc
if not path.exists():
die(f"doc_page {doc} does not exist")
print(path.read_text())
return 0
if args.action == "log":
return cmd_logs(argparse.Namespace(service=name, lines=args.lines, follow=False))
if args.action == "actions":
for a in oikos_policy.safe_actions_for_service(name, svc):
print(f"{a['action']:<24} {a['risk']:<16} approval={a['approval']}")
return 0
if args.action == "history":
entries = oikos_ledger.history(f"service:{name}", limit=args.limit)
if not entries:
print(f"(no ledger entries for service:{name} yet)")
for e in entries:
print(json.dumps(e))
return 0
die(f"unknown service action: {args.action}")
def cmd_change_preflight(args: argparse.Namespace) -> int:
"""Dry-run report before mutating a service: health, risk class, approval
requirement, and the verification command to run after."""
_require_oikos()
name = args.service
svc = service(name)
risk = (oikos_policy.classify_action("tracked-config-edit", name)
if svc.get("config_repo")
else oikos_policy.classify_action("service-restart", name)) or "config_mutation"
approval = oikos_policy.approval_for(risk)
print(f"Preflight: {name}")
print(f" risk class: {risk} (approval: {approval})")
url = svc.get("url") or svc.get("endpoint")
if url:
proc = subprocess.run(
["curl", "-sS", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "3", url],
capture_output=True, text=True,
)
print(f" current health: {url} -> {proc.stdout.strip() or 'no response'}")
if svc.get("config_repo"):
print(f" config repo: {svc['config_repo']} "
f"(verify the backend's working tree is clean before editing)")
if svc.get("risk_notes"):
print(f" risk notes: {svc['risk_notes']}")
print(f" verification after change: "
+ (f"curl -sf {url}" if url else f"homelab logs {name}"))
if approval != "none":
print(f" requires operator approval before mutating ({approval})")
return 0
def cmd_node_relations(args: argparse.Namespace) -> int:
"""Walk the ontology graph both directions for a host or service name."""
_require_oikos()
results = oikos_relations.relations_for_name(args.name)
if not results:
die(f"unknown entity: {args.name}")
for r in results:
print(f"entity: {r['entity']}")
print(f" impacts: {', '.join(r['impacts']) or '(none)'}")
print(f" affected by: {', '.join(r['affected_by']) or '(none)'}")
print(f" full blast radius: {', '.join(r['blast_radius']) or '(none)'}")
return 0
def cmd_decide(args: argparse.Namespace) -> int:
"""Route a proposed action: auto-act or escalate. See oikos/decide.py."""
_require_oikos()
result = oikos_decide.classify(args.action, args.entity, service_name=args.service_name,
record=not args.no_record)
print(json.dumps(result, indent=2))
return 0 if result["route"] == "auto-act" else 1
def cmd_approval_request(args: argparse.Namespace) -> int:
_require_oikos()
entry = oikos_approve.request(
args.entity, args.action, args.risk, args.evidence,
verification=args.verification, requires_phrase=args.requires_phrase,
ttl_hours=args.ttl_hours,
)
print(json.dumps({k: v for k, v in entry.items() if k != "matrix_message"}, indent=2))
print()
print("--- post this to Matrix ---")
print(entry["matrix_message"])
return 0
def cmd_approval_list(args: argparse.Namespace) -> int:
_require_oikos()
for e in oikos_approve.list_approvals(state=args.state):
print(json.dumps(e))
return 0
def cmd_approval_reply(args: argparse.Namespace) -> int:
_require_oikos()
try:
entry = oikos_approve.reply(args.id, args.decision, phrase=args.phrase,
decided_by=args.decided_by)
except (ValueError, RuntimeError) as e:
die(str(e))
print(json.dumps(entry, indent=2))
return 0
def cmd_approval_check(args: argparse.Namespace) -> int:
_require_oikos()
ok, reason = oikos_approve.check_grant(args.id, args.entity, args.action)
print(f"{'GRANTED' if ok else 'DENIED'}: {reason}")
return 0 if ok else 1
def cmd_signal_raise(args: argparse.Namespace) -> int:
_require_oikos()
action = None
if args.action_runbook or args.action_risk:
action = {"runbook": args.action_runbook, "risk": args.action_risk}
entry = oikos_signal.raise_signal(args.kind, args.severity, args.entity, args.evidence,
likely_cause=args.likely_cause,
recommended_action=action,
verification=args.verification)
print(json.dumps(entry, indent=2))
return 0
def cmd_signal_list(args: argparse.Namespace) -> int:
_require_oikos()
for e in oikos_signal.list_signals(state=args.state, entity=args.entity,
severity=args.severity, kind=args.kind):
print(json.dumps(e))
return 0
def cmd_signal_ack(args: argparse.Namespace) -> int:
_require_oikos()
print(json.dumps(oikos_signal.acknowledge(args.id, args.note), indent=2))
return 0
def cmd_signal_resolve(args: argparse.Namespace) -> int:
_require_oikos()
print(json.dumps(oikos_signal.resolve(args.id, args.note), indent=2))
return 0
def cmd_signal_mute(args: argparse.Namespace) -> int:
_require_oikos()
print(json.dumps(oikos_signal.mute(args.id, args.ttl_hours, args.note), indent=2))
return 0
def cmd_nuke(args: argparse.Namespace) -> int:
name = args.name
if not args.yes:
@@ -1459,6 +1740,12 @@ def main() -> int:
sp.add_argument("command", nargs=argparse.REMAINDER)
sp.set_defaults(func=cmd_ssh)
sp = sub.add_parser("ssh-config",
help="generate ~/.ssh/config.d/homelab from inventory.yaml")
sp.add_argument("--install", "-i", action="store_true",
help=f"write to ~/.ssh/config.d/homelab and wire Include into main config")
sp.set_defaults(func=cmd_ssh_config)
sp = sub.add_parser("pct", help="proxy pct commands via ssh to hubris")
sp.add_argument("lxc")
sp.add_argument("action")
@@ -1475,6 +1762,9 @@ def main() -> int:
sp = sub.add_parser("restart", help="restart a service")
sp.add_argument("service")
sp.add_argument("--yes", "-y", action="store_true")
sp.add_argument("--approval-id", default=None,
help="required if the service's risk class needs approval "
"(see 'homelab approval request')")
sp.set_defaults(func=cmd_restart)
sp = sub.add_parser("open", help="open a service's URL in browser")
@@ -1530,6 +1820,101 @@ def main() -> int:
help="skip the pre-flight dpkg-audit gate AND proceed past snapshot failures")
sp.set_defaults(func=cmd_apt_upgrade)
sp = sub.add_parser("service", help="Service Console v0 — explain/health/docs/log/actions/history")
sp.add_argument("name")
sp.add_argument("action", choices=["explain", "health", "docs", "log", "actions", "history"])
sp.add_argument("--lines", "-n", type=int, default=200, help="for 'log'")
sp.add_argument("--limit", type=int, default=20, help="for 'history'")
sp.add_argument("--live", action="store_true",
help="for 'health': force a fresh probe instead of the scheduler's cache")
sp.set_defaults(func=cmd_service)
change = sub.add_parser("change", help="change/mutation workflow")
chsub = change.add_subparsers(dest="action", required=True)
ch_preflight = chsub.add_parser("preflight")
ch_preflight.add_argument("service")
ch_preflight.set_defaults(func=cmd_change_preflight)
sp = sub.add_parser("node", help="ontology queries on a host/service")
sp.add_argument("name")
sp.add_argument("action", choices=["relations"])
sp.set_defaults(func=cmd_node_relations)
sp = sub.add_parser("decide", help="classify a proposed action: auto-act or escalate")
sp.add_argument("action")
sp.add_argument("entity")
sp.add_argument("--service-name", default=None)
sp.add_argument("--no-record", action="store_true",
help="skip writing this classification to the change ledger")
sp.set_defaults(func=cmd_decide)
approval = sub.add_parser("approval", help="approval-engine requests (escalate route)")
apsub = approval.add_subparsers(dest="action", required=True)
ap_req = apsub.add_parser("request")
ap_req.add_argument("entity")
ap_req.add_argument("action")
ap_req.add_argument("risk")
ap_req.add_argument("evidence")
ap_req.add_argument("--verification")
ap_req.add_argument("--requires-phrase", action="store_true")
ap_req.add_argument("--ttl-hours", type=int, default=24)
ap_req.set_defaults(func=cmd_approval_request)
ap_list = apsub.add_parser("list")
ap_list.add_argument("--state", choices=["pending", "approved", "denied", "expired", "executed"])
ap_list.set_defaults(func=cmd_approval_list)
ap_reply = apsub.add_parser("reply")
ap_reply.add_argument("id")
ap_reply.add_argument("decision", choices=["approve", "deny"])
ap_reply.add_argument("--phrase")
ap_reply.add_argument("--decided-by")
ap_reply.set_defaults(func=cmd_approval_reply)
ap_check = apsub.add_parser("check")
ap_check.add_argument("id")
ap_check.add_argument("entity")
ap_check.add_argument("action")
ap_check.set_defaults(func=cmd_approval_check)
signal = sub.add_parser("signal", help="the attention layer (oikos/signal.py)")
sigsub = signal.add_subparsers(dest="action", required=True)
sig_raise = sigsub.add_parser("raise")
sig_raise.add_argument("kind")
sig_raise.add_argument("severity", choices=["info", "warning", "critical"])
sig_raise.add_argument("entity")
sig_raise.add_argument("evidence")
sig_raise.add_argument("--likely-cause")
sig_raise.add_argument("--action-runbook")
sig_raise.add_argument("--action-risk")
sig_raise.add_argument("--verification")
sig_raise.set_defaults(func=cmd_signal_raise)
sig_list = sigsub.add_parser("list")
sig_list.add_argument("--state", choices=["raised", "acknowledged", "acting", "resolved", "muted"])
sig_list.add_argument("--entity")
sig_list.add_argument("--severity", choices=["info", "warning", "critical"])
sig_list.add_argument("--kind")
sig_list.set_defaults(func=cmd_signal_list)
sig_ack = sigsub.add_parser("ack")
sig_ack.add_argument("id")
sig_ack.add_argument("--note")
sig_ack.set_defaults(func=cmd_signal_ack)
sig_resolve = sigsub.add_parser("resolve")
sig_resolve.add_argument("id")
sig_resolve.add_argument("--note")
sig_resolve.set_defaults(func=cmd_signal_resolve)
sig_mute = sigsub.add_parser("mute")
sig_mute.add_argument("id")
sig_mute.add_argument("--ttl-hours", type=int, default=24)
sig_mute.add_argument("--note")
sig_mute.set_defaults(func=cmd_signal_mute)
sp = sub.add_parser("nuke", help="shred /etc/age/key.txt + /opt/homelab-context on a host")
sp.add_argument("name")
sp.add_argument("--yes", "-y", action="store_true")
@@ -1544,7 +1929,7 @@ def main() -> int:
csub_add.add_argument("--with-hermes", action="store_true",
help="also grant secrets/openrouter-api-key.yaml so this "
"host can run the Hermes agent (see "
"operations/hermes-agent.md). Combine with --finalize-pubkey.")
".agents/operations/hermes-agent.md). Combine with --finalize-pubkey.")
csub_add.set_defaults(func=cmd_client_add)
csub_rm = csub.add_parser("remove")
csub_rm.add_argument("name")

View File

@@ -7,7 +7,11 @@
# curl ... | sudo bash -s -- --with-mcp # also wire Claude's .mcp.json
# curl ... | sudo bash -s -- --with-hermes # also install Goose + Hermes wrapper
# curl ... | sudo bash -s -- --dry-run # show what would happen
# curl ... | sudo bash -s -- --no-secrets # skip age-key issuance
# curl ... | sudo bash -s -- --no-secrets # skip age-key issuance entirely
# curl ... | sudo bash -s -- --no-mesh # get secrets over LAN only, skip
# # installing/connecting Netbird
# # (host must be on 192.168.8.0/24
# # or otherwise reach secrets.hubris.network)
#
# Prerequisites the script verifies:
# - running as root
@@ -23,7 +27,7 @@ REPO_HTTPS="${HOMELAB_REPO_URL:-https://git.hubris.network/dtoro/Homelab-Docs.gi
CLONE_DIR="${HOMELAB_CONTEXT_DIR:-/opt/homelab-context}"
ISSUANCE_URL_NETBIRD="${HOMELAB_ISSUANCE_NETBIRD:-https://secrets.hubris.network/issue}"
ISSUANCE_URL_TAILSCALE="${HOMELAB_ISSUANCE_TAILSCALE:-https://secrets.hubris.network/issue}"
MCP_URL="${HOMELAB_MCP_URL:-https://mcp.hubris.network/sse}"
MCP_URL="${HOMELAB_MCP_URL:-https://mcp.hubris.network/mcp}"
HERMES_MCP_URI="${HOMELAB_HERMES_MCP_URI:-https://mcp.hubris.network/mcp}"
HERMES_MODEL="${HOMELAB_HERMES_MODEL:-nousresearch/hermes-4-405b}"
@@ -31,6 +35,7 @@ WITH_MCP=0
WITH_HERMES=0
DRY_RUN=0
NO_SECRETS=0
NO_MESH=0
GITEA_TOKEN="${HOMELAB_GITEA_TOKEN:-}"
GITEA_USER="${HOMELAB_GITEA_USER:-dtoro}"
@@ -41,6 +46,7 @@ while [ $# -gt 0 ]; do
--with-hermes) WITH_HERMES=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
--no-secrets) NO_SECRETS=1; shift ;;
--no-mesh) NO_MESH=1; shift ;;
--gitea-token) GITEA_TOKEN="$2"; shift 2 ;;
--gitea-user) GITEA_USER="$2"; shift 2 ;;
--help|-h)
@@ -83,6 +89,21 @@ run() {
fi
}
# Run a command as the enrolling human user when one exists (i.e. this
# script was invoked via `sudo bash bootstrap.sh` from a real login), and
# directly otherwise. Minimal Linux images (bare Proxmox/Debian installs
# reached via `ssh root@host`) often don't even have a `sudo` binary
# installed — calling `sudo -u root ...` on those unconditionally fails
# with "sudo: command not found" even though we're already root and don't
# need to switch users at all.
run_as() {
if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ]; then
sudo -u "$SUDO_USER" -- "$@"
else
"$@"
fi
}
# -------- preflight --------
if [ "$(id -u)" -ne 0 ]; then
echo "bootstrap.sh must run as root (use sudo)." >&2
@@ -118,6 +139,22 @@ fi
if [ "$NO_SECRETS" -eq 0 ]; then
for cmd in age sops; do command -v "$cmd" >/dev/null || missing+=("$cmd"); done
fi
# sops isn't a real Debian/Fedora package (there is no apt/dnf "sops"), so it
# always needs the direct-binary-download path, on both distros. Only Darwin
# (brew) can install it via a package manager.
install_sops_binary() {
local sops_version=v3.9.4
local arch
arch="$(uname -m)"
case "$arch" in
x86_64|amd64) arch=amd64 ;;
aarch64|arm64) arch=arm64 ;;
*) echo "[bootstrap] unsupported arch for sops binary download: $arch" >&2; return 1 ;;
esac
curl -fsSL "https://github.com/getsops/sops/releases/download/${sops_version}/sops-${sops_version}.linux.${arch}" \
-o /usr/local/bin/sops && chmod +x /usr/local/bin/sops
}
if [ "${#missing[@]}" -gt 0 ]; then
if [ "$DRY_RUN" -eq 1 ]; then
echo "+ would install missing tools: ${missing[*]}"
@@ -138,13 +175,23 @@ if [ "${#missing[@]}" -gt 0 ]; then
for m in "${missing[@]}"; do
case "$m" in
python3-yaml) dnf_list+=("python3-pyyaml") ;;
sops) install_sops_binary ;;
*) dnf_list+=("$m") ;;
esac
done
dnf install -y "${dnf_list[@]}"
[ "${#dnf_list[@]}" -gt 0 ] && dnf install -y "${dnf_list[@]}"
elif command -v apt-get >/dev/null 2>&1; then
DEBIAN_FRONTEND=noninteractive apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y "${missing[@]}"
apt_list=()
for m in "${missing[@]}"; do
case "$m" in
sops) install_sops_binary ;;
*) apt_list+=("$m") ;;
esac
done
if [ "${#apt_list[@]}" -gt 0 ]; then
DEBIAN_FRONTEND=noninteractive apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y "${apt_list[@]}"
fi
else
echo "[bootstrap] no supported package manager for: ${missing[*]}" >&2
echo "[bootstrap] install with your package manager + re-run" >&2
@@ -165,10 +212,14 @@ if [ "${#missing[@]}" -gt 0 ]; then
fi
# -------- ensure netbird is installed + connected (workstation/VM hosts) --------
# Skipped on --no-secrets (LXCs that route via the LAN already) and --dry-run.
# Installs netbird if missing, then drives `netbird up` against the homelab
# management server. The operator clicks the printed device-code URL once.
if [ "$NO_SECRETS" -eq 0 ] && [ "$DRY_RUN" -eq 0 ]; then
# Skipped on --no-secrets (LXCs that route via the LAN already), --no-mesh
# (explicit opt-out — secrets issuance still works if the mesh check below
# falls back to LAN reachability), and --dry-run. Installs netbird if
# missing, then drives `netbird up` against the homelab management server.
# The operator clicks the printed device-code URL once — this blocks
# indefinitely if nobody approves it, so don't skip --no-mesh on a host
# nobody's watching interactively.
if [ "$NO_SECRETS" -eq 0 ] && [ "$DRY_RUN" -eq 0 ] && [ "$NO_MESH" -eq 0 ]; then
if ! command -v netbird >/dev/null 2>&1 && ! command -v tailscale >/dev/null 2>&1; then
echo "[bootstrap] no mesh CLI found; installing netbird..."
if [ "$OS" = "Darwin" ]; then
@@ -430,9 +481,9 @@ if [ "$WITH_HERMES" -eq 1 ]; then
echo "+ would run upstream goose installer and symlink to /usr/local/bin/goose"
else
# Upstream installer drops the binary at ~/.local/bin/goose for the
# invoking user. We run it as $H_USER then symlink system-wide.
sudo -u "$H_USER" \
env CONFIGURE=false \
# invoking user. We run it as $H_USER (via run_as) then symlink
# system-wide.
run_as env CONFIGURE=false \
bash -c 'curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh | bash'
if [ -x "$H_HOME/.local/bin/goose" ]; then
ln -sfn "$H_HOME/.local/bin/goose" /usr/local/bin/goose
@@ -456,7 +507,7 @@ if [ "$WITH_HERMES" -eq 1 ]; then
Linux) HERMES_LINK=/root/HERMES.md ;;
Darwin) HERMES_LINK=/etc/HERMES.md ;;
esac
run "ln -sfn '$CLONE_DIR/HERMES.md' '$HERMES_LINK'"
run "ln -sfn '$CLONE_DIR/.agents/HERMES.md' '$HERMES_LINK'"
echo "[bootstrap] linked HERMES.md → $HERMES_LINK"
# 4. Drop the Goose config. Idempotent YAML merge — preserves any keys the
@@ -512,7 +563,7 @@ PYEOF
# 5. Symlink HERMES.md as the global .goosehints — Goose injects it into
# the system prompt on every session start.
run "ln -sfn '$CLONE_DIR/HERMES.md' '$GOOSEHINTS'"
run "ln -sfn '$CLONE_DIR/.agents/HERMES.md' '$GOOSEHINTS'"
if [ "$DRY_RUN" -eq 0 ]; then
chown -h "$H_USER" "$GOOSEHINTS" 2>/dev/null || true
fi
@@ -595,7 +646,7 @@ if [ "$HKIND" != "lxc" ] && [ "$HKIND" != "vm" ]; then
# Make sure pipx is available; OS-specific install.
if ! command -v pipx >/dev/null 2>&1; then
if [ "$OS" = "Darwin" ] && command -v brew >/dev/null 2>&1; then
sudo -u "${SUDO_USER:-$USER}" brew install pipx 2>&1 | tail -2 || true
run_as brew install pipx 2>&1 | tail -2 || true
elif command -v dnf >/dev/null 2>&1; then
dnf install -y pipx 2>&1 | tail -2 || true
elif command -v apt-get >/dev/null 2>&1; then
@@ -603,9 +654,9 @@ if [ "$HKIND" != "lxc" ] && [ "$HKIND" != "vm" ]; then
fi
fi
if command -v pipx >/dev/null 2>&1; then
INVOKING_USER="${SUDO_USER:-$USER}"
sudo -u "$INVOKING_USER" -- bash -lc "pipx install 'mcp[cli]'" 2>&1 | tail -3 || true
sudo -u "$INVOKING_USER" -- bash -lc "pipx ensurepath" >/dev/null 2>&1 || true
INVOKING_USER="${SUDO_USER:-root}"
run_as bash -lc "pipx install 'mcp[cli]'" 2>&1 | tail -3 || true
run_as bash -lc "pipx ensurepath" >/dev/null 2>&1 || true
echo "[bootstrap] mcp CLI installed for $INVOKING_USER via pipx"
else
echo "[bootstrap] WARNING: pipx unavailable; install manually: pipx install 'mcp[cli]'" >&2

View File

@@ -1,34 +0,0 @@
# 101 — `jellyfin`
Media server: serves the movies / TV / anime / music / audiobooks / podcasts libraries from `/mnt/library` to LAN clients.
## At a glance
- **Hostname:** `jellyfin`
- **IP:** `192.168.8.206`
- **Privilege:** **unprivileged** + idmap (so it can write to the `media` group on `/mnt/library`)
- **Resources:** 2 cores / 4 GiB RAM / 16 GiB rootfs
- **Mounts:** `/mnt/library``/mnt/library`
- **Public hostname:** [`media.hubris.network`](../infrastructure/dns.md) → [caddy](121-caddy.md) → `:8096`
## Service / port map
| Service | Listen | Notes |
| -------- | ------ | ----- |
| jellyfin | `:8096` | HTTP (caddy terminates TLS) |
## Permissions
Member of the [media GID 10000](../infrastructure/media-permissions.md) standard. Service user `jellyfin` is in the `media` group inside the container; idmap block in `/etc/pve/lxc/101.conf` maps in-container GID 10000 to host GID 10000.
## Related
- [Caddy reverse proxy](121-caddy.md)
- [Media permissions](../infrastructure/media-permissions.md)
- [arriman](122-arriman.md) — \*arr stack writes the libraries jellyfin reads
- [DNS split-horizon](../infrastructure/dns.md)
## Changelog
### 2026-04-28 — wiki entry created
Initial documentation. No config changes.
### 2026-04-20 — joined the `media` GID 10000 standard
Idmap block applied; in-container `media` group at GID 10000 mapped to host GID 10000. See [media permissions](../infrastructure/media-permissions.md). Config backup: `/root/101.conf.bak.*`.

View File

@@ -1,186 +0,0 @@
# 124 — `authentik`
Central Identity Provider for the lab. Also runs the [split-horizon dnsmasq](../infrastructure/dns.md) — ergo "the SSO and DNS box".
## At a glance
- **Hostname:** `authentik`
- **IP:** `192.168.8.180` (statically configured — the only LXC with a static IP)
- **Privilege:** privileged
- **Resources:** 2 cores / 4 GiB RAM / 20 GiB rootfs
- **Mounts:** none from `/mnt/library`
- **Public hostname:** [`auth.hubris.network`](../infrastructure/dns.md) → [caddy (121)](121-caddy.md) → `:9000`
- **Container DNS (in `/etc/pve/lxc/124.conf`):** `192.168.8.1 1.1.1.1` (router DNS plus a fallback added 2026-04-21 because router DNS flakes intermittently — Authentik is the resolver itself for the *rest* of the LAN, but its own LXC uses upstream).
## Authentik stack (`/opt/authentik/`)
Upstream `docker-compose.yml` + `.env`. Services: `postgresql` (16-alpine), `server`, `worker`. Authentik 2026.x dropped the Redis dependency.
- `.env` mode 600, **untracked**, holds `AUTHENTIK_SECRET_KEY` and `PG_PASS`.
- `AUTHENTIK_TAG=2026.2.2` — pinned. Don't let it drift to `:latest`. Telemetry / update-check / error-reporting disabled.
- Ports: 9000 (http), 9443 (https) on the LXC.
- Embedded outpost lives at `/outpost.goauthentik.io/*` on the Authentik host — the forward-auth endpoint Caddy points at.
- Stack is **not** git-tracked yet. If/when wiring auto-deploy: mirror the `mule-image` pattern (webhook receiver outside the app repo at `/opt/authentik-deploy/`). Repo `dtoro/authentik-conf` is reserved but not created.
## Forward-auth pattern (every gated app)
- **One Proxy Provider per app.** Authentik enforces a UNIQUE constraint `application.provider_id`, so one Provider = one Application. "Domain-level" only means they share the cookie domain. Each provider in "Forward auth (domain level)" mode, External host `https://auth.hubris.network`, Cookie domain `hubris.network`. First one was `hubris-forward-auth` (Paperless).
- **Authentication flow:** MUST be `default-authentication-flow` (NOT `default-source-authentication` — that's for IdP federation; gives `FlowNonApplicableException` + 404 on the authorize endpoint).
- **Authorization flow:** `default-provider-authorization-implicit-consent` (or explicit).
- **Application Launch URL** MUST be the full public URL `https://<sub>.hubris.network/` — outpost matches incoming `X-Forwarded-Host` against it.
- Each Application MUST have at least one **policy/group/user binding** — zero bindings = outpost returns 404 on access.
- **Restart Authentik after binding new apps to the outpost:**
```
pct exec 124 -- docker compose -f /opt/authentik/docker-compose.yml restart server worker
```
Caddy snippet `(authentik)` lives at the top of `/etc/caddy/Caddyfile`. Points at `http://192.168.8.180:9000` directly (NOT `https://auth.hubris.network`) to avoid hairpin TLS round-trip stripping `X-Forwarded-Host`. Must explicitly set `header_up X-Forwarded-Host {host}` in the forward-auth block. Used by gated sites with `import authentik`.
### Per-app username override pattern (Authentik)
Used when the app's local user ID doesn't match the user's Authentik username (e.g., Nextcloud's `admin` ≠ Authentik's `dtoro`).
1. On the Authentik user: add attribute `<app>_uid: <target_local_username>` (YAML, Directory → Users → Edit → Attributes).
2. Customization → Property Mappings → Create → **Scope Mapping** (not SAML):
- Name: `<app>-uid-override`, Scope name: `<app>-uid`, Expression:
```python
return {"nc_uid": user.attributes.get("<app>_uid", user.username)}
```
- **Use a custom claim key** (e.g. `nc_uid`), not `preferred_username` — the default `profile` scope mapping emits `preferred_username` and will overwrite yours depending on ordering.
3. Attach the new scope to the provider (Providers → app → Scopes).
4. On the app side, point its OIDC UID-mapping setting at the custom claim.
For Nextcloud:
```
occ user_oidc:provider <name> --mapping-uid=nc_uid
occ user_oidc:provider <name> --scope="openid profile email <app>-uid"
```
### Bypass forward-auth for API paths (mobile apps)
If the app has its own token-based API auth and a mobile client, API paths must bypass forward-auth — mobile apps can't follow the browser login redirect. Pattern in the Caddyfile site block:
```
paperless.hubris.network {
tls { dns ionos {env.IONOS_AUTH_API_TOKEN} }
@api path /api/*
handle @api {
reverse_proxy 192.168.8.130:8000
}
handle {
import authentik
reverse_proxy 192.168.8.130:8000
}
}
```
API paths to bypass per app:
- [Paperless](103-paperless.md): `/api/*` (Bearer)
- [Sonarr / Radarr / Lidarr / etc.](122-arriman.md): `/api/*` (X-Api-Key)
- [qBittorrent](122-arriman.md): `/api/*` (session cookie from `/api/v2/auth/login`)
- [SABnzbd](122-arriman.md): `/api?*` (apikey query param) — match `/api*` for query-string APIs
- Homarr: no mobile client
- [Portainer](105-apps.md): mobile uses same session auth as web; no bypass typically needed
### Backend trust of Authentik headers (skip the app's own login after SSO)
- [**Paperless**](103-paperless.md): `PAPERLESS_ENABLE_HTTP_REMOTE_USER=true` and `PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME=HTTP_X_AUTHENTIK_USERNAME` in `/opt/paperless/paperless.conf`. Restart `paperless-webserver paperless-task-queue paperless-scheduler paperless-consumer`. Django auto-creates matching users on first SSO login; promote to superuser via existing admin UI.
- Apps without header-auth support: users log in twice (SSO + app login). Acceptable but degraded UX.
## Per-app integration map
| App | Type | Notes |
| ----------------------------------------- | ---------------- | ----- |
| [Paperless (103)](103-paperless.md) | Forward-auth + REMOTE_USER | `/api/*` bypass |
| [Nextcloud (114)](114-nextcloud.md) | Native OIDC | `nc_uid` override; local dnsmasq required (Guzzle bypasses `/etc/hosts`) |
| [mulita (120)](120-mule-images.md) | Native OIDC | `extra_hosts` override in compose |
| [Booklore (105)](105-apps.md) | Native OIDC | Redirect URI `/oauth2-callback`; `extra_hosts` |
| [Portainer (105)](105-apps.md) | Native OAuth2 | `portainer_uid` custom claim; `--trusted-origins` flag |
| [WriteFreely (105)](105-apps.md) | Native OIDC | `[oauth.generic]` block; `extra_hosts` |
| [qBittorrent (122)](122-arriman.md) | Forward-auth via IP whitelist | Reverse-proxy support enabled in qBit |
| [Artifacto (105)](105-apps.md) | Forward-auth + gateway-secret auto-login | Public `/p/*` paths bypass |
| [Home Assistant VM (108)](../vms/108-haos.md) | HACS `christiaangoossens/hass-oidc-auth` | `automatic_user_linking: true`, `default_redirect: true`. Supervisor DNS via `ha dns options`. |
## Netbird IdP integration — LANDED 2026-05-21
The combined `netbirdio/netbird-server` image was replaced with the canonical vanilla stack (`mgmt + signal + relay + dashboard` 0.71.3) on the VPS so external OIDC actually works. Authentik is now the netbird dashboard's IdP. Full migration context in [mesh.md changelog](../infrastructure/mesh.md#changelog).
**Active provider & app:**
- Provider `NetBird` (OAuth2/OpenID), **Client type: `Public`** (PKCE-only — `Confidential` would break the dashboard SPA's token exchange).
- Client ID: `netbird-dashboard`. Client Secret is in `/opt/management.json` `PKCEAuthorizationFlow.ProviderConfig.ClientSecret` on the VPS (TODO: sops-encrypt as `secrets/netbird-authentik-oidc.yaml`).
- Application `NetBird`, slug `netbird`, launch URL `https://netbird.hubris.network/`.
- Redirect URIs: `https://netbird.hubris.network/peers`, `/nb-auth`, `/nb-silent-auth`, plus `https://netbird.hubris.network/` for post-logout.
- Scopes enabled on the provider: `openid`, `profile`, `email`.
- Discovery URL: `https://auth.hubris.network/application/o/netbird/.well-known/openid-configuration` — netbird mgmt fetches this on startup; logs `loaded OIDC configuration from the provided IDP configuration endpoint`.
**Login flow:** netbird dashboard PKCE → Authentik authorize → redirect back to `/nb-auth` → JS token exchange at Authentik's `/token` endpoint → mgmt validates the bearer against Authentik's JWKS.
**First-time owner promotion gotcha** (write-down for future operators):
When a new Authentik user logs in for the first time against an account that already has peers, netbird mgmt adds them as `role=user, blocked=1, pending_approval=1`. The OLD account-owner (the one in `store.db` from before the IdP swap) can't be reached anymore, so there's no admin to approve. Recovery is a direct sqlite update on `mgmt_data`:
```
docker stop netbird-mgmt
sqlite3 /var/lib/docker/volumes/opt_mgmt_data/_data/store.db \
"UPDATE users SET role='owner', blocked=0, pending_approval=0 WHERE id='<new authentik sub>';"
docker start netbird-mgmt
```
The Authentik sub-claim is the value of the `id` column on the newly-created user row (look for `role=user, blocked=1, pending_approval=1`).
### Device Code grant — configured (2026-05-21)
`netbird up` (interactive, without `--setup-key`) works against Authentik. The recipe:
1. **Flow** `default-device-code-flow` (designation: `Stage Configuration`) with 4 stage bindings in order:
- 10: `default-authentication-identification` (username/email lookup)
- 20: `default-authentication-password` (password validation)
- 30: `default-authentication-login` (attach authenticated user to session)
- 40: `default-provider-authorization-explicit-consent`'s Consent Stage (`default-provider-authorization-consent`) — the "Authorize NetBird?" approval
2. **Brand** (System → Brands → edit the brand serving `auth.hubris.network`): set **Device code flow** field to `default-device-code-flow`.
3. No provider-side change is required — Authentik 2026.x routes `/device` via the brand's device-code flow, not via the OAuth2/OpenID provider's `Authorization flow`.
**Why this matters for the lab**: Authentik 2026.x doesn't ship a default device-code flow. Without this configuration, the URL `https://auth.hubris.network/device` renders blank (the `/device` endpoint is unrouted), so `netbird up` device-codes expire without consent → only `--setup-key` works for onboarding. The above unblocks interactive onboarding.
**Verifying** from a browser tab: visit `https://auth.hubris.network/device`. You should see a form with one **Code** input + Continue button. Then `netbird up` (no setup-key) end-to-end:
- CLI prints `verification_uri_complete: https://auth.hubris.network/device?code=...`
- Open URL → identification (skipped if logged in) → password (re-auth check) → consent ("Authorize NetBird?") → Continue
- CLI completes registration with `Connected`
### Self-service onboarding (not yet — future-session)
`auth.hubris.network` is only reachable from inside the netbird mesh (split-horizon DNS). A brand-new client that isn't on the mesh yet can't OIDC-login → setup-key is the only path. To enable self-service onboarding via Authentik from the public internet:
- Add a Traefik route on the VPS for `auth.hubris.network` that forwards via the netbird-routed `192.168.8.0/24` to LXC 124.
- DNS already points `auth.hubris.network → 82.165.190.79` (IONOS wildcard).
Tracked in homelab memory as a queued follow-up.
### Old pre-work to remove
The previous `Provider for Netbird` + `netbird` app from 2026-04-22 (client ID `xZwVTFCsxWdBM3uIGS15wAAcVvsJiTtWdxVCEela`, service account `netbird-service`) is now obsolete — replaced by `netbird-dashboard` above. Safe to delete from Authentik admin UI; nothing currently uses the old client ID. The service account + API token can also be removed unless we wire IdpManagerConfig in mgmt later (currently `ManagerType: none`).
## DNS responsibility
dnsmasq runs alongside Authentik on this LXC, listening on `192.168.8.180:53` + `127.0.0.1:53`, serving every `*.hubris.network` subdomain → `192.168.8.175`. **There is no wildcard** — every site needs an explicit `address=` entry. See [DNS split-horizon](../infrastructure/dns.md).
## Related
- [DNS split-horizon](../infrastructure/dns.md)
- [Caddy (121)](121-caddy.md)
- [Mesh migration](../infrastructure/mesh.md)
- Every gated app under [containers/index](index.md)
## Changelog
### 2026-05-21 — Netbird IdP swap landed (Phase 6 done)
VPS migrated from combined netbird-server to vanilla mgmt+signal+relay+dashboard 0.71.3 (see [mesh.md](../infrastructure/mesh.md)), enabling Authentik as the dashboard IdP via PKCE. New Provider/App = `netbird-dashboard`, replacing the deferred pre-work. Device Code Stage still missing — interactive `netbird up` fails consent; setup-keys are the workaround until that's added.
### 2026-04-28 — wiki entry created
Initial documentation.
### 2026-04-22 — Phase 6 (Netbird IdP swap) deferred
Combined netbird-server image couldn't take an external IdP. Pre-work in Authentik kept for later (now superseded by 2026-05-21 above). Netbird mgmt host instead joined its own mesh as a peer (`100.122.165.149`) for split-horizon DNS access.
### 2026-04-22 — Artifacto, mulita, WriteFreely, Portainer wired
Native OIDC for mulita / WriteFreely / Portainer; gateway-secret auto-login pattern for Artifacto.
### 2026-04-21 — deployed; Phases 15 complete
LXC 124 provisioned, stack at `/opt/authentik`, public URL via Caddy, Paperless + Booklore + Nextcloud + Home Assistant wired. dnsmasq for split-horizon DNS lives on the same LXC.

View File

@@ -1,108 +0,0 @@
# 126 — `plato`
Docker host for [Plato](https://git.hubris.network/dtoro/Plato) — a cross-linked notes workspace (SvelteKit SPA embedded into a Go HTTP server, SQLite-backed). LAN+mesh only, no public ingress.
## At a glance
- **Hostname:** `plato`
- **IP:** `192.168.8.190`
- **Privilege:** privileged
- **Resources:** 2 cores / 2 GiB RAM / 8 GiB rootfs / 1 GiB swap
- **Mounts:** host `/mnt/library/documents/plato` ↔ container `/opt/plato/data`
- **Public hostname:** [`plato.hubris.network`](../infrastructure/dns.md) → [caddy (121)](121-caddy.md) → `192.168.8.190:8080`
## Stack
Single-container deploy. The repo's `Dockerfile` is a three-stage build (Node → Go → distroless/static-debian12:nonroot, ~23 MiB final image). The container exposes `:8080` and writes its SQLite db to `/data`.
- **Checkout:** `/opt/plato/app` (clone of `http://192.168.8.121:3000/dtoro/Plato.git`, using the cached gitea PAT in `/root/.git-credentials` — same pattern as [caddy (121)](121-caddy.md)).
- **Data:** host `/mnt/library/documents/plato` (owned `65532:65532` to match the distroless nonroot UID) bind-mounted into the LXC at `/opt/plato/data`, then bound into the container at `/data` via a `docker-compose.override.yml`:
```yaml
services:
plato:
volumes: !override
- /opt/plato/data:/data
restart: unless-stopped
```
- **`.env`** at `/opt/plato/app/.env` (optional, untracked) — LLM provider keys (`OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`, etc.) and `PLANTUML_BASE_URL` override. Absent by default; LLM features stay greyed out, PlantUML defaults to the public service.
- **Run / update:** push to `dtoro/Plato` (auto-deploys, see below) or `cd /opt/plato/app && git pull && docker compose up -d --build` for a manual rebuild.
## Auto-deploy
Push to `dtoro/Plato` `main` triggers a rebuild — same Shape B pattern as [Artifacto / mule-image](../infrastructure/auto-deploy.md). Webhook receiver at `/opt/plato-deploy/`, systemd unit `plato-deploy-webhook.service`, port `9799`, gitea hook id 8.
- Receiver: `http://192.168.8.190:9799/deploy`, signed payload (HMAC-SHA256, secret in `/etc/plato-deploy/secret`).
- Logs: `journalctl -u plato-deploy-webhook -f`.
- Health: `curl http://127.0.0.1:9799/health`.
- Manual deploy: `/opt/plato-deploy/deploy.sh`.
- Gitea's `app.ini` `ALLOWED_HOST_LIST` was extended with `192.168.8.190` to allow this delivery.
## Fresh-DB bootstrap workaround
The `schema` constant in `backend/internal/views/store.go` (as of commit `e0542c0`) creates the `views` table without `project_id`, then immediately runs `CREATE UNIQUE INDEX … ON views(project_id, lower(title))`. On a fresh DB this fails (no such column) and Plato crash-loops with `open views store: SQL logic error: no such column: project_id`. `ensureProjectIDColumn()` adds the column on subsequent migrations, but the schema apply happens first.
Until the upstream fix lands, pre-seed the DB before first start:
```
docker compose stop
rm -f /opt/plato/data/plato.db
python3 - <<'PY'
import sqlite3
c = sqlite3.connect('/opt/plato/data/plato.db')
c.executescript("""
CREATE TABLE views (
id TEXT PRIMARY KEY,
type TEXT NOT NULL DEFAULT 'document',
title TEXT NOT NULL,
aliases TEXT NOT NULL DEFAULT '[]',
content TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
project_id TEXT NOT NULL DEFAULT ''
);
CREATE UNIQUE INDEX views_project_title_lower ON views(project_id, lower(title));
CREATE INDEX views_project_id ON views(project_id);
""")
c.commit()
PY
chown 65532:65532 /opt/plato/data/plato.db
docker compose up -d
```
Once the column exists, every subsequent boot's `IF NOT EXISTS` clauses no-op. Once Plato is fixed upstream (remove the `CREATE UNIQUE INDEX` line from the boot `schema` constant — `ensureTitleIndexPerProject()` already re-creates it after the migration), this preseed becomes unnecessary.
## Why privileged
Matches the docker-host convention used by [120 mule-images](120-mule-images.md) and [122 arriman](122-arriman.md). Distroless nonroot's UID `65532` on the host bind mount maps directly through; unprivileged would shift the UID by the idmap offset and the container couldn't write `/data` without extra plumbing.
## Caddy
```
plato.hubris.network {
tls {
dns ionos {env.IONOS_AUTH_API_TOKEN}
}
reverse_proxy 192.168.8.190:8080
}
```
No Authentik forward-auth — Plato has no auth model yet; access control is "be on the LAN or the mesh".
## DNS
dnsmasq entry on [124-authentik](124-authentik.md):
```
address=/plato.hubris.network/192.168.8.175
```
## Related
- [Caddy (121)](121-caddy.md)
- [DNS (split-horizon)](../infrastructure/dns.md)
- [Media permissions](../infrastructure/media-permissions.md)
## Changelog
### 2026-05-13 — auto-deploy wired
Shape B pipeline added (`/opt/plato-deploy/`, port `9799`, gitea hook id 8). `ALLOWED_HOST_LIST` in gitea `app.ini` extended with `192.168.8.190`. See [auto-deploy](../infrastructure/auto-deploy.md#plato).
### 2026-05-13 — container created, Plato deployed
LXC 126 stood up on Debian 12 standard, privileged, docker-ce installed. Plato cloned from `dtoro/Plato`, built and started. Caddy site and dnsmasq split-horizon entry added. Recycled the IP/ID slot freed earlier the same day by the [decommissioned Seafile experiment (LXC 125)](index.md#recently-destroyed-kept-for-archaeology). Hit the [fresh-DB bootstrap bug](#fresh-db-bootstrap-workaround) on first boot; worked around by pre-seeding the SQLite schema.

View File

@@ -1,50 +0,0 @@
# LXC containers — index
All containers live on [`hubris`](../hosts/hubris.md). Each row links to the per-container page.
| ID | Name | IP | Priv | Cores | RAM | Disk | Mounts | Public hostname | Status |
| --- | ---------------- | --------------- | ---- | ----- | ----- | ----- | --------------------- | ------------------------------------- | -------- |
| 101 | [jellyfin](101-jellyfin.md) | 192.168.8.206 | unpriv (idmap) | 2 | 4 GiB | 16 GiB | `/mnt/library` | `media.hubris.network` | running |
| 103 | [paperless](103-paperless.md) | 192.168.8.130 | priv | 2 | 3 GiB | 8 GiB | `/mnt/library` | `paperless.hubris.network` | running |
| 104 | [gitea](104-gitea.md) | 192.168.8.121 | priv | 1 | 1 GiB | 8 GiB | `/mnt/library` | `git.hubris.network` | running |
| 105 | [apps](105-apps.md) | 192.168.8.205 | priv | 2 | 4 GiB | 30 GiB | `/mnt/library` | `docker` / `books` / `artifacto` / `blog` | running |
| 114 | [nextcloud](114-nextcloud.md) | 192.168.8.224 | priv | 4 | 6 GiB | 25 GiB | `/mnt/library` | `cloud.hubris.network` | running |
| 118 | [elementsynapse](118-elementsynapse.md) | 192.168.8.239 | unpriv | 1 | 2 GiB | 8 GiB | — | `matrix.hubris.network` | running |
| 119 | [sophia](119-sophia.md) | 192.168.8.157 | priv | 2 | 1 GiB | 10 GiB | `/mnt/library` | — | running |
| 120 | [mule-images](120-mule-images.md) | 192.168.8.136 | priv | 6 | 12 GiB | 60 GiB | `/mnt/library` + `/dev/dri` (iGPU passthrough) | `photos.hubris.network` | running |
| 121 | [caddy](121-caddy.md) | 192.168.8.175 | unpriv | 1 | 512 MiB | 6 GiB | — | (terminates all `*.hubris.network`) | running |
| 122 | [arriman](122-arriman.md) | 192.168.8.132 | priv | 4 | 8 GiB | 24 GiB | `/mnt/library` | `jellyseerr` / `qbit` / `sab` | running |
| 123 | [claudio-bot](123-claudio-bot.md) | 192.168.8.230 | unpriv | 1 | 512 MiB | 8 GiB | — | — | running |
| 124 | [authentik](124-authentik.md) | 192.168.8.180 | priv | 2 | 4 GiB | 20 GiB | — | `auth.hubris.network` | running |
| 126 | [plato](126-plato.md) | 192.168.8.190 | priv | 2 | 2 GiB | 8 GiB | `/mnt/library/documents/plato` | `plato.hubris.network` | running |
## Recently destroyed (kept for archaeology)
| ID | Name | Destroyed | Reason |
| --- | ---------------- | --------------- | --------------------------------------------- |
| 127 | mule-photos-new | 2026-05-22 | PhotoPrism + sidecar + SvelteKit stack promoted to LXC 120 via Mulimage 2.0 merge (`70dc1b6`); M0 test LXC retired. Caddy + dnsmasq + gitea webhook + NC webhook listeners all cleaned up in the same cutover. |
| 100 | arr (yunohost) | ~2026-04-28 | Migrated to docker stack on [arriman](122-arriman.md); planned retention window expired |
| 106 | flaresolverr | ~2026-04-28 | Folded into the arriman docker compose |
| 116 | heaper | 2026-05-14 | Decommissioned by user; data subtree at `/mnt/library/heaper` (224 MiB) retained |
| 109 | syncthing | 2026-05-14 | Decommissioned by user; `/mnt/library/syncthing` was already empty |
| 125 | seafile | 2026-05-13 | Seafile Pro evaluation, user disliked the product; teardown also removed `files.hubris.network` from caddy + dnsmasq |
| 107 | marimo | between 2026-04-21 and 2026-04-28 | Decommissioned |
| 110 | photoprism | between 2026-04-21 and 2026-04-28 | Replaced by [mulita](120-mule-images.md) |
| 111 | karakeep | between 2026-04-21 and 2026-04-28 | Decommissioned |
| 112 | immich | between 2026-04-21 and 2026-04-28 | Replaced by [mulita](120-mule-images.md) |
| 115 | reticulum | between 2026-04-21 and 2026-04-28 | Decommissioned |
> Several `.conf.bak` files survive under `/etc/pve/lxc/` if you need to recover any of the configs.
## Conventions
- All net0 are `bridge=vmbr0`, `ip=dhcp` except [124 (authentik)](124-authentik.md) which is statically `192.168.8.180/24`. IPs are stable via the LAN router's DHCP reservations.
- `onboot=1` on every container — the host brings them up after `pve-guests.service`.
- Bind mounts are declared as `mp0: /mnt/library,mp=/mnt/library`. Containers that don't mount `/mnt/library` don't need it.
- Most containers are privileged. Unprivileged ones (`101`, `118`, `121`, `123`) require an idmap block in their conf to participate in the [media GID 10000](../infrastructure/media-permissions.md) standard.
## Related
- [Hubris host](../hosts/hubris.md)
- [Media permissions](../infrastructure/media-permissions.md)
- [Caddy](121-caddy.md) — terminates every public hostname
- [DNS](../infrastructure/dns.md) — split-horizon entries for each subdomain

View File

@@ -5,6 +5,7 @@ name: apps
kind: lxc
os: linux
role: docker-apps
state: active
host: hubris
pve_id: 105
lan_ip: 192.168.8.205
@@ -33,23 +34,29 @@ services_hosted:
- name: artifacto
backend: apps
url: https://artifacto.hubris.network
doc_page: knowledge/wiki/containers/105-apps.md
config_repo: dtoro/Artifacto
- name: homelab_mcp
backend: apps
port: 9810
systemd_unit: homelab-mcp
public_host: mcp.hubris.network
endpoint: https://mcp.hubris.network/sse
endpoint: https://mcp.hubris.network/mcp
doc_page: knowledge/wiki/infrastructure/homelab-context.md
config_repo: dtoro/Homelab-Docs
note: MCP server. Read-only context + management. Reachable on the LAN via Caddy and from off-LAN via
Netbird (192.168.8.0/24 is a network resource routed through hubris).
risk_notes: "agents' primary read surface \u2014 outage degrades every agent to grepping the clone"
- name: secrets_issuance
backend: apps
port: 9820
systemd_unit: secrets-issuance
public_host: secrets.hubris.network
endpoint: https://secrets.hubris.network/issue
doc_page: .agents/operations/agent-enrollment.md
config_repo: dtoro/Homelab-Docs
note: Issues per-client age private keys. Gated at source-IP layer (mesh + LAN subnets in MESH_SUBNETS).
risk_notes: "identity issuance \u2014 any change is security-sensitive; key operations are destructive-class"
age_pubkey: age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0
see_also:
- containers/105-apps.md
mcp_endpoint: https://mcp.hubris.network/sse
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -5,9 +5,10 @@ name: arriman
kind: lxc
os: linux
role: arr-stack
host: hubris
state: active
host: strong
pve_id: 122
lan_ip: 192.168.8.132
lan_ip: 192.168.8.245
mesh:
tailscale:
fqdn: arr
@@ -17,7 +18,7 @@ mesh_globals:
- netbird
- tailscale
mounts:
- /mnt/library
- /mnt/media_local
public_hosts:
- jellyseerr.hubris.network
- qbit.hubris.network
@@ -28,7 +29,10 @@ services_hosted:
- name: arr_stack
backend: arriman
note: jellyseerr / qbit / sab on docker compose
see_also:
- containers/122-arriman.md
mcp_endpoint: https://mcp.hubris.network/sse
doc_page: knowledge/wiki/containers/122-arriman.md
notes:
- Migrated from hubris to strong 2026-07-05 (Phase 2). Library on ludo-lvm.
- Contains homarr, radarr, sonarr, lidarr, sabnzbd, qbittorrent, bazarr, flaresolverr, prowlarr, jellyseerr
- qBittorrent auth subnet whitelist expanded to 192.168.8.0/24 (for seanime + Caddy access)
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -1,22 +1,20 @@
# Generated by mcp/build_host_files.py from inventory.yaml.
# Do NOT edit by hand — your changes will be overwritten.
# Source of truth: ../inventory.yaml
name: claudio-bot
name: auth-outpost
kind: lxc
os: linux
role: matrix-agent
role: authentik-gateway
state: active
host: hubris
pve_id: 123
lan_ip: 192.168.8.230
pve_id: 106
lan_ip: 192.168.8.6
mesh_globals:
primary: netbird
accepted:
- netbird
- tailscale
notes:
- Reads /opt/homelab-context/ on startup
age_pubkey: age1xmkeq968areza2necqyq0065dpeegngzyr6dhagh0n6pl33lccfqe5mqn9
see_also:
- containers/123-claudio-bot.md
mcp_endpoint: https://mcp.hubris.network/sse
- Runs Authentik outpost (reverse-proxy/SSO enforcement) for protected services
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -1,32 +0,0 @@
# Generated by mcp/build_host_files.py from inventory.yaml.
# Do NOT edit by hand — your changes will be overwritten.
# Source of truth: ../inventory.yaml
name: authentik
kind: lxc
os: linux
role: idp
host: hubris
pve_id: 124
lan_ip: 192.168.8.180
mesh_globals:
primary: netbird
accepted:
- netbird
- tailscale
public_host: auth.hubris.network
runs:
- authentik
- dnsmasq
services_hosted:
- name: authentik
url: https://auth.hubris.network
backend: authentik
- name: dnsmasq
backend: authentik
note: split-horizon DNS, /etc/dnsmasq.d/hubris-split.conf
notes:
- 'Also hosts split-horizon dnsmasq: /etc/dnsmasq.d/hubris-split.conf'
see_also:
- containers/124-authentik.md
mcp_endpoint: https://mcp.hubris.network/sse
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -5,6 +5,7 @@ name: caddy
kind: lxc
os: linux
role: reverse-proxy
state: active
host: hubris
pve_id: 121
lan_ip: 192.168.8.175
@@ -23,10 +24,12 @@ services_hosted:
backend: caddy
role: reverse-proxy
note: terminates all *.hubris.network
doc_page: knowledge/wiki/containers/121-caddy.md
config_repo: dtoro/caddy-conf
risk_notes: "wide blast radius \u2014 every *.hubris.network route rides on it (see oikos/policy.yaml\
\ service_overrides)"
notes:
- Terminates all *.hubris.network
- /etc/caddy is a git checkout of dtoro/caddy-conf
see_also:
- containers/121-caddy.md
mcp_endpoint: https://mcp.hubris.network/sse
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

29
hosts/dns.yaml Normal file
View File

@@ -0,0 +1,29 @@
# Generated by mcp/build_host_files.py from inventory.yaml.
# Do NOT edit by hand — your changes will be overwritten.
# Source of truth: ../inventory.yaml
name: dns
kind: lxc
os: linux
role: dns-server
state: active
host: hubris
pve_id: 107
lan_ip: 192.168.8.2
mesh_globals:
primary: netbird
accepted:
- netbird
- tailscale
runs:
- dns
services_hosted:
- name: dns
backend: dns
note: Technitium DNS, split-horizon zone
doc_page: knowledge/wiki/containers/107-dns.md
risk_notes: "LAN-wide resolver \u2014 misconfig breaks name resolution for every client"
notes:
- Technitium DNS, split-horizon zone for *.hubris.network
- Primary DNS for 192.168.8.0/24 LAN (inventory.services.dns references this)
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -5,12 +5,12 @@ name: elementsynapse
kind: lxc
os: linux
role: matrix-server
host: hubris
state: active
host: strong
pve_id: 118
lan_ip: 192.168.8.239
lan_ip: 192.168.8.242
mesh:
tailscale:
fqdn: elementsynapse
tailscale: {}
mesh_globals:
primary: netbird
accepted:
@@ -23,7 +23,9 @@ services_hosted:
- name: matrix
url: https://matrix.hubris.network
backend: elementsynapse
see_also:
- containers/118-elementsynapse.md
mcp_endpoint: https://mcp.hubris.network/sse
doc_page: knowledge/wiki/containers/118-elementsynapse.md
risk_notes: "alert/approval channel for Oikos \u2014 outage silences agent escalation"
notes:
- Migrated from hubris to strong 2026-07-05 (Phase 1 of strong migration plan).
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -5,6 +5,7 @@ name: gitea
kind: lxc
os: linux
role: git-server
state: active
host: hubris
pve_id: 104
lan_ip: 192.168.8.121
@@ -26,9 +27,10 @@ services_hosted:
url: https://git.hubris.network
backend: gitea
backend_url: http://192.168.8.121:3000
doc_page: knowledge/wiki/containers/104-gitea.md
config_repo: dtoro/gitea-customizations
risk_notes: hosts all config repos + deploy webhooks; outage blocks auto-deploy and sync
notes:
- Bare repos live at /mnt/library/repos/dtoro/*.git
see_also:
- containers/104-gitea.md
mcp_endpoint: https://mcp.hubris.network/sse
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

25
hosts/grimmory.yaml Normal file
View File

@@ -0,0 +1,25 @@
# Generated by mcp/build_host_files.py from inventory.yaml.
# Do NOT edit by hand — your changes will be overwritten.
# Source of truth: ../inventory.yaml
name: grimmory
kind: lxc
os: linux
role: book-library
state: active
host: strong
pve_id: 130
lan_ip: 192.168.8.247
mesh_globals:
primary: netbird
accepted:
- netbird
- tailscale
mounts:
- /mnt/media_local
public_host: books.hubris.network
notes:
- Docker host for Grimmory (community fork of Booklore). Created 2026-06-29.
- Migrated from hubris to strong 2026-07-05 (Phase 2d). Books on ludo-lvm.
age_pubkey: age1uellsemnjrzgfg9fxw4jefpy05laxzggwnwhh6ny3wl7alyp6v8q0muxet
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -5,6 +5,7 @@ name: haos
kind: vm
os: linux
role: home-automation
state: active
host: hubris
pve_id: 108
lan_ip: 192.168.8.101
@@ -21,7 +22,6 @@ runs:
services_hosted:
- name: haos
backend: haos
see_also:
- vms/108-haos.md
mcp_endpoint: https://mcp.hubris.network/sse
doc_page: knowledge/wiki/vms/108-haos.md
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

26
hosts/house.yaml Normal file
View File

@@ -0,0 +1,26 @@
# Generated by mcp/build_host_files.py from inventory.yaml.
# Do NOT edit by hand — your changes will be overwritten.
# Source of truth: ../inventory.yaml
name: house
kind: lxc
os: linux
role: family-planner
state: active
host: strong
pve_id: 129
lan_ip: 192.168.8.244
mesh_globals:
primary: netbird
accepted:
- netbird
- tailscale
public_host: house.hubris.network
notes:
- Docker host for Yuvomi (family planner). Created 2026-06-26.
- Migrated from hubris to strong 2026-07-05 (Phase 1 of strong migration plan).
- Runs Yuvomi container + WebDAV doc bridge to paperless
- 192.168.8.212 was the hubris IP before migration (briefly picked up by teddycloud via DHCP; teddycloud
has since been given a static IP, see hosts.teddycloud)
age_pubkey: age1s07zs83ehtlg8jtwvr75ltc3c4cdlemfwjuxrwjtwkqxkl9tpggsyrzn2h
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -5,6 +5,7 @@ name: hubris
kind: proxmox-host
os: linux
role: hypervisor
state: active
lan_ip: 192.168.8.77
mesh:
netbird:
@@ -28,8 +29,8 @@ services_hosted:
url: https://proxmox.hubris.network
backend: hubris
port: 8006
doc_page: knowledge/wiki/hosts/hubris.md
risk_notes: "hypervisor UI \u2014 changes here affect every guest on the node"
age_pubkey: age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6
see_also:
- hosts/hubris.md
mcp_endpoint: https://mcp.hubris.network/sse
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -5,9 +5,10 @@ name: jellyfin
kind: lxc
os: linux
role: media-server
host: hubris
state: active
host: strong
pve_id: 101
lan_ip: 192.168.8.206
lan_ip: 192.168.8.246
mesh:
tailscale:
fqdn: jellyfin
@@ -17,7 +18,7 @@ mesh_globals:
- netbird
- tailscale
mounts:
- /mnt/library
- /mnt/media_local
public_host: media.hubris.network
runs:
- jellyfin
@@ -25,7 +26,14 @@ services_hosted:
- name: jellyfin
url: https://media.hubris.network
backend: jellyfin
see_also:
- containers/101-jellyfin.md
mcp_endpoint: https://mcp.hubris.network/sse
doc_page: knowledge/wiki/containers/101-jellyfin.md
risk_notes: native Authentik OIDC via SSO-Auth plugin, no Caddy forward-auth gate; VAAPI transcode depends
on GPU passthrough on strong
notes:
- Jellyfin 10.11.11 with VAAPI hardware acceleration (Radeon 680M iGPU on strong)
- 4 cores / 8 GiB RAM / 1 GiB swap
- SSO-Auth plugin v4.0.0.4 with Authentik OIDC (no Caddy forward-auth gate)
- GPU passed via dev0+dev1: /dev/dri/renderD128 + card0
- Migrated from hubris to strong 2026-07-05 (Phase 2). Library on ludo-lvm.
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -5,6 +5,8 @@ name: mac-mini
kind: workstation
os: macos
role: dev
state: active
lan_ip: 192.168.178.182
mesh:
netbird:
fqdn: mac-mini-234-17.netbird.selfhosted
@@ -17,5 +19,6 @@ ssh:
user: dtoro
notes:
- Only macOS in the fleet. Bootstrap uses launchd.
mcp_endpoint: https://mcp.hubris.network/sse
age_pubkey: age1z62ff2ak9zj5ctcvaxwyyhedwjvlwgm2dkn9nk3wrwk8fkavcpmsqwc2vs
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -5,6 +5,7 @@ name: mule-images
kind: lxc
os: linux
role: photo-management
state: active
host: hubris
pve_id: 120
lan_ip: 192.168.8.136
@@ -25,7 +26,7 @@ services_hosted:
- name: photos
url: https://photos.hubris.network
backend: mule-images
see_also:
- containers/120-mule-images.md
mcp_endpoint: https://mcp.hubris.network/sse
doc_page: knowledge/wiki/containers/120-mule-images.md
config_repo: dtoro/mule-image
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -5,6 +5,7 @@ name: netbird-vps
kind: external
os: linux
role: netbird-mgmt
state: active
mesh:
netbird:
ip: 100.122.165.149
@@ -16,6 +17,16 @@ mesh_globals:
- tailscale
ssh:
user: root
runs:
- authentik
services_hosted:
- name: authentik
url: https://auth.hubris.network
backend: netbird-vps
doc_page: knowledge/wiki/containers/106-auth-outpost.md
note: core runs on the VPS since 2026-05-31; LAN forward-auth outpost is auth-outpost (LXC 106) at 192.168.8.6:9000.
Previous backend value "authentik" referenced the retired embedded-outpost host (LXC 124).
risk_notes: "SSO provider \u2014 outage locks login to OIDC/forward-auth services"
notes:
- "Public IONOS VPS \u2014 hosts the vanilla netbird mgmt+signal+relay+dashboard stack + host coturn (see\
\ infrastructure/vps-hardening.md + infrastructure/mesh.md changelog 2026-05-21)."
@@ -25,5 +36,5 @@ notes:
- Configs rendered by `homelab render-vps-configs` from vps/turnserver.conf.tmpl + vps/management.json.tmpl,
with secrets decrypted from secrets/turn-shared-secret.yaml + secrets/netbird-authentik-oidc.yaml on
hubris.
mcp_endpoint: https://mcp.hubris.network/sse
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -5,6 +5,7 @@ name: nextcloud
kind: lxc
os: linux
role: file-sync
state: active
host: hubris
pve_id: 114
lan_ip: 192.168.8.224
@@ -25,7 +26,6 @@ services_hosted:
- name: nextcloud
url: https://cloud.hubris.network
backend: nextcloud
see_also:
- containers/114-nextcloud.md
mcp_endpoint: https://mcp.hubris.network/sse
doc_page: knowledge/wiki/containers/114-nextcloud.md
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -5,6 +5,7 @@ name: nfs-export
kind: lxc
os: linux
role: storage-export
state: active
host: hubris
pve_id: 102
lan_ip: 192.168.8.200
@@ -13,7 +14,5 @@ mesh_globals:
accepted:
- netbird
- tailscale
see_also:
- containers/102-nfs-export.md
mcp_endpoint: https://mcp.hubris.network/sse
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -5,6 +5,7 @@ name: paperless
kind: lxc
os: linux
role: document-archive
state: active
host: hubris
pve_id: 103
lan_ip: 192.168.8.130
@@ -25,7 +26,7 @@ services_hosted:
- name: paperless
url: https://paperless.hubris.network
backend: paperless
see_also:
- containers/103-paperless.md
mcp_endpoint: https://mcp.hubris.network/sse
doc_page: knowledge/wiki/containers/103-paperless.md
risk_notes: "document archive \u2014 treat data as irreplaceable; DB operations are destructive-class"
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -1,28 +0,0 @@
# Generated by mcp/build_host_files.py from inventory.yaml.
# Do NOT edit by hand — your changes will be overwritten.
# Source of truth: ../inventory.yaml
name: plato
kind: lxc
os: linux
role: app
host: hubris
pve_id: 126
lan_ip: 192.168.8.190
mesh_globals:
primary: netbird
accepted:
- netbird
- tailscale
mounts:
- /mnt/library/documents/plato
public_host: plato.hubris.network
runs:
- plato
services_hosted:
- name: plato
url: https://plato.hubris.network
backend: plato
see_also:
- containers/126-plato.md
mcp_endpoint: https://mcp.hubris.network/sse
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -1,17 +1,19 @@
# Generated by mcp/build_host_files.py from inventory.yaml.
# Do NOT edit by hand — your changes will be overwritten.
# Source of truth: ../inventory.yaml
name: ludo-mini
kind: workstation
name: rclone
kind: lxc
os: linux
role: dev
role: backup
state: active
mesh:
netbird:
fqdn: ludo-mini.netbird.selfhosted
fqdn: rclone.netbird.selfhosted
mesh_globals:
primary: netbird
accepted:
- netbird
- tailscale
mcp_endpoint: https://mcp.hubris.network/sse
age_pubkey: age1pwtdws2thdh7vzp2dzttl3zxgcs2tgpcsjsqgw3q04nyml4kvuqq467u4x
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -5,6 +5,7 @@ name: republic-laptop
kind: workstation
os: linux
role: primary-dev
state: active
mesh:
netbird:
fqdn: republic-laptop.netbird.selfhosted
@@ -15,5 +16,5 @@ mesh_globals:
- tailscale
ssh:
user: dtoro
mcp_endpoint: https://mcp.hubris.network/sse
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

26
hosts/romm.yaml Normal file
View File

@@ -0,0 +1,26 @@
# Generated by mcp/build_host_files.py from inventory.yaml.
# Do NOT edit by hand — your changes will be overwritten.
# Source of truth: ../inventory.yaml
name: romm
kind: lxc
os: linux
role: rom-manager
state: active
host: strong
pve_id: 134
lan_ip: 192.168.8.249
mesh_globals:
primary: netbird
accepted:
- netbird
- tailscale
mounts:
- /mnt/media_local
public_host: roms.hubris.network
notes:
- Docker host for RomM (romm.app) self-hosted ROM manager. Created 2026-07-05.
- MariaDB sidecar at /opt/romm/docker-compose.yml.
- ROMs on ludo-lvm media volume at /mnt/media_local/roms.
- 1 core / 2 GiB RAM / 16 GiB rootfs (ludo-lvm).
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

29
hosts/seanime.yaml Normal file
View File

@@ -0,0 +1,29 @@
# Generated by mcp/build_host_files.py from inventory.yaml.
# Do NOT edit by hand — your changes will be overwritten.
# Source of truth: ../inventory.yaml
name: seanime
kind: lxc
os: linux
role: anime-media-server
state: active
host: strong
pve_id: 133
lan_ip: 192.168.8.248
mesh_globals:
primary: netbird
accepted:
- netbird
- tailscale
mounts:
- /mnt/media_local/anime
public_host: seanime.hubris.network
notes:
- Seanime anime media server for online streaming + local library scanning
- Created 2026-07-05. Binary at /opt/seanime/bin/seanime, systemd service.
- Connected to qBittorrent on arriman (192.168.8.245:8080)
- 8 online streaming extensions installed (HiAnime, AniWatch, KickAssAnime, etc.)
- /anime mounted from strong ludo-lvm (/mnt/media_local/anime)
- Caddy: "https://seanime.hubris.network \u2192 192.168.8.248:43211"
- qBittorrent auth subnet whitelist expanded to 192.168.8.0/24 for seanime access
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -5,9 +5,10 @@ name: sophia
kind: lxc
os: linux
role: workshop
state: active
host: hubris
pve_id: 119
lan_ip: 192.168.8.157
lan_ip: 192.168.8.109
mesh:
tailscale:
fqdn: sophia
@@ -18,7 +19,5 @@ mesh_globals:
- tailscale
mounts:
- /mnt/library
see_also:
- containers/119-sophia.md
mcp_endpoint: https://mcp.hubris.network/sse
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

32
hosts/strong.yaml Normal file
View File

@@ -0,0 +1,32 @@
# Generated by mcp/build_host_files.py from inventory.yaml.
# Do NOT edit by hand — your changes will be overwritten.
# Source of truth: ../inventory.yaml
name: strong
kind: proxmox-host
os: linux
role: hypervisor
state: active
lan_ip: 192.168.178.181
mesh_globals:
primary: netbird
accepted:
- netbird
- tailscale
ssh:
user: root
notes:
- Reformatted from Linux workstation ("ludo-mini" in this wiki, still the machine's nickname) to Proxmox
VE 9.2.3 on 2026-07-01. Renamed the inventory/wiki identity from ludo-mini to strong on the same day
so it matches the OS/cluster hostname everywhere (bootstrap looks up hosts/$(hostname).yaml, so a mismatch
would break enrollment).
- "Joined hubris's \"Homelab\" cluster same day. 2-node, no QDevice tiebreaker yet \u2014 see hosts/hubris.md\
\ quorum note."
- Netbird not yet installed (fresh OS wiped prior enrollment); reachable today only via the household
LAN / existing Fritz static route to 192.168.8.0/24. Re-enroll in mesh as a follow-up if off-LAN access
to this host itself (not just its future guests) is needed.
- "First step of the planned library-SSD migration \u2014 see .hermes/plans/2026-06-03_110000-library-ssd-migration-to-ludo-mini.md\
\ (filename kept as-is, it's a historical planning doc). Only Phase 1 (Proxmox install + cluster join)\
\ is done; no physical drive move, service migration, or GPU passthrough has happened yet."
age_pubkey: age1rtwvdct6avjkr3cyxv3vue3vqx4d524fjfr3vk7xrnvyrylnry5sm54sn4
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

42
hosts/teddycloud.yaml Normal file
View File

@@ -0,0 +1,42 @@
# Generated by mcp/build_host_files.py from inventory.yaml.
# Do NOT edit by hand — your changes will be overwritten.
# Source of truth: ../inventory.yaml
name: teddycloud
kind: lxc
os: linux
role: teddycloud
state: active
host: hubris
pve_id: 131
lan_ip: 192.168.8.150
mesh_globals:
primary: netbird
accepted:
- netbird
- tailscale
mounts:
- /mnt/library
public_host: teddy.hubris.network
runs:
- teddycloud
services_hosted:
- name: teddycloud
url: https://teddy.hubris.network
backend: teddycloud
doc_page: knowledge/wiki/containers/131-teddycloud.md
note: self-hosted TeddyCloud (Toniebox cloud reimplementation), docker compose
risk_notes: "no Caddy forward-auth gate (unlike sab.hubris.network on the same Caddyfile) \u2014 reachable\
\ to anyone on the LAN/mesh who can resolve teddy.hubris.network; undocumented in inventory.yaml until\
\ 2026-07-06 (drift-caught)"
notes:
- Docker host for TeddyCloud (ghcr.io/toniebox-reverse-engineering/teddycloud), a self-hosted reimplementation
of the Toniebox cloud backend. Debian 12 (bookworm).
- 1 core / 1 GiB RAM / 512 MiB swap / 16 GiB rootfs (local-lvm).
- "Predates the client-enrollment convention \u2014 undocumented in inventory.yaml until 2026-07-06, when\
\ Oikos's drift detector (oikos/drift.py) caught pve_id 131 live on hubris (`pct list`) with no inventory\
\ entry. Static IP assigned 2026-07-05 during the strong migration (was picking up 192.168.8.243 via\
\ DHCP before that \u2014 see hosts/strong.md's 2026-07-05 changelog)."
- "No age_pubkey / homelab-context enrollment \u2014 not a homelab CLI client, just a docker-compose app\
\ container. Not a required follow-up unless it needs secrets."
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

28
hosts/trmnl.yaml Normal file
View File

@@ -0,0 +1,28 @@
# Generated by mcp/build_host_files.py from inventory.yaml.
# Do NOT edit by hand — your changes will be overwritten.
# Source of truth: ../inventory.yaml
name: trmnl
kind: lxc
os: linux
role: trmnl-middleware
state: active
host: hubris
pve_id: 128
lan_ip: 192.168.8.211
mesh_globals:
primary: netbird
accepted:
- netbird
- tailscale
public_host: trmnl.hubris.network
runs:
- trmnl
services_hosted:
- name: trmnl
backend: trmnl
url: https://trmnl.hubris.network
note: self-hosted middleware for TRMNL e-ink plugins (polled by TRMNL cloud)
doc_page: knowledge/wiki/containers/128-trmnl.md
config_repo: dtoro/terminalito
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -5,6 +5,7 @@ name: zimaos
kind: vm
os: linux
role: nas-frontend-eval
state: active
host: hubris
pve_id: 100
lan_ip: 192.168.8.195
@@ -20,7 +21,6 @@ services_hosted:
- name: zimaos
url: https://zimaos.hubris.network
backend: zimaos
see_also:
- vms/100-zimaos.md
mcp_endpoint: https://mcp.hubris.network/sse
doc_page: knowledge/wiki/vms/100-zimaos.md
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -1,104 +0,0 @@
# DNS — split-horizon `*.hubris.network`
LAN clients resolve `*.hubris.network` to the [Caddy reverse proxy](../containers/121-caddy.md) (`192.168.8.175`). Public clients resolve to the IONOS VPS (`82.165.190.79`) via an IONOS wildcard, where they hit the [VPS traefik public ingress](ingress.md).
There is **no wildcard on the LAN side**. Every subdomain needs an explicit entry.
## Components
- **Authoritative public DNS:** IONOS. `*.hubris.network → 82.165.190.79` (was `74.118.126.4` until 2026-04-22).
- **LAN authoritative for `hubris.network` records:** dnsmasq on [authentik (124)](../containers/124-authentik.md), `192.168.8.180:53` and `127.0.0.1:53`. Config at `/etc/dnsmasq.d/hubris-split.conf`. Forwards everything else to `1.1.1.1` and `8.8.8.8` (`no-resolv`, `server=...`).
- **PVE host** (`192.168.8.77`): resolver is the local Netbird daemon at `100.122.38.109:53`, which forwards to the LAN/upstream and learns hubris.network answers via that path. `netbird status` says "Nameservers: 0/0 Available" — confirming netbird does NOT manage a hubris.network zone; it just caches whatever the system resolver returns.
- **Some LXCs** keep router DNS (`192.168.8.1`) or Tailscale MagicDNS (`100.100.100.100`), both of which return the public IONOS A record. Those LXCs need either a `/etc/hosts` override or local dnsmasq — see [mesh migration](mesh.md) for which technique applies where.
## Live entries (as of 2026-04-28)
```
address=/auth.hubris.network/192.168.8.175
address=/git.hubris.network/192.168.8.175
address=/media.hubris.network/192.168.8.175
address=/paperless.hubris.network/192.168.8.175
address=/books.hubris.network/192.168.8.175
address=/home.hubris.network/192.168.8.175
address=/cloud.hubris.network/192.168.8.175
address=/matrix.hubris.network/192.168.8.175
address=/proxmox.hubris.network/192.168.8.175
address=/docker.hubris.network/192.168.8.175
address=/jellyseerr.hubris.network/192.168.8.175
address=/qbit.hubris.network/192.168.8.175
address=/sab.hubris.network/192.168.8.175
address=/blog.hubris.network/192.168.8.175
address=/photos.hubris.network/192.168.8.175
address=/photos-new.hubris.network/192.168.8.175
address=/artifacto.hubris.network/192.168.8.175
address=/plato.hubris.network/192.168.8.175
address=/zimaos.hubris.network/192.168.8.175
address=/nfs-export.hubris.network/192.168.8.200
```
Note: `nfs-export.hubris.network` is the only `.hubris.network` entry that points to a non-HTTP service (NFSv4 on port 2049). It bypasses [caddy (121)](../containers/121-caddy.md) because NFS is L4, not HTTP — Caddy has nothing to do.
## Why split-horizon
The IONOS wildcard points at the VPS for public ingress (per-host routers in [VPS traefik](ingress.md)). The VPS only routes hostnames it knows — anything else 404s. So LAN clients pointing at the public IP are a dead end for any service that isn't explicitly published. The dnsmasq override on LXC 124 keeps LAN traffic on the home Caddy.
## The gotcha that cost a debug session (2026-04-22)
Creating a new Caddyfile site block is necessary but **not sufficient**. Without the LXC-124 dnsmasq entry, LAN queries fall through to upstream, get the public IONOS answer, and time out. Symptom: "subdomain doesn't load" even though Caddy config + cert are fine.
## Recipe — adding a new subdomain
1. Edit `/etc/caddy/Caddyfile` on [caddy (121)](../containers/121-caddy.md), commit + push to `dtoro/caddy-conf`. Webhook reloads caddy. See [auto-deploy](auto-deploy.md).
2. Append to `/etc/dnsmasq.d/hubris-split.conf` on [authentik (124)](../containers/124-authentik.md):
```
address=/<new>.hubris.network/192.168.8.175
```
3. `pct exec 124 -- systemctl restart dnsmasq` — reload/SIGHUP may not pick up the new `address=` line; hard restart is the reliable path.
4. Verify: `dig @192.168.8.180 +short <new>.hubris.network` → `192.168.8.175`.
5. On macOS clients, flush: `sudo dscacheutil -flushcache && sudo killall -HUP mDNSResponder`.
> The dnsmasq config is **not** tracked in git. If you stand up a `dtoro/dnsmasq-conf` repo, wire it like `caddy-conf` (webhook on LXC 124).
## Public path — what does and doesn't follow the LAN map
- Hostnames published in [VPS traefik dynamic config](ingress.md) (currently `artifacto.hubris.network`, `blog.hubris.network`) reach a real backend over the netbird mesh.
- Anything else with a `*.hubris.network` URL hits the VPS but isn't routed anywhere — returns 404.
- `netbird.hubris.network` is its own thing — TCP passthrough at the VPS, served by netbird-proxy. Doesn't follow the file-provider router pattern.
## Long-term plan
Either:
- Move split-horizon DNS to the LAN router so `*.hubris.network → 192.168.8.175` is answered for every LAN client. Eliminates per-LXC overrides.
- Or, once the [Tailscale → Netbird migration](mesh.md) completes, every LXC's resolver becomes the netbird daemon, which already learns hubris.network answers via the system resolver chain.
## Related
- [Caddy (121)](../containers/121-caddy.md) — every LAN entry points here
- [Ingress (VPS traefik)](ingress.md) — public-side counterpart
- [Mesh migration](mesh.md) — per-LXC DNS workarounds during the transition
- [Authentik (124)](../containers/124-authentik.md) — host of the split-horizon dnsmasq
## Changelog
### 2026-05-14 — `nfs-export.hubris.network` added (direct, non-HTTP)
NFSv4 export server [nfs-export (102)](../containers/102-nfs-export.md) at `192.168.8.200`. Direct entry, not Caddy-fronted — NFS is L4, no HTTP reverse-proxy meaningful.
### 2026-05-14 — `zimaos.hubris.network` added (Caddy-fronted, standard pattern)
New LAN entry for [100-zimaos](../vms/100-zimaos.md) → [caddy (121)](../containers/121-caddy.md) → `192.168.8.195`. Briefly pointed direct-to-VM during install for the initial smoke-test, then re-pointed once a Caddyfile block was added (`reverse_proxy 192.168.8.195` + IONOS DNS-01 TLS).
### 2026-05-13 — `plato.hubris.network` added; `files.hubris.network` removed
New LAN-only entry for [plato (126)](../containers/126-plato.md). Same day, the `files.hubris.network` entry for the just-decommissioned seafile experiment was dropped; queries now fall through to the public IONOS answer (no LAN backend).
### 2026-05-12 — `files.hubris.network` added (since removed 2026-05-13)
Originally added for the seafile (LXC 125) Nextcloud-replacement evaluation. Pointed at 192.168.8.175 (Caddy reverse-proxied to 192.168.8.185:80). Entry removed when the experiment was torn down a day later.
### 2026-04-28 — wiki entry created
Initial documentation. 16 active entries.
### 2026-04-22 — IONOS wildcard moved 74.118.126.4 → 82.165.190.79
Public path now lands on the VPS traefik, not the old yunohost. Necessary for the [public ingress](ingress.md) pattern. The LAN dead-end semantics didn't change — public DNS still doesn't help LAN clients reach LAN-only services.
### 2026-04-22 — three caddy sites without DNS entries (jellyseerr, qbit, sab)
Caddy + certs were working but LAN resolution failed because the dnsmasq lines weren't added. Lesson recorded; entries added later that day.
### 2026-04-21 — dnsmasq stood up on LXC 124
Co-located with Authentik. Initial entries cover everything routed through Caddy.

View File

@@ -1,75 +0,0 @@
# Monitoring — `claudio-monitor`
Per-5-minute host-health watchdog on [hubris](../hosts/hubris.md). Edge-triggered Matrix alerts via [claudio-bot (123)](../containers/123-claudio-bot.md). Home Assistant pulls the same metrics independently from the official Proxmox VE integration — claudio-monitor focuses on alerting only.
## Why
After the [2026-04-21 thermal crash](../investigations/2026-04-21-hubris-crash-loop.md), the old single-purpose `thermal-watch` (which lived in `dtoro/backup-library`) was extended into a general health monitor. Single sink: claudio-bot. The MQTT/REST push paths were ripped out 2026-04-21 (commit `82f0596` in `dtoro/claudio-monitor`) once HA started consuming PVE metrics directly.
## Components
- **Repo:** `dtoro/claudio-monitor`
- **Checkout:** `/opt/claudio-monitor` on the PVE host
- **No auto-deploy webhook yet** — push, then `/opt/claudio-monitor/scripts/deploy.sh` manually.
- **Main loop:** `claudio-monitor.timer` (every 5 min) → `/opt/claudio-monitor/claudio-monitor.sh` → sources `lib/check-{thermal,host,lxc}.sh`.
- **Module shape:** every check is a `check_XXX()` function in `lib/check-XXX.sh`, sourced by the orchestrator. To add: write `lib/check-new.sh` and add `. "$LIB/check-new.sh"` + one `check_new` call in `claudio-monitor.sh`.
- **Thresholds:** `/etc/claudio-monitor/thresholds.env` (installed from `config/thresholds.env` — admin edits there are preserved by `deploy.sh`).
- **Tokens:** `/etc/claudio-monitor/bot.token` (same value as `/etc/restic/bot.token`, copied by hand). Missing → Matrix delivery is silently skipped.
- **State files:** `/var/lib/claudio-monitor/<check>-<key>.alert` and `.counter`. `notify.sh` ships two primitives: `maybe_alert` (warn/clear hysteresis) and `sticky_alert` (N-consecutive-ticks).
## Per-LXC data
A single `pvesh get /cluster/resources --type vm --output-format json` per tick gives CPU/mem/disk for every LXC and VM. Same call HA's PVE integration uses — keep them aligned.
## Matrix-side plugin
`plugins/monitor.py` in `dtoro/claudio-bot`. Registered in `/etc/claudio-bot/config.yaml` under `plugins:`. Slash commands:
- `/monitor status`
- `/monitor history [N]`
- `/monitor clear <key>`
Tools (for NL queries via the LLM): `get_alerts`, `get_monitor_history`.
Events posted to claudio-bot `/notify`:
```json
{"plugin": "monitor", "event": "critical|warning|info", "check": "thermal|host|lxc|apt|docker", "message": "...", "data": {...}}
```
`event:"info"` is the recovery / clear event. The plugin keys active alerts by `_alert_key(check, data)`.
## Current thresholds & active alerts
Active warnings at the time monitor was deployed (2026-04-21):
- `lxc:103` ([paperless](../containers/103-paperless.md)) — disk 86.9%
- `lxc:118` ([elementsynapse](../containers/118-elementsynapse.md)) — disk 86.8%
## Testing
- Force an alert: `NVME_WARN_TEMP_C=1 /opt/claudio-monitor/claudio-monitor.sh` (any threshold env override).
- Clean state: `rm -f /var/lib/claudio-monitor/*.alert`.
- Verify Matrix delivery: `pct exec 123 -- journalctl -u claudio-bot --since "30s ago" | grep "plugin=monitor"`.
## Not yet deployed (tracked in plan file)
- `check-apt.sh` + `check-docker.sh` + `claudio-monitor-updates.timer` (daily at 04:00) — apt upgradable counts per host/LXC, docker image digest drift on [apps (105)](../containers/105-apps.md).
- Deploy webhook (`scripts/webhook/`) mirroring `dtoro/backup-library-deploy`.
## Related repos
- `dtoro/backup-library` — origin of the now-removed `thermal-watch.*`.
- `dtoro/claudio-bot` — the `monitor` plugin lives at `plugins/monitor.py`.
- `dtoro/haos-config` — empty placeholder created 2026-04-21 for HA `/config` once bootstrapped from inside HAOS via the SSH addon.
## Related pages
- [Hubris host](../hosts/hubris.md)
- [claudio-bot (123)](../containers/123-claudio-bot.md)
- [HAOS VM (108)](../vms/108-haos.md)
- [Backups (disabled)](backups.md)
- [Investigation: 2026-04-21 crash loop](../investigations/2026-04-21-hubris-crash-loop.md)
## Changelog
### 2026-04-28 — wiki entry created
Initial documentation.
### 2026-04-21 — claudio-monitor stood up; thermal-watch removed
General health monitor with per-LXC checks. MQTT/REST push paths ripped out (commit `82f0596`) because HA gets PVE metrics directly. Matrix-side `monitor` plugin added in claudio-bot commit `e56da25`.

View File

@@ -1,45 +0,0 @@
# SSH access
How to reach hubris and the VPS over SSH, and the dual-server gotcha.
## Hubris
Two SSH endpoints — easy to hit the wrong one.
| Server | Listen | Auth | Notes |
| -------------- | ---------------------------- | --------------------------------- | ----- |
| OpenSSH | `0.0.0.0:22` | `authorized_keys` at `/etc/pve/priv/authorized_keys` (Proxmox cluster-synced; symlinked from `/root/.ssh/authorized_keys`) | Standard. |
| Netbird SSH | `100.122.38.109:22022` | OIDC / browser auth — bypasses `authorized_keys` | If a client lands here it'll open a browser tab to authenticate, then sometimes hang. Force port 22 or use the LAN IP. |
### Authorized root keys
- `root@hubris` (self, RSA) — original.
- `d.toro.v@pm.me` (ed25519) — user's iMac (`mac-mini.netbird.selfhosted`, LAN `192.168.8.174`), added 2026-04-22.
### Notes
- Password auth is enabled on hubris but the root password is **not** the one the user expects. Prefer key flows; don't try `ssh-copy-id` blind.
- Off-LAN access from the iMac uses the LAN path. As of 2026-04-22 the iMac's Netbird tunnel to hubris was P2P healthy but no packets were captured on `wt0`; needs revisit if remote access becomes critical.
## VPS (`82.165.190.79` / `100.122.165.149`)
- **Mesh-only.** Public `:22` is dropped by the nftables firewall. SSH reaches the VPS only over `wt0`.
- Key-only (`PasswordAuthentication no`, `PermitRootLogin prohibit-password`) via drop-in at `/etc/ssh/sshd_config.d/10-hubris-hardening.conf`. Original config backed up at `/etc/ssh/sshd_config.bak.<ts>`.
- `ListenAddress` is still 0.0.0.0; gating is firewall-layer.
- Authorized root keys: PVE (`root@hubris`), iMac (`d.toro.v@pm.me`). Add a new device with `ssh-copy-id root@100.122.165.149` from a mesh peer before disabling its access paths.
See [VPS hardening](vps-hardening.md) for the firewall + fail2ban rules and recovery paths.
## Related
- [Hubris host](../hosts/hubris.md)
- [Mesh migration](mesh.md)
- [VPS hardening](vps-hardening.md)
## Changelog
### 2026-04-28 — wiki entry created
Initial documentation.
### 2026-04-23 — VPS SSH hardened to mesh-only
Public `:22` blocked at nftables. Key-only sshd. See [VPS hardening](vps-hardening.md).
### 2026-04-22 — iMac key authorized on hubris
`d.toro.v@pm.me` added to `/etc/pve/priv/authorized_keys`.

View File

@@ -35,71 +35,126 @@ mesh:
- tailscale
netbird_subnet: 100.122.0.0/16
netbird_domain: netbird.selfhosted
# Service contract (Oikos, 2026-07-05): each service should carry
# backend host/container that runs it (required)
# url public URL if ingress-exposed
# doc_page owning wiki page
# config_repo tracked config repo, if any (mutations go commit+push)
# health health-check URL if it differs from `url`
# risk_notes what an agent must know before touching it
# See oikos/ontology.yaml + oikos/policy.yaml.
services:
proxmox_ui:
url: https://proxmox.hubris.network
backend: hubris
port: 8006
doc_page: knowledge/wiki/hosts/hubris.md
risk_notes: hypervisor UI — changes here affect every guest on the node
gitea:
url: https://git.hubris.network
backend: gitea
backend_url: http://192.168.8.121:3000
doc_page: knowledge/wiki/containers/104-gitea.md
config_repo: dtoro/gitea-customizations
risk_notes: hosts all config repos + deploy webhooks; outage blocks auto-deploy and sync
caddy:
backend: caddy
role: reverse-proxy
note: terminates all *.hubris.network
doc_page: knowledge/wiki/containers/121-caddy.md
config_repo: dtoro/caddy-conf
risk_notes: wide blast radius — every *.hubris.network route rides on it (see oikos/policy.yaml service_overrides)
authentik:
url: https://auth.hubris.network
backend: authentik
dnsmasq:
backend: authentik
note: split-horizon DNS, /etc/dnsmasq.d/hubris-split.conf
backend: netbird-vps
doc_page: knowledge/wiki/containers/106-auth-outpost.md
note: >-
core runs on the VPS since 2026-05-31; LAN forward-auth outpost is
auth-outpost (LXC 106) at 192.168.8.6:9000. Previous backend value
"authentik" referenced the retired embedded-outpost host (LXC 124).
risk_notes: SSO provider — outage locks login to OIDC/forward-auth services
dns:
backend: dns
note: Technitium DNS, split-horizon zone
doc_page: knowledge/wiki/containers/107-dns.md
risk_notes: LAN-wide resolver — misconfig breaks name resolution for every client
jellyfin:
url: https://media.hubris.network
backend: jellyfin
doc_page: knowledge/wiki/containers/101-jellyfin.md
risk_notes: native Authentik OIDC via SSO-Auth plugin, no Caddy forward-auth gate; VAAPI transcode depends on GPU passthrough on strong
nextcloud:
url: https://cloud.hubris.network
backend: nextcloud
doc_page: knowledge/wiki/containers/114-nextcloud.md
paperless:
url: https://paperless.hubris.network
backend: paperless
doc_page: knowledge/wiki/containers/103-paperless.md
risk_notes: document archive — treat data as irreplaceable; DB operations are destructive-class
matrix:
url: https://matrix.hubris.network
backend: elementsynapse
doc_page: knowledge/wiki/containers/118-elementsynapse.md
risk_notes: alert/approval channel for Oikos — outage silences agent escalation
photos:
url: https://photos.hubris.network
backend: mule-images
plato:
url: https://plato.hubris.network
backend: plato
doc_page: knowledge/wiki/containers/120-mule-images.md
config_repo: dtoro/mule-image
arr_stack:
backend: arriman
note: jellyseerr / qbit / sab on docker compose
doc_page: knowledge/wiki/containers/122-arriman.md
artifacto:
backend: apps
url: https://artifacto.hubris.network
doc_page: knowledge/wiki/containers/105-apps.md
config_repo: dtoro/Artifacto
trmnl:
backend: trmnl
url: https://trmnl.hubris.network
note: self-hosted middleware for TRMNL e-ink plugins (polled by TRMNL cloud)
doc_page: knowledge/wiki/containers/128-trmnl.md
config_repo: dtoro/terminalito
zimaos:
url: https://zimaos.hubris.network
backend: zimaos
doc_page: knowledge/wiki/vms/100-zimaos.md
haos:
backend: haos
doc_page: knowledge/wiki/vms/108-haos.md
teddycloud:
url: https://teddy.hubris.network
backend: teddycloud
doc_page: knowledge/wiki/containers/131-teddycloud.md
note: self-hosted TeddyCloud (Toniebox cloud reimplementation), docker compose
risk_notes: no Caddy forward-auth gate (unlike sab.hubris.network on the same Caddyfile) —
reachable to anyone on the LAN/mesh who can resolve teddy.hubris.network; undocumented
in inventory.yaml until 2026-07-06 (drift-caught)
homelab_mcp:
backend: apps
port: 9810
systemd_unit: homelab-mcp
public_host: mcp.hubris.network
endpoint: https://mcp.hubris.network/sse
endpoint: https://mcp.hubris.network/mcp
doc_page: knowledge/wiki/infrastructure/homelab-context.md
config_repo: dtoro/Homelab-Docs
note: MCP server. Read-only context + management. Reachable on the LAN via Caddy
and from off-LAN via Netbird (192.168.8.0/24 is a network resource routed through
hubris).
risk_notes: agents' primary read surface — outage degrades every agent to grepping the clone
secrets_issuance:
backend: apps
port: 9820
systemd_unit: secrets-issuance
public_host: secrets.hubris.network
endpoint: https://secrets.hubris.network/issue
doc_page: .agents/operations/agent-enrollment.md
config_repo: dtoro/Homelab-Docs
note: Issues per-client age private keys. Gated at source-IP layer (mesh + LAN
subnets in MESH_SUBNETS).
risk_notes: identity issuance — any change is security-sensitive; key operations are destructive-class
hosts:
hubris:
kind: proxmox-host
@@ -117,19 +172,49 @@ hosts:
mounts:
- /mnt/library
age_pubkey: age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6
trmnl:
kind: lxc
pve_id: 128
host: hubris
os: linux
role: trmnl-middleware
lan_ip: 192.168.8.211
public_host: trmnl.hubris.network
# not yet mesh/SOPS-enrolled — see containers/128-trmnl.md
house:
kind: lxc
pve_id: 129
host: strong
os: linux
role: family-planner
lan_ip: 192.168.8.244
public_host: house.hubris.network
notes:
- Docker host for Yuvomi (family planner). Created 2026-06-26.
- Migrated from hubris to strong 2026-07-05 (Phase 1 of strong migration plan).
- Runs Yuvomi container + WebDAV doc bridge to paperless
- 192.168.8.212 was the hubris IP before migration (briefly picked up by teddycloud via
DHCP; teddycloud has since been given a static IP, see hosts.teddycloud)
age_pubkey: age1s07zs83ehtlg8jtwvr75ltc3c4cdlemfwjuxrwjtwkqxkl9tpggsyrzn2h
jellyfin:
kind: lxc
pve_id: 101
host: hubris
host: strong
os: linux
role: media-server
lan_ip: 192.168.8.206
lan_ip: 192.168.8.246
public_host: media.hubris.network
mesh:
tailscale:
fqdn: jellyfin
mounts:
- /mnt/library
- /mnt/media_local
notes:
- Jellyfin 10.11.11 with VAAPI hardware acceleration (Radeon 680M iGPU on strong)
- 4 cores / 8 GiB RAM / 1 GiB swap
- SSO-Auth plugin v4.0.0.4 with Authentik OIDC (no Caddy forward-auth gate)
- GPU passed via dev0+dev1: /dev/dri/renderD128 + card0
- Migrated from hubris to strong 2026-07-05 (Phase 2). Library on ludo-lvm.
age_pubkey: ''
nfs-export:
kind: lxc
@@ -189,7 +274,27 @@ hosts:
- plantuml
- homelab-mcp
- secrets-issuance
# booklore removed 2026-06-29 → migrated to grimmory (LXC 130)
age_pubkey: age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0
auth-outpost:
kind: lxc
pve_id: 106
host: hubris
os: linux
role: authentik-gateway
lan_ip: 192.168.8.6
notes:
- Runs Authentik outpost (reverse-proxy/SSO enforcement) for protected services
dns:
kind: lxc
pve_id: 107
host: hubris
os: linux
role: dns-server
lan_ip: 192.168.8.2
notes:
- Technitium DNS, split-horizon zone for *.hubris.network
- Primary DNS for 192.168.8.0/24 LAN (inventory.services.dns references this)
nextcloud:
kind: lxc
pve_id: 114
@@ -207,21 +312,22 @@ hosts:
elementsynapse:
kind: lxc
pve_id: 118
host: hubris
host: strong
os: linux
role: matrix-server
lan_ip: 192.168.8.239
lan_ip: 192.168.8.242
public_host: matrix.hubris.network
mesh:
tailscale:
fqdn: elementsynapse
tailscale: {}
notes:
- Migrated from hubris to strong 2026-07-05 (Phase 1 of strong migration plan).
sophia:
kind: lxc
pve_id: 119
host: hubris
os: linux
role: workshop
lan_ip: 192.168.8.157
lan_ip: 192.168.8.109
mesh:
tailscale:
fqdn: sophia
@@ -258,10 +364,10 @@ hosts:
arriman:
kind: lxc
pve_id: 122
host: hubris
host: strong
os: linux
role: arr-stack
lan_ip: 192.168.8.132
lan_ip: 192.168.8.245
public_hosts:
- jellyseerr.hubris.network
- qbit.hubris.network
@@ -270,39 +376,81 @@ hosts:
tailscale:
fqdn: arr
mounts:
- /mnt/library
- /mnt/media_local
notes:
- Migrated from hubris to strong 2026-07-05 (Phase 2). Library on ludo-lvm.
- Contains homarr, radarr, sonarr, lidarr, sabnzbd, qbittorrent, bazarr, flaresolverr, prowlarr, jellyseerr
- qBittorrent auth subnet whitelist expanded to 192.168.8.0/24 (for seanime + Caddy access)
age_pubkey: ''
claudio-bot:
grimmory:
kind: lxc
pve_id: 123
host: hubris
pve_id: 130
host: strong
os: linux
role: matrix-agent
lan_ip: 192.168.8.230
notes:
- Reads /opt/homelab-context/ on startup
age_pubkey: age1xmkeq968areza2necqyq0065dpeegngzyr6dhagh0n6pl33lccfqe5mqn9
authentik:
kind: lxc
pve_id: 124
host: hubris
os: linux
role: idp
lan_ip: 192.168.8.180
public_host: auth.hubris.network
notes:
- 'Also hosts split-horizon dnsmasq: /etc/dnsmasq.d/hubris-split.conf'
plato:
kind: lxc
pve_id: 126
host: hubris
os: linux
role: app
lan_ip: 192.168.8.190
public_host: plato.hubris.network
role: book-library
lan_ip: 192.168.8.247
public_host: books.hubris.network
mounts:
- /mnt/library/documents/plato
age_pubkey: ''
- /mnt/media_local
notes:
- Docker host for Grimmory (community fork of Booklore). Created 2026-06-29.
- Migrated from hubris to strong 2026-07-05 (Phase 2d). Books on ludo-lvm.
age_pubkey: age1uellsemnjrzgfg9fxw4jefpy05laxzggwnwhh6ny3wl7alyp6v8q0muxet
teddycloud:
kind: lxc
pve_id: 131
host: hubris
os: linux
role: teddycloud
lan_ip: 192.168.8.150
public_host: teddy.hubris.network
mounts:
- /mnt/library
state: active
notes:
- Docker host for TeddyCloud (ghcr.io/toniebox-reverse-engineering/teddycloud), a
self-hosted reimplementation of the Toniebox cloud backend. Debian 12 (bookworm).
- 1 core / 1 GiB RAM / 512 MiB swap / 16 GiB rootfs (local-lvm).
- Predates the client-enrollment convention — undocumented in inventory.yaml until
2026-07-06, when Oikos's drift detector (oikos/drift.py) caught pve_id 131 live on
hubris (`pct list`) with no inventory entry. Static IP assigned 2026-07-05 during the
strong migration (was picking up 192.168.8.243 via DHCP before that — see
hosts/strong.md's 2026-07-05 changelog).
- No age_pubkey / homelab-context enrollment — not a homelab CLI client, just a
docker-compose app container. Not a required follow-up unless it needs secrets.
seanime:
kind: lxc
pve_id: 133
host: strong
os: linux
role: anime-media-server
lan_ip: 192.168.8.248
public_host: seanime.hubris.network
mounts:
- /mnt/media_local/anime
notes:
- Seanime anime media server for online streaming + local library scanning
- Created 2026-07-05. Binary at /opt/seanime/bin/seanime, systemd service.
- Connected to qBittorrent on arriman (192.168.8.245:8080)
- 8 online streaming extensions installed (HiAnime, AniWatch, KickAssAnime, etc.)
- /anime mounted from strong ludo-lvm (/mnt/media_local/anime)
- Caddy: https://seanime.hubris.network → 192.168.8.248:43211
- qBittorrent auth subnet whitelist expanded to 192.168.8.0/24 for seanime access
romm:
kind: lxc
pve_id: 134
host: strong
os: linux
role: rom-manager
lan_ip: 192.168.8.249
public_host: roms.hubris.network
mounts:
- /mnt/media_local
notes:
- Docker host for RomM (romm.app) self-hosted ROM manager. Created 2026-07-05.
- MariaDB sidecar at /opt/romm/docker-compose.yml.
- ROMs on ludo-lvm media volume at /mnt/media_local/roms.
- 1 core / 2 GiB RAM / 16 GiB rootfs (ludo-lvm).
zimaos:
kind: vm
pve_id: 100
@@ -334,6 +482,7 @@ hosts:
kind: workstation
os: macos
role: dev
lan_ip: 192.168.178.182
mesh:
netbird:
fqdn: mac-mini-234-17.netbird.selfhosted
@@ -341,17 +490,32 @@ hosts:
user: dtoro
notes:
- Only macOS in the fleet. Bootstrap uses launchd.
age_pubkey: ''
ludo-mini:
kind: workstation
age_pubkey: age1z62ff2ak9zj5ctcvaxwyyhedwjvlwgm2dkn9nk3wrwk8fkavcpmsqwc2vs
strong:
kind: proxmox-host
os: linux
role: dev
mesh:
netbird:
fqdn: ludo-mini.netbird.selfhosted
# ssh.user defaults to root; uncomment + set to the actual login user
# before relying on `homelab ssh ludo-mini` or netbird-ssh INTO this host.
# ssh: { user: ludo }
role: hypervisor
lan_ip: 192.168.178.181
ssh:
user: root
notes:
- Reformatted from Linux workstation ("ludo-mini" in this wiki, still
the machine's nickname) to Proxmox VE 9.2.3 on 2026-07-01. Renamed
the inventory/wiki identity from ludo-mini to strong on the same day
so it matches the OS/cluster hostname everywhere (bootstrap looks up
hosts/$(hostname).yaml, so a mismatch would break enrollment).
- Joined hubris's "Homelab" cluster same day. 2-node, no QDevice
tiebreaker yet — see hosts/hubris.md quorum note.
- Netbird not yet installed (fresh OS wiped prior enrollment); reachable
today only via the household LAN / existing Fritz static route to
192.168.8.0/24. Re-enroll in mesh as a follow-up if off-LAN access
to this host itself (not just its future guests) is needed.
- First step of the planned library-SSD migration — see
.hermes/plans/2026-06-03_110000-library-ssd-migration-to-ludo-mini.md
(filename kept as-is, it's a historical planning doc). Only Phase 1
(Proxmox install + cluster join) is done; no physical
drive move, service migration, or GPU passthrough has happened yet.
age_pubkey: age1rtwvdct6avjkr3cyxv3vue3vqx4d524fjfr3vk7xrnvyrylnry5sm54sn4
netbird-vps:
kind: external
os: linux
@@ -373,3 +537,82 @@ hosts:
vps/turnserver.conf.tmpl + vps/management.json.tmpl, with secrets
decrypted from secrets/turn-shared-secret.yaml +
secrets/netbird-authentik-oidc.yaml on hubris.
rclone:
kind: lxc
os: linux
role: backup
mesh:
netbird:
fqdn: rclone.netbird.selfhosted
age_pubkey: age1pwtdws2thdh7vzp2dzttl3zxgcs2tgpcsjsqgw3q04nyml4kvuqq467u4x
# Destroyed nodes (lifecycle state: destroyed — see oikos/ontology.yaml).
# Kept so agents can answer "what happened to X?" from structured data and
# so drift detectors can flag anything still referencing them.
# Full narrative table: containers/index.md "Recently destroyed".
archaeology:
claudio-bot:
kind: lxc
pve_id: 123
destroyed: 2026-06-04
reason: replaced by Hermes Agent on mac-mini; monitoring moved to homelab-health-watchdog cron
plato:
kind: lxc
pve_id: 126
destroyed: 2026-06-28
reason: notes workspace decommissioned; data retained at /mnt/library/documents/plato
mule-photos-new:
kind: lxc
pve_id: 127
destroyed: 2026-05-22
reason: PhotoPrism test stack promoted to LXC 120 (Mulimage 2.0 merge)
heaper:
kind: lxc
pve_id: 116
destroyed: 2026-05-14
reason: decommissioned; data retained at /mnt/library/heaper
syncthing:
kind: lxc
pve_id: 109
destroyed: 2026-05-14
reason: decommissioned; library subtree was empty
seafile:
kind: lxc
pve_id: 125
destroyed: 2026-05-13
reason: Seafile Pro evaluation rejected; files.hubris.network removed from caddy + dns
arr-yunohost:
kind: lxc
pve_id: 100
destroyed: 2026-04-28
reason: migrated to docker stack on arriman (LXC 122)
flaresolverr:
kind: lxc
pve_id: 106
destroyed: 2026-04-28
reason: folded into the arriman docker compose
marimo:
kind: lxc
pve_id: 107
destroyed: 2026-04-28
reason: decommissioned
photoprism:
kind: lxc
pve_id: 110
destroyed: 2026-04-28
reason: replaced by mule-images (LXC 120)
karakeep:
kind: lxc
pve_id: 111
destroyed: 2026-04-28
reason: decommissioned
immich:
kind: lxc
pve_id: 112
destroyed: 2026-04-28
reason: replaced by mule-images (LXC 120)
reticulum:
kind: lxc
pve_id: 115
destroyed: 2026-04-28
reason: decommissioned

View File

@@ -1,16 +0,0 @@
# Investigations
Time-stamped incident reports and experiments. One entry per incident; the entry is the canonical source. Per-node changelog entries link back here.
## Index
| Date | Title | Status |
| ------------ | ------------------------------------------------------------------ | ------------- |
| 2026-04-21 | [Hubris crash loop — thermal + USB drive](2026-04-21-hubris-crash-loop.md) | Drive removal A/B test passing as of 2026-04-28 (3+ days uptime) |
## Conventions
- File name: `YYYY-MM-DD-<slug>.md`. Use the *first* date if the incident spans multiple days.
- Mandatory sections: Summary, Timeline, Root cause, Mitigations applied, Open questions.
- Update the entry as the situation evolves; never rewrite history. Add new dated sections at the bottom.
- Link back from every node's changelog that's affected.

33
knowledge/GLOSSARY.md Normal file
View File

@@ -0,0 +1,33 @@
# Glossary
Terms and abbreviations used throughout the homelab wiki.
| Term | Meaning |
|------|---------|
| **Authentik** | SSO/identity provider. Core runs on the VPS; forward-auth outpost at LXC 106 on hubris |
| **Caddy** | Reverse proxy (LXC 121). Terminates TLS for every `*.hubris.network` hostname |
| **Caveman** | Terse communication standard for agent responses — no filler, keep substance |
| **Forward-auth** | Caddy snippet that delegates authentication to an Authentik outpost. Protects web UIs like qBit, SABnzbd |
| **Gitea** | Git server at `git.hubris.network`. Hosts all tracked config repos |
| **Gluetun** | WireGuard VPN sidecar on arriman. All \*arr traffic routes through it |
| **HAOS** | Home Assistant Operating System. VM 108 on hubris |
| **Hubris** | Primary Proxmox VE node (GMKtec NucBox M6 Ultra). PVE hostname, cluster member 1 |
| **LXC** | Linux Container (Proxmox). VM-like isolation without a full OS kernel |
| **LVM-thin** | Thin-provisioned logical volume manager. Used for all container/VM storage |
| **MCP** | Model Context Protocol (MCP server at `mcp.hubris.network`). Structured tools for agents to query homelab state |
| **Mesh** | Overlay VPN for off-LAN connectivity. Netbird is current; Tailscale is legacy |
| **Netbird** | Preferred mesh VPN. VPS hosts the management plane; all homelab nodes are members |
| **OIDC** | OpenID Connect. Protocol used by Authentik for SSO login flows |
| **Oikos** | Agent operating model ([.agents/OIKOS.md](../.agents/OIKOS.md)). OODA loop, risk classes, policy, ontology |
| **PVE** | Proxmox Virtual Environment — the hypervisor on both hubris and strong |
| **SOPS** | `sops` — Mozilla SOPS. Encrypts secrets with age keys so they live in the git repo |
| **Strong** | Secondary Proxmox VE node. Cluster member 2 (hostname `strong`, nickname ludo/ludo-mini) |
| **Traefik** | Reverse proxy on IONOS VPS. Serves `*.hubris.network` to the public internet |
| **VAAPI** | Video Acceleration API. Intel/AMD GPU-based hardware transcode for Jellyfin |
| **VPS** | Virtual Private Server at IONOS (`82.165.190.79`). Runs Authentik core + Netbird management |
| **\\*arr** | Media automation suite: Sonarr (TV), Radarr (movies), Lidarr (music), Prowlarr (indexer), Bazarr (subtitles), Readarr (books — not in use) |
## See also
- [Infrastructure index](wiki/infrastructure/index.md) — cross-cutting systems each with their own doc page
- [OIKOS operating model](../.agents/OIKOS.md) — agent policy, risk classes, lifecycle

14
knowledge/index.md Normal file
View File

@@ -0,0 +1,14 @@
# Knowledge
The durable, authoritative current-state documentation of the homelab: one page per node and per
cross-cutting system, synthesized from live state and evidence. Structure and rules are in
[the knowledge schema](../.agents/domains/knowledge/schema.md).
| Section | What it covers |
|---------|----------------|
| [wiki/hosts/](wiki/hosts/index.md) | Proxmox host narratives — `hubris`, `strong`. |
| [wiki/containers/](wiki/containers/index.md) | LXC fleet — one page per container, plus the master table and archaeology. |
| [wiki/vms/](wiki/vms/index.md) | Virtual machines — ZimaOS, Home Assistant OS. |
| [wiki/infrastructure/](wiki/infrastructure/index.md) | Cross-cutting systems — DNS, ingress, mesh, storage, auth, monitoring, generated topology. |
| [sources/](sources/index.md) | External reference docs and the pointer to incident evidence. |
| [GLOSSARY.md](GLOSSARY.md) | Term definitions. |

10
knowledge/log.md Normal file
View File

@@ -0,0 +1,10 @@
# Knowledge — operations log
Append-only record of documentation-maintenance operations on the knowledge wiki (restructures,
source ingests, lint sweeps). One line per operation, newest last. Infrastructure changes belong in
each page's `## Changelog` and the Oikos change ledger, not here.
## [2026-07-06] restructure | moved node/infrastructure narratives under knowledge/wiki/; references under knowledge/sources/; repointed inventory doc_page fields and gen-topology.py output.
## [2026-07-06] lint | banned-vocabulary scan of knowledge/ clean; added .agents/skills/docs-lint and knowledge/wiki/hosts/index.md.
## [2026-07-06] lint | fixed 126 pre-existing broken links (124-authentik.md rename, investigations/plans moved to archive/done, archive/ sibling depth, destroyed-node delinks); 2 remaining are an intentional cross-repo reference.
## [2026-07-06] restructure | Phase 6 consolidation: investigations/ -> knowledge/sources/investigations/; operations/ -> .agents/operations/; HERMES.md -> .agents/; deleted root OIKOS/CAVEMAN/CONTRIBUTING stubs.

View File

@@ -0,0 +1,9 @@
# Sources
Immutable evidence the wiki synthesizes from. External reference docs live under `references/`;
incident evidence lives in [`investigations/`](investigations/index.md) (written once at
incident time, then linked from the changelogs of the nodes they implicate).
| Slug | Reference | Summary |
|------|-----------|---------|
| cert-sync-and-traefik-config | [references/cert-sync-and-traefik-config.md](references/cert-sync-and-traefik-config.md) | VPS traefik config and the LAN↔VPS certificate mirror. |

View File

@@ -0,0 +1,191 @@
# mac-mini onboarding — post-mortem & lessons learned
Onboarded the `mac-mini` workstation (macOS Sequoia, arm64) into the hubris
homelab context system with the `--with-hermes` profile. What follows is a
chronological recap of every hitch, the fix, and the systemic improvements
needed so the next workstation takes 5 min instead of an hour.
## Session log
### Step 1 — clone + symlink
- Manually cloned `git.hubris.network/dtoro/Homelab-Docs` to `/Users/dtoro/Homelab-Docs`.
- Created `/opt/homelab-context``/Users/dtoro/Homelab-Docs` symlink.
- **Lesson:** bootstrap.sh was designed to do this from scratch, but we'd
already cloned by hand. The bootstrap's `clone exists; pulling` path handled
it gracefully.
### Step 2 — hostname mismatch
- `scutil --get LocalHostName``Davids-Mac-mini`
- `hostname -s``Mac`
- Inventory file: `hosts/mac-mini.yaml`
- **Fix:** `sudo scutil --set LocalHostName mac-mini && sudo scutil --set HostName mac-mini`
- **Lesson:** The bootstrap and `homelab whoami` use different hostname
resolution. Bootstrap uses `scutil --get LocalHostName` (correct on macOS),
but the `homelab` CLI binary uses `hostname -s`. Both need to match the
inventory key. On a fresh macOS machine, neither does.
### Step 3 — bootstrap dependencies
- pyyaml was missing → `pip install pyyaml`
- age and sops were missing → `brew install age sops`
- Netbird was already installed and connected ✓
- **Lesson:** The bootstrap preflight handles these, but only if running
`bootstrap.sh` from the start. Since we ran it after manual setup, some
steps (netbird install) were correctly skipped as already-present.
### Step 4 — full bootstrap with `--with-mcp --with-hermes`
- Ran `sudo HOMELAB_GITEA_TOKEN=... bash bootstrap.sh --with-mcp --with-hermes`
- Age key issued ✓
- Launchd sync timer installed ✓
- Goose binary installed ✓
- Hermes CLI linked ✓
- MCP config merged ✓
- `refresh-creds` skipped (not yet a recipient) ⚠️
- Cosmetics: `chown: dtoro: illegal group name` at the end (benign, macOS
group-naming quirk)
### Step 5 — finalize from hubris
- Ran `homelab client add mac-mini --finalize-pubkey <age...> --with-hermes` on
hubris
- Push failed: `[rejected] main -> main (fetch first)` — hubris clone was
stale, bootstrap had already pushed from mac-mini
- **Fix:** `git pull --rebase && git push` on hubris
- **Lesson:** bootstrap pushes remote changes before hubris can finalize,
creating a race. The `homelab client add --finalize-pubkey` command should
pull before committing/pushing.
### Step 6 — sops couldn't find the age key
- `homelab secret hello` failed because sops looks in
`/Users/dtoro/.ssh/id_rsa` etc. by default, not `/etc/age/key.txt`
- The `homelab` CLI re-execs via `sudo -E env SOPS_AGE_KEY_FILE=... sops ...`,
but this requires passwordless sudo and the correct env var passthrough
- **Fix:**
1. Added NOPASSWD sudo rules
2. Eventually `SOPS_AGE_KEY` env with the raw key content worked directly
- **Lesson:** Document the explicit `SOPS_AGE_KEY_FILE` incantation in
agent-enrollment troubleshooting. New clients can't assume `homelab secret`
works out of the gate — the sudo re-exec chain has permission pitfall.
### Step 7 — OpenRouter key was a placeholder
- `secrets/openrouter-api-key.yaml` contained
`api_key: PLACEHOLDER_REPLACE_WITH_REAL_OPENROUTER_KEY`
- User ran `sops` on hubris, but got the same error (age key not found on
hubris either — `/root/.config/sops/age/keys.txt` didn't exist)
- **Fix:** `SOPS_AGE_KEY_FILE=/etc/age/key.txt sops ...` on hubris.
Later: the user pasted the real key, but the sops file showed
`sk-or-...5c55` — the literal content was truncated with ellipsis.
### Step 8 — editor loaded the wrong data
- Neovim on the system is configured with `clipboard+=unnamedplus`, which
points `*` and `+` registers to the macOS clipboard manager rather than
X11. When editing SOPS files, this caused the **system clipboard** to be
pasted instead of the actual ciphertext.
- This wasn't diagnosed during the session — the sops file would load empty
or show the wrong content because the editor's idea of "paste" was
disconnected from what sops expected.
- **Fix:** Run `sops` with `EDITOR=nano` or another editor that doesn't
hijack OS clipboards:
```bash
EDITOR=nano SOPS_AGE_KEY_FILE=/etc/age/key.txt sops secrets/openrouter-api-key.yaml
```
- **Lesson:** Add a strong warning to `hermes-agent.md` / `agent-enrollment.md`:
macOS neovim with `clipboard+=unnamedplus` silently breaks sops editing
because the paste register reads from the system clipboard instead of the
sops-managed buffer. Use `EDITOR=nano` or `EDITOR=vim` when running sops
interactively. Alternatively, override the clipboard option with
`EDITOR='nvim -c "set clipboard="'`.
- Also useful for the troubleshooting table in `agent-enrollment.md` under a
new row: "sops file loads empty / wrong content on macOS"
### Step 9 — model doesn't support tool use
- Goose config pinned `nousresearch/hermes-4-405b` via OpenRouter
- Error: `No endpoints found that support tool use`
- **Fix:** Switched to `deepseek/deepseek-v4-flash` in
`~/.config/goose/config.yaml`
- Also updated `operations/hermes-agent.md` with the correct model
- **Lesson:** The default model in `bootstrap.sh` and `hermes-agent.md` was
never validated on OpenRouter for tool-use capability. Need to either:
(a) Pin a model known to work (`deepseek/deepseek-v4-flash`)
(b) Or have the bootstrap probe OpenRouter at install time
### Step 10 — MCP homelab extension returns 404
- Goose's homelab extension configured as `streamable_http` at
`https://mcp.hubris.network/mcp` returns HTTP 404
- The actual MCP server runs on SSE (`/sse`), not streamable_http
- **Known follow-up** from `hermes-agent.md` — server needs migration
- **Workaround:** The agent works fine via the built-in `developer` extension
(shell + file) and the `homelab` CLI
### Step 11 — git credential dance
Several layers of issues with git authentication:
1. **Directory permissions:** `/etc/homelab-context` was `0700 root` — dtoro
couldn't access the credential file, and git's `store` helper couldn't
create its `.lock` file.
- **Fix 1a:** `sudo chmod 755 /etc/homelab-context`
- **Fix 1b:** `sudo chown -R dtoro:staff /etc/homelab-context`
2. **macOS built-in osxkeychain:** Apple's git has `osxkeychain` compiled in
as a default credential helper. Even after configuring the `store` helper,
git calls osxkeychain after every successful `get`, which prompts for
macOS keychain access.
- `credential.helper = ""` does NOT disable the compiled-in default
- The `store` helper's `.lock` file in `/etc/homelab-context/` also failed
silently, causing fallback behaviour
- **Fix:** Embed the credential in the remote URL directly:
`https://dtoro:TOKEN@git.hubris.network/dtoro/Homelab-Docs.git`
- This bypasses all credential helpers entirely
3. **refresh-creds:** The credential is now managed by the remote URL.
If the PAT is rotated, `homelab refresh-creds` won't update the URL.
- **Workaround:** Run `git remote set-url origin` after `refresh-creds`
### Step 12 — `homelab` CLI hostname detection
- `homelab whoami` failed with `no hosts/Mac.yaml` because the CLI uses
`hostname -s` (returning `Mac`) while the bootstrap uses
`scutil --get LocalHostName` (which we fixed to `mac-mini`)
- **Fix:** Set both `LocalHostName` and `HostName` via `scutil`
- **Lesson:** The homelab CLI on macOS should prefer `scutil --get
LocalHostName` like the bootstrap does, or at least try both and pick the
one that matches an inventory key. This is a CLI bug.
## Improvement backlog
### High priority
| # | Issue | Fix |
|---|-------|-----|
| 1 | Homelab CLI uses `hostname -s` on macOS; should use `scutil --get LocalHostName` to match bootstrap | Patch `bin/homelab` to try `scutil` first, fall back to `hostname -s` |
| 2 | `homelab client add --finalize-pubkey` on hubris fails to push when remote is ahead (bootstrap pushes first) | `pull --rebase` before committing in `cmd_client_add` |
| 3 | Default Goose model (`nousresearch/hermes-4-405b`) doesn't support tool use on OpenRouter | Update `bootstrap.sh` default and `hermes-agent.md` to `deepseek/deepseek-v4-flash` |
| 4 | MCP server needs streamable_http migration | Follow-up #1 from `hermes-agent.md` — FastMCP `transport="sse"` → `transport="streamable_http"` |
| 5 | `homelab refresh-creds` should also update embedded remote URLs | In `cmd_refresh_creds`, after writing the credential file, update any remote URL that has an embedded credential |
### Medium priority
| # | Issue | Fix |
|---|-------|-----|
| 6 | macOS neovim with `clipboard+=unnamedplus` breaks sops editing | Add troubleshooting row to `agent-enrollment.md`; recommend `EDITOR=nano` |
| 7 | `/etc/homelab-context` directory with 0700 root permissions blocks non-root git | Change to 755 in `bootstrap.sh` on all OSes (or at least macOS) |
| 8 | Bootstrap `chown` at end gives `illegal group name` on macOS | Fix group detection in bootstrap.sh for macOS |
| 9 | `homelab secret` re-exec via sudo chain is fragile — needs passwordless sudo or TTY | Consider `SOPS_AGE_KEY` env-var fallback path in the CLI itself |
### Low priority
| # | Issue | Fix |
|---|-------|-----|
| 10 | Onboarding this machine revealed no `HERMES.md` file in the repo | Create the persona file for the Goose hints symlink |
| 11 | Git credential `store` helper lock mechanism is OS-sensitive and fragile | Document embedded-URL pattern as the preferred approach for workstations |
| 12 | AGENTS.md references `/opt/homelab-context/hosts/<hostname>.yaml` but the canonical path is now a symlink | Verify the path resolution edge cases |
## Changelog
### 2026-06-01 — initial post-mortem
Captured the full onboarding of mac-mini into the hubris homelab context
system, including the hostname mismatch, sudo/sops credential chain, model
selection, git credential gymnastics, and editor clipboard interference.
### 2026-06-02 — MCP server migrated to streamable-http
Item #4 resolved. FastMCP server now uses `transport="streamable-http"` (hyphen,
not underscore), serving at `https://mcp.hubris.network/mcp`. All doc references
and client configs (Goose, bootstrap.sh, agent-enrollment.md) updated. Service
re-deployed on apps LXC (105).

View File

@@ -0,0 +1,84 @@
# 2026-06-03 — Moonlight/Sunshine game streaming unstable over WiFi
## Summary
[`ludo-mini`](../../../hosts/strong.yaml) runs Sunshine as the game-streaming server; [`mac-mini`](../../../hosts/mac-mini.yaml) runs Moonlight as the client. Despite both machines being on the same physical subnet (192.168.178.0/24), streaming was unstable — stuttering, dropouts, and high latency. Root cause: **mac-mini is connected only via WiFi**, while ludo-mini is wired Ethernet (2.5 Gbps). WiFi throughput shows 1-second UDP dropouts and high jitter (28 ms stddev), which breaks real-time video streaming.
## Timeline
### Pre-existing state
- ludo-mini: wired Ethernet (eno1, 2.5 Gbps), 192.168.178.181/24
- mac-mini: WiFi (en1, 802.11ac, 234 Mbps Tx rate, 1 stream, -60 dBm RSSI), 192.168.178.99/24
- Both on the same subnet via a consumer router at 192.168.178.1
- Sunshine configured: bitrate=80 Mbps, encoder=amf (AMD GPU), fec_percentage=5, hevc_mode=2
- Moonlight configured: bitrate=57 Mbps, fps=60, localaddr=192.168.178.181 (correct)
### 2026-06-03 — investigation
**Network tests (iperf3 between mac-mini ↔ ludo-mini):**
- TCP throughput: 4986 Mbps, average ~65 Mbps — highly variable
- UDP throughput: 1-second complete dropout during a 5-second test; 0% loss otherwise
- Ping: 4.7 ms avg, but **28 ms stddev**, max **138 ms** — WiFi-level jitter
- Netbird P2P connection also showed 7 ms latency over the tunnel (unnecessary given same-subnet direct connection)
**Additional findings:**
- mac-mini has a built-in Ethernet port (en0) but it is inactive — no cable connected
- mac-mini also has Thunderbolt Ethernet adapters (en5, en6, en7) all inactive
- ludo-mini's en0 shows `speed 2500` (2.5 GbE)
- Netbird routes `192.168.8.0/24` via `utun100`, but 192.168.178.0/24 traffic stays on `en1`
## Root cause
mac-mini is on WiFi. WiFi introduces:
1. **Throughput variance** — 4986 Mbps TCP fluctuation
2. **Dropouts** — 1-second UDP blackouts from radio retransmissions
3. **Jitter** — 28 ms stddev with spikes to 138 ms
Moonlight/Sunshine streams real-time video over UDP. Any jitter spike or dropout causes visual stutter or frame drops. The 57 Mbps Moonlight bitrate was too aggressive for the WiFi link's consistency, even though the average throughput is ~65 Mbps.
## Mitigations applied
### mac-mini — Moonlight (via `defaults write`)
| Setting | Before | After | Rationale |
||---------|--------|-------|-----------|
|| bitrate | 57 Mbps | 25 Mbps | Reduce to a level WiFi can sustain consistently |
|| framepacing | off (0) | on (1) | Smooths frame timing over variable latency |
|| fps | 30 | 60 | Restored to game-appropriate value |
### mac-mini — Moonlight (current, wired — 2026-06-04)
| Setting | WiFi value | Wired value | Rationale |
||---------|-----------|-------------|-----------|
|| bitrate | 60 Mbps | 80 Mbps | Wired 1 GbE can sustain comfortably |
|| framepacing | on (1) | off (0) | Wired latency is consistent, no smoothing needed |
|| fps | 60 | 60 | Unchanged |
### ludo-mini — Sunshine (via SSH)
| Setting | WiFi value | Wired value | Rationale |
||---------|-----------|-------------|-----------|
|| bitrate | 40 Mbps | 80 Mbps | Wired can handle full bandwidth |
|| fec_percentage | 2 | 5 | Restored to default — wired has no dropout concern |
|| packetsize | 1024 | 1316 | Restored to default (Ethernet MTU) |
Sunshine restarted after changes; confirmed active.
## Resolution
### 2026-06-04 — mac-mini wired to LAN
mac-mini plugged into Ethernet (en0, 192.168.178.182). This definitively resolves the WiFi jitter that caused streaming instability. All WiFi-era conservative settings reverted to appropriate wired values on both ends.
## Open questions
~~1. **Ethernet wiring** — mac-mini has an active en0 port. Plugging it into the same switch/router as ludo-mini would eliminate WiFi jitter entirely. This is the definitive fix.~~ **RESOLVED** — mac-mini now wired via en0.
2. **WiFi alternatives** — If wiring is impractical, a WiFi 6 (802.11ax) USB adapter or Thunderbolt-to-Ethernet adapter would help, but wired Ethernet is the only reliable solution for game streaming.
3. **Netbird route**`192.168.8.0/24` is routed over Netbird `utun100`. If Moonlight ever falls back to that subnet (ludo-mini's 192.168.8.133 LAN IP), traffic tunnels unnecessarily. Moonlight correctly uses 192.168.178.181, so this is not currently an issue.
## Changelog
### 2026-06-04 — mac-mini wired to LAN, configs reverted from WiFi mitigations
- mac-mini connected via en0 (wired Ethernet, 192.168.178.182), WiFi mitigations no longer needed
- Moonlight: bitrate 60→80 Mbps, framepacing disabled
- Sunshine: bitrate 40→80 Mbps, fec_percentage 2→5, packetsize restored to default (1316)
- First root cause in investigation (Open questions #1) resolved

View File

@@ -0,0 +1,101 @@
# 2026-06-06 — Frequent Authentik login prompts fixed (session duration)
## Summary
User needed to re-authenticate to Authentik several times per day. Root cause was the Django session being configured as a **session cookie** (cleared on browser close) with a short 24-hour lifetime. Fixed by extending both the session cookie lifetime and the user_login stage session duration to 30 days.
## Timeline
- **2026-06-06 ~23:00** — User reports "having to login to authentik several times per day"
- **~23:10** — Investigation begins: check Authentik OAuth2 provider settings, proxy session table, Django session config
- **~23:20** — Found `SESSION_EXPIRE_AT_BROWSER_CLOSE=True` — the `authentik_session` cookie has no Max-Age, cleared on browser close
- **~23:25** — Found the `user_login` stage has `session_duration=seconds=0`, which calls `session.set_expiry(0)``_session_expire_at_browser_close=True`
- **~23:30** — Applied DB fix: updated `authentik_stages_user_login_userloginstage``session_duration='days=30'`
- **~23:35** — Added `AUTHENTIK_SESSIONS__UNAUTHENTICATED_AGE=days=30` to `/opt/authentik.env` (maps to `sessions.unauthenticated_age` in Authentik config, setting `SESSION_COOKIE_AGE` to 30 days)
- **~23:40** — Recreated container with `docker compose up -d` (restart doesn't re-read env file)
- **~23:45** — Verified both changes took effect
## Root cause
Two independent but compounding issues:
1. **`SESSION_EXPIRE_AT_BROWSER_CLOSE=True`** — hardcoded in `/authentik/root/settings.py`. Makes the `authentik_session` cookie a session cookie (no `Max-Age`), cleared when the browser closes. Cannot be changed via env vars or config files (hardcoded in Python source).
2. **`user_login` stage `session_duration=seconds=0`** — the default-authentication-login stage called `session.set_expiry(0)`, which in Django sets `_session_expire_at_browser_close=True` on the session object, overriding any session cookie lifetime set via `SESSION_COOKIE_AGE`.
3. **`sessions.unauthenticated_age=days=1`** (default) — `SESSION_COOKIE_AGE` was 86,400 seconds (24 hours), so even with the browser left open, the server-side session data expired after 24 hours with no way to extend it (`SESSION_SAVE_EVERY_REQUEST=False`).
### Why forward-auth worked but OAuth2 didn't
- **Forward-auth (Caddy-gated services)** — The `authentik_proxy_*` cookie is a *persistent* cookie with 24-hour validity, set by the Authentik proxy outpost. It survives browser restart. Caddy's forward-auth validates this cookie directly with the outpost API — no Django session needed.
- **OAuth2 (Gitea, Jellyfin, etc.)** — These services redirect to `auth.hubris.network/application/o/authorize/`, which checks the Django session (`authentik_session` cookie). If the browser was closed, this cookie is gone → user is redirected to the login form.
## Changes applied
### 1. Database — `authentik_stages_user_login_userloginstage`
```sql
UPDATE authentik_stages_user_login_userloginstage
SET session_duration = 'days=30'
WHERE stage_ptr_id = (
SELECT stage_uuid FROM authentik_flows_stage
WHERE name = 'default-authentication-login'
);
```
This causes the login stage to call `session.set_expiry(timedelta(days=30))`, which:
- Sets `_session_expire_at_browser_close=False` for that session
- Sets the session cookie `Max-Age` to 30 days (persistent cookie)
- Sets the server-side session expiry to 30 days
### 2. Environment — `AUTHENTIK_SESSIONS__UNAUTHENTICATED_AGE`
Added to `/opt/authentik.env`:
```
AUTHENTIK_SESSIONS__UNAUTHENTICATED_AGE=days=30
```
This maps to config key `sessions.unauthenticated_age`, which Authentik's `settings.py` reads via:
```python
SESSION_COOKIE_AGE = timedelta_from_string(
CONFIG.get("sessions.unauthenticated_age", "days=1")
).total_seconds()
```
Result: `SESSION_COOKIE_AGE` = 2,592,000 seconds (30 days). This is a fallback for sessions that don't go through the `user_login` stage or when the stage's explicit `set_expiry` doesn't apply.
## Verification
```python
# Inside authentik-server container:
import os
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "authentik.root.settings")
import django
django.setup()
from django.conf import settings
print("SESSION_COOKIE_AGE:", settings.SESSION_COOKIE_AGE) # → 2592000.0
print("SESSION_EXPIRE_AT_BROWSER_CLOSE:", settings.SESSION_EXPIRE_AT_BROWSER_CLOSE) # → True
from authentik.stages.user_login.models import UserLoginStage
stage = UserLoginStage.objects.filter(name="default-authentication-login").first()
print("session_duration:", stage.session_duration) # → "days=30"
```
## Open questions
- `SESSION_COOKIE_SECURE=False` — Should be `True` since Authentik is HTTPS-only, but the custom `SessionMiddleware.is_secure()` method dynamically sets the cookie's `secure` flag based on the request, so it's fine.
- 30 days is generous. Could be reduced to 7-14 days if desired. Change the DB value and env var accordingly.
## Related
- [Container 106 — auth-outpost](../../wiki/containers/106-auth-outpost.md)
- [Authentik VPS migration](archive/2026-05-31-authentik-vps-migration.md)
- [Ingress (VPS Traefik)](../../wiki/infrastructure/ingress.md)
- `.hermes/plans/2026-06-06_232200-authentik-frequent-login-fix.md` — original plan
## Changelog
### 2026-06-06 — created
Documented the session lifetime investigation, root cause, and applied fixes.

View File

@@ -0,0 +1,61 @@
# Investigation: Caddyfile truncation — all LAN services down (2026-06-06)
**Date:** 2026-06-06
**Status:** resolved
**Duration:** ~10 hours (from last known good state ~12:39 UTC to restoration ~22:40 UTC)
## Symptom
All `*.hubris.network` URLs except `photos.hubris.network` and `auth.hubris.network` returned `tlsv1 alert internal error` or TCP timeouts from LAN/mesh clients. `dig @192.168.8.2` and `dig @100.122.255.254` both resolved to `192.168.8.175` correctly — DNS was fine. The issue was at the Caddy level.
## Root cause
The Caddyfile on LXC 121 was manually edited directly on the filesystem (not via the `dtoro/caddy-conf` git repo), reducing it from 260 lines/30+ site blocks to 43 lines with only 3 photo-related site blocks: `photos.hubris.network`, `prism.hubris.network`, and `photos2.hubris.network`.
### Timeline
| Time (UTC+2) | Event |
|---|---|
| Jun 04 23:43 | Last successful git-push deploy — full Caddyfile (260 lines) |
| Jun 06 ~12:00 | Caddyfile manually edited locally, truncating to 3 sites |
| Jun 06 12:39 | Deploy webhook triggered → `git pull --ff-only` failed: "Your local changes would be overwritten" |
| Jun 06 14:13 | Deploy webhook triggered again → `deploy ok` (the truncated file was committed or merged somehow) |
| Jun 06 22:34 | Investigation began |
| Jun 06 22:43 | Caddyfile restored from `origin/master`, `systemctl reload caddy` |
### Evidence
- `git diff HEAD -- Caddyfile` on LXC 121: `+3 / -159` lines
- Git reflog: HEAD at `32575ce` (fix: sab port 8081→8082), working tree diverged
- Backup file `Caddyfile.bak.1780263919`: 225 lines, full original config
- `git stash list` shows one auto-stash entry
- `origin/master` at `1b977aa`: 260 lines, all site blocks present
### Secondary root cause found during investigation
**elementsynapse (LXC 118)** had `iface eth0 inet dhcp` internally despite `pct set 118 --net0 ... ip=192.168.8.239/24`. On DHCP lease renewal, dhclient grabbed `.244` from Technitium's pool. Caddy's `reverse_proxy 192.168.8.239:8008` was hitting a dead IP.
This is the same class of drift as the June 5th incidents (paperless, HAOS, apps, mule-images). Elementsynapse was missed during the 2026-06-02 static-IP migration.
## Fix applied
1. **Caddyfile**`git checkout --force origin/master -- Caddyfile` + `systemctl reload caddy`
2. **elementsynapse** → replaced `iface eth0 inet dhcp` with static, killed dhclient, verified connectivity
## Permanent safeguards (all deployed)
| Safeguard | Location | What it does |
|---|---|---|
| Site-count guard | `/etc/caddy/scripts/deploy.sh` | Refuses reload if <20 `hubris.network` site blocks |
| Dirty-tree auto-stash | `/etc/caddy/scripts/deploy.sh` | Stashes local edits before git pull |
| Auto-backup | `/etc/caddy/scripts/deploy.sh` | Saves Caddyfile.bak.<timestamp> before any change, keeps 5 |
| Caddy backend health | `/etc/cron.d/caddy-backend-health` on hubris | Runs `check-caddy-backends.sh` every 10 min |
| DNS sync | `/etc/cron.d/dns-sync` on LXC 107 | Runs `dns-sync.py` every 10 min (was missing since 2026-06-04) |
## Related
- DHCP drift investigation (previous incident) — not filed as its own investigation; see the [DNS sync fix](../../../.hermes/plans/2026-06-05_170000-prevent-dhcp-ip-drift.md)
- [Caddy (121)](../../wiki/containers/121-caddy.md)
- [elementsynapse (118)](../../wiki/containers/118-elementsynapse.md)
- [dns-sync script](../../../scripts/dns-sync.py)
- [check-caddy-backends script](../../../scripts/check-caddy-backends.sh)

View File

@@ -2,12 +2,12 @@
## Summary
[`hubris`](../hosts/hubris.md) hard-locked repeatedly on 2026-04-21 (silent CPU hangs, no panic, no OOM, no MCE). Two contributors identified: idle CPU sitting at ~95 °C on the `performance` governor, and a USB-attached external SSD whose UAS interaction with the AMD USB4/Thunderbolt PCIe tunnel triggered hard locks. CPU thermal addressed via `cpu-epp.service`; drive removed 2026-04-22 as an A/B test. As of 2026-04-28 the host has 3+ days uptime — the drive looks like the primary contributor; `cpu-epp` remains as belt-and-suspenders.
[`hubris`](../../../wiki/hosts/hubris.md) hard-locked repeatedly on 2026-04-21 (silent CPU hangs, no panic, no OOM, no MCE). Two contributors identified: idle CPU sitting at ~95 °C on the `performance` governor, and a USB-attached external SSD whose UAS interaction with the AMD USB4/Thunderbolt PCIe tunnel triggered hard locks. CPU thermal addressed via `cpu-epp.service`; drive removed 2026-04-22 as an A/B test. As of 2026-04-28 the host has 3+ days uptime — the drive looks like the primary contributor; `cpu-epp` remains as belt-and-suspenders.
## Timeline
### 2026-04-19 — drive attached
External `Silicon Motion Portable SSD` (vid:pid `090c:2320`) attached for the new restic [backup pipeline](../infrastructure/backups.md). Pre-attach uptime had been 33 days stable.
External `Silicon Motion Portable SSD` (vid:pid `090c:2320`) attached for the new restic [backup pipeline](../../../wiki/infrastructure/backups.md). Pre-attach uptime had been 33 days stable.
### 2026-04-19 → 2026-04-21 — first crashes
Two hard crashes in 2.5 days (46 h then 12 h uptime). Kernel logs ended abruptly with routine apparmor entries — no panic, OOM, or MCE — the classic hard-lock signature. Preceded by `uas_eh_abort_handler` storms and xHCI resets on port 6-1.
@@ -24,13 +24,13 @@ Two hard crashes in 2.5 days (46 h then 12 h uptime). Kernel logs ended abruptly
- **Mount-on-demand** for the drive: `/usr/local/sbin/backup-usb.sh attach|detach|status` toggles `/sys/bus/usb/devices/*/authorized` so the drive is de-authorized when no backup is running.
### 2026-04-22 — recurrence after 30 h 37 m
Same silent-cutoff signature at 18:42:08. Much longer than any pre-`cpu-epp` crash (12 h max), so `cpu-epp` helps but is not sufficient on its own. [claudio-monitor](../infrastructure/monitoring.md) showed healthy runtimes up to 43 s before the hang (no pre-crash degradation). No MCE / no RAS / pstore empty.
Same silent-cutoff signature at 18:42:08. Much longer than any pre-`cpu-epp` crash (12 h max), so `cpu-epp` helps but is not sufficient on its own. [claudio-monitor](../../../wiki/infrastructure/monitoring.md) showed healthy runtimes up to 43 s before the hang (no pre-crash degradation). No MCE / no RAS / pstore empty.
### 2026-04-22 — `cpu-epp.service` design bug fixed
Was `After=multi-user.target` + `WantedBy=multi-user.target` — queued behind `pve-guests.service`. The hottest window of every boot (20 LXCs + 1 VM coming up) ran on the `performance` governor. Fixed: now `After=sysinit.target` + `Before=pve-guests.service`.
### 2026-04-22 — drive removed (A/B test)
User physically removed the external USB drive. [Backup timers disabled](../infrastructure/backups.md#status), fstab entry commented, drive de-authorized. Goal: confirm whether the drive + UAS + AMD USB4 PCIe-tunnel interaction is the dominant root cause.
User physically removed the external USB drive. [Backup timers disabled](../../../wiki/infrastructure/backups.md#status), fstab entry commented, drive de-authorized. Goal: confirm whether the drive + UAS + AMD USB4 PCIe-tunnel interaction is the dominant root cause.
### 2026-04-23 — SSD cooling + thermal pads installed
Cold-boot baseline (3 min uptime): nvme0n1 35 °C composite / sensor1 (controller) **53 °C**; nvme1n1 36 °C composite / both sensors ≤36 °C. Lifetime warning-time counters at install: nvme0n1 709 min warn + 5 min crit; nvme1n1 778 min warn + 45 min crit — both drives had spent real time in thermal warning historically.
@@ -78,9 +78,9 @@ Checked 2026-04-21. GMKtec is **not on LVFS**, so `fwupdmgr` can't update the Nu
| `pcie_aspm=off pci=nomsi` | NOT applied | Reserved for if crashes recur without the drive |
## Affected nodes
- [Hubris host](../hosts/hubris.md)
- [Backups (disabled)](../infrastructure/backups.md)
- [Monitoring](../infrastructure/monitoring.md)
- [Hubris host](../../../wiki/hosts/hubris.md)
- [Backups (disabled)](../../../wiki/infrastructure/backups.md)
- [Monitoring](../../../wiki/infrastructure/monitoring.md)
## Open questions
- Will the host stay up indefinitely without the drive? (Test ongoing — 3+ days as of 2026-04-28.)

View File

@@ -0,0 +1,109 @@
# 2026-05-31 — Authentik migrated from LXC 124 to the VPS
## Summary
The NetBird management server (on the [VPS](../../../wiki/infrastructure/ingress.md)) crash-looped 1200+ times because it fetches the Authentik OIDC discovery document on startup, and Authentik was only reachable via the NetBird mesh — which was down *because* mgmt couldn't start. A classic bootstrap deadlock: **mgmt needs OIDC → OIDC needs the mesh → the mesh needs mgmt.**
Resolved by moving Authentik off [LXC 124](../../../wiki/containers/106-auth-outpost.md) onto the VPS itself, so `auth.hubris.network` resolves to a container co-located with netbird-mgmt — no mesh dependency. A `depends_on: condition: service_healthy` on the mgmt service makes the deadlock structurally impossible to recur.
The full Authentik Postgres DB (all users, apps, passwords, groups) was migrated, so every gated app keeps working with no per-app reconfiguration.
## Timeline
### Trigger
`netbird status` on clients showed `Management: Disconnected`; `netbird.hubris.network` unreachable. VPS `docker logs netbird-mgmt`:
```
Error: failed reading provided config file: /etc/netbird/management.json:
OIDC configuration request returned status 504 with response: Gateway Timeout
```
The 504 came from VPS traefik trying to reach the old `auth-authentik` backend (`https://192.168.8.175`, home Caddy) over the down mesh.
### Fix — Phase A (restore the mesh)
1. Stood up Authentik on the VPS in `/opt/docker-compose.yml`: `authentik-postgres`, `authentik-redis`, `authentik-server`, `authentik-worker` (image `ghcr.io/goauthentik/server:2026.5.2`), on a dedicated `auth` Docker network (`172.30.1.0/24`). Traefik joined both `netbird` and `auth` networks.
2. Removed the `auth-authentik``192.168.8.175` backend from `/opt/traefik-dynamic.yaml`; Authentik now routed via Docker provider labels.
3. Added `depends_on: authentik-server: condition: service_healthy` to the mgmt service.
4. Created the NetBird OAuth2 provider/app manually (only manual step), then mgmt auto-recovered within 30 s.
### Fix — Phase B (full data migration)
Once the mesh was back, the homelab DB was reachable:
1. `ssh proxmox 'pct exec 124 -- docker exec authentik-postgresql-1 pg_dump -U authentik authentik'` → gzip.
2. Stopped VPS authentik server/worker, dropped + recreated the VPS DB (connect to the `postgres` DB to drop `authentik`), restored the dump.
3. **Swapped `AUTHENTIK_SECRET_KEY` in `/opt/authentik.env` to the homelab's key** — Authentik encrypts OAuth2 client secrets in the DB with this key; a mismatch makes every client secret unreadable and silently breaks all SSO.
4. Restarted Authentik. All users / apps / passwords / groups present.
Scripts left on the VPS: `/opt/migrate-authentik.sh` (re-runnable), `/opt/backup-authentik.sh` (daily cron, 14-day retention).
### Fix — DNS cutover (the long tail)
The real reason the browser kept hitting the *old* Authentik even after the VPS one was healthy: split-horizon dnsmasq on LXC 124 still had `address=/auth.hubris.network/192.168.8.175`, and the NetBird per-client resolver (`100.122.255.254`) cached it. Symptom: the login page console logged `authentik(early): version 2026.2.2` (the old LXC) instead of `2026.5.2` (the VPS).
- Updated dnsmasq: `address=/auth.hubris.network/82.165.190.79`, `systemctl restart dnsmasq`.
- `netbird down && netbird up` does **not** reliably flush the NetBird resolver cache. Immediate per-client fixes: `/etc/hosts` override (mac), `sudo resolvectl flush-caches` (Linux), `sudo dscacheutil -flushcache && sudo killall -HUP mDNSResponder` (mac).
## Issues hit during the migration (and fixes)
1. **Redirect URI error.** The restored DB had redirect URIs in `REGEX` matching mode; in Authentik 2026.5.x they failed to match. Fixed by switching to `STRICT` exact matching (Django ORM, `RedirectURIMatchingMode.STRICT`). Set all four: `http://localhost:53000/` (CLI), `https://netbird.hubris.network/{peers,nb-auth,nb-silent-auth}`.
2. **Only the password field showed (no username).** NetBird passes `login_hint=<email>` in the OAuth2 URL → Authentik pre-identifies and skips the identification stage. Expected behavior; not a bug.
3. **"Request has been denied. Unknown error."** Several overlapping causes: wrong password (reset via Django shell), reputation lockout after repeated failures (`Reputation.objects.all().delete()` — see [124-authentik](../../../wiki/containers/106-auth-outpost.md)), and **broken default expression policies**. The restored DB carried 8 default policies authored in old `return`-style syntax incompatible with 2026.5.x's eval context; `ak apply_blueprints` re-applied the current defaults.
4. **Browser ran stale frontend JS.** Console showed `version 2026.2.2` while the backend was `2026.5.2` — because DNS still pointed at the old LXC (see DNS cutover above), not a cache issue.
5. **WebAuthn devices dead post-migration.** Passkeys are device/origin-bound and don't survive a host move. Deleted all WebAuthn devices via Django ORM; users must re-register MFA.
## Architecture delta
| | Before | After |
|---|---|---|
| Authentik host | [LXC 124](../../../wiki/containers/106-auth-outpost.md) `192.168.8.180` | VPS `82.165.190.79`, `auth` Docker net `172.30.1.0/24` |
| Version | `2026.2.2` | `2026.5.2` |
| `auth.hubris.network` (LAN) | dnsmasq → `192.168.8.175` (Caddy) | dnsmasq → `82.165.190.79` (VPS traefik) |
| `auth.hubris.network` (public) | IONOS wildcard → VPS → mesh → LXC 124 | IONOS wildcard → VPS → local container |
| Reachability | mesh-only (split-horizon) | **public** (auth UI now exposed on the open internet) |
| Redis | none (docs said 2026.x dropped it) | present (this deploy uses Redis) |
| DB engine | sqlite? (`StoreConfig` in mgmt) / Postgres for Authentik | Postgres, separate from netbird |
> **Security note:** the Authentik admin UI is now publicly reachable. Mitigated with a traefik `admin-allowlist` ipAllowList middleware on `/if/admin/` (currently `5.61.168.0/24`). Login/flow endpoints are public by necessity. The `auth` Docker network isolates Postgres/Redis from the netbird containers.
## Lessons learned
1. **Never put the IdP behind the VPN it authenticates.** The circular dependency is invisible until a restart, then it's total.
2. **`depends_on: condition: service_healthy` is the structural fix.** Docker refuses to start mgmt until Authentik is healthy, regardless of boot order.
3. **Migrating an Authentik DB requires carrying its `AUTHENTIK_SECRET_KEY`.** It encrypts client secrets at rest; a mismatch breaks SSO silently with no useful error.
4. **Default expression policies break across major Authentik versions.** Always run `ak apply_blueprints` after restoring an older DB into a newer Authentik.
5. **Split-horizon DNS + a VPN resolver cache = an invisible stale answer.** The NetBird resolver (`100.122.255.254`) outlives `netbird down/up`. Removing the dnsmasq override (fall through to public DNS) is the durable fix once a service is genuinely public.
6. **Redirect URIs: prefer `STRICT` over `REGEX`** in Authentik 2026.x.
7. **Frontend/backend version skew shows as "Unknown error."** Confirm which Authentik the browser actually loaded (`version` line in the console) before chasing backend logs.
8. **WebAuthn/passkeys do not survive host migrations** — delete and re-enroll.
## Forward-auth outpost — RESOLVED 2026-06-01
Forward-auth apps (Paperless, qBittorrent, Artifacto) initially still validated against LXC 124's *embedded* outpost (Caddy → `192.168.8.180:9000`) — split-brain against the frozen DB. Pointing Caddy at `https://auth.hubris.network` instead fails: VPS Traefik rewrites `X-Forwarded-Host` → outpost can't match the app → 404 (tested + reverted).
Fixed with a **dedicated LAN outpost** ([106 — auth-outpost](../../../wiki/containers/106-auth-outpost.md), `192.168.8.6`): `goauthentik/proxy` connects outbound to the VPS core and serves forward-auth locally; Caddy → outpost over the LAN, no Traefik, header preserved. Outpost `hubris-lan-outpost` carries the 3 proxy providers. Verified with 124-Authentik **stopped**. This was Phase 1 of the broader architecture migration (plan: VPS edge / hubris LAN core / Mac Mini redundancy).
### 2026-06-05 — identification stage skip: broken "Trust me" reputation policy
The default authentication flow was skipping the identification stage (showing only a password field with `pending_user: ""`), making login impossible. Root cause: a **"Trust me" reputation policy** (`threshold: -3`, `negate: true`) attached to the identification stage binding (#10). The `negate: true` + broken policy evaluation caused the stage binding to fail re-evaluation, and Authentik's `ReevaluateMarker` skipped the stage entirely.
**Fix:** Deleted from Postgres:
```sql
DELETE FROM authentik_policies_policybinding WHERE policy_id = '<uuid>';
DELETE FROM authentik_policies_reputation_reputationpolicy WHERE policy_ptr_id = '<uuid>';
DELETE FROM authentik_policies_policy WHERE policy_uuid = '<uuid>';
```
The policy was orphaned (no matched type data or had incompatible evaluation). Removing it restores normal two-stage login (identification → password).
**Note:** This is different from the 2026-05-31 issue where NetBird's `login_hint` OAuth2 param intentionally pre-identifies users. That was expected behavior; this was a genuine bug.
## Outstanding
- **NetBird client version skew.** Mac client is `0.68.3` vs mgmt `0.71.3`; NetBird-SSH cert exchange fails between them. Upgrade all clients toward `0.71.x`.
- **republic-laptop** regular SSH publickey auth still rejected (correct `authorized_keys`/perms/SELinux context, yet denied — `sshd_config` `AuthorizedKeysFile` suspect). DNS not yet flushed there either.
- **muli-laptop** needs `netbird down && netbird up` + `resolvectl flush-caches`.
- **VPS port 22** opened for this repair; close once remote access is otherwise stable.
- **Decommission LXC 124 Authentik** after a ~2-week dual-run validation. dnsmasq stays on 124 regardless (separate service).
- **Reconcile [124-authentik](../../../wiki/containers/106-auth-outpost.md) provider notes** — docs describe a `Public`/PKCE provider; the migrated DB carries the `Confidential` `netbird-dashboard` client. Verify which is live and correct the page.
- **sops-encrypt** the VPS secrets (`/opt/authentik.env`) into the `secrets/` tree.
## Related
- [124 — authentik](../../../wiki/containers/106-auth-outpost.md)
- [DNS split-horizon](../../../wiki/infrastructure/dns.md)
- [Public ingress (VPS traefik)](../../../wiki/infrastructure/ingress.md)
- [Mesh migration](../../../wiki/infrastructure/mesh.md)

View File

@@ -0,0 +1,29 @@
# Investigations
Time-stamped incident reports and experiments. One entry per incident; the entry is the canonical source. Per-node changelog entries link back here.
## Active / recent
| Date | Title | Status |
| ------------ | ------------------------------------------------------------------ | ------------- |
| 2026-06-06 | [Caddyfile truncation incident](2026-06-06-caddyfile-truncation.md) | Resolved — permanent safeguards deployed (site-count guard, auto-stash, auto-backup) |
| 2026-06-06 | [Frequent Authentik login prompts — session lifetime fix](2026-06-06-authentik-session-lifetime.md) | Resolved — `session_duration=days=30`, `SESSION_COOKIE_AGE=30d` |
| 2026-06-03 | [Moonlight/Sunshine streaming — WiFi jitter](2026-06-03-moonlight-sunshine-wifi-jitter.md) | Mitigations applied; definitive fix requires wiring mac-mini via Ethernet |
| 2026-06-01 | [Mac-mini onboarding](2026-06-01-mac-mini-onboarding.md) | Onboarded |
## Resolved (archived)
See [`archive/`](archive/):
| Date | Title |
| ------------ | ------------------------------------------------------------------ |
| 2026-04-21 | [Hubris crash loop — thermal + USB drive](archive/2026-04-21-hubris-crash-loop.md) |
| 2026-05-31 | [Authentik migrated from LXC 124 to the VPS](archive/2026-05-31-authentik-vps-migration.md) |
## Conventions
- File name: `YYYY-MM-DD-<slug>.md`. Use the *first* date if the incident spans multiple days.
- Mandatory sections: Summary, Timeline, Root cause, Mitigations applied, Open questions.
- Update the entry as the situation evolves; never rewrite history. Add new dated sections at the bottom.
- Link back from every node's changelog that's affected.
- Move to `archive/` when the incident is fully resolved and no longer actively referenced.

View File

@@ -0,0 +1,351 @@
# Current cert sync script + traefik dynamic config
Snapshot of the two artifacts that control public service exposure as of
2026-07-05. Updated 2026-07-05: fixed Jellyfin backend from dead hubris IP
(192.168.8.206) to new strong IP (192.168.8.246).
## hubris-public-cert-sync.sh (PVE host, `/usr/local/bin/`)
```bash
#!/bin/bash
# Mirrors home caddy's LE certs for publicly-exposed hubris.network hostnames
# into the VPS traefik's /letsencrypt volume. Traefik file-watches the volume
# and hot-reloads.
#
# Why: netbird-proxy's HostSNI(*) TCP passthrough intercepts ACME TLS-ALPN-01
# challenges before traefik's allowACMEByPass can respond, so traefik can't
# obtain its own cert. Home caddy uses IONOS DNS-01 (no such conflict);
# we just mirror what it already has.
#
# Runs daily via hubris-public-cert-sync.timer.
set -euo pipefail
CADDY_LXC=121
CADDY_BASE=/var/lib/caddy/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory
VPS_HOST=root@100.122.165.149
VPS_DEST=/var/lib/docker/volumes/opt_netbird_traefik_letsencrypt/_data
# Map: source hostname -> "crt_filename key_filename" on the VPS.
# Stable names so traefik dynamic.yaml doesn't need edits on renewal.
declare -A HOSTS=(
[artifacto.hubris.network]="fullchain.crt privkey.key"
[blog.hubris.network]="blog.fullchain.crt blog.privkey.key"
[trmnl.hubris.network]="trmnl.fullchain.crt trmnl.privkey.key"
[sso.hubris.network]="sso.fullchain.crt sso.privkey.key"
[media.hubris.network]="media.fullchain.crt media.privkey.key"
[paperless.hubris.network]="paperless.fullchain.crt paperless.privkey.key"
)
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
for host in "${!HOSTS[@]}"; do
read -r crt_name key_name <<< "${HOSTS[$host]}"
pct pull "$CADDY_LXC" "$CADDY_BASE/$host/$host.crt" "$TMP/$crt_name"
pct pull "$CADDY_LXC" "$CADDY_BASE/$host/$host.key" "$TMP/$key_name"
if ssh -o BatchMode=yes "$VPS_HOST" "test -f $VPS_DEST/$crt_name && diff -q - $VPS_DEST/$crt_name" < "$TMP/$crt_name" >/dev/null 2>&1; then
echo "hubris-public-cert-sync: $host unchanged"
continue
fi
scp -q -o BatchMode=yes "$TMP/$crt_name" "$TMP/$key_name" "$VPS_HOST:$VPS_DEST/"
echo "hubris-public-cert-sync: shipped $host ($(openssl x509 -in "$TMP/$crt_name" -noout -enddate))"
done
```
### Adding a new host
1. Caddy must already have the cert (verify `pct exec 121 -- ls "$CADDY_BASE/$host/"`)
2. Add a line to the HOSTS array: `[new-host.hubris.network]="nickname.fullchain.crt nickname.privkey.key"`
3. `systemctl start hubris-public-cert-sync.service` to sync immediately
4. Verify certs landed: `ssh "$VPS_HOST" "ls -la $VPS_DEST/nickname.*"`
5. Add matching `tls.certificates` entry in traefik dynamic config
---
## traefik-dynamic.yaml (VPS, `/opt/`)
```yaml
tcp:
serversTransports:
pp-v2:
proxyProtocol:
version: 2
tls:
certificates:
- certFile: /letsencrypt/fullchain.crt
keyFile: /letsencrypt/privkey.key
- certFile: /letsencrypt/blog.fullchain.crt
keyFile: /letsencrypt/blog.privkey.key
- certFile: /letsencrypt/trmnl.fullchain.crt
keyFile: /letsencrypt/trmnl.privkey.key
- certFile: /letsencrypt/sso.fullchain.crt
keyFile: /letsencrypt/sso.privkey.key
- certFile: /letsencrypt/media.fullchain.crt
keyFile: /letsencrypt/media.privkey.key
- certFile: /letsencrypt/paperless.fullchain.crt
keyFile: /letsencrypt/paperless.privkey.key
http:
routers:
artifacto-public:
rule: 'Host(`artifacto.hubris.network`) && (PathPrefix(`/p/`) || PathPrefix(`/static/`) || Path(`/healthz`))'
entryPoints:
- websecure
priority: 10
tls:
certResolver: letsencrypt
middlewares:
- artifacto-strip-sso
- artifacto-ratelimit
service: artifacto-public
blog-public:
rule: 'Host(`blog.hubris.network`)'
entryPoints:
- websecure
priority: 10
tls:
certResolver: letsencrypt
middlewares:
- blog-ratelimit
service: blog-public
trmnl-public:
rule: 'Host(`trmnl.hubris.network`)'
entryPoints:
- websecure
priority: 10
tls:
certResolver: letsencrypt
middlewares:
- trmnl-ratelimit
service: trmnl-public
matrix-public:
rule: 'Host(`matrix.hubris.network`) && !PathPrefix(`/.well-known/matrix/`)'
entryPoints:
- websecure
priority: 10
tls:
certResolver: letsencrypt
middlewares:
- matrix-ratelimit
service: matrix-public
matrix-wellknown:
rule: 'Host(`matrix.hubris.network`) && (PathPrefix(`/.well-known/matrix/`) || PathPrefix(`/.well-known/acme-challenge/`))'
entryPoints:
- websecure
priority: 20
tls:
certResolver: letsencrypt
service: matrix-wellknown-svc
house-public:
rule: Host(`house.hubris.network`)
entryPoints:
- websecure
priority: 10
tls:
certResolver: letsencrypt
middlewares:
- house-ratelimit
service: house-public
sso-public:
rule: 'Host(`sso.hubris.network`)'
entryPoints:
- websecure
priority: 10
tls:
certResolver: letsencrypt
middlewares:
- sso-ratelimit
service: sso-public
media-public:
rule: 'Host(`media.hubris.network`)'
entryPoints:
- websecure
priority: 10
tls:
certResolver: letsencrypt
middlewares:
- media-ratelimit
service: media-public
paperless-api-public:
rule: 'Host(`paperless.hubris.network`) && PathPrefix(`/api/`)'
entryPoints:
- websecure
priority: 20
tls:
certResolver: letsencrypt
middlewares:
- paperless-ratelimit
service: paperless-public
paperless-public:
rule: 'Host(`paperless.hubris.network`)'
entryPoints:
- websecure
priority: 10
tls:
certResolver: letsencrypt
middlewares:
- authentik-forwardauth
- paperless-ratelimit
service: paperless-public
middlewares:
artifacto-strip-sso:
headers:
customRequestHeaders:
X-Artifacto-Gateway: ""
X-Authentik-Username: ""
X-Authentik-Groups: ""
X-Authentik-Email: ""
X-Authentik-Name: ""
X-Authentik-Uid: ""
X-Authentik-Jwt: ""
X-Authentik-Meta-Jwks: ""
X-Authentik-Meta-Outpost: ""
X-Authentik-Meta-Provider: ""
X-Authentik-Meta-App: ""
X-Authentik-Meta-Version: ""
admin-allowlist:
ipAllowList:
sourceRange:
- "5.61.168.0/24"
artifacto-ratelimit:
rateLimit:
average: 50
period: 1s
burst: 100
blog-ratelimit:
rateLimit:
average: 100
period: 1s
burst: 200
trmnl-ratelimit:
rateLimit:
average: 20
period: 1s
burst: 40
matrix-ratelimit:
rateLimit:
average: 30
period: 1s
burst: 60
house-ratelimit:
rateLimit:
average: 30
period: 1s
burst: 60
sso-ratelimit:
rateLimit:
average: 30
period: 1s
burst: 60
media-ratelimit:
rateLimit:
average: 30
period: 1s
burst: 60
paperless-ratelimit:
rateLimit:
average: 20
period: 1s
burst: 40
authentik-forwardauth:
forwardAuth:
address: "http://192.168.8.6:9000/outpost.goauthentik.io/auth/traefik"
trustForwardHeader: true
authResponseHeaders:
- X-authentik-username
- X-authentik-groups
- X-authentik-email
- X-authentik-name
- X-authentik-uid
- X-authentik-jwt
- X-authentik-meta-jwks
- X-authentik-meta-outpost
- X-authentik-meta-provider
- X-authentik-meta-app
- X-authentik-meta-version
services:
artifacto-public:
loadBalancer:
servers:
- url: 'http://192.168.8.205:3100'
blog-public:
loadBalancer:
servers:
- url: 'http://192.168.8.205:8080'
trmnl-public:
loadBalancer:
servers:
- url: 'http://192.168.8.211:9851'
matrix-public:
loadBalancer:
servers:
- url: 'http://192.168.8.242:8008'
matrix-wellknown-svc:
loadBalancer:
servers:
- url: 'http://matrix-wellknown:80'
house-public:
loadBalancer:
servers:
- url: 'http://192.168.8.244:3000'
sso-public:
loadBalancer:
servers:
- url: 'http://192.168.8.6:9000'
media-public:
loadBalancer:
servers:
- url: 'http://192.168.8.246:8096'
paperless-public:
loadBalancer:
servers:
- url: 'http://192.168.8.130:8000'
```
### Adding a new service — four blocks needed
1. **Router**`http.routers.<name>-public` with `tls: {}` (not
`certResolver`)
2. **Middleware** — rate limit, one per service
3. **Service**`http.services.<name>-public` with the backend URL
4. **tls.certificates** — add a new `- certFile/keryFile` pair matching the
cert sync HOSTS entry
### Key file naming convention
| Cert name | Host | Convention |
|-----------|------|------------|
| `fullchain.crt` + `privkey.key` | `artifacto.hubris.network` | First service — no prefix |
| `blog.fullchain.crt` + `blog.privkey.key` | `blog.hubris.network` | `{nickname}.fullchain.crt` |
| `trmnl.fullchain.crt` + `trmnl.privkey.key` | `trmnl.hubris.network` | `{nickname}.fullchain.crt` |
| `sso.fullchain.crt` + `sso.privkey.key` | `sso.hubris.network` | `{nickname}.fullchain.crt` |
| `media.fullchain.crt` + `media.privkey.key` | `media.hubris.network` | `{nickname}.fullchain.crt` |
| `paperless.fullchain.crt` + `paperless.privkey.key` | `paperless.hubris.network` | `{nickname}.fullchain.crt` |
### ⚠️ Critical — keep backends in sync after LXC migrations
When moving an LXC between Proxmox nodes, update **both**:
1. **Caddy** (`/etc/caddy/Caddyfile` on LXC 121)
2. **VPS traefik** (`/opt/traefik-dynamic.yaml` — via hubris bridge SSH)
Jellyfin migration from hubris to strong (2026-07-05) was fixed in Caddy
but **missed** in VPS traefik — old IP `192.168.8.206` remained. This caused
Bad Gateway for off-LAN users. Use Python-based editing (see
`references/traefik-config-editing.md`) for accurate surgical fixes.

View File

@@ -0,0 +1,160 @@
# 101 — `jellyfin`
Media server: serves the movies / TV / anime / music libraries from `/mnt/media_local` to LAN clients. Hardware transcoding via AMD Radeon 680M + RX 7600 VAAPI. Authentik SSO via OIDC.
## At a glance
- **Host:** **strong** (migrated from hubris 2026-07-05)
- **IP:** `192.168.8.246`
- **Privilege:** privileged (recreated on strong as priv)
- **Resources:** 4 cores / 8 GiB RAM / 1 GiB swap / 16 GiB rootfs
- **GPU:** `/dev/dri/renderD128` + `/dev/dri/card0` (AMD Radeon 680M iGPU + RX 7600 dGPU) passed via `dev0` / `dev1` in LXC config
- **Mounts:** `/mnt/media_local``/mnt/library`
- **Public hostname:** [`media.hubris.network`](../infrastructure/dns.md) → [caddy](121-caddy.md) → `:8096`
- **Version:** Jellyfin 10.11.11 (apt package, Ubuntu 24.04 noble repo)
- **FFmpeg:** jellyfin-ffmpeg7 7.1.4
## Service / port map
| Service | Listen | Notes |
| -------- | ------ | ----- |
| jellyfin | `:8096` | HTTP (caddy terminates TLS) |
## Hardware acceleration (VAAPI)
GPU is passed through to the LXC via `dev0: /dev/dri/renderD128,gid=993` and
`dev1: /dev/dri/card0,gid=44` in
`/etc/pve/lxc/101.conf` (strong). The `jellyfin` user is in the `render` (GID 993) and
`video` groups inside the container.
| GPU | Model | Role |
|-----|-------|------|
| Radeon 680M | iGPU (AMD Ryzen 7 PRO 6850U) | Primary VAAPI encoder/decoder |
| RX 7600 | dGPU (add-in) | Secondary transcode, HEVC/AV1 encoding |
Encoding settings (`/etc/jellyfin/encoding.xml`):
- `HardwareAccelerationType`: `vaapi`
- `VaapiDevice`: `/dev/dri/renderD128`
- `EnableHardwareEncoding`: `true`
- `AllowHevcEncoding`: `true`
- `AllowAv1Encoding`: `true`
- `EnableTonemapping`: `true`
- `HardwareDecodingCodecs`: h264, hevc, vc1, vp9, av1
- `EnableThrottling`: `true`
- `EnableSegmentDeletion`: `true`
Trickplay (`/etc/jellyfin/system.xml`):
- `EnableHwAcceleration`: `true`
- `EnableHwEncoding`: `true`
## Authentik SSO (OIDC)
Jellyfin uses the [SSO-Auth plugin](https://github.com/9p4/jellyfin-plugin-sso)
v4.0.0.4 for Authentik OIDC login. No Caddy forward-auth gate — the SSO plugin
handles auth directly via OIDC redirect flow.
### Architecture
```
User → media.hubris.network → Caddy (TLS, no forward-auth) → Jellyfin :8096
Login page with "Sign in with Authentik" button
↓ (click)
/sso/OID/start/Authentik
↓ (302 redirect)
auth.hubris.network OIDC
↓ (login)
/sso/OID/redirect/Authentik?code=...&state=...
Jellyfin SSO plugin validates token → logged in
```
### Components
1. **SSO-Auth plugin** — installed at `/var/lib/jellyfin/plugins/SSO-Auth_4.0.0.4/`
- Config: `/var/lib/jellyfin/plugins/configurations/SSO-Auth.xml`
- Provider name: `Authentik`
- OIDC endpoint: `https://auth.hubris.network/application/o/jellyfin/`
- `SchemeOverride`: `https` (required — without it, plugin generates
`http://` redirect URIs that Authentik rejects)
- `EnableAuthorization`: `false` (prevents plugin from overwriting admin
permissions on each SSO login — see
[jellyfin-sso-plugin](../../../devops/homelab-authentik-admin/references/jellyfin-sso-plugin.md))
- `OidScopes`: `["email"]` (openid+profile added by default by the plugin;
must be non-null or `OidChallenge()` throws `ArgumentNullException`)
2. **Authentik OIDC provider**`Provider for Jellyfin` (PK 6)
- Client ID: `vt61t5Y2ZVtN6l3QjitkBvwUJjFKvSyl4TDBXcJx`
- Redirect URI: `https://media.hubris.network/sso/OID/redirect/Authentik`
- Application slug: `jellyfin`
3. **SSO button injection**`/usr/share/jellyfin/web/sso-inject.js`
- Injected via `<script defer src="sso-inject.js?v=3">` in `index.html`
- Polls for `.readOnlyContent` div on the login page, adds "Sign in with
Authentik" button linking to `/sso/OID/start/Authentik`
- Cache-busted with `?v=N` parameter (bump on changes)
- **Lost on apt upgrade** — re-inject the script tag and copy the JS file
after `apt-get upgrade jellyfin*`
4. **Caddy config** — no forward-auth gate for media.hubris.network:
```caddy
media.hubris.network {
tls { dns ionos {env.IONOS_AUTH_API_TOKEN} }
reverse_proxy 192.168.8.206:8096
}
```
### Known issues / pitfalls
- **`OidScopes` must be non-null** — if the field is missing from the plugin
config XML, `OidChallenge()` throws `System.ArgumentNullException`. Always
include `OidScopes` in the provider config (even if empty array).
- **`SchemeOverride: "https"` is required** — without it, the plugin generates
`http://` redirect URIs (from the internal HTTP listener). Authentik rejects
them with "Redirect URI Error".
- **SSO button JS is not served by the plugin** — the `__plugin/SSO-Auth.js`
endpoint returns 404 on Jellyfin 10.11.x when the plugin is installed
manually (not via Jellyfin's plugin manager). The `sso-inject.js` workaround
in `index.html` is the fallback.
- **No Caddy forward-auth gate** — the SSO plugin's OIDC redirect flow is
incompatible with Caddy's `import authentik` forward-auth. If both are
enabled, the forward-auth intercepts the OIDC callback and breaks the flow.
Use one or the other, not both. SSO plugin (OIDC redirect) is preferred.
- **API key for setup** — a temp API key can be inserted directly into the
`ApiKeys` SQLite table for automated configuration:
```sql
INSERT INTO ApiKeys VALUES (1, '2026-07-04', '2026-07-04', 'setup', 'jf-setup-key-...');
```
## Permissions
Member of the [media GID 10000](../infrastructure/media-permissions.md) standard. Service user `jellyfin` is in the `media` group inside the container; `/mnt/media_local` on strong's ludo-lvm is owned `root:media` with mode `2775`.
## Related
- [Caddy reverse proxy](121-caddy.md)
- [Media permissions](../infrastructure/media-permissions.md)
- [arriman](122-arriman.md) — \*arr stack writes the libraries jellyfin reads
- [DNS split-horizon](../infrastructure/dns.md)
- [Authentik admin](../../../devops/homelab-authentik-admin/SKILL.md) — OIDC provider creation, SSO plugin config
## Changelog
### 2026-07-06 — wiki: IP, host, GPU, mount path updated for strong migration
Updated At-a-glance: IP 206→246, host hubris→strong, mount /mnt/library→/mnt/media_local, GPU Radeon 760M→680M+RX7600, privilege unpriv→priv. Permissions section updated. Changelog entry in 122-arriman.md updated similarly.
### 2026-07-04 — VAAPI hardware acceleration + Authentik SSO + resource bump
- Upgraded Jellyfin 10.11.8 → 10.11.11 (purge + reinstall to fix DB migration bug)
- Enabled VAAPI hardware acceleration (Radeon 760M): h264/hevc/vc1/vp9/av1 decode + encode
- Bumped resources: 2→4 cores, 4→8 GiB RAM, 512→1024 MiB swap
- Enabled trickplay HW acceleration + throttling + segment deletion
- Installed SSO-Auth plugin v4.0.0.4 with Authentik OIDC provider
- Configured `SchemeOverride: https`, `OidScopes: ["email"]`, `EnableAuthorization: false`
- Injected SSO button via `sso-inject.js` in web `index.html`
- Removed Caddy forward-auth gate (incompatible with SSO plugin OIDC flow)
- **Database was wiped** during cache relocation attempt — no LVM snapshot
existed. All watch states, user accounts, and library configs lost.
Libraries re-added via setup wizard.
### 2026-04-28 — wiki entry created
Initial documentation. No config changes.
### 2026-04-20 — joined the `media` GID 10000 standard
Idmap block applied; in-container `media` group at GID 10000 mapped to host GID 10000. See [media permissions](../infrastructure/media-permissions.md). Config backup: `/root/101.conf.bak.*`.

View File

@@ -8,7 +8,7 @@ Dedicated, single-purpose LXC that re-exports `/mnt/library` over NFSv4 to clien
- **LAN DNS:** `nfs-export.hubris.network``192.168.8.200` (direct, no Caddy)
- **Privilege:** privileged (`unprivileged: 0`) + `lxc.apparmor.profile: unconfined` — required for `nfs-kernel-server`
- **Resources:** 1 core / 512 MiB RAM / 2 GiB rootfs / 256 MiB swap
- **Mounts:** host `/mnt/library` ↔ container `/mnt/library` (same path on both sides — matches the bind-mount convention used by jellyfin, paperless, arriman, nextcloud, mule-images, plato, apps)
- **Mounts:** host `/mnt/library` ↔ container `/mnt/library` (same path on both sides — matches the bind-mount convention used by jellyfin, paperless, arriman, nextcloud, mule-images, apps)
## What it does
@@ -54,7 +54,7 @@ We considered three options before building this:
| Option | Outcome |
|---|---|
| **NFS on hubris bare-metal host** | Best performance, but adds long-lived NFS/RPC daemons to a host with a recent crash episode ([hubris crash 2026-04-21/22](../investigations/index.md)). Rejected. |
| **NFS on hubris bare-metal host** | Best performance, but adds long-lived NFS/RPC daemons to a host with a recent crash episode ([hubris crash 2026-04-21/22](../../sources/investigations/index.md)). Rejected. |
| **SMB on host** | Same host-blast-radius problem, plus 3050% lower throughput than NFS on Linux↔Linux. Rejected. |
| **NFS in a dedicated LXC** ← this | Within ~2% of host performance (LXC is namespace isolation; IO path is unchanged), zero new daemons on hubris, matches the existing fleet pattern. Selected. |

View File

@@ -18,16 +18,16 @@ Paperless-ngx for document management. Ingests scans / PDFs from `/mnt/library/d
| paperless-task-queue, paperless-scheduler, paperless-consumer | — | systemd workers |
## Auth
Behind [Authentik forward-auth](124-authentik.md). API path `/api/*` bypasses forward-auth (mobile clients can't follow the browser login redirect; bearer token still enforces auth on `/api`). Header propagation: `PAPERLESS_ENABLE_HTTP_REMOTE_USER=true` and `PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME=HTTP_X_AUTHENTIK_USERNAME` in `/opt/paperless/paperless.conf`. Django auto-creates matching users on first SSO login.
Behind [Authentik forward-auth](106-auth-outpost.md). API path `/api/*` bypasses forward-auth (mobile clients can't follow the browser login redirect; bearer token still enforces auth on `/api`). Header propagation: `PAPERLESS_ENABLE_HTTP_REMOTE_USER=true` and `PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME=HTTP_X_AUTHENTIK_USERNAME` in `/opt/paperless/paperless.conf`. Django auto-creates matching users on first SSO login.
## Storage
- Documents at `/mnt/library/documents` (owner `www-data:www-data`, mode 750 — *not* on the `media` group, by design).
## Known issues
- Disk usage was 86.9% at last claudio-monitor reading on 2026-04-21. Monitor or grow rootfs.
- ~~Disk usage was 86.9% at last legacy monitor reading on 2026-04-21~~ — resolved by growing rootfs to 16 GiB on 2026-05-15.
## Related
- [Authentik](124-authentik.md)
- [Authentik](106-auth-outpost.md)
- [Caddy](121-caddy.md)
- [DNS](../infrastructure/dns.md)
- [Monitoring](../infrastructure/monitoring.md)

View File

@@ -45,6 +45,9 @@ LXC has `/etc/hosts` override mapping `auth.hubris.network → 192.168.8.175` (r
## Changelog
### 2026-06-24 — terminalito deploy webhook (id 12)
Push webhook on `dtoro/terminalito``http://192.168.8.211:9797/deploy` ([trmnl (128)](128-trmnl.md)); `app.ini` `ALLOWED_HOST_LIST` extended with `192.168.8.211`. See [auto-deploy](../infrastructure/auto-deploy.md).
### 2026-04-28 — wiki entry created
Initial documentation.
@@ -52,7 +55,7 @@ Initial documentation.
Added `192.168.8.205`. See [Artifacto auto-deploy on apps (105)](105-apps.md).
### 2026-04-21 — `/etc/hosts` override for `auth.hubris.network` added
For OIDC integration with [authentik (124)](124-authentik.md). Outside the PVE markers, with a hubris-hosts-override.service for idempotency.
For OIDC integration with [authentik (124)](106-auth-outpost.md). Outside the PVE markers, with a hubris-hosts-override.service for idempotency.
### 2026-04-20 — gitea customizations + auto-deploy pipeline shipped
`dtoro/gitea-customizations` repo created; webhook receiver at loopback `:9797` validates HMAC and runs `deploy.sh`. CAD and PlantUML loaders live in `footer.tmpl`.

View File

@@ -1,6 +1,6 @@
# 105 — `apps`
Docker host for everything that doesn't justify its own LXC. Currently runs Artifacto, Booklore, PlantUML server, Portainer (and historically WriteFreely / blog), plus the [homelab-context distribution services](../infrastructure/homelab-context.md) (MCP + secrets-issuance) since 2026-05-20.
Docker host for everything that doesn't justify its own LXC. Currently runs Artifacto, PlantUML server, Portainer (and historically WriteFreely / blog), plus the [homelab-context distribution services](../infrastructure/homelab-context.md) (MCP + secrets-issuance) since 2026-05-20. Booklore migrated to [grimmory (130)](130-grimmory.md) on 2026-06-29.
## At a glance
- **Hostname:** `apps`
@@ -15,7 +15,6 @@ Docker host for everything that doesn't justify its own LXC. Currently runs Arti
| Hostname | Container | Backend port | Notes |
| --------------------------------- | ---------------- | ------------ | ----- |
| `docker.hubris.network` | Portainer | `:9443` | Native OAuth2 via Authentik. Trusted-origins requires hostname only (no scheme/port). |
| `books.hubris.network` | Booklore | `:6060` | Native OIDC. Redirect URI `/oauth2-callback`. |
| `artifacto.hubris.network` | Artifacto | `:3100` | Public `/p/*`, `/static/*`, `/healthz` exposed via [VPS traefik](../infrastructure/ingress.md). |
| `blog.hubris.network` | WriteFreely | `:8080` | Native OIDC via `[oauth.generic]`. |
| `git.hubris.network/_plantuml/*` | PlantUML server | `:8079` | Same-origin route from [gitea (104)](104-gitea.md). |
@@ -46,11 +45,6 @@ Receiver at `/opt/artifacto-deploy/` (outside the app repo): `deploy.sh` + `webh
### Portainer
Native OAuth2 (Settings → Authentication → OAuth → Custom). Manual endpoints (no OIDC discovery). Uses `portainer-uid` custom-claim scope from Authentik. Container is **not** compose-managed — safe to `docker run` recreate; data lives in named volume `portainer_data`. CLI flag: `--trusted-origins docker.hubris.network` (hostname only — `IsTrustedOrigin` rejects strings containing `://`).
### Booklore
Native OIDC via Authentik (Settings → OIDC). Redirect URI `/oauth2-callback` (NOT `/api/oidc`). Container needs `extra_hosts: auth.hubris.network:192.168.8.175`. **Edit via Portainer UI** if it's a Portainer-managed stack.
> ⚠️ **Never `docker compose up` Portainer-managed stacks from the host shell.** Portainer's compose state lives at `/var/lib/docker/volumes/portainer_data/_data/compose/<N>/`. Running `docker compose up -d <svc>` from the host triggers recreates of OTHER services in the stack and silently destroys bind-mounted data. **This wiped Booklore's mariadb data on 2026-04-22.** Use the Portainer UI editor for compose changes. See [mesh migration](../infrastructure/mesh.md#critical-never-docker-compose-up-portainer-managed-stacks) for the full warning.
### homelab-mcp (`/opt/homelab-mcp/`)
FastMCP server (Python venv at `/opt/homelab-mcp/.venv`). Reads from
`/opt/homelab-context/` (this LXC is itself an enrolled
@@ -60,7 +54,7 @@ is `dtoro/Homelab-Docs/mcp/server.py`; service unit
disabled at the FastMCP layer because mesh+LAN gating is the actual
trust boundary.
- Endpoint: `https://mcp.hubris.network/sse` (Caddy → `:9810`).
- Endpoint: `https://mcp.hubris.network/mcp` (Caddy → `:9810`). StreamableHTTP transport (POST `/mcp`).
- 14 tools registered: `get_host`, `list_services`, `find_service`,
`get_topology`, `search_docs`, `get_page`, `get_changelog`, `whoami`,
`list_my_secrets` (context); `get_service_status`, `tail_log`,
@@ -106,20 +100,23 @@ Native OIDC via `[oauth.generic]` in `config/config.ini`. `host = https://auth.h
## Related
- [Gitea (104)](104-gitea.md) — uses the PlantUML server
- [Caddy (121)](121-caddy.md)
- [Authentik (124)](124-authentik.md)
- [Authentik (124)](106-auth-outpost.md)
- [DNS](../infrastructure/dns.md)
- [Auto-deploy](../infrastructure/auto-deploy.md)
- [Public ingress (Artifacto + blog)](../infrastructure/ingress.md)
## Changelog
### 2026-06-29 — Booklore migrated to Grimmory on LXC 130
Booklore stack removed from Portainer. MariaDB dump taken first, then restored into [grimmory (130)](130-grimmory.md)'s fresh MariaDB. `books.hubris.network` Caddy backend updated to `192.168.8.213:6060`. Authentik OIDC provider updated to Public client type (PKCE) for Grimmory compatibility.
### 2026-05-20 — homelab-mcp + secrets-issuance live
Two new services from the [homelab-context distribution plan](../infrastructure/homelab-context.md):
`homelab-mcp.service` on `:9810` (MCP read+management surface) and
`secrets-issuance.service` on `:9820` (per-client age-key provisioning).
Caddy fronts both with Let's Encrypt; new vhosts on
[caddy](121-caddy.md), split-horizon DNS entries on
[authentik (124)](124-authentik.md). Gitea webhook ids 10 + 11 wire
[authentik (124)](106-auth-outpost.md). Gitea webhook ids 10 + 11 wire
auto-deploy. LXC is itself an enrolled context client
(`/opt/homelab-context/`).

View File

@@ -0,0 +1,56 @@
# 106 — `auth-outpost`
Authentik **forward-auth outpost** for LAN-gated apps. A stateless proxy that connects outbound to the [VPS Authentik core](../../sources/investigations/archive/2026-05-31-authentik-vps-migration.md) and serves forward-auth locally, so [Caddy (121)](121-caddy.md) never hairpins auth through VPS Traefik.
## At a glance
- **Hostname:** `auth-outpost`
- **IP:** `192.168.8.6` (static)
- **Privilege:** privileged (Docker-in-LXC, `features: nesting=1`)
- **Resources:** 1 core / 512 MiB / 4 GiB rootfs
- **Mounts:** none
- **Created:** 2026-06-01, Debian 13, replacing the embedded outpost on [124](106-auth-outpost.md)
## Role
Runs one container — `ghcr.io/goauthentik/proxy` — that opens an outbound websocket to `https://auth.hubris.network` (the VPS core), pulls its proxy-provider config, and answers Caddy's `forward_auth` subrequests on `192.168.8.6:9000` (LAN-only bind). Because the call path is **Caddy → outpost (LAN)**, with no Traefik in between, `X-Forwarded-Host` is preserved — the failure that 404s when Caddy is pointed at `https://auth.hubris.network` directly (Traefik rewrites the header). See the [migration investigation](../../sources/investigations/archive/2026-05-31-authentik-vps-migration.md).
## Service / port map
| Service | Listen | Notes |
| --- | --- | --- |
| authentik proxy outpost | `192.168.8.6:9000` | `/outpost.goauthentik.io/*` (ping, auth/caddy) |
## Config paths
- `/opt/authentik-outpost/docker-compose.yml``ghcr.io/goauthentik/proxy:2026.5.2`, `AUTHENTIK_HOST=https://auth.hubris.network`, `AUTHENTIK_INSECURE=false`, port bound `192.168.8.6:9000`.
- `/opt/authentik-outpost/.env` (mode 600, **untracked**) — `AUTHENTIK_TOKEN` for outpost `hubris-lan-outpost` (sops-encrypt into `secrets/` — TODO Phase 5).
## The outpost object (VPS Authentik)
- Outpost **`hubris-lan-outpost`** (type proxy), providers: `hubris-forward-auth` (domain, Paperless + domain-level apps), `Provider for Torrent` (qBittorrent), `Provider for Artifacto` (single).
- Health: VPS admin → Applications → Outposts → last-seen current; `curl http://192.168.8.6:9000/outpost.goauthentik.io/ping``204`; container log `Successfully connected websocket`.
## Caddy wiring
[Caddy (121)](121-caddy.md) `(authentik)` snippet `reverse_proxy`/`forward_auth``http://192.168.8.6:9000` (was `192.168.8.180:9000` on LXC 124). Tracked in `dtoro/caddy-conf`.
## `sso.hubris.network` — the callback domain (critical)
Domain-level (`forward_domain`) providers redirect the browser to `{external_host}/outpost.goauthentik.io/callback` after login. On LXC 124 that `external_host` was `auth.hubris.network`, which resolved (LAN) to Caddy → the 124 outpost — same box, so it worked. Post-migration `auth.hubris.network` points to the **VPS core**, so the callback hit the wrong outpost → **400 / redirect-uri mismatch**.
Fix: the LAN outpost gets its **own** domain.
- DNS: `sso.hubris.network → 192.168.8.175` (Caddy). *(in dnsmasq today — must be carried into Technitium in DNS Phase 2.)*
- Caddy: site `sso.hubris.network { tls dns ionos; reverse_proxy 192.168.8.6:9000 }` (in `caddy-conf`).
- Authentik: `hubris-forward-auth` and `Provider for Torrent` have `external_host=https://sso.hubris.network`; their `redirect_uris` must match (`set_oauth_defaults()` regenerates them from `external_host` — changing `external_host` alone does NOT update them).
- `Provider for Artifacto` is `forward_single` on its own domain, so it was unaffected.
**Lesson:** when the IdP core and the forward-auth outpost live on different hosts, the outpost needs a dedicated domain distinct from the core's — and proxy-provider `redirect_uris` must be regenerated, not just `external_host`.
## Related
- [124 — authentik](106-auth-outpost.md) — old embedded-outpost host (now DNS-only)
- [Caddy (121)](121-caddy.md) — forward-auth consumer
- [Ingress (VPS traefik)](../infrastructure/ingress.md)
- [Authentik VPS migration](../../sources/investigations/archive/2026-05-31-authentik-vps-migration.md)
## Changelog
### 2026-06-06 — Authentik session lifetime extended to 30 days
VPS Authentik core `user_login` stage updated: `session_duration` changed from `seconds=0` (session cookie, cleared on browser close) to `days=30` (persistent 30-day cookie). Also set `AUTHENTIK_SESSIONS__UNAUTHENTICATED_AGE=days=30` in `/opt/authentik.env` on the VPS. See [investigation](../../sources/investigations/2026-06-06-authentik-session-lifetime.md).
### 2026-06-01 — created; forward-auth cut over from LXC 124
New dedicated LXC for the LAN forward-auth outpost (Phase 1 of the [architecture migration](../../sources/investigations/archive/2026-05-31-authentik-vps-migration.md)). Deployed `goauthentik/proxy:2026.5.2` pointed at the VPS core; repointed Caddy `(authentik)` from `192.168.8.180:9000``192.168.8.6:9000`. Verified Paperless/qBittorrent/Artifacto return the SSO redirect with **124-Authentik stopped**, confirming the frozen instance is out of the path. dnsmasq stays on 124 until [DNS is relocated](106-auth-outpost.md).

View File

@@ -0,0 +1,67 @@
# 107 — `dns`
Homelab DNS server (Technitium). Replaces the dnsmasq that lived on [124 — authentik](106-auth-outpost.md); single-purpose, one job.
## At a glance
- **Hostname:** `dns`
- **IP:** `192.168.8.2` (static — stable, decoupled from any app)
- **Privilege:** privileged (Docker-in-LXC, `features: nesting=1`)
- **Resources:** 1 core / 1 GiB / 8 GiB rootfs
- **Created:** 2026-06-01, Debian 13. Its own resolver is `1.1.1.1` (no circular dependency on the DNS it serves).
## Role
Authoritative split-horizon DNS for `hubris.network` on the LAN/mesh, plus recursive forwarding (`1.1.1.1`, `8.8.8.8`) for everything else. Technitium runs in Docker (`technitium/dns-server:latest`, `network_mode: host`), web console on `:5380`.
## The `hubris.network` zone
- Specific A overrides: app names → `192.168.8.175` (Caddy), `nfs-export → 192.168.8.200`, `auth/sso/...` as needed.
- `auth.hubris.network → 82.165.190.79` (VPS Authentik), `sso.hubris.network → 192.168.8.175` (LAN forward-auth outpost).
- **Wildcard** `*.hubris.network → 82.165.190.79` — mirrors the public IONOS wildcard so undefined names (e.g. `netbird`) resolve to the VPS, matching public behaviour.
- **MX / SPF-TXT / CAA replicated from public** so an authoritative zone doesn't shadow `hubris.network` email/cert records.
## Config / access
- `/opt/technitium/docker-compose.yml`; admin password in `/opt/technitium/admin_password.txt` (mode 600 — sops-encrypt in Phase 5).
- Console: `http://192.168.8.2:5380` (user `admin`).
- API: `http://192.168.8.2:5380/api/...` (token via `/api/user/login`). Zone was built via the API.
## Who points here
- **NetBird mesh peers:** resolve via the **NetBird managed DNS zone**, kept in sync *from* this Technitium (see dns-sync below). The `home-lab-dns` nameserver group (`→ 192.168.8.2`) is a thin fallback forwarder.
- **Homelab DHCP clients:** Technitium's own DHCP scope hands out `192.168.8.2` as the DNS server for `192.168.8.x` leases (see DHCP section below).
- **Plain LAN clients (`192.168.178.x`):** Fritz!Box DHCP still hands out Fritz!Box itself (`192.168.178.1`) as DNS — no split-horizon for non-mesh clients. Changing this requires a secondary DNS fallback, which Fritz!OS 8.x doesn't expose in a single DHCP field.
## dns-sync (Technitium = authoring source)
`/opt/dns-sync/sync.py` (cron `*/10`, logs `/var/log/dns-sync.log`) reconciles this zone's named A-records → the NetBird managed DNS zone via the NetBird API (`/api/dns/zones/{id}/records`). Token at `/opt/dns-sync/netbird-token` (mode 600; source of truth in sops `secrets/netbird-pat.yaml`). **Edit DNS only here**; the sync propagates to the mesh. It deletes NetBird records absent from Technitium. Tracked: [scripts/dns-sync.py](../../../scripts/dns-sync.py). *Why this exists:* NetBird won't forward to Technitium for mesh peers (self-IP / nameserver-group quirks), so we sync into the managed zone instead — see [dns.md](../infrastructure/dns.md).
## DHCP
Technitium also runs a DHCP server for the homelab subnet (enabled 2026-06-02):
- **Scope:** `homelab``192.168.8.241 192.168.8.254`
- **Gateway:** `192.168.8.1` (Proxmox `vmbr0` alias)
- **DNS:** `192.168.8.2` (self)
- **Lease time:** 24 h
Replaces the DHCP that was previously served by the Slate AX router. Static-IP LXCs (`.101.239`) are excluded from the pool. Pool narrowed from `.100.240` to `.241.254` on 2026-06-03 to eliminate IP conflict risk.
## Related
- [124 — authentik](106-auth-outpost.md) — retired host of the old dnsmasq
- [DNS split-horizon](../infrastructure/dns.md)
- [Mesh](../infrastructure/mesh.md)
## Changelog
### 2026-06-24 — A record `trmnl.hubris.network → 192.168.8.175`
Added for [trmnl (128)](128-trmnl.md) (LAN path via [Caddy (121)](121-caddy.md)); propagated to the NetBird managed zone by `dns-sync`.
### 2026-06-06 — dns-sync cron installed (had been missing since deployment)
Although the 2026-06-03 changelog claimed "cron */10", **no crontab was actually configured** on the LXC. The sync was running only via ad-hoc manual invocations during incident debugging. Fixed by adding `/etc/cron.d/dns-sync`.
### 2026-06-03 — DHCP pool narrowed to `.241.254`
Previous pool `.100.240` overlapped with all static LXCs/VMs (`.101.239`). Shrunk via API (`/api/dhcp/scopes/set`). 11 stale DHCP leases in `.101.110` remain until natural expiry (2026-06-04). See [plan](../../../.hermes/plans/2026-06-03_223218-dhcp-pool-exclude-static-ips.md).
### 2026-06-03 — dns-sync added (Technitium → NetBird managed zone)
This Technitium became the single DNS authoring source; `/opt/dns-sync/sync.py` (cron */10) reconciles named A-records into the NetBird managed zone via the API. Fixed previously-broken mesh names (`sso`, `nfs-export`, `mcp`, `secrets`) by adding them to the managed zone; reaped obsolete `files`/`photos-new`. See [dns.md](../infrastructure/dns.md).
### 2026-06-02 — DHCP server enabled; replaces Slate AX DHCP
Enabled Technitium's built-in DHCP server for `192.168.8.0/24` (scope `homelab`, range `.100.240`, gateway `192.168.8.1`, DNS self). Previously the Slate AX sub-router served DHCP for the homelab subnet. With the Slate AX retired and Proxmox now the subnet router, Technitium takes over DHCP. Configured via the Technitium API (`/api/dhcp/scopes/set`). DHCP LXCs kept their Slate AX leases until expiry, then renewed from Technitium.
### 2026-06-01 — created; replaced dnsmasq on 124
Stood up Technitium at `192.168.8.2`, imported the split-horizon zone (specific A + wildcard + MX/SPF/CAA), made it the primary nameserver in the NetBird `home-lab-dns` group. Verified all names resolve with dnsmasq/124 stopped; [LXC 124 retired](106-auth-outpost.md).

View File

@@ -11,7 +11,7 @@ Personal cloud / file collaboration. Source-of-truth for the photo libraries sur
- **Public hostname:** [`cloud.hubris.network`](../infrastructure/dns.md) → [caddy](121-caddy.md)
## Auth
Native OIDC via `user_oidc` app. **Username override pattern**: Authentik user `dtoro` maps to local Nextcloud user `admin` via the `nc_uid` custom-claim scope. Configured via `occ user_oidc:provider <name> --mapping-uid=nc_uid` and `--scope="openid profile email <app>-uid"`. See [Authentik](124-authentik.md#per-app-username-override-pattern-authentik) for the full pattern.
Native OIDC via `user_oidc` app. **Username override pattern**: Authentik user `dtoro` maps to local Nextcloud user `admin` via the `nc_uid` custom-claim scope. Configured via `occ user_oidc:provider <name> --mapping-uid=nc_uid` and `--scope="openid profile email <app>-uid"`. See [Authentik](106-auth-outpost.md#per-app-username-override-pattern-authentik) for the full pattern.
Redirect URI: `/index.php/apps/user_oidc/code` (NOT `/apps/...` — pretty URLs aren't on).
@@ -74,7 +74,7 @@ Apply with `systemctl restart mariadb` (not reload — `innodb_log_file_size` ne
## Related
- [mulita (120)](120-mule-images.md) — reads NC user trees + writes back via WebDAV
- [Authentik (124)](124-authentik.md)
- [Authentik (124)](106-auth-outpost.md)
- [Caddy (121)](121-caddy.md)
- [DNS](../infrastructure/dns.md)
- [Mesh migration (DNS overrides explained)](../infrastructure/mesh.md)

View File

@@ -1,10 +1,11 @@
# 118 — `elementsynapse`
Matrix homeserver (Synapse). Backs `@dtoro:avispero` and `@claudio:avispero`.
Matrix homeserver (Synapse). Backs `@dtoro:avispero`.
## At a glance
- **Hostname:** `elementsynapse`
- **IP:** `192.168.8.239`
- **IP:** `192.168.8.242`
- **Host:** **strong** (migrated from hubris 2026-07-05)
- **Privilege:** **unprivileged**
- **Resources:** 1 core / 2 GiB RAM / **16 GiB rootfs** (grown from 8 GiB on 2026-05-15 after disk-full incident)
- **Mounts:** none from `/mnt/library`
@@ -33,16 +34,29 @@ All five bridges run as plain `docker compose` stacks under `/root/mautrix-<name
- `/var/lib/matrix-synapse/media_store` is the dominant grower (~2 GiB at last check). If disk pressure returns, purge remote media via the Synapse admin API before resizing further.
## Known issues
- ~~Disk usage was 86.8% at last claudio-monitor reading on 2026-04-21~~ — resolved by growing rootfs to 16 GiB on 2026-05-15.
- ~~Disk usage was 86.8% at last legacy monitor reading on 2026-04-21~~ — resolved by growing rootfs to 16 GiB on 2026-05-15.
## Related
- [claudio-bot (123)](123-claudio-bot.md) — connects directly to `192.168.8.239:8008` (avoids hairpin-NAT TLS issue on the public URL)
- ~~[claudio-bot (123)](archive/123-claudio-bot.md)~~ — decommissioned 2026-06-04, replaced by Hermes Agent
- [Caddy](121-caddy.md)
- [DNS](../infrastructure/dns.md)
- [Monitoring](../infrastructure/monitoring.md)
## Changelog
### 2026-06-06 — DHCP drift fixed: internal `/etc/network/interfaces` was `dhcp` despite Proxmox static config
**Symptom:** Matrix was down. Caddy at `192.168.8.175` couldn't reach `192.168.8.239:8008` — the LXC was actually at `192.168.8.244` because the guest-side dhclient had overridden the PVE-assigned static IP.
**Root cause:** During the 2026-06-02 static-IP migration, `pct set 118 --net0 ... ip=192.168.8.239/24` was applied to the Proxmox config, but the internal `/etc/network/interfaces` still had `iface eth0 inet dhcp`. On every DHCP lease renewal, dhclient grabbed `.244` from Technitium's pool.
**Fix:**
- Replaced `iface eth0 inet dhcp` with `iface eth0 inet static` + `address 192.168.8.239/24` + `gateway 192.168.8.1`
- `ifdown eth0 && ifup eth0` applied the static IP
- Killed lingering dhclient process
- Verified: `curl http://192.168.8.239:8008` returns 302 from Caddy's LXC
**Prevention:** The `check-caddy-backends.sh` cron on hubris now runs every 10 minutes, which would have caught this drift within 10 minutes of occurrence.
### 2026-05-15 — phantom-notification cleanup for `@admin`
After the disk-full incident, the mobile (Element X) badge showed ~125 unread but every room read clean in the UI. Root cause: stale rows in `event_push_actions` that were never reaped — Synapse's read-receipt-driven cleanup didn't catch up. Two contributors:
1. **8 of 12 affected rooms** had read receipts past the "unread" stream_ordering — pure stale state, likely from the disk-full window stalling rotation/cleanup.

View File

@@ -60,7 +60,7 @@ For pushes from inside the LXC, gitea creds at `/etc/mule-deploy/git-credentials
## Related
- [Nextcloud (114)](114-nextcloud.md) — source of truth for photo libraries
- [Authentik (124)](124-authentik.md)
- [Authentik (124)](106-auth-outpost.md)
- [Caddy (121)](121-caddy.md)
- [DNS](../infrastructure/dns.md)
- [Auto-deploy](../infrastructure/auto-deploy.md)

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