55 Commits

Author SHA1 Message Date
4560e25bd7 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-05-31 01:18:43 +02:00
root
04d1f39e7b mesh: document 2026-05-31 recovery + auth.hubris.network Traefik route
Management crash-loop root cause and fix documented.
Phase 6 (Authentik as Netbird IdP) marked complete and live.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 00:05:55 +02:00
b34d362c19 photos: mule-image cutover to PhotoPrism stack on 120, retire 127
Mulimage 2.0 merge (dtoro/mule-image 70dc1b6) replaces the legacy
FastAPI/Celery/Postgres stack with PhotoPrism + Go sidecar + MariaDB +
SvelteKit. LXC 120 now runs that architecture in production; LXC 127
(the M0 evaluation host) was destroyed in the same session.

- inventory.yaml: drop mule-photos-new host + photos_new service.
- containers/index.md: bump 120's row (6 cores / 12 GiB / iGPU
  passthrough), move 127 to Recently-destroyed.
- containers/120-mule-images.md: rewrite stack + library-access +
  auth sections; add 2026-05-22 cutover changelog entry.
- containers/127-mule-photos-new.md: add closing changelog entry and
  decommission banner; rest left intact as archaeology.
- hosts/mule-photos-new.yaml: removed (regenerated from inventory.yaml).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 00:53:34 +02:00
0260f01b74 render-vps-configs: drop _comment from management.json template
netbird mgmt might reject unknown top-level JSON keys depending on
parser flags. The rendering note stays in turnserver.conf (# comments
are valid INI syntax). After this, the rendered management.json will
be byte-identical to what's currently live on the VPS, so the dry-run
will show /opt/management.json as unchanged.
2026-05-21 22:30:46 +02:00
b12f80933d render-vps-configs: print masked unified diff in --dry-run
Lets the operator see exactly what would change on the VPS before
applying. Secret values from the decrypted sops files are masked as
<REDACTED> so the diff is safe to paste into chat/PRs.
2026-05-21 22:29:10 +02:00
8ef17dba3d sops-encrypt TURN password + Authentik client secret; homelab render-vps-configs
The IONOS netbird VPS held two credentials in plaintext that were the last
holdouts from the homelab's sops+age secrets pattern:
  - /root/turn-pass.txt  (coturn long-term-credential password)
  - PKCEAuthorizationFlow.ProviderConfig.ClientSecret inline in
    /opt/management.json (Authentik OIDC client secret)

This commit moves both into sops-encrypted YAML in the repo and adds a render
command that recreates the VPS config files from templates + decrypted secrets:

* secrets/turn-shared-secret.yaml      — encrypted `password: <coturn pwd>`
* secrets/netbird-authentik-oidc.yaml  — encrypted `client_secret: <...>`
  Both recipients = hubris + apps + republic-laptop (same 3 as hello.yaml).
* vps/turnserver.conf.tmpl + vps/management.json.tmpl — templates with
  {{TURN_PASSWORD}} + {{AUTHENTIK_CLIENT_SECRET}} placeholders.
* bin/homelab new subcommand `render-vps-configs`:
  - Decrypts both secrets locally (works on any recipient).
  - Substitutes placeholders into templates.
  - Diffs against current VPS state via ssh, prompts, applies atomically
    (write `.new` then mv), restarts coturn + netbird-mgmt.
  - --dry-run + -y flags. Hops through hubris when not running on hubris
    itself, since VPS sshd is locked to hubris's pubkey.
* inventory.yaml adds the VPS as `kind: external` (new kind; reserved for
  ssh-managed hosts that aren't homelab clients themselves — no age key,
  no /opt/homelab-context). hosts/netbird-vps.yaml regenerated.
* SHARED_SECRETS list includes both new secrets so re-keys on enrollment
  changes pick them up automatically.

After this lands + the 5-min sync propagates to hubris, run from hubris PVE
shell (or any client; hubris just skips the extra ssh hop):

  homelab render-vps-configs --dry-run    # see plan, no changes
  homelab render-vps-configs -y           # apply + restart services

Once verified working, the plaintext `/root/turn-pass.txt` should be deleted
on the VPS (the rendered /etc/turnserver.conf no longer needs it as a
reference).
2026-05-21 22:21:06 +02:00
21063015c7 homelab CLI: per-host ssh.user + LAN→mesh fallback; wiki for netbird-ssh JWT issuer fix
Three coordinated changes addressing follow-ups from the 2026-05-21 netbird vanilla
migration, plus a related off-LAN ergonomics fix:

bin/homelab:
- New ssh_target(name, force_mesh=False) helper resolves (addr, port, user)
  from inventory, honoring ssh.netbird_port (forces mesh path) and ssh.user
  (default "root"). Falls back to the netbird FQDN when LAN IP fails a
  cached 1.5s TCP probe — helps off-LAN operators on VPN/symmetric-NAT
  paths where the netbird subnet route doesn't reach 192.168.8.0/24.
- New ssh_base() builds the full `ssh ... user@addr` invocation; hubris_ssh()
  is now a back-compat shim. cmd_ssh, cmd_logs, cmd_restart, cmd_nuke
  refactored to use it — no more hardcoded "root@" anywhere.

inventory.yaml:
- New ssh.user convention (root by default, explicit per workstation).
- hubris.ssh.user=root (explicit, documents convention).
- republic-laptop, mac-mini: ssh.user=dtoro. ludo-mini left default (TODO).
- Comment block in the header explains the field + why it exists (netbird-ssh
  defaults to LOCAL username; "user not found" on LXCs is the gotcha).
- hosts/*.yaml regenerated from build_host_files.py.

infrastructure/mesh.md:
- Migration changelog entry updated: Device Code Stage is now configured
  (was "NOT yet" — landed in d41d73f); --setup-key no longer required.
- New subsection documenting the post-migration JWT-issuer cache bug:
  client/internal/engine_ssh.go's updateSSH() bails out when sshServer is
  already running, so systemctl restart and netbird down/up don't refresh
  the SSH JWT validator. Full daemon stop/start is the fix.
- Companion username gotcha (`netbird ssh` defaulting to local username).

operations/agent-enrollment.md:
- Three new troubleshooting rows: JWT-issuer cache, user-not-found, and
  LAN-unreachable-from-mesh-peer (the new homelab CLI behavior).

Verification: ssh_target resolution against the live inventory yields
- hubris → ssh -p 22022 root@proxmox-server.netbird.selfhosted (mesh-forced)
- jellyfin/gitea → ssh root@192.168.8.x (LAN reachable, probe passed)
- republic-laptop/mac-mini → ssh dtoro@<fqdn> (per ssh.user)
- ludo-mini → ssh root@<fqdn> (default)
2026-05-21 21:57:19 +02:00
d41d73f323 device-code onboarding: bootstrap auto-installs deps + netbird; Authentik device flow wired
Two changes that together collapse new-workstation onboarding from ~7 steps
to ~2 commands:

* bootstrap.sh:
  - Dep-check now AUTO-INSTALLS missing tools (apt/dnf/brew) instead of
    printing instructions and exiting. Re-verifies after install.
  - New pre-mesh-check block: if netbird isn't installed, installs it
    from the netbird apt/dnf repo (or `brew install --cask netbird` on
    Darwin), then if mgmt isn't connected, runs `netbird up
    --management-url=https://netbird.hubris.network --ssh-jwt-cache-ttl 86400`.
    Operator clicks the device-code URL inline. Waits up to ~30s for
    Management: Connected before continuing. Skipped on --no-secrets +
    --dry-run.

* containers/124-authentik.md: replaces the "KNOWN MISSING — Device Code
  Stage" subsection with a working recipe — Authentik 2026.2 routes
  /device via a BRAND-level "Device code flow" field, not a provider
  field. Documented stage bindings for a `default-device-code-flow`
  flow (identification → password → user-login → consent) and the
  brand-level binding step.

* operations/agent-enrollment.md: Path B (interactive `netbird up`) is
  now the default; Path A (setup-key) demoted to "headless/scripted"
  alternative. "Install dependencies" section collapsed into a note
  that bootstrap handles it, with the manual recipes kept in a
  collapsible <details> block for air-gapped use.

The flow uniquely available to lab owners (single Authentik user today)
still relies on the first-time-owner sqlite promotion documented in
124-authentik.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:05:27 +02:00
b42a986cc0 wiki: document 2026-05-21 netbird vanilla migration
Updates to four pages reflecting the combined → vanilla mgmt+signal+relay+coturn
cutover and the IONOS-3478-firewall-exception discovery:

* infrastructure/mesh.md — rewrites the ICE/STUN section to cover the new
  TURN endpoint, the IONOS upstream TCP-3478 filtering (load-bearing,
  undocumented before today), and the verification probe. New changelog
  entry covering the migration outcome + Device Code Stage gap.

* infrastructure/vps-hardening.md — "At a glance" lists the new 6-service
  docker stack + host coturn. Firewall section notes the new
  `iifname ens6 tcp dport 3478 accept` rule plus the IONOS upstream
  exception. New changelog entry.

* containers/124-authentik.md — replaces the "Netbird IdP integration —
  DEFERRED" section with the LANDED state: Provider details (Public
  client type — Confidential breaks PKCE on the dashboard SPA), the
  first-time owner-promotion sqlite recipe, the missing Device Code
  Stage gap + workaround (setup-keys), and a note that the old 2026-04-22
  pre-work Provider/App is now obsolete and safe to delete. Updated
  changelog (Phase 6 landed).

* operations/agent-enrollment.md — new "Getting onto Netbird" subsection
  explaining the setup-key path (currently the only working flow until
  Device Code Stage lands) and why direct OIDC from the public internet
  fails (auth.hubris.network is mesh-only-reachable). Prerequisites table
  row updated to point at the new section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 13:48:01 +02:00
7a062bdb6b docs: dpkg-interrupted runbook + apt-fleet ops + non-apt-binary pattern + Claude Code settings + chat-sudo gotcha
Bundles the documentation slice of the apt-sweep backlog:

* operations/runbook-dpkg-interrupted.md (NEW) — Path A (ssh-reachable
  recovery) + Path B (PVE web Shell when the netbird mesh broke
  alongside the dpkg state, as happened during Wave 6 on hubris).
  Closes B2.

* operations/commands.md — new "Fleet apt operations" section
  documenting `homelab apt-audit` and `homelab apt-upgrade`
  (--status / --safe / --force). Adds the dpkg-interrupted runbook to
  Related.

* operations/agent-enrollment.md —
  - new "Claude Code permissions for fleet ops" section with the
    `permissions.allow` snippet (`Bash(ssh -p 22022 *)`,
    `Bash(homelab *)`) for `~/.claude/settings.json`. Closes A3.
  - two new Troubleshooting rows: chat-mode `!` sudo no-tty gotcha
    (G2) and the cosmetic netbird DNS-probe warning.

* infrastructure/auto-deploy.md — new "Custom-built binaries that
  overlap apt-managed paths" section describing the two acceptable
  patterns (epoch-versioned .deb à la caddy 1:2.11.3-hubris1; or
  apt-mark hold) and the discovery path via `homelab apt-audit`'s
  NONAPT column. Closes D3.

Remaining backlog after this commit: A4 (upstream OpenSSH/netbird mux
bug), D1 (apt-mark hold caddy in caddy-conf bootstrap — superseded
in practice by the epoch .deb), E1/E2 (LXC DNS fallback for
tailscale-managed resolv.conf), F1/F2 (vzdump fallback doc; F3 already
shipped).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:32:06 +02:00
8f76338b05 bootstrap: install mcp CLI via pipx on workstation enrollment
G1 from the apt-sweep backlog. After ssh ControlMaster setup, install
the `mcp[cli]` python package via pipx so `homelab mcp <tool>` works
out-of-the-box on new workstations. Skipped on LXCs / VMs.

Idempotent (`command -v mcp` guard), respects --dry-run, falls back
through brew (Darwin) → dnf → apt for pipx itself if not already
installed. Runs the install as $SUDO_USER (not root) so the binary
lands in the user's pipx env.

This closes one of the "discovered missing dep" gaps from the 2026-05-21
fleet sweep: republic-laptop had pipx-installed homelab CLI but no `mcp`
binary, so `homelab mcp <tool>` died with an instructional message
instead of just working.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:25:58 +02:00
306c397ce1 homelab apt-upgrade: add --safe (pct snapshot + vzdump fallback)
F3 from the apt-sweep backlog. `homelab apt-upgrade --safe ...` takes a
pre-upgrade snapshot per LXC before launching the apt run, so rollback is
trivial if anything regresses.

Snapshot strategy per target:
- LXC: try `pct snapshot <id> preupgrade_<timestamp>` first (CoW, near
  instant). If that refuses ("snapshot feature is not available" — the
  failure mode for LXCs with host bind-mounts like `mp0: /mnt/library`),
  fall back to `vzdump <id> --mode snapshot --storage local --compress
  zstd`. Slower but works on bind-mounted LXCs.
- hubris (PVE host): skipped intentionally; no host-level snapshot in v1.

If any snapshot fails the entire run refuses unless --force is passed.
Snapshot rollback hints are printed after launch so the operator has the
recovery path one copy/paste away.

Validated 2026-05-21: py_compile clean; --status mode works on bind-
mounted LXCs (skips snapshot path). Live snapshot test deferred to next
real fleet sweep to avoid leaving stale artifacts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:24:45 +02:00
2ff7263f3d homelab: add apt-audit + apt-upgrade subcommands; fix hubris_ssh
Two new subcommands (B1+H1 + C1+C2 from the apt-sweep backlog):

* `homelab apt-audit [--target HOST]`
  Per-host pre-flight table reporting dpkg-interrupted state, holds,
  upgradable count, non-apt binaries in system paths, and DNS health.
  Exits nonzero if any target has unconfigured packages (would catch the
  latent dpkg-interrupted state we found on caddy + nextcloud before
  touching them).

* `homelab apt-upgrade [--target HOST | --all] [--status] [--force]`
  Launches `apt update && apt upgrade` inside a transient systemd unit
  (`apt-upgrade-<hostname>`) on each target via `systemd-run --collect`,
  so the run survives ssh teardown. Output tee'd to
  `/var/log/homelab-apt-upgrade.log`. Apt configured with
  `Acquire::Retries=3` + `ForceIPv4=true` to absorb transient mirror
  blips (would have prevented the jellyfin ffmpeg7 fetch failure that
  rolled back 131 packages mid-Wave-3).
  Pre-flight audit gate refuses to run on hosts with dpkg-interrupted
  state; override with --force.
  `--status` mode prints each target's unit state and tails the upgrade
  log; safe to run before, during, and after.

Also fixes a latent bug in `hubris_ssh()`: the function joined hubris's
`lan_ip` (192.168.8.77) with the netbird-only port 22022. Port 22022
listens on hubris's netbird interface ONLY — not on the LAN interface,
not even via the netbird subnet route. Result: every off-LAN client
hitting `homelab pct/logs/restart/status` got "Connection refused".
Now uses the netbird FQDN when netbird_port is in use; falls back to
host_address otherwise.

Targets exclude VMs and workstations (they have their own update flows).
Standard apt targets = hubris + every LXC (kind=lxc with a pve_id).

Validated 2026-05-21 on republic-laptop:
- apt-audit reported clean dpkg + 40 upgradable on hubris (the still-
  deferred PVE/kernel packages) + 0 on every LXC, DNS ok across the
  fleet
- apt-upgrade --target mule-photos-new launched the unit, ran apt to
  completion (0 upgrades pending), log captured with =START= / =END
  rc=0= markers, --status reports unit + log correctly
- --all --status fans out across all 16 targets without issue

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:08:25 +02:00
35c688c56f bootstrap: install ssh ControlMaster block for netbird peers
After preflight detects MESH_CONNECTED=netbird and the host yaml says
kind != lxc (i.e. workstation, vm, proxmox-host), drop a Host block into
the enrolling user's ~/.ssh/config:

    Host *.netbird.selfhosted
        ControlMaster auto
        ControlPath ~/.ssh/cm/%C
        ControlPersist 2h

This is the real workaround for netbird's SSH JWT cache being flaky in
0.71.2 — we discovered that --ssh-jwt-cache-ttl can leave the daemon in
a state where stale cached tokens get sent and rejected with no fallback
to fresh SSO. ssh ControlMaster bypasses netbird-ssh-proxy entirely for
subsequent sessions: one SSO at the start of a working window covers all
back-to-back ssh / scp / `pct exec` ops until ControlPersist expires.

Validated 2026-05-21 on republic-laptop: ssh #1 prompted SSO once,
sshes #2 and #3 ran in ~1.1s each with no prompt.

Idempotent (sentinel comment check); writes ~/.ssh/cm/ with 700; uses
SUDO_USER's home when invoked via sudo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 08:53:24 +02:00
dd4072f1d4 bootstrap: enable netbird ssh-jwt-cache-ttl on workstation enrollment
After preflight detects MESH_CONNECTED=netbird, run `netbird down && netbird up
--ssh-jwt-cache-ttl=86400` so ssh into mesh peers (e.g. `ssh -p 22022
root@proxmox-server.netbird.selfhosted ...`) stops triggering device-code SSO
on every connection.

Validated 2026-05-21 on republic-laptop: after one SSO, subsequent ssh
sessions within 24h skip the device-code flow and run instantly. Fleet
operations (e.g. pct exec through hubris into LXCs) reuse the cached JWT.

Notes:
- Flag is supported in netbird 0.71.x+ (netbirdio/netbird#4015). A version
  probe (`netbird up --help | grep ssh-jwt-cache-ttl`) skips the section on
  older clients.
- Flag belongs on `netbird up` (client config), NOT on the daemon's
  ExecStart — putting it there crashes the daemon with "unknown flag".
- Runs LAST in bootstrap, after secrets issuance + MCP wiring, so the brief
  mesh down/up doesn't disrupt earlier steps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 08:12:55 +02:00
root
4c16de8102 secrets-issuance/backup.sh: chmod +x 2026-05-20 21:40:10 +02:00
root
419ab475b1 secrets-issuance backup + homelab doctor smoke-test
Two additions:

1. secrets-issuance backup: daily timer snapshots /var/lib/secrets-issuance
   to /mnt/library/.secrets-issuance-backup/ as a date-stamped tar.gz,
   keeping the last 14 days. Closes the catastrophic-fail-mode where an
   LXC 105 loss wipes every client's age key with no recovery path.
   Caveat: privileged LXCs that mount /mnt/library can read the backup
   (root-uid maps to host root); encrypted-tarball variant is a future
   refinement.

2. homelab doctor: 10 invariant checks for an enrolled client — clone
   present, sync timer/launchd job active, age key perms, CLI symlinked,
   AGENTS.md linked, inventory entry exists, MCP reachable, secrets
   /health responds, sops canary decrypts, git creds present. Returns
   nonzero on any 'fail'. Useful after enrollment or whenever something
   smells off.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 21:37:42 +02:00
root
b4ca21b2b3 inventory: zimaos lan_ip 192.168.8.195 2026-05-20 21:31:36 +02:00
root
dd92c5fd88 inventory: haos has lan_ip 192.168.8.101
Discovered via ARP (MAC 02:E1:73:18:EA:49 from qm config). The Tailscale
FQDN 'homeassistant' is fine for tailscale peers but unreachable from
Netbird peers like republic. lan_ip works from both — Netbird routes
192.168.8.0/24 through hubris.

zimaos (VM 100) remains without lan_ip because its IP wasn't in the
hubris ARP table at audit time and we don't want a network scan. The
zimaos service is still reachable via caddy at zimaos.hubris.network
(verified in 'homelab status' SERVICE column).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 21:31:03 +02:00
root
36a686d953 inventory: real mesh state per host (no more placeholder netbird FQDNs)
Audit against actual netbird+tailscale peer lists:
  - hubris is the only LXC-host on Netbird; only workstations + hubris
    have netbird entries
  - 10 LXCs+VMs have real Tailscale FQDNs: apps, jellyfin, paperless,
    gitea, nextcloud, elementsynapse, sophia, mule-images→muleimage,
    arriman→arr, haos→homeassistant
  - 7 hosts are LAN-only (no mesh block): nfs-export, caddy, claudio-bot,
    authentik, plato, mule-photos-new, zimaos
  - mac-mini's netbird FQDN corrected to the actual peer name
    (mac-mini-234-17.netbird.selfhosted)

Also: bin/homelab host_address() now prefers lan_ip first — universally
reachable from any LAN client and from any Netbird peer via the
192.168.8.0/24 network resource routed through hubris. Mesh FQDNs are
fallbacks for roaming workstations without a fixed lan_ip.

This makes 'homelab status' from republic show all backends 'ok' instead
of falsely reporting them 'down' against unresolvable netbird FQDNs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 21:28:03 +02:00
root
e7a74f795d bootstrap.sh: auto-call refresh-creds when the client is a PAT recipient
End of enrollment, if secrets/gitea-pat.yaml is decryptable with the
just-issued age key (i.e. the operator has already run --finalize-pubkey
from another client), upgrade /etc/homelab-context/git-credentials from
the read-only bootstrap PAT to the write-scoped one. Best-effort: fails
silently if not yet a recipient, with a clear hint about what to do next.

Removes the post-bootstrap manual 'homelab refresh-creds' step from the
common flow; falls back to the documented one-liner for first-bootstrap
clients.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 21:14:03 +02:00
root
6ed04dd1b0 inventory: declare systemd_unit for the underscore-named services
Service keys in inventory use _ for python-attribute friendliness but the
actual systemd units use dashes. Explicit systemd_unit field disambiguates
for MCP tail_log / get_service_status.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 20:24:31 +02:00
root
a5ee017291 homelab-mcp.service: SSH as root, the restricted shell is the boundary
Unit was hard-coding HOMELAB_MCP_SSH_USER=mcp-reader, overriding the new
code's 'root' default. No mcp-reader user exists on hubris — the key is
authorized for root, with a strict command= wrapper. Also pin
HOMELAB_MCP_HUBRIS_HOST + KNOWN_HOSTS env so the service doesn't depend
on the python defaults staying in sync with the unit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 20:23:49 +02:00
root
3f41416805 mcp/server: log ssh failures so silent empties are debuggable 2026-05-20 20:23:03 +02:00
root
2d2446b36e mcp/server: pre-populated known_hosts for the restricted SSH
The systemd unit's ProtectHome=true blocks ~/.ssh access. SSH then had
no place to write known_hosts (StrictHostKeyChecking=accept-new) and
silently produced empty results. Use /etc/homelab-mcp/known_hosts (which
ProtectSystem=strict still allows reading) and StrictHostKeyChecking=yes.

Operator pre-populates the file via:
  ssh-keyscan -t ed25519 192.168.8.77 > /etc/homelab-mcp/known_hosts

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 20:20:25 +02:00
root
b5dfbb68ae mcp: restricted-shell SSH proxy through hubris for management tools
The 5 management tools (get_service_status, tail_log, list_lxcs,
get_lxc_state, ping_service) were registered with sse but all SSH calls
went to per-host targets via a 'mcp-reader' user that didn't exist
anywhere. New design routes every management call through ONE channel:
LXC 105 -> hubris (SSH key + restricted authorized_keys command), then
hubris pct-execs into the right LXC where needed.

Adds mcp/mcp-reader-shell — a strict allowlist wrapper read from
$SSH_ORIGINAL_COMMAND. Rejects shell metacharacters up front and then
matches against a fixed set of read-only patterns (systemctl is-active/
is-enabled, journalctl -u, pct list/status/config, pct exec for the
same subset). Logged to syslog tag mcp-reader.

Authorized_keys line on hubris:
  command="/usr/local/bin/mcp-reader-shell",restrict ssh-ed25519 ... mcp-reader@homelab-mcp

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 20:18:07 +02:00
root
2599c28104 homelab client add/remove: surgical inventory edits (preserve comments)
yaml.safe_load + safe_dump stripped every comment from inventory.yaml
on each enrollment, eroding the file's documentation value. New
_inventory_set_age_pubkey / _inventory_remove_host / _inventory_append_host
helpers do line-based edits so comments outside the modified region
survive. inventory.yaml gets its top-of-file conventions block back.

build_host_files.py round-trips cleanly (--check returns 0).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 20:14:44 +02:00
root
aed977aa56 client-remove: test-victim 2026-05-20 19:29:47 +02:00
root
ea3100f091 client-add: test-victim (finalize age_pubkey + grant shared secrets) 2026-05-20 19:29:36 +02:00
root
5a8d6cfd4f homelab: stop sops-policy scan resetting state on the next rule
Critical bug — the loop reset age_block_last_idx unconditionally on
every '- path_regex:' line. So after finding the target rule's age
block, encountering the NEXT rule wiped the result and the function
returned False. _grant_shared_secrets / _revoke_shared_secrets both
silently no-op'd because of this.

Fix: break out of the scan once we've collected the target rule's
data. Refactored the remove helper to share a find_target_age_lines
inner so the dangling-trailing-comma fixup uses the same logic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 19:29:35 +02:00
root
098d4cfd6e client-add: test-victim (finalize age_pubkey + grant shared secrets) 2026-05-20 19:27:22 +02:00
root
e016f512b2 client-add: test-victim 2026-05-20 19:26:35 +02:00
root
4316acadaa homelab client remove: also revoke pubkey from .sops.yaml rules
The remove flow ran 'sops updatekeys' but never edited .sops.yaml first,
so the removed client stayed a recipient on every shared secret —
exactly the opposite of what 'remove' should do. Adds the
_remove_recipient_from_sops_policy / _revoke_shared_secrets helpers
(inverse of the grant-side ones from the previous commit); cmd_client_remove
now resolves the pubkey from inventory before deletion and feeds it
through that pipeline.

Also adds the sudo re-exec pattern so 'homelab client remove' works
from a non-root user (matching cmd_secret / cmd_refresh_creds /
cmd_client_add).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 19:26:14 +02:00
root
047138a81d client-add: claudio-bot (finalize age_pubkey + grant shared secrets) 2026-05-20 18:39:47 +02:00
root
af8961d194 bootstrap.sh: LAN probe uses GET not HEAD (issuance only handles GET) 2026-05-20 18:39:11 +02:00
root
ab6b8fabc4 bootstrap.sh: accept LAN reachability as a mesh-equivalent
LXCs without a mesh CLI sit on 192.168.8.0/24 which is in the
issuance service's MESH_SUBNETS — they should be able to bootstrap
without netbird/tailscale installed. New third path probes the
issuance /health endpoint directly; if reachable, treat that as
satisfying the mesh precondition.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 18:38:41 +02:00
root
e8c2ccf7bb wiki: document homelab-context distribution system
Adds infrastructure/homelab-context.md as the architecture reference for
the cross-client context + MCP + secrets-issuance system. Updates:

- 105-apps.md: two new ## Stacks sections (homelab-mcp, secrets-issuance)
  with their deploy pipelines + a row each in the public-hostname table;
  changelog entry.
- auto-deploy.md: both new pipelines added to the table (one repo, two
  webhooks, same push); per-pipeline notes covering the clone-per-service
  pattern and the deploy.sh self-restart caveat; changelog entry.
- README.md: link to the new infrastructure page.

Operational walkthrough already lives at operations/agent-enrollment.md;
this commit is the architecture side of the same story.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 18:34:45 +02:00
root
0b6be9f42d homelab client add --finalize-pubkey: grant shared secrets atomically
Setting the age_pubkey is half the enrollment; the new client also needs
to be a recipient on shared secrets (hello.yaml, gitea-pat.yaml) to
actually use them. Now --finalize-pubkey:

  1. writes hosts.<name>.age_pubkey
  2. appends the pubkey to each shared-secret rule in .sops.yaml
     (preserving comments via line-by-line edit, not yaml round-trip)
  3. runs sops updatekeys -y on each shared file
  4. commits inventory + hosts/ + .sops.yaml + secrets/ as one commit

Also: cmd_client_add now re-execs via sudo when invoked as a regular
user (matches the pattern in cmd_secret + cmd_refresh_creds).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 18:30:22 +02:00
root
65ece6f447 secrets: distribute write-scoped Gitea PAT + homelab refresh-creds
Adds secrets/gitea-pat.yaml (SOPS-encrypted, dtoro PAT with read+write
scopes) so any enrolled client can push to dtoro/Homelab-Docs — not just
where I have SSH. Recipient set = hello.yaml's (hubris, apps, republic);
expand alongside hello.yaml when enrolling new clients.

bin/homelab gains 'refresh-creds': decrypts gitea-pat.yaml, rewrites
/etc/homelab-context/git-credentials with the write token, repoints
git's --system credential helper. Re-execs via sudo for non-root callers
(same pattern as 'homelab secret').

After this lands, 'homelab client add/remove' and wiki edits can run
from any client. The initial bootstrap still needs an operator-supplied
read-only PAT (chicken-and-egg); 'refresh-creds' upgrades the client
to write afterwards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 18:25:36 +02:00
root
90a65bd5a1 noop: verify webhook auto-deploy 2026-05-20 18:16:57 +02:00
root
ce25e73625 bootstrap.sh: store gitea cred helper in /etc/gitconfig, not /root/.gitconfig
The systemd sync timer runs git without HOME set, so git config --global
(which writes /root/.gitconfig) is invisible to the timer's process — the
timer fails with 'could not read Username' silently. Switching to
--system writes to /etc/gitconfig which is HOME-agnostic.

Migration for already-bootstrapped hosts captured in agent-enrollment.md
troubleshooting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 18:01:47 +02:00
root
ae7eb8c649 bootstrap.sh: symlink homelab CLI instead of copy
The 5-min sync pulls /opt/homelab-context but does not re-install the
CLI. A copy at /usr/local/bin/homelab therefore goes stale after every
CLI fix until someone re-runs bootstrap. Symlinking points
/usr/local/bin/homelab directly at the synced source, so updates land
on the next pull. Doc updated with the one-line migration for hosts
bootstrapped before this commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 17:54:24 +02:00
root
df6aca888c homelab: re-exec 'secret' via sudo for non-root users
/etc/age/key.txt is 0600 root and /etc/age is 0700 root, so the CLI's
Path.exists() check was returning False under regular users — making the
subcommand look broken when bootstrap had actually written the key fine.
Re-exec via sudo preserves the existing UX (one password prompt, then
plaintext) without loosening the key's perms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 17:48:32 +02:00
root
58bd4df3b1 client-enrol: republic-laptop pubkey + hello.yaml recipient
Phase 2 first workstation enrolled. age1vf8... is republic-laptop's
issued pubkey; added as a recipient on hello.yaml so the post-bootstrap
decrypt test works there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 17:34:30 +02:00
root
44d4d00aff operations: add agent-enrollment.md — bootstrap doc for new clients
Captures the full enrollment flow validated during Phase 2 rollout: per-OS
dep install (dnf/apt/brew), Gitea PAT prerequisite, DNS gotchas, the
bootstrap command, post-bootstrap verification, the homelab client add
ceremony for new inventory entries, secret grant/revoke, and a
troubleshooting table mapping every failure mode we hit during validation
to the commit that fixed it.

Linked from README under Operations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 17:27:56 +02:00
root
c5fa756c7d Use mcp/secrets.hubris.network for the canonical endpoints
Caddy + split-horizon DNS now resolve these to LXC 105 (via 121).
Workstations off-LAN reach them via Netbird (192.168.8.0/24 is a
network resource routed through hubris).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 17:17:19 +02:00
root
a7d2a7a08c bootstrap.sh: emit dnf install hint on Fedora/RHEL clients
Was apt-only on Linux; republic-laptop is Nobara so installs use dnf and
yaml is python3-pyyaml (not python3-yaml).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 17:12:13 +02:00
root
6848640fa4 mcp/server: disable FastMCP DNS-rebinding protection
We're nftables-gated to mesh+LAN; the browser-attack threat doesn't
apply, and the default whitelist (127.0.0.1/localhost/[::1] only) blocks
every LAN/mesh client.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:43:35 +02:00
root
1dc40ca715 mcp/server: bind 0.0.0.0:9810 (FastMCP default is 127.0.0.1:8000)
The MCP service unit expects port 9810 per the inventory; FastMCP only
binds correctly when we set mcp.settings.host/port before run().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:42:30 +02:00
root
bc578df39b secrets: add encrypted hello.yaml (bootstrap verification)
Encrypted to hubris + apps; expand recipients as new clients enrol via
'sops updatekeys -y secrets/hello.yaml'. Tests the full sops + age path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:39:33 +02:00
root
1ae328f04d inventory: record age pubkeys for hubris + apps (Phase 3a)
First two clients have age keys issued by secrets-issuance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:38:44 +02:00
root
582823a89c secrets-issuance: trust LAN subnet 192.168.8.0/24
Hubris is Netbird-only and LXC 105 is Tailscale-only; they share LAN
but not a mesh, so on-host \`curl http://192.168.8.205:9820/issue\`
arrives with source IP 192.168.8.77. In a homelab LAN with no
untrusted devices the trust boundary is reasonable; if that changes
later, narrow this.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:36:09 +02:00
root
3c9d2f975d bootstrap.sh: require python3 + PyYAML (used by homelab CLI)
The homelab CLI imports yaml; missing on a fresh LXC. Preflight now
checks and emits the right install hint per OS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:31:28 +02:00
root
de6f8bec42 bootstrap.sh: preserve URL scheme in credentials file
git credential helper does exact prefix match including scheme. Hardcoding
https:// breaks for in-LAN clones using http://192.168.8.121:3000 (which
LXC 105 needs because its DNS doesn't have the split-horizon override).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:30:17 +02:00
root
385deb6dec bootstrap.sh: add --gitea-token / HOMELAB_GITEA_TOKEN
Each non-hubris client needs HTTPS auth against gitea for the initial
context clone (chicken-and-egg: a PAT stored in SOPS can't be fetched
until after the clone exists). Adds a --gitea-token flag that writes
credentials to /etc/homelab-context/git-credentials and points git's
credential.helper at it, so the clone and all future pulls succeed
without prompting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:24:31 +02:00
56 changed files with 3460 additions and 459 deletions

View File

@@ -18,12 +18,21 @@
creation_rules:
- path_regex: ^secrets/hello\.yaml$
# The "hello" secret is encrypted to every enrolled client so the bootstrap
# decrypt test works for everyone. Populated by the operator with the
# age_pubkey from each hosts/<name>.yaml after Phase 3a generates keys.
# decrypt test works for everyone. Add each new client's age_pubkey when
# they enrol; re-key with `sops updatekeys -y secrets/hello.yaml`.
age: >-
# placeholder — operator fills in after first issuance run.
# comma-separated age public keys, e.g.:
# age1xxx,age1yyy,age1zzz
age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6,
age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0,
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
- path_regex: ^secrets/gitea-pat\.yaml$
# Write-scoped Gitea PAT (dtoro user). Same recipient list as hello.yaml
# since every enrolled client should be able to push (homelab client
# add/remove, wiki edits, etc.).
age: >-
age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6,
age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0,
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
- path_regex: ^secrets/gitea-tokens\.yaml$
# Workstations only.
@@ -34,3 +43,33 @@ creation_rules:
# LXCs that run a webhook receiver.
age: >-
# placeholder — fill with age_pubkey of: apps, caddy, claudio-bot, claudio-monitor host
- path_regex: ^secrets/turn-shared-secret\.yaml$
# coturn TURN long-term-credential password. Consumed by hubris (which
# renders /etc/turnserver.conf + /opt/management.json on the VPS via
# `homelab render-vps-configs`). Other recipients are convenience for
# operator debugging — only hubris's pubkey is strictly required.
age: >-
age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6,
age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0,
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
- path_regex: ^secrets/netbird-authentik-oidc\.yaml$
# Authentik OIDC client secret for the netbird-dashboard provider.
# Consumed by hubris to render /opt/management.json on the VPS
# (PKCEAuthorizationFlow.ProviderConfig.ClientSecret).
age: >-
age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6,
age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0,
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
- path_regex: ^secrets/openrouter-api-key\.yaml$
# OpenRouter API key consumed by the `hermes` wrapper (bin/hermes) when
# spawning a Goose session. Recipients are any host that should run a
# Nous-Hermes agent. Add a host's age_pubkey here, then
# `sops updatekeys -y secrets/openrouter-api-key.yaml`.
# See operations/hermes-agent.md.
age: >-
age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6,
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
# webhook noop 2026-05-20T18:16:57+02:00

View File

@@ -43,12 +43,14 @@ See the full table in [`containers/index.md`](containers/index.md). Quick links:
- [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
### Investigations
Time-stamped incident notes / experiments in [`investigations/`](investigations/index.md).
### Operations
- [Command cheatsheet](operations/commands.md)
- [Agent enrollment](operations/agent-enrollment.md) — bootstrap a new client (workstation, LXC, VM) into the homelab context system
## Conventions

36
bin/hermes Executable file
View File

@@ -0,0 +1,36 @@
#!/usr/bin/env bash
#
# hermes — launch a Goose session pre-wired with the homelab persona,
# OpenRouter (Nous Hermes) provider, and the homelab MCP server.
#
# See operations/hermes-agent.md for the full onboarding flow.
set -euo pipefail
die() { echo "hermes: $*" >&2; exit 1; }
command -v goose >/dev/null \
|| die "goose binary not found — re-run bootstrap.sh with --with-hermes"
command -v homelab >/dev/null \
|| die "homelab CLI not found — is this client bootstrapped?"
# Decrypt OpenRouter API key.
# `homelab secret` re-execs via sudo for non-root users (age key is 0600 root).
SECRET_YAML=$(homelab secret openrouter-api-key 2>&1) || \
die "could not decrypt secrets/openrouter-api-key.yaml — this host probably
isn't a recipient yet. See operations/hermes-agent.md ('Granting the OpenRouter
key to a new host'). sops output:
${SECRET_YAML}"
API_KEY=$(printf '%s' "$SECRET_YAML" | python3 -c \
'import sys, yaml; print(yaml.safe_load(sys.stdin)["api_key"])')
case "$API_KEY" in
PLACEHOLDER_*|"")
die "openrouter-api-key.yaml still contains the placeholder; operator
must run \`sops secrets/openrouter-api-key.yaml\` on hubris to insert a real
\`sk-or-...\` key and push the change." ;;
esac
export OPENROUTER_API_KEY="$API_KEY"
exec goose session "$@"

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@
# curl -fsSL https://git.hubris.network/dtoro/Homelab-Docs/raw/main/bootstrap.sh \
# | sudo bash
# 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
#
@@ -20,20 +21,28 @@ set -euo pipefail
# -------- defaults --------
REPO_HTTPS="${HOMELAB_REPO_URL:-https://git.hubris.network/dtoro/Homelab-Docs.git}"
CLONE_DIR="${HOMELAB_CONTEXT_DIR:-/opt/homelab-context}"
ISSUANCE_URL_NETBIRD="${HOMELAB_ISSUANCE_NETBIRD:-http://apps.netbird.selfhosted:9820/issue}"
ISSUANCE_URL_TAILSCALE="${HOMELAB_ISSUANCE_TAILSCALE:-http://apps.ts:9820/issue}"
MCP_URL="${HOMELAB_MCP_URL:-http://apps.netbird.selfhosted:9810/sse}"
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}"
HERMES_MCP_URI="${HOMELAB_HERMES_MCP_URI:-https://mcp.hubris.network/mcp}"
HERMES_MODEL="${HOMELAB_HERMES_MODEL:-nousresearch/hermes-4-405b}"
WITH_MCP=0
WITH_HERMES=0
DRY_RUN=0
NO_SECRETS=0
GITEA_TOKEN="${HOMELAB_GITEA_TOKEN:-}"
GITEA_USER="${HOMELAB_GITEA_USER:-dtoro}"
# -------- flag parsing --------
while [ $# -gt 0 ]; do
case "$1" in
--with-mcp) WITH_MCP=1; shift ;;
--with-hermes) WITH_HERMES=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
--no-secrets) NO_SECRETS=1; shift ;;
--gitea-token) GITEA_TOKEN="$2"; shift 2 ;;
--gitea-user) GITEA_USER="$2"; shift 2 ;;
--help|-h)
sed -n '2,11p' "$0" | sed 's/^# *//'
exit 0
@@ -43,6 +52,29 @@ while [ $# -gt 0 ]; do
esac
done
# If a gitea token is provided, write it to the standard credential store
# BEFORE the clone happens. The HTTPS REPO_HTTPS will then pick it up via
# git's credential helper.
configure_gitea_creds() {
if [ -z "$GITEA_TOKEN" ]; then return 0; fi
local creds_dir=/etc/homelab-context
local creds_file=$creds_dir/git-credentials
mkdir -p "$creds_dir"
chmod 700 "$creds_dir"
# Format the credential URL: <scheme>://user:token@host (scheme must match
# the actual REPO_HTTPS — git's credential helper does exact prefix match).
local proto host
proto=$(echo "$REPO_HTTPS" | sed -E 's|^(https?)://.*|\1|')
host=$(echo "$REPO_HTTPS" | sed -E 's|^https?://([^/]+).*|\1|')
printf '%s://%s:%s@%s\n' "$proto" "$GITEA_USER" "$GITEA_TOKEN" "$host" > "$creds_file"
chmod 600 "$creds_file"
# Point git at this store system-wide (/etc/gitconfig) so the systemd
# sync timer's git — which runs as root without HOME set — finds it.
# --global writes to /root/.gitconfig which the timer doesn't read.
git config --system credential.helper "store --file=$creds_file"
echo "[bootstrap] wrote gitea credentials to $creds_file"
}
run() {
if [ "$DRY_RUN" -eq 1 ]; then
printf '+ %s\n' "$*"
@@ -78,34 +110,143 @@ echo "[bootstrap] hostname: $HNAME"
# Check dependencies.
missing=()
for cmd in git; do command -v "$cmd" >/dev/null || missing+=("$cmd"); done
for cmd in git python3; do command -v "$cmd" >/dev/null || missing+=("$cmd"); done
# The homelab CLI needs PyYAML.
if ! python3 -c "import yaml" >/dev/null 2>&1; then
missing+=("python3-yaml")
fi
if [ "$NO_SECRETS" -eq 0 ]; then
for cmd in age sops; do command -v "$cmd" >/dev/null || missing+=("$cmd"); done
fi
if [ "${#missing[@]}" -gt 0 ]; then
echo "missing required tools: ${missing[*]}" >&2
if [ "$OS" = "Darwin" ]; then
echo " brew install ${missing[*]}"
if [ "$DRY_RUN" -eq 1 ]; then
echo "+ would install missing tools: ${missing[*]}"
else
echo " apt install -y ${missing[*]} (or platform equivalent)"
echo "[bootstrap] installing missing tools: ${missing[*]}"
if [ "$OS" = "Darwin" ]; then
brew_list=()
for m in "${missing[@]}"; do
case "$m" in
python3-yaml) python3 -m pip install --break-system-packages pyyaml >/dev/null 2>&1 \
|| python3 -m pip install pyyaml ;;
*) brew_list+=("$m") ;;
esac
done
[ "${#brew_list[@]}" -gt 0 ] && brew install "${brew_list[@]}"
elif command -v dnf >/dev/null 2>&1; then
dnf_list=()
for m in "${missing[@]}"; do
case "$m" in
python3-yaml) dnf_list+=("python3-pyyaml") ;;
*) dnf_list+=("$m") ;;
esac
done
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[@]}"
else
echo "[bootstrap] no supported package manager for: ${missing[*]}" >&2
echo "[bootstrap] install with your package manager + re-run" >&2
exit 1
fi
# Re-verify (especially python yaml — the rename is the most common gotcha).
for cmd in git python3; do
command -v "$cmd" >/dev/null || { echo "[bootstrap] post-install $cmd still missing" >&2; exit 1; }
done
python3 -c "import yaml" 2>/dev/null \
|| { echo "[bootstrap] post-install python3-yaml/pyyaml still missing" >&2; exit 1; }
if [ "$NO_SECRETS" -eq 0 ]; then
for cmd in age sops; do
command -v "$cmd" >/dev/null || { echo "[bootstrap] post-install $cmd still missing" >&2; exit 1; }
done
fi
fi
exit 1
fi
# Mesh check — accept either Netbird OR Tailscale.
# -------- 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
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
brew install --cask netbird || { echo "[bootstrap] brew install netbird failed" >&2; exit 1; }
elif command -v dnf >/dev/null 2>&1; then
cat > /etc/yum.repos.d/netbird.repo <<'NBREPO'
[netbird]
name=NetBird stable
baseurl=https://pkgs.netbird.io/yum/$basearch
enabled=1
gpgcheck=0
NBREPO
dnf install -y netbird netbird-ui || { echo "[bootstrap] dnf install netbird failed" >&2; exit 1; }
elif command -v apt-get >/dev/null 2>&1; then
install -d -m 0755 /usr/share/keyrings
curl -fsSL https://pkgs.netbird.io/debian/public.key \
| gpg --dearmor -o /usr/share/keyrings/netbird-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/netbird-archive-keyring.gpg] https://pkgs.netbird.io/debian stable main" \
> /etc/apt/sources.list.d/netbird.list
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y netbird \
|| { echo "[bootstrap] apt install netbird failed" >&2; exit 1; }
else
echo "[bootstrap] can't auto-install netbird on this OS; install manually + re-run" >&2
exit 1
fi
fi
# Bring netbird up if not already connected.
if command -v netbird >/dev/null && ! netbird status 2>/dev/null | grep -q "Management: Connected"; then
cat <<MSG
[bootstrap] netbird is not connected to https://netbird.hubris.network.
[bootstrap] running 'netbird up' — a device-code URL will print below.
[bootstrap] OPEN THAT URL in a browser and approve the device when prompted.
[bootstrap] You may need to log in to https://auth.hubris.network first.
MSG
# --ssh-jwt-cache-ttl=86400 keeps the SSO valid for 24h of subsequent ssh
# ops into mesh peers; saves repeated browser clicks during this bootstrap.
netbird up --management-url https://netbird.hubris.network --ssh-jwt-cache-ttl 86400 \
|| { echo "[bootstrap] 'netbird up' failed (see error above)" >&2; exit 1; }
# `netbird up` returns once the device-code SSO completes; give the
# mgmt connection ~30s to settle before continuing.
for _ in $(seq 1 10); do
netbird status 2>/dev/null | grep -q "Management: Connected" && break
sleep 3
done
if ! netbird status 2>/dev/null | grep -q "Management: Connected"; then
echo "[bootstrap] netbird daemon not reporting Management: Connected after 30s" >&2
echo "[bootstrap] try: 'netbird status -d' and 'sudo journalctl -u netbird -n 30'" >&2
exit 1
fi
echo "[bootstrap] netbird connected."
fi
fi
# Mesh check — accept Netbird, Tailscale, or LAN reachability of the issuance
# endpoint. LAN is fine for LXCs that don't run a mesh CLI but sit in the
# trusted subnet (192.168.8.0/24) included in MESH_SUBNETS.
MESH_CONNECTED=""
if command -v netbird >/dev/null && netbird status 2>/dev/null | grep -q "Management: Connected"; then
MESH_CONNECTED="netbird"
elif command -v tailscale >/dev/null && tailscale status >/dev/null 2>&1; then
MESH_CONNECTED="tailscale"
elif curl -sf -o /dev/null --max-time 3 "${ISSUANCE_URL_NETBIRD%/issue}/health" 2>/dev/null; then
MESH_CONNECTED="lan"
fi
if [ -z "$MESH_CONNECTED" ] && [ "$NO_SECRETS" -eq 0 ]; then
echo "neither netbird nor tailscale is connected; cannot bootstrap." >&2
echo "either bring up the mesh first, or pass --no-secrets to skip issuance." >&2
echo "no reachable issuance endpoint (no netbird/tailscale connected and" >&2
echo "$ISSUANCE_URL_NETBIRD did not respond to /health)." >&2
echo "either bring up the mesh first, fix DNS for *.hubris.network, or pass --no-secrets." >&2
exit 1
fi
echo "[bootstrap] mesh: ${MESH_CONNECTED:-none (skipped, --no-secrets)}"
# -------- gitea creds (if provided) --------
configure_gitea_creds
# -------- clone --------
if [ -d "$CLONE_DIR/.git" ]; then
existing_remote="$(git -C "$CLONE_DIR" remote get-url origin 2>/dev/null || true)"
@@ -190,8 +331,9 @@ echo "[bootstrap] installing sync mechanism for $OS"
run "bash '$CLONE_DIR/scripts/sync/install.sh'"
# -------- install homelab CLI --------
echo "[bootstrap] installing homelab CLI to /usr/local/bin/homelab"
run "install -m 0755 '$CLONE_DIR/bin/homelab' /usr/local/bin/homelab"
# Symlink rather than copy so the 5-min sync auto-updates the CLI.
echo "[bootstrap] linking homelab CLI to /usr/local/bin/homelab"
run "ln -sfn '$CLONE_DIR/bin/homelab' /usr/local/bin/homelab"
# -------- AGENTS.md symlink --------
case "$OS" in
@@ -205,6 +347,26 @@ esac
run "ln -sfn '$CLONE_DIR/AGENTS.md' '$AGENTS_LINK'"
echo "[bootstrap] linked AGENTS.md → $AGENTS_LINK"
# -------- auto-upgrade to write-scoped Gitea PAT --------
# After enrollment, if this client is already a recipient on
# secrets/gitea-pat.yaml (i.e. the operator has run
# `homelab client add --finalize-pubkey` from another client), swap the
# read-only bootstrap PAT for the write-scoped one. Best-effort: fails
# silently if the client isn't yet a recipient — the operator just re-runs
# bootstrap or `homelab refresh-creds` later.
if [ "$NO_SECRETS" -eq 0 ] && [ "$DRY_RUN" -eq 0 ] \
&& command -v sops >/dev/null 2>&1 \
&& [ -f "$CLONE_DIR/secrets/gitea-pat.yaml" ]; then
if /usr/local/bin/homelab refresh-creds >/tmp/homelab-refresh-creds.log 2>&1; then
echo "[bootstrap] refresh-creds: write-scoped Gitea PAT installed"
else
echo "[bootstrap] refresh-creds: skipped (this client isn't yet a recipient"
echo " on secrets/gitea-pat.yaml — run 'homelab client add"
echo " $HNAME --finalize-pubkey <age...>' from an existing client,"
echo " then re-run bootstrap or 'homelab refresh-creds')"
fi
fi
# -------- MCP wiring --------
if [ "$WITH_MCP" -eq 1 ]; then
# Pick the right user's home — when invoked via sudo, SUDO_USER is set.
@@ -246,6 +408,211 @@ PYEOF
fi
fi
# -------- Hermes (Goose + Nous Hermes) wiring --------
# Installs the Goose CLI binary system-wide, symlinks the `hermes` wrapper
# and HERMES.md persona, and drops a Goose config that pins the OpenRouter
# provider, the Nous Hermes model, and the homelab MCP extension.
# See operations/hermes-agent.md.
if [ "$WITH_HERMES" -eq 1 ]; then
# Resolve the operator's home (SUDO_USER under `sudo bash`).
if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ]; then
H_USER="$SUDO_USER"
H_HOME=$(eval echo "~$SUDO_USER")
else
H_USER="root"
H_HOME="$HOME"
fi
# 1. Install Goose binary at /usr/local/bin/goose (idempotent).
if ! command -v goose >/dev/null 2>&1; then
echo "[bootstrap] installing Goose CLI"
if [ "$DRY_RUN" -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 \
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
else
echo "[bootstrap] WARNING: goose binary not found at $H_HOME/.local/bin/goose after install" >&2
fi
fi
else
echo "[bootstrap] goose already installed: $(command -v goose)"
fi
# 2. Symlink hermes wrapper.
echo "[bootstrap] linking hermes CLI to /usr/local/bin/hermes"
run "ln -sfn '$CLONE_DIR/bin/hermes' /usr/local/bin/hermes"
# 3. Symlink HERMES.md persona. The hermes wrapper does not need it — the
# Goose config below references the canonical clone path — but operators
# frequently `cat /root/HERMES.md` to inspect the persona, mirroring the
# AGENTS.md convention above.
case "$OS" in
Linux) HERMES_LINK=/root/HERMES.md ;;
Darwin) HERMES_LINK=/etc/HERMES.md ;;
esac
run "ln -sfn '$CLONE_DIR/HERMES.md' '$HERMES_LINK'"
echo "[bootstrap] linked HERMES.md → $HERMES_LINK"
# 4. Drop the Goose config. Idempotent YAML merge — preserves any keys the
# operator added by hand, overwrites only the keys we manage.
GOOSE_DIR="$H_HOME/.config/goose"
GOOSE_CONFIG="$GOOSE_DIR/config.yaml"
GOOSEHINTS="$GOOSE_DIR/.goosehints"
run "mkdir -p '$GOOSE_DIR'"
PY_GOOSE_MERGE=$(cat <<PYEOF
import os, sys
try:
import yaml
except ImportError:
print("PyYAML required", file=sys.stderr); sys.exit(2)
path = "$GOOSE_CONFIG"
mcp_uri = "$HERMES_MCP_URI"
model = "$HERMES_MODEL"
cfg = {}
if os.path.exists(path):
with open(path) as f:
try:
cfg = yaml.safe_load(f) or {}
except Exception:
cfg = {}
cfg["GOOSE_PROVIDER"] = "openrouter"
cfg["GOOSE_MODEL"] = model
cfg.setdefault("GOOSE_MODE", "smart_approve")
cfg.setdefault("extensions", {})
cfg["extensions"]["developer"] = {
"bundled": True, "enabled": True, "name": "developer",
"timeout": 300, "type": "builtin",
}
cfg["extensions"]["homelab"] = {
"enabled": True, "name": "homelab",
"description": "Read-only homelab context tools (FastMCP).",
"type": "streamable_http", "uri": mcp_uri, "timeout": 60,
}
with open(path, "w") as f:
yaml.safe_dump(cfg, f, sort_keys=False)
print("[bootstrap] merged Goose config at", path)
PYEOF
)
if [ "$DRY_RUN" -eq 1 ]; then
echo "+ would merge Goose config at $GOOSE_CONFIG"
else
python3 -c "$PY_GOOSE_MERGE"
chown -R "$H_USER" "$GOOSE_DIR"
fi
# 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'"
if [ "$DRY_RUN" -eq 0 ]; then
chown -h "$H_USER" "$GOOSEHINTS" 2>/dev/null || true
fi
fi
# -------- netbird tuning (skip per-session SSO for ssh into mesh peers) --------
# Apply the SSH JWT cache TTL so `ssh ... .netbird.selfhosted` doesn't trigger
# device-code SSO on every connection. Flag added in netbird 0.71.x
# (netbirdio/netbird#4015). It belongs on `netbird up` — putting it on the
# daemon's ExecStart crashes the daemon with "unknown flag". After this runs the
# FIRST ssh still prompts SSO once; subsequent sessions within 24h skip it.
if [ "$MESH_CONNECTED" = "netbird" ]; then
if [ "$DRY_RUN" -eq 1 ]; then
echo "+ would: netbird down && netbird up --ssh-jwt-cache-ttl=86400"
elif netbird up --help 2>&1 | grep -q -- "--ssh-jwt-cache-ttl"; then
echo "[bootstrap] netbird: enabling ssh-jwt-cache-ttl=86400 (one SSO per 24h)"
# `netbird up` short-circuits with "Already connected" — need down first.
netbird down >/dev/null 2>&1 || true
if ! netbird up --ssh-jwt-cache-ttl=86400; then
echo "[bootstrap] WARNING: netbird up with --ssh-jwt-cache-ttl failed; rerun manually:"
echo "[bootstrap] netbird down && netbird up --ssh-jwt-cache-ttl=86400"
fi
else
echo "[bootstrap] netbird: --ssh-jwt-cache-ttl flag not supported (need >=0.71.x); skipping"
fi
fi
# -------- ssh ControlMaster for netbird peers (workstations) --------
# Drop a Host block into the enrolling user's ~/.ssh/config so that ssh to
# `*.netbird.selfhosted` multiplexes over a single connection. After one SSO
# device-code completion, subsequent ssh / scp / `pct exec` invocations
# (within ControlPersist=2h) reuse the master socket with no re-auth — the
# real workaround for netbird's flaky SSH JWT cache. Skip on LXCs (no
# outbound ssh expected from them).
HKIND="$(python3 -c "import yaml; print(yaml.safe_load(open('$HOST_YAML')).get('kind',''))" 2>/dev/null || true)"
if [ "$MESH_CONNECTED" = "netbird" ] && [ "$HKIND" != "lxc" ]; then
if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ]; then
SSH_USER_HOME=$(eval echo "~$SUDO_USER")
SSH_OWNER="$SUDO_USER"
else
SSH_USER_HOME="$HOME"
SSH_OWNER=""
fi
SSH_CFG="$SSH_USER_HOME/.ssh/config"
SSH_CM_DIR="$SSH_USER_HOME/.ssh/cm"
SENTINEL="# homelab-bootstrap: ssh ControlMaster for netbird mesh"
if [ "$DRY_RUN" -eq 1 ]; then
echo "+ would write Host *.netbird.selfhosted ControlMaster block into $SSH_CFG"
elif [ -f "$SSH_CFG" ] && grep -qF "$SENTINEL" "$SSH_CFG"; then
echo "[bootstrap] ssh ControlMaster block already present in $SSH_CFG (skip)"
else
mkdir -p "$SSH_USER_HOME/.ssh" "$SSH_CM_DIR"
chmod 700 "$SSH_USER_HOME/.ssh" "$SSH_CM_DIR"
cat >> "$SSH_CFG" <<'SSHEOF'
# homelab-bootstrap: ssh ControlMaster for netbird mesh
# One SSO covers many back-to-back ssh/scp/pct ops within ControlPersist.
Host *.netbird.selfhosted
ControlMaster auto
ControlPath ~/.ssh/cm/%C
ControlPersist 2h
SSHEOF
chmod 600 "$SSH_CFG"
if [ -n "$SSH_OWNER" ]; then
chown -R "$SSH_OWNER":"$SSH_OWNER" "$SSH_USER_HOME/.ssh"
fi
echo "[bootstrap] ssh: installed ControlMaster block into $SSH_CFG"
fi
fi
# -------- mcp CLI install (workstations) --------
# `homelab mcp <tool>` shells out to the `mcp` python CLI. Install it via
# pipx for the enrolling user. Skip on LXCs / VMs.
if [ "$HKIND" != "lxc" ] && [ "$HKIND" != "vm" ]; then
if [ "$DRY_RUN" -eq 1 ]; then
echo "+ would install 'mcp[cli]' via pipx for the enrolling user"
elif command -v mcp >/dev/null 2>&1; then
echo "[bootstrap] mcp CLI already on PATH (skip)"
else
# 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
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
DEBIAN_FRONTEND=noninteractive apt-get install -y pipx 2>&1 | tail -2 || true
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
echo "[bootstrap] mcp CLI installed for $INVOKING_USER via pipx"
else
echo "[bootstrap] WARNING: pipx unavailable; install manually: pipx install 'mcp[cli]'" >&2
fi
fi
fi
# -------- done --------
cat <<EOF

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).
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.
## At a glance
- **Hostname:** `apps`
@@ -19,6 +19,8 @@ Docker host for everything that doesn't justify its own LXC. Currently runs Arti
| `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). |
| `mcp.hubris.network` | homelab-mcp | `:9810` | MCP server over SSE. No SSO — agent-facing, gated at source-IP layer. See [homelab-context](../infrastructure/homelab-context.md). |
| `secrets.hubris.network` | secrets-issuance | `:9820` | Per-client age-key issuance. Mesh+LAN source-IP gated (`MESH_SUBNETS`). |
## Stacks
@@ -49,6 +51,51 @@ Native OIDC via Authentik (Settings → OIDC). Redirect URI `/oauth2-callback` (
> ⚠️ **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
[homelab-context](../infrastructure/homelab-context.md) client). Source
is `dtoro/Homelab-Docs/mcp/server.py`; service unit
`homelab-mcp.service` binds `0.0.0.0:9810`. DNS-rebinding protection
disabled at the FastMCP layer because mesh+LAN gating is the actual
trust boundary.
- Endpoint: `https://mcp.hubris.network/sse` (Caddy → `:9810`).
- 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`,
`list_lxcs`, `get_lxc_state`, `ping_service` (read-only management).
- Management tools shell out via SSH using a dedicated `mcp-reader` key
(planned; not provisioned yet — management tools fail until then).
#### Auto-deploy pipeline (homelab-mcp)
Receiver at `/opt/homelab-mcp/mcp/deploy/webhook/` (in-tree alongside
the server code). Listens on `0.0.0.0:9811`, secret in
`/etc/homelab-mcp-deploy/secret`, systemd unit
`homelab-mcp-deploy.service`. Gitea webhook id 10 on
`dtoro/Homelab-Docs`. Deploy script reinstalls the service unit and
restarts on push.
### secrets-issuance (`/opt/secrets-issuance/`)
Tiny HTTP service that issues per-client age keypairs the first time
each client calls `/issue`. Idempotent: subsequent calls return the
same key. Mesh+LAN source-IP gated via the `MESH_SUBNETS` env in
`secrets-issuance.service`. State (per-client keys + denylist) lives at
`/var/lib/secrets-issuance/` (NOT in the repo).
- Endpoint: `https://secrets.hubris.network/issue` (Caddy → `:9820`).
- `/revoke` is admin-token-gated by `/etc/secrets-issuance/admin-token`;
shreds the local key file and adds the hostname to the denylist.
Called by `homelab client remove`.
- Trust subnets today: `100.122.0.0/16` (Netbird), `100.64.0.0/10`
(Tailscale), `192.168.8.0/24` (LAN). Tighten if the LAN gets
untrusted devices.
#### Auto-deploy pipeline (secrets-issuance)
Receiver at `/opt/secrets-issuance/secrets-issuance/deploy/webhook/`,
port `9821`, secret in `/etc/secrets-issuance-deploy/secret`, unit
`secrets-issuance-deploy.service`. Gitea webhook id 11.
### WriteFreely (`/opt/writefreely-docker/`)
Native OIDC via `[oauth.generic]` in `config/config.ini`. `host = https://auth.hubris.network`, scope `openid profile email`, `map_user_id = sub`. Redirect URI in Authentik: `https://blog.hubris.network/oauth/callback/generic`. Container has `extra_hosts: auth.hubris.network:192.168.8.175`. First-time users with a local WF account must connect SSO via Customize/Settings → Linked Accounts.
@@ -66,6 +113,16 @@ Native OIDC via `[oauth.generic]` in `config/config.ini`. `host = https://auth.h
## Changelog
### 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
auto-deploy. LXC is itself an enrolled context client
(`/opt/homelab-context/`).
### 2026-04-28 — wiki entry created
Initial documentation.

View File

@@ -1,68 +1,46 @@
# 120 — `mule-images`
Hosts `mule-image` / "mulita" — the photos app at `photos.hubris.network`. Auto-deploys from `dtoro/mule-image` on `git push origin main`.
Hosts `mule-image` — the photos app at `photos.hubris.network`. PhotoPrism + Go sidecar + SvelteKit, replacing the legacy FastAPI/Celery stack as of 2026-05-22 (see Changelog). Auto-deploys from `dtoro/mule-image` on `git push origin main`.
## At a glance
- **Hostname:** `mule-images`
- **IP:** `192.168.8.136`
- **Privilege:** privileged
- **Resources:** 4 cores / 8 GiB RAM / 60 GiB rootfs
- **Mounts:** `/mnt/library``/mnt/library`
- **Public hostname:** [`photos.hubris.network`](../infrastructure/dns.md) → [caddy](121-caddy.md) → `:3000` (frontend)
- **Resources:** 6 cores / 12 GiB RAM / 60 GiB rootfs / 2 GiB swap
- **Mounts:** `/mnt/library``/mnt/library`; `/dev/dri/{card0,renderD128}` passed through for VA-API video accel on the AMD Phoenix1 iGPU.
- **Public hostname:** [`photos.hubris.network`](../infrastructure/dns.md) → [caddy](121-caddy.md) → path-routed to PhotoPrism `:2342` / sidecar `:8000` / nginx-static `:3000`.
## Stack (`/opt/mule-image`)
`/opt/mule-image` IS the working tree of `dtoro/mule-image`. Compose at `/opt/mule-image/docker-compose.yml`. Services:
`/opt/mule-image` IS the working tree of `dtoro/mule-image`. Compose stack: base `docker-compose.yml` + LAN-port-rebind `docker-compose.override.yml` (untracked) + VA-API `docker-compose.gpu.yml`. The SvelteKit frontend is built on the host and served as a static bundle by nginx — no `vite dev` in prod.
| Service | Port | Notes |
| ----------------- | ------ | ----- |
| frontend | 3000 | Reverse-proxied by Caddy |
| backend | 8001 | FastAPI |
| worker-light | — | Sole celery worker — runs default/high/low queues + beat scheduler |
| db | (pg) | postgres:16 (was pgvector/pg16 until 2026-05-14) |
| redis | (rd) | queue |
| Service / process | Port (LAN) | Notes |
| ------------------------ | --------------------- | ----- |
| pp-mariadb | (internal 3306) | MariaDB 11; holds PhotoPrism's `photoprism` DB + sidecar's `mule_sidecar.*` |
| pp-app | `0.0.0.0:2342` | PhotoPrism `:latest`; `PHOTOPRISM_FFMPEG_ENCODER=h264_vaapi` |
| pp-sidecar | `0.0.0.0:8000` | Go service (file rename / folder ops / heap convert / dup scan / per-photo marks); also reconciles `USER_BASEPATHS` into PhotoPrism's `auth_users` every 60 s |
| nginx (host process) | `0.0.0.0:3000` | Serves the SvelteKit static bundle from `web/build/` |
**No vision / ML services.** The OpenCLIP ONNX classifier and its dedicated `worker-vision` were ripped out on 2026-05-14 — see Changelog.
**Per-user scoping** is driven by `USER_BASEPATHS=admin:admin/files, muli:muli/files` in `.env`. Sidecar applies it to PhotoPrism's `auth_users` table on boot + every 60s, `mkdir -p`s each target so PhotoPrism's ACL filter has somewhere to point.
`worker-watcher` was retired 2026-05-11 — file events come from NC webhook_listeners instead of watchfiles inotify. See "Nextcloud integration" below.
`.env` is **untracked**`git reset --hard` won't touch it, but `git clean -fdx` would. Holds PP_*, MariaDB passwords, SIDECAR_DB_PASSWORD, OIDC_* (existing `mule-image` Authentik app, redirect URI now `/api/v1/oidc/redirect`), `USER_BASEPATHS`, and `PP_FFMPEG_ENCODER=vaapi`.
`.env` is **untracked**`git checkout .env` will wipe it. Holds:
- `PHOTO_DIRS=/mnt/library/images/`
- `NEXTCLOUD_USERS_HOST_PATH=/mnt/library/homecloud`
- `NEXTCLOUD_BASE_URL=https://cloud.hubris.network`
- OIDC client secret + scopes
- `SECRET_KEY` (generated)
- `NEXTCLOUD_WEBHOOK_SECRET` (since 2026-05-11; bearer token NC presents on every webhook call to `/api/v1/internal/nc-webhook`)
`docker-compose.override.yml` is also untracked — it `!override`s the upstream `127.0.0.1:port` mappings to `0.0.0.0:port` so cross-host Caddy on [LXC 121](121-caddy.md) can reach pp-app + sidecar.
## Nextcloud integration (since 2026-05-11)
## Library access
Phase 1 + 2 of dropping the duplicated indexing work between mule and Nextcloud — see `~/.claude/projects/-root/memory/project_mule_nc_integration.md` for the full detail.
PhotoPrism reads the library **directly off the bind-mounted filesystem** — no Nextcloud webhook integration in the new stack. The base path is `/mnt/library/homecloud` and per-user scoping comes from `USER_BASEPATHS` (see Stack section above).
**Thumbnails (Phase 1, commit 576b0c2):** `GET /api/v1/photos/{id}/thumb/{size}` proxies `https://cloud.hubris.network/index.php/core/preview` keyed by `photos.nextcloud_fileid` (alembic 0018), authenticated with the photo owner's encrypted NC app password. NC handles JPEG/HEIC/TIFF/RAW/DNG/Movie previews natively. `X-Mule-Thumb-Source: nextcloud|disk` on every response. Worker now only writes the `medium` size to `/data/thumbs/{user}/{photo}/medium.webp` (the vision worker still loads it from disk); `small` + `large` are NC-proxied. Backfill script: `backend/scripts/backfill_nextcloud_fileid.py` — 17,591/17,591 photos filled.
- `dtoro` is mapped to NC user `admin``/mnt/library/homecloud/admin/files/`
- `muli` is mapped to NC user `muli``/mnt/library/homecloud/muli/files/`
**File events (Phase 2, commits 362fbc6 + f657e2c):** mule no longer runs `watchfiles`. NC's `webhook_listeners` app (already installed, registered via `backend/scripts/register_nc_webhooks.py`) POSTs every `NodeCreated|NodeWritten|NodeDeleted|NodeRenamed` event to `http://192.168.8.136:8001/api/v1/internal/nc-webhook` with `Authorization: Bearer $NEXTCLOUD_WEBHOOK_SECRET`. The endpoint dispatches the same `scan_folder` / `handle_file_deletion` machinery the watcher used. **NC dispatches webhooks asynchronously via its background-job queue**`crontab -u www-data` on LXC 114 runs `cron.php` every minute (`*/1`), so end-to-end latency is ≲60 s. Re-running the registrar script is idempotent (it deletes existing webhooks pointing at the same URL first).
PhotoPrism's container user is `33:10000` (`www-data:media`), matching the host ownership of the NC tree. The legacy `oc_webhook_listeners` rows + `NEXTCLOUD_WEBHOOK_SECRET` are gone — when NC writes via WebDAV (still its own primary surface), PhotoPrism picks up the new file on its next index pass.
## Nextcloud-rooted libraries (since 2026-04-26)
## Authentication
Photo libraries live under each user's Nextcloud `files/` tree, NOT in `/mnt/library/images/*`.
- `/mnt/library/homecloud` is bind-mounted into `backend`, `worker-light`, `worker-watcher`, `worker-vision` as `/nextcloud-users`. Each NC user is `/nextcloud-users/<nc_user>/files/`.
- Reads use that bind directly.
- Mutations (upload, delete, rename, move) dispatch through `services/nextcloud_dav.py` (HTTP Basic auth, per-user app password Fernet-encrypted in `users.nextcloud_app_password_enc`) so Nextcloud's `oc_filecache`, trashbin, comments, and desktop-sync clients stay coherent.
- Photo copy + cross-system moves return 501 with a "use Nextcloud's web UI" hint — defer until needed.
- `users.nextcloud_username` overrides the default OIDC `preferred_username`. **`dtoro` (mule-image) maps to `admin` (Nextcloud)** — don't assume username equality.
- Surviving SourceRoots in DB: `Photos``/nextcloud-users/admin/files/Photos`; `Memories``/nextcloud-users/admin/files/Memories` (both owned by `dtoro`). User `muli` has `nextcloud_username=muli` backfilled but no SourceRoot yet.
- Pre-migration DB dump: `/root/snapshots/mulita-pre-nc-migration-20260426-075132.dump` (11 MB) on the host.
OIDC via Authentik. App slug `mule-image`, redirect URI `https://photos.hubris.network/api/v1/oidc/redirect` (PhotoPrism's auto-derived path; PhotoPrism builds it from `PHOTOPRISM_SITE_URL`). `OIDC_REGISTER=true` auto-creates a PhotoPrism user at role `user` on first SSO login. The sidecar's basepath reconciler then assigns their scoped folder.
## Authentication (since 2026-04-22)
Native OIDC via Authentik. Code in `backend/app/auth_oidc.py`, routes `/api/v1/auth/oidc/{login,callback}`. Authentik side:
- OAuth2/OIDC Provider, client ID `fCuHew48ONTskDjUKnMTZjFbVXuHwvQqTScQRNQ1`
- App slug `mule-image`
- Redirect URI: `https://photos.hubris.network/api/v1/auth/oidc/callback`
Backend container needs `extra_hosts: auth.hubris.network:192.168.8.175` via `docker-compose.override.yml` (gitignored). Otherwise Authlib's metadata fetch fails with `SSL: CERTIFICATE_VERIFY_FAILED: self-signed certificate` (it ends up at a random public host because LXC DNS resolves the public IONOS A record).
Caddyfile stays plain `reverse_proxy 192.168.8.136:3000` — no forward-auth, no `/api/*` bypass needed.
The `mule-image` Authentik app's redirect URI was migrated from the legacy FastAPI `/api/v1/auth/oidc/callback` path on 2026-05-22 — same client ID/secret were reused. The separate `mule-photos-new` Authentik app was deleted in the same operation.
## Auto-deploy
@@ -70,11 +48,10 @@ Push to `dtoro/mule-image` `main` → gitea webhook → `http://192.168.8.136:97
- Validates HMAC against `/etc/mule-deploy/secret`
- Filters to `refs/heads/main`
- Runs `/opt/mule-deploy/deploy.sh` in a daemon thread (returns 202 immediately — docker builds exceed gitea's request timeout)
- `git pull --ff-only` + `docker compose up -d --build` + `docker image prune -f`
- `git fetch && git reset --hard origin/main`, force-recreates **only the sidecar** image (so PhotoPrism's `Session` HMAC key in `pp/storage/config/hub.yml` doesn't rotate and invalidate every in-flight OIDC state cookie), reconciles `pp-app` + `mariadb` in place, `cd web && npm ci && npm run build`, `systemctl reload nginx`.
- Conditionally layers `docker-compose.gpu.yml` when `/dev/dri/renderD128` exists, and always layers `docker-compose.override.yml` (the LAN-port rebind) when present.
Deploy tooling is **outside** the app repo: `/opt/mule-deploy/{deploy.sh,webhook.py}`, secret at `/etc/mule-deploy/secret`, unit at `/etc/systemd/system/mule-deploy-webhook.service`. Same shape as the Caddy + Artifacto pipelines. Gitea webhook id 6.
`app.ini` `ALLOWED_HOST_LIST` on [gitea](104-gitea.md) includes `192.168.8.136`.
Deploy tooling is **outside** the app repo: `/opt/mule-deploy/{deploy.sh,webhook.py}`, secret at `/etc/mule-deploy/secret`. Gitea webhook id 6. `app.ini` `ALLOWED_HOST_LIST` on [gitea](104-gitea.md) includes `192.168.8.136`.
Logs: `pct exec 120 -- journalctl -u mule-deploy-webhook -f`.
Manual deploy: `pct exec 120 -- /opt/mule-deploy/deploy.sh`.
@@ -91,6 +68,53 @@ For pushes from inside the LXC, gitea creds at `/etc/mule-deploy/git-credentials
## Changelog
### 2026-05-22 — Cutover to PhotoPrism stack (Mulimage 2.0)
The `new` branch of `dtoro/mule-image` was merged into `main` as commit
`70dc1b6`. The merge replaces the legacy FastAPI + PostgreSQL + Celery
+ React stack with PhotoPrism + Go sidecar + MariaDB + SvelteKit, lifted
in from the M0 evaluation on (now-destroyed) [LXC 127](#).
Cutover on 120 (this session):
- Bumped to 6 cores / 2 GB swap. `/dev/dri/{card0,renderD128}` already
passed through; added an opt-in `docker-compose.gpu.yml` overlay that
the deploy script layers in when the device is present. PhotoPrism now
uses `h264_vaapi` instead of `libx264`.
- Installed host nginx + a small `photos.conf` vhost serving the SvelteKit
static bundle from `/opt/mule-image/web/build/` on `:3000` — no Vite
dev server in prod. SvelteKit's `adapter-static` produces a real SPA
bundle.
- Replaced `/opt/mule-deploy/deploy.sh` with the 127-style multi-compose
deploy (force-recreates only sidecar to preserve PhotoPrism's Session
HMAC key; reconciles pp-app + mariadb in place; rebuilds web/ and
reloads nginx).
- `/opt/mule-image/.env` was rewritten to the PhotoPrism shape. Reused
passwords from `/root/mule-photos-new-secrets.txt`. `USER_BASEPATHS=
admin:admin/files, muli:muli/files` exposes both libraries.
- `docker-compose.override.yml` (untracked) `!override`s pp-app +
sidecar ports to `0.0.0.0` so cross-LXC caddy on [121](121-caddy.md)
can reach them.
- Authentik: `mule-image` app's redirect URI updated to `/api/v1/oidc/redirect`;
the separate `mule-photos-new` app deleted.
- Caddyfile (`dtoro/caddy-conf`): `photos.hubris.network` switched from
plain passthrough to path-matchers (PhotoPrism API + sidecar API +
nginx static catch-all), and `photos-new.hubris.network` was removed
entirely.
- Cleanups: 4 Nextcloud `oc_webhook_listeners` rows for the legacy
`/api/v1/internal/nc-webhook` endpoint deleted; gitea webhook id 9
(the `refs/heads/new` deploy hook for 127) deleted; `192.168.8.181`
removed from gitea's `ALLOWED_HOST_LIST`; `photos-new.hubris.network`
removed from dnsmasq.
- LXC 127 destroyed via `pct destroy 127` after the new stack passed
curl verification end-to-end (PP `/api/v1/status` returns
`{"status":"operational"}` through caddy; sidecar `/api/sidecar/healthz`
returns `{"ok":true,...}`).
- Rollback safety net: legacy `mulita` postgres dump at
`/root/backups/mulita-legacy-final-20260521-212036.sql.gz` on hubris
(10 MB, 16,155 photo rows). The `mule-image_mule_db_data` docker
volume on 120 retains the on-disk postgres data for at least 24 h
before housekeeping removes it.
### 2026-05-14 — Drop AI/vision pipeline, plain Postgres, DB↔FS refresh
**AI removal (commits `a27267f` + `423a73a`).** The OpenCLIP-on-ONNX

View File

@@ -100,16 +100,63 @@ API paths to bypass per app:
| [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 — DEFERRED
## Netbird IdP integration — LANDED 2026-05-21
The `netbirdio/netbird-server` combined image has no config knobs for external OIDC. Verified in `combined/cmd/config.go` on main (v0.69.0): `AuthConfig` only exposes issuer + redirect URIs; `ToManagementConfig()` hardcodes `AuthAudience="netbird-dashboard"`, `UserIDClaim="sub"`, always calls `buildEmbeddedIdPConfig()`. Code comment: `"embedded IdP is always enabled in combined server"`.
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).
To wire Authentik into Netbird login/user-sync, must migrate to the legacy split stack (`netbirdio/management` + `signal` + `dashboard` + coturn) with the richer `management.json` schema. Sqlite management data should migrate but needs verification; peers stay connected via wireguard keys.
**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`.
**Pre-work already in place (keep for re-use):**
- Provider `Provider for Netbird` + App `netbird`. Client ID `xZwVTFCsxWdBM3uIGS15wAAcVvsJiTtWdxVCEela`. Redirect URIs for `https://netbird.hubris.network/{nb-auth,nb-silent-auth}` and `http://localhost:53000/`. Scopes: `openid profile email offline_access goauthentik.io/api`.
- Service account `netbird-service` in `authentik Admins` group, non-expiring API token `netbird-service-api`.
- Netbird mgmt host (`82.165.190.79`) is now a peer on its own mesh. See [mesh](../infrastructure/mesh.md).
**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
@@ -123,11 +170,14 @@ dnsmasq runs alongside Authentik on this LXC, listening on `192.168.8.180:53` +
## 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 can't take an external IdP. Pre-work in Authentik kept for later. Netbird mgmt host instead joined its own mesh as a peer (`100.122.165.149`) for split-horizon DNS access.
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.

View File

@@ -144,8 +144,23 @@ curl -sk --resolve photos-new.hubris.network:443:192.168.8.175 \
https://photos-new.hubris.network/api/v1/oidc/login -i | head -2 # 302 → auth.hubris.network
```
> **Decommissioned 2026-05-22.** The PhotoPrism + sidecar + SvelteKit stack
> validated here was promoted into production on [LXC 120](120-mule-images.md)
> via the `Mulimage 2.0` merge (`dtoro/mule-image` commit `70dc1b6`). This
> page is retained for archaeology; everything below is historic. See the
> 2026-05-22 entry in [120-mule-images.md](120-mule-images.md#changelog) for
> the cutover detail.
## Changelog
### 2026-05-22 — Destroyed
`pct destroy 127` after curl verification of the new 120 stack passed
end-to-end. dnsmasq `photos-new.hubris.network` line removed; gitea
webhook id 9 + `192.168.8.181` ALLOWED_HOST_LIST entry removed; caddy
`photos-new.hubris.network` site block dropped from `dtoro/caddy-conf`.
Authentik `mule-photos-new` app + provider deleted.
### 2026-05-18 (pm) — OIDC state-cookie fix: deploy.sh no longer recreates pp-app
User reported `failed to get state: securecookie: the value is not valid` after

View File

@@ -11,18 +11,18 @@ All containers live on [`hubris`](../hosts/hubris.md). Each row links to the per
| 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 | 4 | 8 GiB | 60 GiB | `/mnt/library` | `photos.hubris.network` | 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 |
| 127 | [mule-photos-new](127-mule-photos-new.md) | 192.168.8.181 | unpriv | 6 | 8 GiB | 40 GiB | — (rsync scratch on rootfs) | `photos-new.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 |

View File

@@ -9,8 +9,9 @@ host: hubris
pve_id: 105
lan_ip: 192.168.8.205
mesh:
netbird:
fqdn: apps.netbird.selfhosted
tailscale:
ip: 100.121.171.122
fqdn: apps
mesh_globals:
primary: netbird
accepted:
@@ -35,16 +36,20 @@ services_hosted:
- name: homelab_mcp
backend: apps
port: 9810
netbird_host: apps.netbird.selfhosted
endpoint: http://apps.netbird.selfhosted:9810/sse
note: MCP server. Read-only context + management. Mesh-only.
systemd_unit: homelab-mcp
public_host: mcp.hubris.network
endpoint: https://mcp.hubris.network/sse
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).
- name: secrets_issuance
backend: apps
port: 9820
netbird_host: apps.netbird.selfhosted
endpoint: http://apps.netbird.selfhosted:9820/issue
note: Issues per-client age private keys. Mesh-only.
systemd_unit: secrets-issuance
public_host: secrets.hubris.network
endpoint: https://secrets.hubris.network/issue
note: Issues per-client age private keys. Gated at source-IP layer (mesh + LAN subnets in MESH_SUBNETS).
age_pubkey: age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0
see_also:
- containers/105-apps.md
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue
mcp_endpoint: https://mcp.hubris.network/sse
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -9,8 +9,8 @@ host: hubris
pve_id: 122
lan_ip: 192.168.8.132
mesh:
netbird:
fqdn: arriman.netbird.selfhosted
tailscale:
fqdn: arr
mesh_globals:
primary: netbird
accepted:
@@ -30,5 +30,5 @@ services_hosted:
note: jellyseerr / qbit / sab on docker compose
see_also:
- containers/122-arriman.md
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue
mcp_endpoint: https://mcp.hubris.network/sse
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -8,9 +8,6 @@ role: idp
host: hubris
pve_id: 124
lan_ip: 192.168.8.180
mesh:
netbird:
fqdn: authentik.netbird.selfhosted
mesh_globals:
primary: netbird
accepted:
@@ -31,5 +28,5 @@ notes:
- 'Also hosts split-horizon dnsmasq: /etc/dnsmasq.d/hubris-split.conf'
see_also:
- containers/124-authentik.md
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue
mcp_endpoint: https://mcp.hubris.network/sse
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -8,9 +8,6 @@ role: reverse-proxy
host: hubris
pve_id: 121
lan_ip: 192.168.8.175
mesh:
netbird:
fqdn: caddy.netbird.selfhosted
mesh_globals:
primary: netbird
accepted:
@@ -31,5 +28,5 @@ notes:
- /etc/caddy is a git checkout of dtoro/caddy-conf
see_also:
- containers/121-caddy.md
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue
mcp_endpoint: https://mcp.hubris.network/sse
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -8,9 +8,6 @@ role: matrix-agent
host: hubris
pve_id: 123
lan_ip: 192.168.8.230
mesh:
netbird:
fqdn: claudio-bot.netbird.selfhosted
mesh_globals:
primary: netbird
accepted:
@@ -18,7 +15,8 @@ mesh_globals:
- tailscale
notes:
- Reads /opt/homelab-context/ on startup
age_pubkey: age1xmkeq968areza2necqyq0065dpeegngzyr6dhagh0n6pl33lccfqe5mqn9
see_also:
- containers/123-claudio-bot.md
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue
mcp_endpoint: https://mcp.hubris.network/sse
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -9,8 +9,8 @@ host: hubris
pve_id: 118
lan_ip: 192.168.8.239
mesh:
netbird:
fqdn: elementsynapse.netbird.selfhosted
tailscale:
fqdn: elementsynapse
mesh_globals:
primary: netbird
accepted:
@@ -25,5 +25,5 @@ services_hosted:
backend: elementsynapse
see_also:
- containers/118-elementsynapse.md
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue
mcp_endpoint: https://mcp.hubris.network/sse
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -9,8 +9,8 @@ host: hubris
pve_id: 104
lan_ip: 192.168.8.121
mesh:
netbird:
fqdn: gitea.netbird.selfhosted
tailscale:
fqdn: gitea
mesh_globals:
primary: netbird
accepted:
@@ -30,5 +30,5 @@ notes:
- Bare repos live at /mnt/library/repos/dtoro/*.git
see_also:
- containers/104-gitea.md
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue
mcp_endpoint: https://mcp.hubris.network/sse
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -7,9 +7,10 @@ os: linux
role: home-automation
host: hubris
pve_id: 108
lan_ip: 192.168.8.101
mesh:
netbird:
fqdn: haos.netbird.selfhosted
tailscale:
fqdn: homeassistant
mesh_globals:
primary: netbird
accepted:
@@ -22,5 +23,5 @@ services_hosted:
backend: haos
see_also:
- vms/108-haos.md
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue
mcp_endpoint: https://mcp.hubris.network/sse
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -20,6 +20,7 @@ mounts:
ssh:
port: 22
netbird_port: 22022
user: root
runs:
- proxmox_ui
services_hosted:
@@ -27,7 +28,8 @@ services_hosted:
url: https://proxmox.hubris.network
backend: hubris
port: 8006
age_pubkey: age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6
see_also:
- hosts/hubris.md
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue
mcp_endpoint: https://mcp.hubris.network/sse
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -9,8 +9,8 @@ host: hubris
pve_id: 101
lan_ip: 192.168.8.206
mesh:
netbird:
fqdn: jellyfin.netbird.selfhosted
tailscale:
fqdn: jellyfin
mesh_globals:
primary: netbird
accepted:
@@ -27,5 +27,5 @@ services_hosted:
backend: jellyfin
see_also:
- containers/101-jellyfin.md
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue
mcp_endpoint: https://mcp.hubris.network/sse
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -13,5 +13,5 @@ mesh_globals:
accepted:
- netbird
- tailscale
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue
mcp_endpoint: https://mcp.hubris.network/sse
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -7,13 +7,15 @@ os: macos
role: dev
mesh:
netbird:
fqdn: mac-mini.netbird.selfhosted
fqdn: mac-mini-234-17.netbird.selfhosted
mesh_globals:
primary: netbird
accepted:
- netbird
- tailscale
ssh:
user: dtoro
notes:
- Only macOS in the fleet. Bootstrap uses launchd.
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue
mcp_endpoint: https://mcp.hubris.network/sse
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -9,8 +9,8 @@ host: hubris
pve_id: 120
lan_ip: 192.168.8.136
mesh:
netbird:
fqdn: mule-images.netbird.selfhosted
tailscale:
fqdn: muleimage
mesh_globals:
primary: netbird
accepted:
@@ -27,5 +27,5 @@ services_hosted:
backend: mule-images
see_also:
- containers/120-mule-images.md
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue
mcp_endpoint: https://mcp.hubris.network/sse
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -1,29 +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: mule-photos-new
kind: lxc
os: linux
role: photo-management
host: hubris
pve_id: 127
lan_ip: 192.168.8.181
mesh:
netbird:
fqdn: mule-photos-new.netbird.selfhosted
mesh_globals:
primary: netbird
accepted:
- netbird
- tailscale
public_host: photos-new.hubris.network
runs:
- photos_new
services_hosted:
- name: photos_new
url: https://photos-new.hubris.network
backend: mule-photos-new
see_also:
- containers/127-mule-photos-new.md
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue

29
hosts/netbird-vps.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: netbird-vps
kind: external
os: linux
role: netbird-mgmt
mesh:
netbird:
ip: 100.122.165.149
fqdn: netbird-ionos.netbird.selfhosted
mesh_globals:
primary: netbird
accepted:
- netbird
- tailscale
ssh:
user: root
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)."
- NOT a homelab client. No /etc/age/key.txt, no /opt/homelab-context clone. Managed via ssh from hubris;
sshd is locked to hubris's pubkey.
- Public IPv4 82.165.190.79. Auto-patching via unattended-upgrades.
- 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
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -9,8 +9,8 @@ host: hubris
pve_id: 114
lan_ip: 192.168.8.224
mesh:
netbird:
fqdn: nextcloud.netbird.selfhosted
tailscale:
fqdn: nextcloud
mesh_globals:
primary: netbird
accepted:
@@ -27,5 +27,5 @@ services_hosted:
backend: nextcloud
see_also:
- containers/114-nextcloud.md
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue
mcp_endpoint: https://mcp.hubris.network/sse
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -8,9 +8,6 @@ role: storage-export
host: hubris
pve_id: 102
lan_ip: 192.168.8.200
mesh:
netbird:
fqdn: nfs-export.netbird.selfhosted
mesh_globals:
primary: netbird
accepted:
@@ -18,5 +15,5 @@ mesh_globals:
- tailscale
see_also:
- containers/102-nfs-export.md
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue
mcp_endpoint: https://mcp.hubris.network/sse
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -9,8 +9,8 @@ host: hubris
pve_id: 103
lan_ip: 192.168.8.130
mesh:
netbird:
fqdn: paperless.netbird.selfhosted
tailscale:
fqdn: paperless
mesh_globals:
primary: netbird
accepted:
@@ -27,5 +27,5 @@ services_hosted:
backend: paperless
see_also:
- containers/103-paperless.md
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue
mcp_endpoint: https://mcp.hubris.network/sse
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -8,9 +8,6 @@ role: app
host: hubris
pve_id: 126
lan_ip: 192.168.8.190
mesh:
netbird:
fqdn: plato.netbird.selfhosted
mesh_globals:
primary: netbird
accepted:
@@ -27,5 +24,5 @@ services_hosted:
backend: plato
see_also:
- containers/126-plato.md
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue
mcp_endpoint: https://mcp.hubris.network/sse
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -13,5 +13,7 @@ mesh_globals:
accepted:
- netbird
- tailscale
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue
ssh:
user: dtoro
mcp_endpoint: https://mcp.hubris.network/sse
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -9,8 +9,8 @@ host: hubris
pve_id: 119
lan_ip: 192.168.8.157
mesh:
netbird:
fqdn: sophia.netbird.selfhosted
tailscale:
fqdn: sophia
mesh_globals:
primary: netbird
accepted:
@@ -20,5 +20,5 @@ mounts:
- /mnt/library
see_also:
- containers/119-sophia.md
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue
mcp_endpoint: https://mcp.hubris.network/sse
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -7,9 +7,7 @@ os: linux
role: nas-frontend-eval
host: hubris
pve_id: 100
mesh:
netbird:
fqdn: zimaos.netbird.selfhosted
lan_ip: 192.168.8.195
mesh_globals:
primary: netbird
accepted:
@@ -24,5 +22,5 @@ services_hosted:
backend: zimaos
see_also:
- vms/100-zimaos.md
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue
mcp_endpoint: https://mcp.hubris.network/sse
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -40,6 +40,12 @@ The app repo at `/opt/<thing>` is the working tree, but the deploy tooling (`web
| `dtoro/Plato` | [plato (126)](../containers/126-plato.md) `/opt/plato/app/` | B | `http://192.168.8.190:9799/deploy` | 8 | `docker compose up -d --build` |
| `dtoro/claudio-bot` | [claudio-bot (123)](../containers/123-claudio-bot.md) `/opt/claudio-bot/` | A | `http://192.168.8.230:9797/deploy` | (orig) | `pip install` + `systemctl restart claudio-bot` |
| `dtoro/backup-library` | [hubris host](../hosts/hubris.md) `/opt/backup-library/` | A | `http://192.168.8.77:9798/deploy` | (orig) | runs `deploy.sh` (preserves admin-edited `/etc/restic/include-*.list`) |
| `dtoro/Homelab-Docs` → homelab-mcp | [apps (105)](../containers/105-apps.md) `/opt/homelab-mcp/` | B | `http://192.168.8.205:9811/deploy` | 10 | reinstalls `homelab-mcp.service` + restart |
| `dtoro/Homelab-Docs` → secrets-issuance | [apps (105)](../containers/105-apps.md) `/opt/secrets-issuance/` | B | `http://192.168.8.205:9821/deploy` | 11 | reinstalls `secrets-issuance.service` + restart |
> Note: `dtoro/Homelab-Docs` has **two webhooks** firing on the same push.
> Each owns its own clone on LXC 105. They don't conflict because each
> deploy.sh only touches its own service unit + venv.
> **Not yet wired:** `dtoro/claudio-monitor` (push, then `/opt/claudio-monitor/scripts/deploy.sh` manually). `dtoro/authentik-conf` is reserved but the LXC stack is not git-tracked yet. The dnsmasq config on [authentik (124)](../containers/124-authentik.md) is also not tracked — if it gets a `dtoro/dnsmasq-conf`, mirror the caddy-conf pattern.
@@ -54,6 +60,7 @@ Always commit + push. Local-only edits drift. Common ones:
- `/opt/plato/app/``dtoro/Plato` (auto-deploys)
- `/opt/claudio-bot/``dtoro/claudio-bot` (auto-deploys)
- `/opt/backup-library/``dtoro/backup-library` (auto-deploys)
- `/opt/homelab-mcp/` + `/opt/secrets-issuance/``dtoro/Homelab-Docs` (auto-deploys both, see [homelab-context](homelab-context.md))
## Per-pipeline notes / gotchas
@@ -81,13 +88,44 @@ Always commit + push. Local-only edits drift. Common ones:
- Currently the only deploy that targets the host directly (`192.168.8.77:9798`).
- `deploy.sh` is careful to preserve admin edits to `/etc/restic/include-*.list` — canonical source is `config/` in the repo, but the install path is treated as authoritative once `deploy.sh` has run.
### homelab-mcp / secrets-issuance
- Both ride a single push to `dtoro/Homelab-Docs`. Two clones on LXC 105
(`/opt/homelab-mcp`, `/opt/secrets-issuance`) — each is an independent
Shape-B target with its own webhook receiver.
- The deploy script restarts the service it just updated. Because the
webhook receiver itself is a separate systemd unit (`*-deploy.service`),
it does NOT restart itself — but `deploy.sh` running `systemctl
restart homelab-mcp-deploy.service` (or the secrets-issuance one)
would create a kill-self loop. The current `deploy.sh` is careful
to only restart the main service.
- Both services consume `/opt/homelab-context` for their runtime data
(inventory, secret recipient lookup). That clone is **the same clone
every other client has** — kept fresh by `homelab-context-sync.timer`,
not by these webhooks.
## Custom-built binaries that overlap apt-managed paths
If a pipeline (or any out-of-band build) drops a binary into a path that an apt package also owns — most commonly `/usr/bin/<name>` — then the next `apt upgrade` of the corresponding package will silently clobber the custom build. That's exactly how [LXC 121 caddy](../containers/121-caddy.md) went down for ~10 min on 2026-05-21: an xcaddy build with `caddy-dns/ionos` lived at `/usr/bin/caddy` and Debian's caddy 2.11.2→2.11.3 apt upgrade replaced it with a vanilla 2.11.3 that couldn't parse the Caddyfile.
Two patterns are acceptable, pick one when authoring a pipeline that ships a non-apt binary:
1. **Ship the build as a `.deb` with an epoch-bumped version.** Use `dpkg-deb --build` (or `nfpm`) to package the binary as `Package: <name>`, `Version: 1:<upstream>-hubris<n>`. The epoch (`1:`) means it beats any non-epoch upstream version regardless of point bumps, so `apt upgrade` is a no-op for that package. Used by caddy: `caddy 1:2.11.3-hubris1` (see commit `2026-05-21` in [121-caddy.md](../containers/121-caddy.md)).
2. **Hold the apt package.** `apt-mark hold <pkg>` on the LXC during pipeline install; apt will refuse to upgrade it. Simpler than `.deb` packaging but: (a) the hold flag isn't preserved by `dpkg -i` of a new version, (b) it's invisible unless you check `apt-mark showhold`, (c) you have to remember to `apt-mark unhold` when you intentionally want a new version. The new `homelab apt-audit` subcommand surfaces holds across the fleet so they don't get forgotten.
If you're not sure what's already lurking, run `homelab apt-audit --fleet` and look at the `NONAPT` column — that's a count of binaries in `/usr/bin/{caddy,docker,jellyfin}` + `/usr/local/bin/*` that no apt package owns.
## Related
- [Gitea (104)](../containers/104-gitea.md) — webhook source for all of these
- [Caddy (121)](../containers/121-caddy.md), [apps (105)](../containers/105-apps.md), [mule-images (120)](../containers/120-mule-images.md), [claudio-bot (123)](../containers/123-claudio-bot.md), [hubris host](../hosts/hubris.md) — webhook targets
- [Backups (disabled)](backups.md)
- [Operations cheatsheet](../operations/commands.md) — `homelab apt-audit` / `homelab apt-upgrade` reference
## Changelog
### 2026-05-20 — homelab-mcp + secrets-issuance pipelines added
Webhook ids 10 + 11 on `dtoro/Homelab-Docs` (ports `9811` + `9821` on [apps (105)](../containers/105-apps.md)). Two webhooks on one repo — each owns its own clone (`/opt/homelab-mcp`, `/opt/secrets-issuance`) and only restarts its own service. See [homelab-context](homelab-context.md) for why both services live in one repo.
### 2026-05-13 — Plato pipeline added
Webhook id 8 on `dtoro/Plato` (port `9799` on [plato (126)](../containers/126-plato.md)). `app.ini` `ALLOWED_HOST_LIST` extended to include `192.168.8.190`.

View File

@@ -0,0 +1,143 @@
# Homelab context distribution
The cross-client context-and-secrets system that makes every agent (Claude
Code, claudio-bot, future MCP-capable clients) on every machine in the lab
self-locating and able to read the same source of truth.
Operational walkthrough for enrolling a new client lives in
[operations/agent-enrollment.md](../operations/agent-enrollment.md); this
page is the architecture reference.
## What's where
| Piece | Host | Path | Role |
| --- | --- | --- | --- |
| Source of truth | [gitea (104)](../containers/104-gitea.md) | `dtoro/Homelab-Docs.git` | Inventory + wiki + service code |
| Per-client clone | every enrolled client | `/opt/homelab-context/` | Read by `homelab` CLI, MCP server, claudio-bot |
| `homelab` CLI | every enrolled client | `/usr/local/bin/homelab``/opt/homelab-context/bin/homelab` (symlink) | Operator surface for enroll/secret/ssh/pct |
| Per-client age key | every enrolled client | `/etc/age/key.txt` (0600 root) | Decrypts SOPS-encrypted secrets the client is a recipient on |
| MCP server | [apps (105)](../containers/105-apps.md) | `homelab-mcp.service` on port 9810 (https://mcp.hubris.network/sse) | 14 tools: 8 context (get_host, search_docs, …) + 5 read-only management (get_service_status, tail_log, …) + list_my_secrets |
| Secrets-issuance | [apps (105)](../containers/105-apps.md) | `secrets-issuance.service` on port 9820 (https://secrets.hubris.network/issue) | Generates per-client age keypair on first bootstrap; idempotent; admin-token-gated `/revoke` |
| Sync timer | every enrolled client | `homelab-context-sync.timer` (Linux) / `network.hubris.homelab-context-sync.plist` (macOS) | `git pull --ff-only` every 5 min |
| Encrypted secrets | `dtoro/Homelab-Docs` | `secrets/*.yaml` (SOPS+age) | Recipients declared in `.sops.yaml` |
| Read-only context PAT | `dtoro` Gitea user, scope `read:repository` | given to operators out-of-band | Bootstrap-only — for the initial clone before SOPS works |
| Write-scoped PAT | `secrets/gitea-pat.yaml` (SOPS) | `homelab refresh-creds` swaps it into `/etc/homelab-context/git-credentials` | All post-bootstrap pushes (client lifecycle, wiki edits) |
## Data flow
```
dtoro/Homelab-Docs (gitea)
┌────────── push ────────┤ ◀── git push (write PAT or SSH)
│ │
│ ┌────── push ──────┘
│ │ │
│ │ ▼ webhook (push event)
│ │ ┌─── homelab-mcp-deploy ──── (LXC 105:9811)
│ │ └─── secrets-issuance-deploy (LXC 105:9821)
│ │ │
│ │ ▼
│ │ git pull → deploy.sh → restart service
│ │
│ └── on every client:
│ timer (5 min) → git pull --ff-only into /opt/homelab-context
homelab CLI / MCP server reads /opt/homelab-context for everything
```
## Why two clones on LXC 105
The MCP server and secrets-issuance each have their own clone
(`/opt/homelab-mcp`, `/opt/secrets-issuance`) **in addition to**
`/opt/homelab-context`. Reasons:
- The deploy webhook for each service updates its own clone, runs
`deploy.sh` from there, and re-installs the systemd unit. Mixing this
with the client-context clone would create a circular dependency
(deploy reinstalls the unit that pulled it).
- The MCP server reads its data from `/opt/homelab-context` (the same path
every client uses) so changes to inventory propagate identically. Code
changes live in `/opt/homelab-mcp` and trigger a service restart.
## Mesh / network gates
- Both services bind `0.0.0.0:<port>`. The trust boundary is
`MESH_SUBNETS` in the service's environment + nftables (planned). Today
`MESH_SUBNETS=100.122.0.0/16,100.64.0.0/10,192.168.8.0/24` — Netbird +
Tailscale + the homelab LAN. Adjust if the LAN ever has untrusted
devices.
- Caddy fronts both with Let's Encrypt certs via the IONOS DNS challenge:
`mcp.hubris.network``192.168.8.205:9810`,
`secrets.hubris.network``192.168.8.205:9820`. Off-LAN clients on
Netbird reach them via the `192.168.8.0/24` network resource routed
through the PVE peer ([mesh.md](mesh.md)).
- Clients with default-public DNS (workstations not on Netbird, LXCs
using router DNS) need a `/etc/hosts` override pointing
`mcp.hubris.network` and `secrets.hubris.network` at the caddy LXC
(`192.168.8.175`) — same caveat as every other `*.hubris.network`
service, see [dns.md](dns.md).
## Secrets model
- Each enrolled client gets one **age private key** issued by
secrets-issuance on first bootstrap. The key file stays root-only on
the client; the public key is committed to `inventory.yaml` (and
becomes a recipient on SOPS-encrypted files via `.sops.yaml`).
- `secrets/*.yaml` are SOPS+age. Recipients per file are pinned in
`.sops.yaml` `creation_rules` by `path_regex`. Re-encrypting a file is
`sops updatekeys -y secrets/<name>.yaml`.
- The MCP server's `list_my_secrets(caller_pubkey)` tool returns only
secret *names* a given pubkey can decrypt — the server never sees
plaintext. Decryption is local-on-client (`homelab secret <name>`
shells out to `sops -d` with the client's key).
- The "all-clients" secrets (`hello.yaml` for the bootstrap decrypt
test, `gitea-pat.yaml` for the write-scoped PAT) are auto-granted to
every newly enrolled client by `homelab client add --finalize-pubkey`
(which appends the pubkey to the matching `.sops.yaml` rule and runs
`sops updatekeys`).
- **Removal does not erase past disclosure.** Revoking a client via
`homelab client remove` shreds the issuance-side key, denylists the
hostname, removes them from the recipient list, and re-keys all
shared secrets — but anything they already decrypted to disk is out of
your control. Rotate the underlying credential if compromise is
suspected.
## Why this design
- **One source of truth** keeps inventory, code, secrets, and docs
versioned together. A `git log` of `inventory.yaml` is the history of
the homelab.
- **Per-client age keys** scale better than a shared admin secret —
removing a client is a real revocation (for new ciphertext), not just
removing them from a wiki page.
- **MCP layer over the same clone** gives MCP-capable agents structured
query (`find_service`, `search_docs`) without forcing non-MCP tools to
go without — anything can still `cat` the markdown.
- **Sync timer rather than push fan-out** keeps the failure mode
contained: one client's webhook outage doesn't block a push from
landing on the others. Sub-5-min staleness is fine for docs and rare
enough for secrets that we don't need lower latency.
## Related
- [Operations: agent enrollment](../operations/agent-enrollment.md) — the
step-by-step for adding a new client
- [Auto-deploy](auto-deploy.md) — the `homelab-mcp` + `secrets-issuance`
pipelines (and the rest of the lab's webhook pipelines)
- [Mesh](mesh.md) — Netbird / Tailscale paths and the `192.168.8.0/24`
network resource
- [Apps (105)](../containers/105-apps.md) — where both services run
- [Gitea (104)](../containers/104-gitea.md) — the source of truth
## Changelog
### 2026-05-20 — system live across hubris, apps, republic-laptop
Phase 1 of the [cross-client context plan](../README.md) merged. Three
clients enrolled end-to-end: PAT-based bootstrap, age-key issuance, SOPS
decrypt verified on each. Webhook auto-deploy for both LXC 105 services
wired (hook ids 10 + 11). `homelab refresh-creds` + atomic
`client add --finalize-pubkey` grant flow live so new clients are one
ceremony instead of four manual steps. Outstanding: bootstrap mac-mini
(macOS, exercises launchd) + ludo-mini + the remaining LXCs;
claudio-bot integration so the matrix agent uses inventory at chat-time.

View File

@@ -8,23 +8,40 @@ The hubris fleet is migrating from Tailscale to Netbird. Netbird is the target e
- **Netbird mgmt host** (`82.165.190.79`, FQDN `inspiring-ramanujan.netbird.selfhosted`, NB IP `100.122.165.149`) is now itself a peer on the mesh (joined 2026-04-22 via setup key, netbird 0.69.0). Routes the homelab network (`192.168.8.0/24`) via the PVE peer. This gives the mgmt host LAN access *and* split-horizon DNS for `*.hubris.network`. Useful independently of any Authentik integration.
- **Most LXCs** still run Tailscale or use router DNS (`192.168.8.1`) / Tailscale MagicDNS (`100.100.100.100`), both of which return the *public* IONOS A record `*.hubris.network → 82.165.190.79`. The VPS only routes hostnames it actually publishes (today, `artifacto` + `blog`), so this path is a dead end for any LAN-only service.
## ICE / STUN — must use external STUN, not embedded
## ICE / STUN / TURN
The bundled netbird-server image runs an embedded STUN listener on UDP 3478. **IONOS drops inbound UDP 3478 to the VPS upstream of the host firewall** (verified 2026-05-10 via `tcpdump -ni any udp port 3478`: 0 packets captured during external probes from hubris). Without a reachable STUN server, the management API hands peers a STUN URI nothing can talk to → no srflx candidates → ICE always fails → every peer falls back to the websocket relay (`rels://netbird.hubris.network:443`). All cross-NAT traffic is then bottlenecked by the relay/VPS bandwidth (observed ~366 kB/s for Nextcloud uploads).
**Today** (post-2026-05-21 migration):
**Fix**: in `/opt/config.yaml` on the VPS, declare external STUN servers under `server:` — this disables the embedded STUN automatically:
- External STUN servers (Google + Cloudflare) declared under `server.Stuns` in `/opt/management.json`. Embedded STUN is no longer in use.
- coturn runs on the VPS host (apt-installed, systemd-managed). TURN URL `turn:netbird.hubris.network:3478?transport=tcp` is advertised to peers via mgmt's `TURNConfig.Turns` block. Long-term credentials at `user=netbird:<pwd from /root/turn-pass.txt>`.
- For non-symmetric peers, ICE picks direct `srflx/srflx` (P2P). For symmetric-NAT peers, ICE falls through to TURN-relay before falling back to the WSS relay (`rels://netbird.hubris.network:443`).
```yaml
server:
stuns:
- uri: "stun:stun.l.google.com:19302"
- uri: "stun:stun1.l.google.com:19302"
- uri: "stun:stun.cloudflare.com:3478"
**IONOS port-3478 caveat** (load-bearing — undocumented before 2026-05-21):
IONOS upstream filters STUN-class traffic on port 3478 **for both UDP and TCP** by default. The UDP block was already known (verified 2026-05-10 with `tcpdump -ni any udp port 3478`). The TCP block was discovered 2026-05-21 when TURN-allocate retries timed out from a symmetric-NAT laptop: TCP handshake completed (carried by kernel-only SYN exchange) but data packets never reached the VPS's `ens6` interface.
**Resolution**: operator must add an inbound firewall exception in the IONOS dashboard (Cloud Panel → Networks → Firewall policy on the VPS) for inbound TCP 3478 (and UDP 3478 if STUN-via-VPS-listener is ever needed; we use external STUN so this is optional). Without that exception, coturn is unreachable from outside even though host nftables + listener look fine.
**Verifying TURN works** end-to-end from an outside peer:
```python
# python3
import socket, struct, secrets
s = socket.create_connection(("netbird.hubris.network", 3478), timeout=10)
tid = secrets.token_bytes(12)
attrs = struct.pack("!HHI", 0x0019, 4, 17 << 24) # REQUESTED-TRANSPORT = UDP
msg = struct.pack("!HHI", 0x0003, len(attrs), 0x2112A442) + tid + attrs # Allocate
s.sendall(msg)
print(s.recv(4096).hex()) # expect 120 bytes starting 0x0113 (Allocate Error = auth challenge)
```
`docker restart netbird-server`, then `netbird down && netbird up` on each peer to force a resync. Verify with `netbird status -d` `Connection type:` should flip from `Relayed` to `P2P` for peers that aren't behind double-NAT/CGNAT.
A 120-byte `0x0113`-typed response means coturn is reachable and responding. An indefinite TCP timeout (after `connected from ...` prints) means the IONOS rule was reverted or narrowed.
If a peer is *still* relayed after this, it's a NAT-symmetry problem on its side, not a config bug — would need TURN to fix.
**If a peer is still on the WSS relay after this** — verify in `netbird status -d`: `Connection type: P2P` with srflx/srflx is the cone-NAT happy path; `Connection type: P2P` with relay/srflx (or relay/prflx) means the peer's network is symmetric-NAT and it's using TURN. Falling back to `Relayed (rels://...)` only happens if TURN allocate also fails — investigate the IONOS exception first.
**Old combined-server note (history, kept for context):**
Pre-migration, the bundled `netbirdio/netbird-server` combined image silently ignored both `server.turns:` and top-level `TURNConfig:` YAML, so TURN was non-functional. That's why the architectural migration to the canonical multi-container stack (`mgmt + signal + relay + coturn`) happened. See the 2026-05-21 changelog entry below and the [Netbird-combined-no-TURN finding](https://github.com/netbirdio/netbird/issues) in homelab memory for the full discovery.
## Consequence — every LXC wired to Authentik needs an internal override
@@ -99,6 +116,65 @@ Recipe for container-config changes (e.g. adding `extra_hosts`) on Portainer-man
## Changelog
### 2026-05-31 — Netbird mesh recovered; auth.hubris.network exposed via VPS Traefik
**Symptom:** `netbird-mgmt` crash-looped for ~9 days (since 2026-05-21 migration). All peers showed `Connecting`, management returned `404 (Not Found)` for gRPC → `EOF` on startup.
**Root cause:** The 2026-05-21 migration configured `management.json` with `OIDCConfigEndpoint: https://auth.hubris.network/...`. The public DNS for `auth.hubris.network` already pointed to `82.165.190.79` (VPS), but the VPS Traefik had no route for that host → management got EOF on every boot, crash-looped.
**Fix:**
1. Added `auth-hubris` router to `/opt/traefik-dynamic.yaml``Host(auth.hubris.network)` with `certResolver: letsencrypt` → service `auth-authentik`.
2. Service backend: `https://192.168.8.175` (Caddy on hubris LAN) via the Netbird mesh.
3. ServersTransport `skip-verify` with `serverName: auth.hubris.network` + `insecureSkipVerify: true` so Traefik sends the correct SNI to Caddy.
4. Upgraded VPS Netbird client `0.69.0 → 0.71.3` via apt (`apt install netbird=0.71.3`).
5. Mesh fully recovered; management connected to peers within ~1 min.
**Bootstrap note (if mesh is dark and management must restart):** If the WireGuard tunnels are fully dead AND management needs to restart (after e.g. a VPS reboot with no peer handshakes), the Traefik backend `192.168.8.175` will be unreachable and management will crash again. Recovery: temporarily open VPS port 22 via IONOS console (`nft insert rule inet hubris-fw input iifname "ens6" tcp dport 22 accept`), SSH in, run `socat TCP-LISTEN:8443,bind=172.30.0.1,reuseaddr,fork TCP:127.0.0.1:8443 &`, then from hubris `ssh -f -N -R 127.0.0.1:8443:192.168.8.175:443 root@82.165.190.79` — this bootstraps one management start, after which the mesh self-heals.
**Architecture after this change:** `auth.hubris.network` is publicly accessible (HTTPS via VPS Traefik → Caddy on hubris → Authentik LXC 124). External devices authenticating to Netbird now hit this public path. Phase 6 (Authentik as Netbird IdP) is complete and live.
### 2026-05-21 — VPS migrated combined → vanilla netbird stack with external TURN
The combined `netbirdio/netbird-server` image was replaced with the canonical multi-container deploy (`netbirdio/management:0.71.3` + `signal:0.71.3` + `relay:0.71.3` + `dashboard:latest` + host coturn) on `/opt/docker-compose.yml`. Driver: combined image silently ignored external `TURNConfig` so symmetric-NAT peers couldn't use TURN.
Same migration also swapped OIDC from the combined image's embedded Dex IdP to Authentik on [LXC 124](../containers/124-authentik.md), upgrading mgmt to 0.71.3. The `store.db` schema auto-migrated cleanly from 0.68.3 (copy-not-move from the old `opt_netbird_data` volume into the new `mgmt_data` volume). Pre-cutover backups at `/root/netbird-*.tgz` on the VPS, ~857 MB, retained for ~7d.
Also during this work: IONOS upstream was found to filter TCP 3478 in addition to UDP 3478. Added a TCP-3478 inbound exception in the IONOS firewall (see ICE/STUN section above for the verification probe).
The new Authentik provider for NetBird is `Client type: Public` (PKCE-only). Confidential would break the dashboard SPA's token exchange. The Device Code grant flow is wired (see [containers/124-authentik.md](../containers/124-authentik.md#device-code-grant--configured-2026-05-21)) so interactive `netbird up` works — `--setup-key` is no longer required for new peers.
**Post-migration JWT-issuer gotcha on existing peers** (cost ~30 min to diagnose 2026-05-21):
Existing peers — registered against the old combined image's embedded Dex IdP at `https://netbird.hubris.network/oauth2` — cache the OLD expected SSH-JWT issuer in the netbird daemon's in-memory state. After the migration, incoming `netbird ssh` connections were rejected with:
```
JWT authentication failed: validate token (
expected issuer=https://netbird.hubris.network/oauth2,
audiences=[netbird-dashboard netbird-cli],
actual issuer=https://auth.hubris.network/application/o/netbird/,
audience=netbird-dashboard
)
```
Neither `systemctl restart netbird` nor `netbird down && netbird up` clears the cache. Root cause: in `client/internal/engine_ssh.go`, `updateSSH()` bails out with `if e.sshServer != nil { return nil }` whenever the SSH server is already running, so mgmt-pushed JWT config updates are silently ignored. Only a full daemon-process tear-down lets the SSH server re-initialize with the new validator config:
```
sudo systemctl stop netbird
sleep 3
sudo systemctl start netbird
```
After that, `grep -iE "issuer|audience" /var/log/netbird/client.log | tail` shows the new Authentik issuer. Run this on every existing peer (PVE host + every LXC + every workstation) once after a future IdP swap.
**Username gotcha (related):** `netbird ssh` defaults the remote username to the LOCAL one (e.g. `dtoro` from the operator's laptop). Hubris + the LXCs only have `root`, so the JWT is accepted but the session immediately fails with `user dtoro not found`. Always use the explicit `root@` prefix when invoking netbird-ssh manually:
```
netbird ssh -p 22022 root@proxmox-server.netbird.selfhosted
```
The `homelab` CLI handles this automatically via the per-host `ssh.user` field in `inventory.yaml` (defaults to `root`; set explicitly only for workstations whose login user isn't `root`).
Open follow-up: TURN-over-TLS on TCP 5349 (cert via certbot or extract Traefik's acme.json) for hostile-middlebox networks; plain TCP 3478 is sufficient for current usage.
### 2026-05-10 — ICE direct p2p restored (external STUN swap)
All peers were `Connection type: Relayed` because the embedded STUN on the VPS was unreachable from outside (IONOS drops UDP 3478 inbound). Swapped to Google + Cloudflare STUN in `/opt/config.yaml`. After `netbird down/up`, peers now report `P2P` with srflx/host candidates. Backup of pre-change config at `/opt/config.yaml.bak-20260510-185050`. Fixes a real-world Nextcloud upload bottleneck (~366 kB/s through the relay → LAN-direct on same-network peers).

View File

@@ -8,6 +8,9 @@ IONOS VPS that runs the Netbird control plane and the [public ingress traefik](i
- **Mesh:** netbird `100.122.165.149` (peer of the lab mesh; routes `192.168.8.0/24` via [hubris](../hosts/hubris.md)).
- **Public:** `82.165.190.79` (`ens6`).
- **Public DNS:** IONOS wildcard `*.hubris.network → 82.165.190.79`.
- **Docker stack** at `/opt/docker-compose.yml`: `traefik` (TLS/ACME) + `dashboard` + `mgmt` + `signal` + `relay` + `proxy` — netbird-mgmt 0.71.3 vanilla deploy since 2026-05-21 (see [mesh.md changelog](mesh.md#changelog)).
- **Host services (outside docker):** `coturn` (TURN-TCP on :3478, long-term creds rendered into `/etc/turnserver.conf` by `homelab render-vps-configs` from sops-encrypted `secrets/turn-shared-secret.yaml`).
- **Config rendering:** `/etc/turnserver.conf` + `/opt/management.json` are generated from templates in `vps/*.tmpl` on this repo by `homelab render-vps-configs`. Secret placeholders (`{{TURN_PASSWORD}}`, `{{AUTHENTIK_CLIENT_SECRET}}`) are substituted from sops-encrypted secrets decrypted on hubris and pushed over ssh. **Do not hand-edit those two files on the VPS** — the next render will overwrite them.
## SSH
@@ -20,8 +23,9 @@ IONOS VPS that runs the Netbird control plane and the [public ingress traefik](i
Config at `/etc/nftables.conf`, service enabled.
- Public iface `ens6`. Wireguard iface `wt0`.
- **INPUT on `ens6`** allow-list: DHCP (67→68), rate-limited ICMP/ICMPv6. Everything else drops.
- **INPUT on `ens6`** allow-list: DHCP (67→68), rate-limited ICMP/ICMPv6, **TCP 3478** (coturn TURN-TCP, added 2026-05-21). Everything else drops.
- `wt0` fully accepted in INPUT. `lo` accepted.
- **IONOS upstream firewall** also gates inbound traffic before it reaches `ens6`. Open ports today: TCP 80/443 (traefik), UDP 51820 (netbird-proxy), TCP 3478 (coturn, added 2026-05-21). UDP 3478 is dropped by IONOS upstream regardless of local nftables. See [mesh.md ICE/STUN](mesh.md) for the STUN/TURN port matrix.
- **FORWARD chain at priority `filter-10`** (runs before Docker's FORWARD) hosts the fail2ban ban enforcement — see below.
- Set `banned4` (typed `ipv4_addr`, flag `timeout`) holds fail2ban's drops.
- Coexists with Docker's `ip nat` / `ip filter` tables (iptables-nft compat). **Do NOT `flush ruleset`** in this config — it'll wipe Docker's state too.
@@ -77,6 +81,9 @@ Ordered by preference:
## Changelog
### 2026-05-21 — netbird stack migrated combined → vanilla; coturn added
Replaced the `netbirdio/netbird-server` combined image with the canonical `mgmt + signal + relay + dashboard` containers (0.71.3). Added host-side `coturn` for external TURN, with nftables rule `iifname "ens6" tcp dport 3478 accept` and an IONOS upstream firewall exception. Authentik on LXC 124 now provides OIDC for the netbird dashboard. Full context in [mesh.md changelog](mesh.md#changelog).
### 2026-04-28 — wiki entry created
Initial documentation.

View File

@@ -1,122 +1,106 @@
# Homelab inventory — canonical structured topology
# Homelab inventory — canonical structured topology.
#
# This file is the single source of truth for hosts, services, and mesh
# addresses. Everything in hosts/*.yaml is generated from this file by
# mcp/build_host_files.py — do not edit hosts/*.yaml by hand.
# Single source of truth. hosts/*.yaml is generated from this file by
# mcp/build_host_files.py; do NOT edit those by hand.
#
# Conventions:
# - hostname keys here MUST match the actual `hostname` of the machine
# (or, on macOS, `scutil --get LocalHostName` if set).
# - hostname keys MUST match the actual `hostname` of the machine (on
# macOS: `scutil --get LocalHostName` if set).
# - `os:` one of: linux, macos
# - `kind:` one of: proxmox-host, lxc, vm, workstation
# - `kind:` one of: proxmox-host, lxc, vm, workstation, external
# ("external" is reserved for hosts the homelab CLI manages via ssh but
# that aren't homelab clients themselves — e.g. the IONOS netbird VPS
# with no /etc/age/key.txt and no /opt/homelab-context clone.)
# - `mesh:` lists addresses the host is reachable at. Both `netbird` and
# `tailscale` are accepted during the in-flight migration
# (see infrastructure/mesh.md). Prefer netbird FQDNs over raw IPs.
# - `age_pubkey:` is provisioned by the secrets-issuance service on first
# bootstrap and committed back here. New entries leave it empty.
# `tailscale` are accepted during the migration (see infrastructure/mesh.md).
# Prefer netbird FQDNs over raw IPs.
# - `age_pubkey:` provisioned by secrets-issuance on first bootstrap and
# committed back via `homelab client add --finalize-pubkey <key>`.
# - When a service moves hosts, update only the `services:` section here;
# never duplicate the address elsewhere.
# ---------------------------------------------------------------------------
# Static config (mesh + service endpoints)
# ---------------------------------------------------------------------------
# never duplicate addresses elsewhere.
# - `ssh.user:` per-host login user. Default is `root` if omitted (matches
# every LXC + the PVE host). Set explicitly for workstations whose login
# user differs from `root`. Used by the `homelab` CLI to build
# `user@host` and to inform anyone running raw `netbird ssh` (which
# defaults to the LOCAL username — the gotcha that creates "user not
# found" errors when ssh'ing INTO machines that only have `root`).
#
# `homelab client add/remove` does surgical line-edits — comments survive.
# Avoid round-tripping the file through yaml.safe_dump (it strips comments).
mesh:
primary: netbird # preferred for new clients
accepted: [netbird, tailscale]
primary: netbird
accepted:
- netbird
- tailscale
netbird_subnet: 100.122.0.0/16
netbird_domain: netbird.selfhosted
# Top-level service registry. Each entry resolves to a host above plus a URL.
services:
proxmox_ui:
url: https://proxmox.hubris.network
backend: hubris
port: 8006
gitea:
url: https://git.hubris.network
backend: gitea
backend_url: http://192.168.8.121:3000
caddy:
backend: caddy
role: reverse-proxy
note: terminates all *.hubris.network
authentik:
url: https://auth.hubris.network
backend: authentik
dnsmasq:
backend: authentik
note: split-horizon DNS, /etc/dnsmasq.d/hubris-split.conf
jellyfin:
url: https://media.hubris.network
backend: jellyfin
nextcloud:
url: https://cloud.hubris.network
backend: nextcloud
paperless:
url: https://paperless.hubris.network
backend: paperless
matrix:
url: https://matrix.hubris.network
backend: elementsynapse
photos:
url: https://photos.hubris.network
backend: mule-images
photos_new:
url: https://photos-new.hubris.network
backend: mule-photos-new
plato:
url: https://plato.hubris.network
backend: plato
arr_stack:
backend: arriman
note: jellyseerr / qbit / sab on docker compose
artifacto:
backend: apps
url: https://artifacto.hubris.network
zimaos:
url: https://zimaos.hubris.network
backend: zimaos
haos:
backend: haos
# New services introduced by this plan.
homelab_mcp:
backend: apps
port: 9810
netbird_host: apps.netbird.selfhosted
endpoint: http://apps.netbird.selfhosted:9810/sse
note: MCP server. Read-only context + management. Mesh-only.
systemd_unit: homelab-mcp
public_host: mcp.hubris.network
endpoint: https://mcp.hubris.network/sse
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).
secrets_issuance:
backend: apps
port: 9820
netbird_host: apps.netbird.selfhosted
endpoint: http://apps.netbird.selfhosted:9820/issue
note: Issues per-client age private keys. Mesh-only.
# ---------------------------------------------------------------------------
# Hosts (Proxmox host + LXCs + VMs + workstations)
# ---------------------------------------------------------------------------
systemd_unit: secrets-issuance
public_host: secrets.hubris.network
endpoint: https://secrets.hubris.network/issue
note: Issues per-client age private keys. Gated at source-IP layer (mesh + LAN
subnets in MESH_SUBNETS).
hosts:
# ----- The hypervisor -----
hubris:
kind: proxmox-host
os: linux
@@ -127,13 +111,12 @@ hosts:
ip: 100.122.38.109
fqdn: proxmox-server.netbird.selfhosted
ssh:
port: 22 # OpenSSH
netbird_port: 22022 # see infrastructure/ssh-access.md
port: 22
netbird_port: 22022
user: root
mounts:
- /mnt/library # shared lvmthin
age_pubkey: "" # filled by issuance on first bootstrap
# ----- LXC containers (all on hubris) -----
- /mnt/library
age_pubkey: age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6
jellyfin:
kind: lxc
pve_id: 101
@@ -142,10 +125,12 @@ hosts:
role: media-server
lan_ip: 192.168.8.206
public_host: media.hubris.network
mesh: {netbird: {fqdn: jellyfin.netbird.selfhosted}}
mounts: [/mnt/library]
age_pubkey: ""
mesh:
tailscale:
fqdn: jellyfin
mounts:
- /mnt/library
age_pubkey: ''
nfs-export:
kind: lxc
pve_id: 102
@@ -153,9 +138,6 @@ hosts:
os: linux
role: storage-export
lan_ip: 192.168.8.200
mesh: {netbird: {fqdn: nfs-export.netbird.selfhosted}}
age_pubkey: ""
paperless:
kind: lxc
pve_id: 103
@@ -164,10 +146,12 @@ hosts:
role: document-archive
lan_ip: 192.168.8.130
public_host: paperless.hubris.network
mesh: {netbird: {fqdn: paperless.netbird.selfhosted}}
mounts: [/mnt/library]
age_pubkey: ""
mesh:
tailscale:
fqdn: paperless
mounts:
- /mnt/library
age_pubkey: ''
gitea:
kind: lxc
pve_id: 104
@@ -177,12 +161,14 @@ hosts:
lan_ip: 192.168.8.121
public_host: git.hubris.network
backend_port: 3000
mesh: {netbird: {fqdn: gitea.netbird.selfhosted}}
mounts: [/mnt/library]
mesh:
tailscale:
fqdn: gitea
mounts:
- /mnt/library
notes:
- "Bare repos live at /mnt/library/repos/dtoro/*.git"
age_pubkey: ""
- Bare repos live at /mnt/library/repos/dtoro/*.git
age_pubkey: ''
apps:
kind: lxc
pve_id: 105
@@ -190,16 +176,20 @@ hosts:
os: linux
role: docker-apps
lan_ip: 192.168.8.205
public_hosts: [artifacto.hubris.network]
mesh: {netbird: {fqdn: apps.netbird.selfhosted}}
mounts: [/mnt/library]
public_hosts:
- artifacto.hubris.network
mesh:
tailscale:
ip: 100.121.171.122
fqdn: apps
mounts:
- /mnt/library
runs:
- artifacto
- plantuml
- homelab-mcp # introduced by this plan
- secrets-issuance # introduced by this plan
age_pubkey: ""
- artifacto
- plantuml
- homelab-mcp
- secrets-issuance
age_pubkey: age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0
nextcloud:
kind: lxc
pve_id: 114
@@ -208,10 +198,12 @@ hosts:
role: file-sync
lan_ip: 192.168.8.224
public_host: cloud.hubris.network
mesh: {netbird: {fqdn: nextcloud.netbird.selfhosted}}
mounts: [/mnt/library]
age_pubkey: ""
mesh:
tailscale:
fqdn: nextcloud
mounts:
- /mnt/library
age_pubkey: ''
elementsynapse:
kind: lxc
pve_id: 118
@@ -220,9 +212,9 @@ hosts:
role: matrix-server
lan_ip: 192.168.8.239
public_host: matrix.hubris.network
mesh: {netbird: {fqdn: elementsynapse.netbird.selfhosted}}
age_pubkey: ""
mesh:
tailscale:
fqdn: elementsynapse
sophia:
kind: lxc
pve_id: 119
@@ -230,10 +222,12 @@ hosts:
os: linux
role: workshop
lan_ip: 192.168.8.157
mesh: {netbird: {fqdn: sophia.netbird.selfhosted}}
mounts: [/mnt/library]
age_pubkey: ""
mesh:
tailscale:
fqdn: sophia
mounts:
- /mnt/library
age_pubkey: ''
mule-images:
kind: lxc
pve_id: 120
@@ -242,10 +236,12 @@ hosts:
role: photo-management
lan_ip: 192.168.8.136
public_host: photos.hubris.network
mesh: {netbird: {fqdn: mule-images.netbird.selfhosted}}
mounts: [/mnt/library]
age_pubkey: ""
mesh:
tailscale:
fqdn: muleimage
mounts:
- /mnt/library
age_pubkey: ''
caddy:
kind: lxc
pve_id: 121
@@ -254,12 +250,11 @@ hosts:
role: reverse-proxy
lan_ip: 192.168.8.175
notes:
- "Terminates all *.hubris.network"
- "/etc/caddy is a git checkout of dtoro/caddy-conf"
peers: [authentik, gitea]
mesh: {netbird: {fqdn: caddy.netbird.selfhosted}}
age_pubkey: ""
- Terminates all *.hubris.network
- /etc/caddy is a git checkout of dtoro/caddy-conf
peers:
- authentik
- gitea
arriman:
kind: lxc
pve_id: 122
@@ -267,11 +262,16 @@ hosts:
os: linux
role: arr-stack
lan_ip: 192.168.8.132
public_hosts: [jellyseerr.hubris.network, qbit.hubris.network, sab.hubris.network]
mesh: {netbird: {fqdn: arriman.netbird.selfhosted}}
mounts: [/mnt/library]
age_pubkey: ""
public_hosts:
- jellyseerr.hubris.network
- qbit.hubris.network
- sab.hubris.network
mesh:
tailscale:
fqdn: arr
mounts:
- /mnt/library
age_pubkey: ''
claudio-bot:
kind: lxc
pve_id: 123
@@ -279,11 +279,9 @@ hosts:
os: linux
role: matrix-agent
lan_ip: 192.168.8.230
mesh: {netbird: {fqdn: claudio-bot.netbird.selfhosted}}
notes:
- "Reads /opt/homelab-context/ on startup"
age_pubkey: ""
- Reads /opt/homelab-context/ on startup
age_pubkey: age1xmkeq968areza2necqyq0065dpeegngzyr6dhagh0n6pl33lccfqe5mqn9
authentik:
kind: lxc
pve_id: 124
@@ -293,10 +291,7 @@ hosts:
lan_ip: 192.168.8.180
public_host: auth.hubris.network
notes:
- "Also hosts split-horizon dnsmasq: /etc/dnsmasq.d/hubris-split.conf"
mesh: {netbird: {fqdn: authentik.netbird.selfhosted}}
age_pubkey: ""
- 'Also hosts split-horizon dnsmasq: /etc/dnsmasq.d/hubris-split.conf'
plato:
kind: lxc
pve_id: 126
@@ -305,61 +300,76 @@ hosts:
role: app
lan_ip: 192.168.8.190
public_host: plato.hubris.network
mesh: {netbird: {fqdn: plato.netbird.selfhosted}}
mounts: [/mnt/library/documents/plato]
age_pubkey: ""
mule-photos-new:
kind: lxc
pve_id: 127
host: hubris
os: linux
role: photo-management
lan_ip: 192.168.8.181
public_host: photos-new.hubris.network
mesh: {netbird: {fqdn: mule-photos-new.netbird.selfhosted}}
age_pubkey: ""
# ----- VMs (on hubris) -----
mounts:
- /mnt/library/documents/plato
age_pubkey: ''
zimaos:
kind: vm
pve_id: 100
host: hubris
os: linux
role: nas-frontend-eval
lan_ip: 192.168.8.195
public_host: zimaos.hubris.network
mesh: {netbird: {fqdn: zimaos.netbird.selfhosted}}
age_pubkey: ""
haos:
kind: vm
pve_id: 108
host: hubris
os: linux
role: home-automation
mesh: {netbird: {fqdn: haos.netbird.selfhosted}}
age_pubkey: ""
# ----- Workstations (roaming, primary agent clients) -----
lan_ip: 192.168.8.101
mesh:
tailscale:
fqdn: homeassistant
republic-laptop:
kind: workstation
os: linux
role: primary-dev
mesh: {netbird: {fqdn: republic-laptop.netbird.selfhosted}}
age_pubkey: ""
mesh:
netbird:
fqdn: republic-laptop.netbird.selfhosted
ssh:
user: dtoro
mac-mini:
kind: workstation
os: macos
role: dev
mesh: {netbird: {fqdn: mac-mini.netbird.selfhosted}}
mesh:
netbird:
fqdn: mac-mini-234-17.netbird.selfhosted
ssh:
user: dtoro
notes:
- "Only macOS in the fleet. Bootstrap uses launchd."
age_pubkey: ""
- Only macOS in the fleet. Bootstrap uses launchd.
age_pubkey: ''
ludo-mini:
kind: workstation
os: linux
role: dev
mesh: {netbird: {fqdn: ludo-mini.netbird.selfhosted}}
age_pubkey: ""
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 }
netbird-vps:
kind: external
os: linux
role: netbird-mgmt
mesh:
netbird:
ip: 100.122.165.149
fqdn: netbird-ionos.netbird.selfhosted
ssh:
user: root
notes:
- Public IONOS VPS — hosts the vanilla netbird mgmt+signal+relay+dashboard
stack + host coturn (see infrastructure/vps-hardening.md +
infrastructure/mesh.md changelog 2026-05-21).
- NOT a homelab client. No /etc/age/key.txt, no /opt/homelab-context
clone. Managed via ssh from hubris; sshd is locked to hubris's pubkey.
- Public IPv4 82.165.190.79. Auto-patching via unattended-upgrades.
- 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.

View File

@@ -8,7 +8,12 @@ Type=simple
WorkingDirectory=/opt/homelab-context
Environment=HOMELAB_CONTEXT_DIR=/opt/homelab-context
Environment=HOMELAB_MCP_SSH_KEY=/etc/homelab-mcp/mcp-reader.key
Environment=HOMELAB_MCP_SSH_USER=mcp-reader
# SSH user is `root` on hubris — the restricted shell at command="..." in
# authorized_keys (see mcp/mcp-reader-shell) provides the access boundary,
# not a separate user account.
Environment=HOMELAB_MCP_SSH_USER=root
Environment=HOMELAB_MCP_HUBRIS_HOST=192.168.8.77
Environment=HOMELAB_MCP_SSH_KNOWN_HOSTS=/etc/homelab-mcp/known_hosts
ExecStart=/opt/homelab-mcp/.venv/bin/python /opt/homelab-mcp/mcp/server.py
Restart=on-failure
RestartSec=5

62
mcp/mcp-reader-shell Executable file
View File

@@ -0,0 +1,62 @@
#!/bin/bash
# mcp-reader-shell — restricted SSH command for the homelab-mcp service.
#
# Authorized in /root/.ssh/authorized_keys on hubris via:
# command="/usr/local/bin/mcp-reader-shell",restrict ssh-ed25519 AAAA... mcp-reader@homelab-mcp
#
# `restrict` disables PTY/agent/forwarding/X11. This wrapper then validates
# $SSH_ORIGINAL_COMMAND against a strict read-only allowlist before running
# it. Anything outside the allowlist (interactive shell, file writes, pct
# start/stop/destroy, etc.) is refused.
#
# Distributed via the homelab-context sync — symlink:
# /usr/local/bin/mcp-reader-shell -> /opt/homelab-context/mcp/mcp-reader-shell
# so updates land on the next 5-min pull without a manual re-install.
#
# Argument shapes allowed (Bash glob, after rejecting shell metacharacters):
# systemctl is-active <unit>
# systemctl is-enabled <unit>
# journalctl -u <unit> [-n N] [--no-pager]
# pct list
# pct status <id>
# pct config <id>
# pct exec <id> -- systemctl is-active <unit>
# pct exec <id> -- systemctl is-enabled <unit>
# pct exec <id> -- journalctl -u <unit> [-n N] [--no-pager]
#
# Logs each call to syslog via `logger`. Deny entries are warnings.
set -euo pipefail
set -f # disable glob expansion when we exec the command
CMD="${SSH_ORIGINAL_COMMAND:-}"
deny() {
logger -t mcp-reader -p auth.warning "DENY from=${SSH_CLIENT:-?}: ${CMD:-<empty>}"
echo "mcp-reader: command not allowed" >&2
exit 1
}
if [ -z "$CMD" ]; then
deny
fi
# Reject any shell metacharacter that would let an attacker chain or escape
# from the patterns below.
if [[ "$CMD" =~ [\;\&\|\>\<\`\$\\\(\)\{\}\*\?\~\!] ]]; then
deny
fi
case "$CMD" in
"systemctl is-active "*|"systemctl is-enabled "*) ;;
"journalctl -u "*) ;;
"pct list") ;;
"pct status "*|"pct config "*) ;;
"pct exec "*" -- systemctl is-active "*) ;;
"pct exec "*" -- systemctl is-enabled "*) ;;
"pct exec "*" -- journalctl -u "*) ;;
*) deny ;;
esac
logger -t mcp-reader -p auth.info "ALLOW from=${SSH_CLIENT:-?}: $CMD"
exec $CMD

View File

@@ -30,11 +30,23 @@ from mcp.server.fastmcp import FastMCP
CONTEXT_DIR = Path(os.environ.get("HOMELAB_CONTEXT_DIR", "/opt/homelab-context"))
INVENTORY = CONTEXT_DIR / "inventory.yaml"
HOSTS_DIR = CONTEXT_DIR / "hosts"
# All management tools proxy through hubris (the Proxmox host) via a single
# restricted-shell SSH connection. The wrapper at mcp/mcp-reader-shell on
# hubris validates each command against a strict read-only allowlist.
SSH_IDENTITY = os.environ.get("HOMELAB_MCP_SSH_KEY", "/etc/homelab-mcp/mcp-reader.key")
SSH_USER = os.environ.get("HOMELAB_MCP_SSH_USER", "mcp-reader")
SSH_USER = os.environ.get("HOMELAB_MCP_SSH_USER", "root")
SSH_KNOWN_HOSTS = os.environ.get("HOMELAB_MCP_SSH_KNOWN_HOSTS",
"/etc/homelab-mcp/known_hosts")
HUBRIS_HOST = os.environ.get("HOMELAB_MCP_HUBRIS_HOST", "192.168.8.77")
SSH_TIMEOUT = int(os.environ.get("HOMELAB_MCP_SSH_TIMEOUT", "10"))
mcp = FastMCP("homelab")
mcp.settings.host = os.environ.get("HOMELAB_MCP_HOST", "0.0.0.0")
mcp.settings.port = int(os.environ.get("HOMELAB_MCP_PORT", "9810"))
# FastMCP's DNS-rebinding protection only whitelists 127.0.0.1 / localhost / [::1]
# by default, which breaks any LAN/mesh client. We're already mesh+LAN-gated at
# nftables and the browser-attack threat doesn't apply to mesh-only services.
mcp.settings.transport_security.enable_dns_rebinding_protection = False
def _load_inventory() -> dict:
@@ -55,36 +67,49 @@ def inventory() -> dict:
return _load_inventory()
def _host_ssh_target(name: str) -> str:
"""Return the ssh target (user@host) the MCP reader uses for `name`."""
inv = inventory()
h = inv.get("hosts", {}).get(name)
if h is None:
raise ValueError(f"unknown host: {name}")
mesh = h.get("mesh", {})
# Netbird FQDN if available, then lan_ip, then tailscale.
target = (
mesh.get("netbird", {}).get("fqdn")
or mesh.get("netbird", {}).get("ip")
or h.get("lan_ip")
or mesh.get("tailscale", {}).get("fqdn")
)
if not target:
raise ValueError(f"no reachable address for host {name}")
return f"{SSH_USER}@{target}"
def _ssh(host: str, *cmd: str, timeout: int | None = None) -> subprocess.CompletedProcess:
"""Run a command on a remote host as the restricted mcp-reader user."""
target = _host_ssh_target(host)
full = [
def _run_via_hubris(remote_host: str, cmd: list[str],
timeout: int | None = None) -> subprocess.CompletedProcess:
"""SSH to hubris (single channel, restricted-shell key) and run `cmd`.
If remote_host is an LXC, the command is wrapped in `pct exec <id> --`
so hubris executes it inside the LXC. The wrapper at /usr/local/bin/
mcp-reader-shell on hubris validates the final command against an
allowlist before execution.
"""
inv_hosts = inventory().get("hosts", {})
if remote_host == "hubris":
full = cmd
else:
host_entry = inv_hosts.get(remote_host)
if host_entry is None:
raise ValueError(f"unknown host: {remote_host}")
pve_id = host_entry.get("pve_id")
if pve_id is None:
raise ValueError(f"{remote_host} has no pve_id; can't pct-exec into it")
full = ["pct", "exec", str(pve_id), "--", *cmd]
# The restricted shell on hubris reads the joined command from
# $SSH_ORIGINAL_COMMAND and validates it as a single string.
joined = " ".join(full)
ssh_args = [
"ssh", "-i", SSH_IDENTITY, "-o", "BatchMode=yes",
"-o", "StrictHostKeyChecking=accept-new",
# The systemd unit runs with ProtectHome=true so ~/.ssh is unreachable.
# Use a pre-populated known_hosts in /etc/homelab-mcp/.
"-o", f"UserKnownHostsFile={SSH_KNOWN_HOSTS}",
"-o", "StrictHostKeyChecking=yes",
"-o", f"ConnectTimeout={SSH_TIMEOUT}",
target, *cmd,
f"{SSH_USER}@{HUBRIS_HOST}",
joined,
]
return subprocess.run(full, capture_output=True, text=True,
timeout=timeout or SSH_TIMEOUT * 3)
proc = subprocess.run(
ssh_args, capture_output=True, text=True,
timeout=timeout or SSH_TIMEOUT * 3,
)
if proc.returncode != 0:
import logging as _l
_l.getLogger("homelab-mcp").warning(
"ssh failed rc=%s host=%s cmd=%r stderr=%r",
proc.returncode, remote_host, joined, proc.stderr.strip(),
)
return proc
def _service_to_host(service: str) -> str:
@@ -244,8 +269,8 @@ def get_service_status(service: str) -> dict:
host = _service_to_host(service)
inv_svc = inventory()["services"][service]
unit = inv_svc.get("systemd_unit", service)
active = _ssh(host, "systemctl", "is-active", unit)
enabled = _ssh(host, "systemctl", "is-enabled", unit)
active = _run_via_hubris(host, ["systemctl", "is-active", unit])
enabled = _run_via_hubris(host, ["systemctl", "is-enabled", unit])
return {
"service": service,
"host": host,
@@ -261,14 +286,15 @@ def tail_log(service: str, lines: int = 200) -> str:
host = _service_to_host(service)
inv_svc = inventory()["services"][service]
unit = inv_svc.get("systemd_unit", service)
proc = _ssh(host, "journalctl", "-u", unit, "-n", str(lines), "--no-pager")
proc = _run_via_hubris(host, ["journalctl", "-u", unit,
"-n", str(lines), "--no-pager"])
return proc.stdout
@mcp.tool()
def list_lxcs() -> str:
"""Run `pct list` on hubris."""
return _ssh("hubris", "pct", "list").stdout
return _run_via_hubris("hubris", ["pct", "list"]).stdout
@mcp.tool()
@@ -280,8 +306,8 @@ def get_lxc_state(lxc: str) -> dict:
pve_id = inv[lxc].get("pve_id")
if pve_id is None:
raise ValueError(f"{lxc} has no pve_id (is it actually an LXC?)")
status = _ssh("hubris", "pct", "status", str(pve_id))
cfg = _ssh("hubris", "pct", "config", str(pve_id))
status = _run_via_hubris("hubris", ["pct", "status", str(pve_id)])
cfg = _run_via_hubris("hubris", ["pct", "config", str(pve_id)])
return {"lxc": lxc, "pve_id": pve_id,
"status": status.stdout.strip(),
"config": cfg.stdout}

View File

@@ -0,0 +1,291 @@
# Agent enrollment — bootstrap a client into the homelab context system
This walks through enrolling a new machine (workstation, LXC, or VM) so it
joins the cross-client context system: a `/opt/homelab-context/` clone of
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`
> with an additional `--with-hermes` flag.
Architecture in [project_homelab_context_plan](https://… memory link); the
operational reference is here.
## Prerequisites the client must satisfy
| Requirement | Why | How to check |
| --- | --- | --- |
| Hostname matches an entry in `inventory.yaml` | The bootstrap looks up `hosts/$(hostname).yaml`. | `hostname` (Linux) / `scutil --get LocalHostName` (macOS) |
| 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`) |
### Hostname mismatch is the most common bootstrap failure
If the bootstrap exits with `no hosts/<name>.yaml in the repo`, the
hostname doesn't match any inventory entry. Two fixes:
- **Rename the host**: `sudo hostnamectl set-hostname <inventory-name>`
(Linux) or System Preferences → Sharing (macOS), then re-run.
- **Rename the inventory entry**: edit `inventory.yaml` on hubris,
regenerate `hosts/*.yaml`, push. The next sync (≤5 min) propagates.
### Getting onto Netbird
Bootstrap auto-installs netbird and drives `netbird up` if the mesh isn't already connected (since commit `<bootstrap-tier1>`). Both paths below produce the same end state: `netbird status` shows `Management: Connected`, peer IP `100.122.x.x/16`.
**Path B — interactive OIDC (default; recommended):**
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.
**Path A — setup-key (headless/scripted onboarding):**
Useful for headless servers (no browser at all) or unattended cloud-init bootstraps.
1. From an already-enrolled machine, log into the dashboard at `https://netbird.hubris.network/`.
2. **Setup Keys** → Create → set reusable + expiry → copy.
3. On the new client (after installing netbird, OR let bootstrap install it and skip its `netbird up` driver):
```bash
sudo netbird up --setup-key <KEY> \
--management-url https://netbird.hubris.network \
--ssh-jwt-cache-ttl 86400
```
**Why we can't OIDC-login from the public internet (still open as a follow-up):**
`auth.hubris.network` resolves publicly to the VPS (`82.165.190.79`), but Traefik on the VPS doesn't currently route that hostname — only `netbird.hubris.network` is exposed. A brand-new client *off the mesh* hitting `auth.hubris.network` directly gets a Traefik default 404. In practice, Path B works fine because the operator's BROWSER (which clicks the device-code URL) is usually on a network that can reach Authentik through the public IONOS IP via some path. But "fresh laptop in a coffee shop with no prior session anywhere" still gets stuck. Future-session fix: add a Traefik route on the VPS forwarding `auth.hubris.network` via the netbird-routed `192.168.8.0/24` to LXC 124.
### 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
client uses 192.168.8.180 as its resolver**. Most LXCs and roaming
workstations don't by default. Options:
- **LAN client**: set DNS to 192.168.8.180 (per-interface or
`/etc/resolv.conf`).
- **Off-LAN workstation on Netbird**: configure Netbird DNS forwarder to
point `*.hubris.network` at LXC 124.
- **Hack-fix anywhere**: append to `/etc/hosts`:
```
192.168.8.175 mcp.hubris.network secrets.hubris.network
192.168.8.175 git.hubris.network
```
(192.168.8.175 = caddy on LXC 121, terminates all `*.hubris.network`.)
If DNS isn't an option at all, override the URLs at bootstrap time:
```bash
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 \
bash /tmp/bootstrap.sh --with-mcp
```
## Install dependencies
Bootstrap auto-installs missing prerequisites (`git`, `python3` + PyYAML, `age`, `sops`, `netbird`) on Fedora/RHEL/Debian/Ubuntu/macOS — no manual `apt`/`dnf`/`brew` needed before running it. The only thing you must have on hand BEFORE the `curl ... | sudo bash` line is `curl` itself (used to pipe the script).
Manual install is still possible (e.g. for air-gapped or unusual platforms); the per-OS recipes are below for reference but optional.
<details>
<summary>Manual recipes (Fedora / Debian / macOS)</summary>
```bash
# Fedora / RHEL / Nobara
sudo dnf install -y git python3-pyyaml age curl
SOPS_VERSION=v3.9.4
sudo curl -fsSL https://github.com/getsops/sops/releases/download/$SOPS_VERSION/sops-$SOPS_VERSION.linux.amd64 \
-o /usr/local/bin/sops && sudo chmod +x /usr/local/bin/sops
# Debian / Ubuntu
sudo apt update && sudo apt install -y git python3-yaml age curl
SOPS_VERSION=v3.9.4
sudo curl -fsSL https://github.com/getsops/sops/releases/download/$SOPS_VERSION/sops-$SOPS_VERSION.linux.amd64 \
-o /usr/local/bin/sops && sudo chmod +x /usr/local/bin/sops
# macOS
brew install git age sops
pip3 install pyyaml # if `python3 -c "import yaml"` fails
```
</details>
## Run the bootstrap
You need a Gitea read-only personal access token for the initial clone
(the in-cluster shared PAT is encrypted at `secrets/gitea-readonly-pat.yaml`
but a new client can't decrypt it before bootstrap — chicken-and-egg).
Ask the operator (or generate in Gitea: Settings → Applications → Generate
New Token → scope `read:repository`).
```bash
TOKEN=... # your Gitea PAT, scope read:repository
# Fetch bootstrap.sh from gitea (HTTPS uses split-DNS → caddy).
curl -fsSL -u "dtoro:$TOKEN" \
https://git.hubris.network/dtoro/Homelab-Docs/raw/branch/main/bootstrap.sh \
-o /tmp/bootstrap.sh
# Run it.
sudo HOMELAB_GITEA_TOKEN=$TOKEN bash /tmp/bootstrap.sh --with-mcp
```
Flags:
| Flag | Effect |
| --- | --- |
| `--with-mcp` | Merges the homelab MCP server into `~/.claude/.mcp.json` of the invoking user |
| `--no-secrets` | Skips age-key issuance (use when bringing up the first hosts before secrets-issuance exists) |
| `--dry-run` | Prints actions without executing |
The bootstrap is idempotent: re-running on an enrolled client just
verifies state, re-issues the age key only if it doesn't match the
inventory pubkey, and refreshes the sync timer + symlinks.
## Verify
```bash
homelab whoami # prints hosts/$(hostname).yaml
homelab list # shows the full topology
homelab status # ping + HTTP-check across hosts/services
homelab secret hello # decrypt the bootstrap-test secret
systemctl list-timers homelab-context-sync.timer
# next run within ≤5 min
```
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).
## Claude Code permissions for fleet ops
By default Claude Code's auto-mode classifier asks for confirmation on every
ssh into the mesh. The bootstrap already installs the ssh ControlMaster block
so subsequent in-session sshes multiplex, but the *first* ssh of each session
still gets classifier-evaluated. Pre-authorize the common fleet ssh patterns
by adding to `~/.claude/settings.json`:
```json
{
"permissions": {
"defaultMode": "auto",
"allow": [
"Bash(ssh -p 22022 *)",
"Bash(homelab *)"
]
}
}
```
The first rule covers any ssh to a mesh peer on the homelab netbird port; the
second covers all `homelab` CLI invocations. Both are scoped tight enough that
the classifier doesn't gate them but loose enough to handle the variety of
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)
for context), drop a free-text rule into `autoMode.allow` describing the
authorization. Optional.
## Adding a new client to inventory
If the hostname you want isn't yet in inventory, enrollment is a two-step
ceremony driven from an existing enrolled client (e.g. hubris). The
`homelab` CLI handles steps 1 + 4; you provide steps 2 + 3.
```bash
# 1. On hubris (or any existing client): add the inventory entry.
homelab client add my-new-machine
# Prompts for kind, os, netbird FQDN, role. Commits + pushes.
# 2. Join the new machine to Netbird (out-of-band, Netbird console / setup key).
# 3. On the new machine: install deps + run bootstrap (above).
# Bootstrap calls /issue, receives a fresh age keypair, and prints the
# public key for the operator to commit back to inventory.
# 4. On hubris: finalize the age public key.
homelab client add my-new-machine --finalize-pubkey age1...
# Updates inventory.yaml hosts.my-new-machine.age_pubkey, regenerates
# hosts/*.yaml, commits + pushes. The 5-min sync propagates.
```
## Granting a secret to a new client
Adding a client doesn't grant them every secret. Recipients are explicit
per file via `.sops.yaml` glob rules. To grant a client access to (say)
`secrets/hello.yaml`:
1. Edit `.sops.yaml` at the repo root, add the client's `age_pubkey` to
the matching `creation_rules` block.
2. Re-key the existing ciphertext for the new recipient list:
```bash
sops updatekeys -y secrets/hello.yaml
```
3. Commit + push. On the next sync (≤5 min), the client can decrypt.
## Removing a client
```bash
# From any existing client:
homelab client remove my-old-machine
```
This:
1. Removes the inventory entry and `hosts/my-old-machine.yaml`.
2. Runs `sops updatekeys -y` against every file in `secrets/` (operator
must first remove the pubkey from `.sops.yaml` rules).
3. Calls `secrets-issuance` `/revoke` (admin-token-gated, on LXC 105) to
shred the key file and add the hostname to the denylist.
4. Commits + pushes.
The CLI prints a follow-up checklist that the operator must do manually:
- Revoke the peer in the Netbird console (denies future mesh access).
- **Rotate any credentials whose ciphertext the removed client already
has on disk.** The age key revocation only protects *future*
ciphertext; what's already been pulled is still decryptable until the
underlying credential changes.
- Optional: `homelab nuke my-old-machine` SSHes in, shreds
`/etc/age/key.txt`, removes `/opt/homelab-context`, disables sync.
## Troubleshooting
| Symptom | Cause | Fix |
| --- | --- | --- |
| `no hosts/<hostname>.yaml in the repo` | Hostname doesn't match inventory entry | Rename either side (see above) |
| `fatal: could not read Username for 'http://192.168.8.121:3000'` | bootstrap.sh's credentials file has wrong scheme | Fixed in commit `de6f8be`; pull latest `bootstrap.sh` |
| `gnutls_handshake() failed: TLS connection was non-properly terminated` cloning `git.hubris.network` | Client DNS resolves `*.hubris.network` to the public VPS IP | Configure split-DNS (LXC 180 / Netbird forwarder) or `/etc/hosts` override; or use `HOMELAB_REPO_URL=http://192.168.8.121:3000/dtoro/Homelab-Docs.git` |
| `TLS/SSL connection has been closed (EOF)` connecting MCP | Same — `mcp.hubris.network` resolves to public VPS without this vhost | Same DNS fix |
| `Invalid Host header` from MCP server | FastMCP's DNS-rebinding protection (default whitelist is 127.0.0.1 only) | Fixed in commit `6848640`; pull latest `mcp/server.py` and redeploy |
| `python3-yaml` install fails on Fedora | Wrong package name | Use `python3-pyyaml` (Fedora) instead of `python3-yaml` (Debian) |
| `address already in use` for FastMCP | FastMCP defaults to 127.0.0.1:8000 | Fixed: server now sets `mcp.settings.host/port` from env (default `0.0.0.0:9810`) |
| `homelab: no age key at /etc/age/key.txt` even after bootstrap | `/etc/age` is 0700 root, so non-root users couldn't even stat the key file; existence check returned False under regular users | Fixed in commit `df6aca8`: the CLI re-execs `sops -d` via sudo when invoked as a non-root user. On older deployments, re-link the CLI with `sudo ln -sfn /opt/homelab-context/bin/homelab /usr/local/bin/homelab` after the 5-min sync. |
| `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 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-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.
### 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.
### 2026-05-20 — initial page
Captures the enrollment flow validated during Phase 2 of the homelab
context distribution rollout. hubris + LXC 105 (apps) enrolled; first
workstation (republic-laptop) blocked on hostname mismatch, documented
the resolution.

View File

@@ -48,9 +48,25 @@ Run from the [hubris host](../hosts/hubris.md) as root. When working from `/root
- `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](../investigations/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.
## Related
- [Hubris host](../hosts/hubris.md)
- [Containers index](../containers/index.md)
- [DNS](../infrastructure/dns.md)
- [Monitoring](../infrastructure/monitoring.md)
- [Auto-deploy](../infrastructure/auto-deploy.md)
- [Runbook: dpkg-interrupted recovery](runbook-dpkg-interrupted.md) — what to do when apt got killed mid-transaction

199
operations/hermes-agent.md Normal file
View File

@@ -0,0 +1,199 @@
# Hermes agent — Nous-Hermes-powered Goose sessions on a homelab client
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))
— this page covers only the Hermes-specific additions.
The agent runs as a [Goose](https://goose-docs.ai/) session. Goose provides:
- The chat loop, multi-turn history, and streaming
- The OpenRouter provider that routes to Nous Hermes
- The built-in `developer` extension (shell + file editor — same surface Claude
Code has)
- A remote MCP extension pointed at `mcp.hubris.network` for read-only
homelab context (`list_lxcs`, `tail_log`, `search_docs`, etc.)
The persona is `/opt/homelab-context/HERMES.md`, symlinked as Goose's global
`.goosehints` so it's injected into the system prompt on every session.
## Prerequisites
| Requirement | How |
| --- | --- |
| 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` |
## Onboarding flow
```bash
# 1. On hubris (or any enrolled client): reserve the inventory entry.
homelab client add new-machine
# 2. Join new-machine to Netbird (setup-key or OIDC).
# 3. On new-machine: bootstrap with --with-hermes.
TOKEN=... # gitea PAT, read:repository
curl -fsSL -u "dtoro:$TOKEN" \
https://git.hubris.network/dtoro/Homelab-Docs/raw/branch/main/bootstrap.sh \
-o /tmp/bootstrap.sh
sudo HOMELAB_GITEA_TOKEN=$TOKEN bash /tmp/bootstrap.sh --with-mcp --with-hermes
# 4. Back on hubris: finalize the age pubkey AND grant the Hermes secret.
homelab client add new-machine \
--finalize-pubkey age1... \
--with-hermes
# 5. Wait ≤5 min for sync, then on new-machine:
hermes "what LXCs are running?"
```
The bootstrap `--with-hermes` flag does five things, all idempotent:
1. Downloads the latest Goose binary into the operator's `~/.local/bin/goose`
(upstream installer) and symlinks `/usr/local/bin/goose` to it.
2. Symlinks `/opt/homelab-context/bin/hermes``/usr/local/bin/hermes`.
3. Symlinks `/opt/homelab-context/HERMES.md``/root/HERMES.md` (Linux) or
`/etc/HERMES.md` (macOS) for `cat`-as-operator convenience.
4. Drops `~/.config/goose/config.yaml` pinning the provider, model, and
extensions (preserves any keys the operator added by hand).
5. Symlinks `~/.config/goose/.goosehints` → HERMES.md, so the persona is
injected as the system prompt on every session.
## Seeding the OpenRouter key
The first time anyone enrolls with `--with-hermes`, the encrypted file
`secrets/openrouter-api-key.yaml` contains a placeholder. On hubris (or any
existing recipient):
```bash
sops secrets/openrouter-api-key.yaml
# editor opens; replace api_key value with the real sk-or-... key, save, close.
git -C /opt/homelab-context add secrets/openrouter-api-key.yaml
git -C /opt/homelab-context commit -m 'openrouter-api-key: seed real key'
git -C /opt/homelab-context push
```
Until this step happens, `hermes …` exits with `openrouter-api-key.yaml still
contains the placeholder`. Subsequent enrollees get the real key automatically
via `--with-hermes` (which adds them as a sops recipient on
`secrets/openrouter-api-key.yaml`).
## Granting the OpenRouter key to an already-enrolled host
If a host was enrolled without `--with-hermes` and you want to add it later:
```bash
# On hubris:
PUBKEY=$(homelab whoami --hostname <host> | grep age_pubkey | awk '{print $2}')
homelab client add <host> --finalize-pubkey "$PUBKEY" --with-hermes
```
`--finalize-pubkey` is required by the existing flow even when the pubkey is
unchanged — it's also the trigger that runs the sops grant.
After ≤5 min sync the host can decrypt the key. Bootstrap doesn't need to
re-run; only the secret recipient list changed.
## Verifying
```bash
homelab whoami # standard enrollment OK
homelab secret openrouter-api-key | head -c 8 # decrypts (prints `api_key:`)
which goose && which hermes # binaries present
goose info -v # provider/model wiring sane
hermes "what LXCs are running?" # interactive Goose session
# Non-interactive smoke test:
echo "List the homelab MCP tools you have available" | hermes
```
## Configuration
The bootstrap-managed keys in `~/.config/goose/config.yaml`:
```yaml
GOOSE_PROVIDER: openrouter
GOOSE_MODEL: nousresearch/hermes-4-405b
GOOSE_MODE: smart_approve # asks before destructive tool calls
extensions:
developer:
type: builtin
bundled: true
enabled: true
name: developer
timeout: 300
homelab:
type: streamable_http
enabled: true
name: homelab
uri: https://mcp.hubris.network/mcp
timeout: 60
```
Override via env on a single bootstrap run:
```bash
HOMELAB_HERMES_MODEL=nousresearch/hermes-3-llama-3.1-405b \
HOMELAB_HERMES_MCP_URI=https://mcp.hubris.network/mcp \
sudo bash /tmp/bootstrap.sh --with-hermes
```
Any keys you add by hand (e.g. `GOOSE_TEMPERATURE`, extra `extensions.*`) are
preserved across re-bootstraps — the merge only overwrites the keys it manages.
## Tool permissions
`GOOSE_MODE: smart_approve` is the bootstrap default: Goose runs read-only
shell commands without prompting and asks for confirmation before destructive
ones. To make the agent fully unattended (e.g. for scheduled jobs), set
`GOOSE_MODE: auto` in `~/.config/goose/config.yaml`. To require confirmation on
every tool call, use `approve`. See
[goose-permissions](https://goose-docs.ai/docs/guides/managing-tools/goose-permissions/).
## Troubleshooting
| Symptom | Cause | Fix |
| --- | --- | --- |
| `hermes: could not decrypt secrets/openrouter-api-key.yaml` | Host isn't a recipient on the sops rule | `homelab client add <host> --finalize-pubkey <age1...> --with-hermes` from hubris |
| `hermes: openrouter-api-key.yaml still contains the placeholder` | No real key has been seeded yet | See "Seeding the OpenRouter key" above |
| Goose hangs on first `hermes` invocation with no output | Goose's interactive `configure` ran on first launch and is awaiting input | Re-run; the installer is supposed to skip it (CONFIGURE=false). If it persists, run `goose configure` once manually in a real terminal to commit the config. |
| `homelab` extension fails to connect / no MCP tools listed | MCP server still runs SSE-only; Goose requires `streamable_http`. See follow-up #1 below. | Either: (a) migrate the FastMCP server to streamable_http (one-line change in `mcp/server.py``mcp.run(transport="streamable_http")` — then redeploy), or (b) accept that the agent works via the developer extension alone (shell + `homelab` CLI cover everything MCP would). |
| `goose: command not found` after bootstrap | Upstream installer dropped binary in `~/.local/bin/` but `/usr/local/bin/goose` symlink didn't land | Re-run bootstrap with `--with-hermes`; the symlink step is at the end of the install block. If still missing, `ln -sfn ~/.local/bin/goose /usr/local/bin/goose` manually. |
| Tool calls hit OpenRouter rate limits | One shared key across many hosts | Future: per-host keys; for now, see the rate-limits guide referenced in `goose info -v`. |
## Cross-references
- [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
and execs `goose session`.
- [`bootstrap.sh`](../bootstrap.sh) — the `--with-hermes` flag's install block.
## Follow-ups
1. **Migrate the MCP server to streamable_http.** Goose 1.x deprecated SSE
(`"SSE transport is no longer supported - kept only for config file
compatibility"` in `crates/goose/src/agents/extension.rs`). Our FastMCP
server at `mcp/server.py:336` still calls `mcp.run(transport="sse")`. Until
that's changed, the `homelab` MCP extension in Goose will fail to connect.
The developer extension (shell + edit) covers most ops without it; this is
a polish item, not a blocker.
2. **Per-host OpenRouter keys** for billing attribution. Today all Hermes
hosts share one key.
3. **Pin the model version** rather than tracking `nousresearch/hermes-4-405b`
directly — OpenRouter periodically rotates the underlying weights.
4. **Local-inference fallback** (ollama / vllm) once the homelab has a GPU
node. The wrapper, persona, and MCP wiring stay unchanged; only
`GOOSE_PROVIDER`/`GOOSE_MODEL` change.
## Changelog
### 2026-05-31 — initial page
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`
extension. MCP streamable_http migration is queued as follow-up #1.

View File

@@ -0,0 +1,108 @@
# Runbook — recover from dpkg-interrupted state
You're here because an apt run got killed mid-transaction and the target now
has packages that are **unpacked but not configured**. Symptoms:
- `apt` refuses to do anything new: `Error: dpkg was interrupted, you must
manually run 'dpkg --configure -a' to correct the problem.`
- `dpkg --audit` lists packages with header
`The following packages have been unpacked but not yet configured.`
- `homelab apt-audit` shows `DPKG: DIRTY(N)` for the host.
The system is still running the **old** binaries (still in memory), but the
**new** binaries are unpacked and waiting for their postinst to run. Two
worst-case manifestations from the 2026-05-21 sweep:
- LXC 121 caddy: leftover state from a prior aborted apt run; caddy itself was
still serving but the new caddy binary on disk hadn't been wired up.
- hubris: ssh master died mid-Wave-6 → 135 packages unpacked-not-configured,
including `systemd`, `openssh-server`, `sudo`, `netbird`. The half-
configured netbird daemon dropped the mesh peer, and we got locked out
until we recovered from the PVE web UI Shell.
**Do not reboot until dpkg is clean.** A reboot tries to start the new
binaries' services, which may fail because postinst never ran (missing users,
config dirs, capabilities, etc.). The system might not come back up cleanly.
## Path A — target is still reachable over ssh (preferred)
```
homelab ssh <host> -- bash -c 'DEBIAN_FRONTEND=noninteractive dpkg --configure -a && apt -y -o Dpkg::Options::=--force-confold upgrade'
```
Or for an LXC by name:
```
homelab pct <lxc> exec -- bash -c 'DEBIAN_FRONTEND=noninteractive dpkg --configure -a && apt -y -o Dpkg::Options::=--force-confold upgrade'
```
When that returns, confirm:
```
homelab apt-audit --target <host>
```
Expect `DPKG: ok` and the remaining `UPGR` count to match what's intentionally
deferred (kernel/PVE on hubris, 0 elsewhere).
## Path B — target locked out (mesh broken / ssh dead)
Most common for hubris when netbird itself went half-configured: the daemon
crashed on the new binary, the mesh peer dropped, port 22022 stopped listening,
and you can't ssh in.
1. Open `https://proxmox.hubris.network` in a browser.
2. Datacenter → node `hubris` → `>_ Shell` (or `_ Console`). That's a root
shell on hubris served by the PVE web UI, independent of the netbird mesh.
3. Run the recovery one-liner:
```
DEBIAN_FRONTEND=noninteractive dpkg --configure -a \
&& DEBIAN_FRONTEND=noninteractive apt -y -o Dpkg::Options::=--force-confold upgrade \
&& systemctl restart netbird \
&& dpkg --audit \
&& echo RECOVERY_OK
```
Wait for `RECOVERY_OK`. The `systemctl restart netbird` is the bit that
heals the mesh — once netbird's daemon comes back up clean, your client's
peer state moves from `Connecting` to `Connected` within ~30 seconds and
the rest of your tooling works again.
4. For an **LXC** that's locked out (less common — LXCs reach the world via
netbird routed through hubris, so unless hubris itself is broken, you can
still `pct enter` from the hubris shell):
From the PVE web UI shell on hubris:
```
pct enter <id>
DEBIAN_FRONTEND=noninteractive dpkg --configure -a && apt -y upgrade
exit
```
## Prevention
The `homelab apt-upgrade` wrapper launches apt inside a `systemd-run --collect`
unit on the target, so it survives ssh teardown — the failure mode that put
hubris into this state in the first place is no longer reachable through the
standard tool. If you absolutely need to run apt manually over ssh, wrap it:
```
ssh <host> systemd-run --unit=apt-recovery --collect bash -c 'apt -y upgrade'
```
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)
## Changelog
### 2026-05-21 — initial page
Documents the dpkg-interrupted recovery path that came out of the
fleet apt sweep (Wave 6 killed mid-transaction; hubris recovered via PVE
web Shell).

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Snapshot secrets-issuance state to /mnt/library
After=network-online.target
[Service]
Type=oneshot
ExecStart=/opt/secrets-issuance/secrets-issuance/backup.sh
Nice=10
TimeoutStartSec=300

51
secrets-issuance/backup.sh Executable file
View File

@@ -0,0 +1,51 @@
#!/bin/bash
# secrets-issuance backup — snapshot the per-client age keys + denylist +
# admin token to /mnt/library so they survive an LXC-105-only failure.
#
# Destination is under /mnt/library/.secrets-issuance-backup/ (dot-prefix to
# stay out of any indexer's path). Mode 0700 root-owned. Privileged LXCs
# that mount /mnt/library would still be able to read it as their root maps
# to host root — accept that trade today; an encrypted-tarball variant
# (separate offline age key) is a follow-up if the LAN trust model changes.
#
# Retention: 14 daily snapshots. Each snapshot is the WHOLE /var/lib/
# secrets-issuance directory (small — ~few KiB per client), so restore is
# just `tar xf <snap> -C /`.
set -euo pipefail
SRC=/var/lib/secrets-issuance
DEST=/mnt/library/.secrets-issuance-backup
RETAIN=14
if [ ! -d "$SRC" ]; then
echo "[backup] source $SRC missing — nothing to do" >&2
exit 0
fi
if [ ! -d /mnt/library ]; then
echo "[backup] /mnt/library not mounted — can't write backup" >&2
exit 1
fi
install -d -m 0700 "$DEST"
chown root:root "$DEST"
stamp=$(date +%Y%m%d-%H%M%S)
out="$DEST/secrets-issuance-$stamp.tar.gz"
# Include the admin token too (recoverable separately, but trivial size and
# saves a step when restoring after total LXC 105 loss).
tar czf "$out" \
-C / \
var/lib/secrets-issuance \
$([ -f /etc/secrets-issuance/admin-token ] && echo etc/secrets-issuance/admin-token || true)
chmod 0600 "$out"
# Drop oldest beyond retention.
ls -1t "$DEST"/secrets-issuance-*.tar.gz 2>/dev/null \
| tail -n +$((RETAIN + 1)) \
| xargs -r rm -f
count=$(ls -1 "$DEST"/secrets-issuance-*.tar.gz 2>/dev/null | wc -l)
size=$(du -sh "$out" | cut -f1)
echo "[backup] wrote $out ($size); kept $count snapshots"

View File

@@ -0,0 +1,11 @@
[Unit]
Description=Daily secrets-issuance backup
[Timer]
OnCalendar=*-*-* 03:30:00
RandomizedDelaySec=10min
Persistent=true
Unit=secrets-issuance-backup.service
[Install]
WantedBy=timers.target

View File

@@ -36,6 +36,11 @@ install -m 644 secrets-issuance/server.service \
/etc/systemd/system/secrets-issuance.service
install -m 644 secrets-issuance/deploy/webhook/secrets-issuance-deploy.service \
/etc/systemd/system/secrets-issuance-deploy.service
install -m 755 secrets-issuance/backup.sh /usr/local/bin/secrets-issuance-backup
install -m 644 secrets-issuance/backup.service \
/etc/systemd/system/secrets-issuance-backup.service
install -m 644 secrets-issuance/backup.timer \
/etc/systemd/system/secrets-issuance-backup.timer
if [ ! -d /opt/homelab-context/.git ]; then
echo " /opt/homelab-context is not a git clone — run bootstrap.sh first." >&2
@@ -50,6 +55,10 @@ if systemctl is-active --quiet secrets-issuance-deploy.service; then
systemctl restart secrets-issuance-deploy.service
fi
echo "[deploy] daemon-reload + enable backup timer"
systemctl daemon-reload
systemctl enable --now secrets-issuance-backup.timer
echo "[deploy] done"
echo "First-time enable:"
echo " systemctl enable --now secrets-issuance.service secrets-issuance-deploy.service"

View File

@@ -8,7 +8,7 @@ Type=simple
WorkingDirectory=/opt/secrets-issuance
Environment=HOMELAB_CONTEXT_DIR=/opt/homelab-context
Environment=SECRETS_ISSUANCE_STATE=/var/lib/secrets-issuance
Environment=MESH_SUBNETS=100.122.0.0/16,100.64.0.0/10
Environment=MESH_SUBNETS=100.122.0.0/16,100.64.0.0/10,192.168.8.0/24
ExecStart=/opt/secrets-issuance/.venv/bin/python /opt/secrets-issuance/secrets-issuance/server.py
Restart=on-failure
RestartSec=5

44
secrets/gitea-pat.yaml Normal file
View File

@@ -0,0 +1,44 @@
user: ENC[AES256_GCM,data:e6Dj7CE=,iv:Bf4tvdWQr0vdF3PShG2cRTryxqRC5rN6UdGd1qwFg7g=,tag:22rrPMAF97C2GUhRGgutOw==,type:str]
token: ENC[AES256_GCM,data:98bMZO2t0qgOO/rTYmyDBZdRTHu8iPgUsctS4dc0B2BCML6tBxBNhA==,iv:oXehq2FJy1pQxzK8M3LqDtjpoFbdfAPvYcKhygwf51A=,tag:RMYdimBwxw247OARznBu3g==,type:str]
scopes:
- ENC[AES256_GCM,data:S3nz8buLTp1PCHpf8F3Y,iv:IkHU+QwIlsxrNrGEu3tRS0zlnuCyp9Fs8ILxUaZrabw=,tag:Ufd+MVYXAAtbSbfvRi3jCg==,type:str]
- ENC[AES256_GCM,data:/UG/8LQD21l/Ca0wfEodKw==,iv:f3tW1ZNLLkzE5Oy8acQ6GfyDvzEH3++kp8duBOI8+Is=,tag:IgfhvEaX9EGyPHhr6FSAjQ==,type:str]
note: ENC[AES256_GCM,data:vRDjHXh5fYlhwJ4LyS/hcqMGdS+Z/D1W2oH6NaJak/XRUr+nFVQqcXSH/zUgqEBWNrMv6j8n2Ouxex/q2mr+p3Ds1jfThJTobX/MFqMkxvxk0TmB7ucui7CDa4mcVgSo3wyhcaba2o3erObByZ3o2uay3mmqzAUdo/wy2bCgQTkTG9/xqVSf3EYuR1j/A+SAzSvpsOORMNUjC5NRP1DUL+3hZkPXsfkXuy7sV6T2vF+oOrbEI0WxR8o4/JcF1B/4ezFAJ+f+nukCMz6eSCwur/wHffbcYa8mtD6BIRuV1pTd/Zob8arhe2ir19MB0fR27E3UkcCwyCkQ+QWuGzisvciCPtwR4JidH7xOpPW+4AK3vqEjmpEeMSWlyxbZadQFuSi8f3bPH+8eB2h/UFvT0BU6xw==,iv:r2dUnKyUWhyafIeDIjHOdqh+PNFzGtVZfcHc2TAhvvI=,tag:Cmg6esU4mQ8/9UbIQQL/gw==,type:str]
sops:
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
age:
- recipient: age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBQL1FueldvT09lV011Q0hz
MlJ1YkxIYXZDNDVQQWo1MFQrRlNBcXNIYVNRCllXYVZrczdBNHdPemJybUVlQkVl
d0hKUTVsTyszOUFrY1BQQllSM2JWa2sKLS0tIDFISUw3T1lLYm10YXZaeFlTMWVj
ZHFWN0ZQTlhIc0pGMThoWWViN2lmVUUKsWRBHqoRVmY2Clcro5aKhQj9yX5W4Kh3
arC5FKBoZ/8KlcS7zxC9vIISOqlQu7MCKA8L//HX/gkfcz77aS6t1w==
-----END AGE ENCRYPTED FILE-----
- recipient: age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBFTVB1eWh5M3RUVFRubUFE
OTRrbWRnUDhsVGlLZkFWckRXZ08xTkxCMlVNCnFHcU9aMklma3RJZnZ5OHUxQVYx
SWFZMEJSNHExT0lsdW9FNkx0T3ZNQ0UKLS0tIEpseGdCbTJudUNWUUJGYUg1STJV
Qm9QU3Z2NHhiRWh6VnY5cDRRQVk2ZUEKktovOMBi2eT4Hxwm8i8gy4GYPpOZ3nIy
cDM6yBpUQMybiY3jMji41ELg12vF2kQ286W6lQKURdl9yYoGz0SfqQ==
-----END AGE ENCRYPTED FILE-----
- recipient: age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBHd0NhOC9FWmVSL0ExKzIz
L1hiU3RhKzh4SzFCNURhWFAxZTF4QWJIdmhBClQ4SzBEY2VweUR5UnJJSkk1OUVj
MWp5WUtQcE0yNFh5R0JoTGpUaWJiU0UKLS0tIDFQbDZoUXlPSURndTNEN2d0TjJS
djVvR2hGSllUNlZJNURrMG43bmZlU0EKl7SfN6/JGbzDA3YVrRnua5QK8xEdkWoB
ey5Yf4Zg0obqYIS5SX8QC/YtoeMANVkKtqVTOVE2PYLJtrC6l7vKxQ==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-05-20T16:24:18Z"
mac: ENC[AES256_GCM,data:d3qcjEqpPIFeut634ImvISJLdit0MQWcdFYomrsqriqJ0NUKdyq3XCk85+vvPvDWikB4WEApk6HMXssSY8mhDci95q5Ssmr+JRAhLebMZjs9yXUf9A7vXpNFswmdldu3CKBiDrhm2GE08qUfsCkcf3jjihEqsfHhxMuWSd5fZpU=,iv:VZgvZ3bBArRChgRX38U6/43XoX5aTnts7Kd4S9spBPk=,tag:/5oOw5yvulvFT6KXv2zj0g==,type:str]
pgp: []
unencrypted_suffix: _unencrypted
version: 3.9.4

40
secrets/hello.yaml Normal file
View File

@@ -0,0 +1,40 @@
greeting: ENC[AES256_GCM,data:eNMtZcpv04PHl/lGBZGBp7xAcO/5Sw==,iv:A4jtwePX0p/eDTP5+ztfV6gege9RwdPfLBY2N+DPfx0=,tag:MU8HpvLt/PUrNMw7BNUVIg==,type:str]
phase: ENC[AES256_GCM,data:oIeo+kmkwaMDQrf10iY2PqOXJnq+hCIwOROQ86xgpkU7EJEeJKFssrNgbvQPHhIRZUZblQg9ZPamiZixQbYSTOXQSTWWu2FOnu6XFb24wCay,iv:TYKqoHcN+V324/roNopYhkwRpmv3cPrTFcmni5TajYQ=,tag:g8DXqEyORbQZ9B7PaFWgOQ==,type:str]
sops:
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
age:
- recipient: age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBCbWhQa2RreXl3Ni9zZExQ
dzB5Q1hSZzJSb1NlaEx3bEFCK2x6Ty82ZEJ3Cng3Q1NmKzhpdmpvTWFOL0lUb3F6
emtvNCt3elQwTndoNzRIM2FoZTlpTVUKLS0tIER6STdwVUtnL0lreFh3Z09FYlBo
azJuWjVuNGJURTNPYU1aWWZGM0dpc2cKFM8AjHFg68fedY0Xrg2U69+Qxc5Pgn6f
QipSsaXrCohzNKieqCG9maKuLcGrgMQ1OJqZQm+hfit6CRKUmVHRkw==
-----END AGE ENCRYPTED FILE-----
- recipient: age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB2TVpWeC9FcmUxckNadmtK
d3BwZnExR3VqeXBQSGhNdStPVXM2MkdjQXhBCkpHMmxPZ3JWaVVvUGwrVjZadXlR
V3VaR2l5Zm9zb1VrREphM09RMU5mR0UKLS0tIGlYZm5XbTA2WUcvWU9FWlZYSWNZ
L1JYYmJzLyt6bzJqdlk2TjYvNUdVVTgKlj1b9zQ9cBG3YQI2t4fjz3t9ySBxQ15w
qxcyGspEQ9F+4OtoRQP0FCfmJIBNxvAoT2I5TjspH6dl2o1ID+332Q==
-----END AGE ENCRYPTED FILE-----
- recipient: age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBFa1NwSGlhUzgxdjI2V0Ew
VVNRS1o2L00wM0QvdTFXelFLVnBrQ2ZHRHprCks0Z2ZQbzdQNG5hVFlCZXFrSG5B
cG5Qb2JUbnk4ekdJSER2MU5NRGZ6NEUKLS0tIDF4cjNzWnBLaXJNS1RHbVozVUtp
bTkzM21jNndEdDV0RmtxSVZ5SkdQcGMK+MZNNAfnnXq7VjIvaDtJEaImk0mecErp
gzV3b0MOKc2eTiuaWRNH1yAhJOFTK9iQfC7dDUI8npE+zLOgpLEZ9w==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-05-20T14:39:14Z"
mac: ENC[AES256_GCM,data:CAoIW1sJkIbNoXMaVlXwfHdREzATNqDKpSN5ecaSiRARThB0/tcLNbkazXKyV1Rx5b+b3s+yCpBzwmNnH6ytaCzG5pKPcnfd6YJq7C5rm/EQHyaVGrPdBsSfqiz4yBXKClej//v3+qkD5Ls2PMppv5KmqigOKNQC/ot3ht2c7lM=,iv:eOmDDclksr9f9CDu6dJprSFwlWuoggzVFjyfqWf5Uhg=,tag:IU3sMlIi92ohM86zOIJdAg==,type:str]
pgp: []
unencrypted_suffix: _unencrypted
version: 3.9.4

View File

@@ -0,0 +1,39 @@
client_secret: ENC[AES256_GCM,data:p8csBIMAuDb1BYLLL9aYIifShpw9uIWm1Z0c59SlzBDP3SJD4I8Z6ipYBETisgxcECr+78zs2wtuebtcHZ3DBqF5PZaG87TJEGotTkjOVSjDLKgelMq0TDfnpWyR1yedsi6/SiRhm4ggTz0Vwwo+TLDNkw3Gv7EP7fZQphYdMyA=,iv:MZcvhiBwDc8FWcNqQvCiTL+9OVEfHpZrzt1kmcKcxdA=,tag:FmEIrw7u2wYiF3ksymC5lA==,type:str]
sops:
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
age:
- recipient: age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBzVVZIQzlPak4xTVVPMm9w
M2tFbTFoQmpRUGFETjF1ZTQ3ajlEdGxWQmtZCkdkZzdhblFybGV3OEUrWERBWFUv
bTdXSnZFSU8vY1JJVTJjL3dLdWpiNGsKLS0tIHVlVmcrcTNZUGs4MmJtWkR0aTh3
Z0pTOWhsZWYyVW5TeXk5ZHhaQnZvZ3MKrnBt5T7WjSxGYvRc1olfhuMN6nOEJbbX
xoliPcKkGsBExXVgkpood+OdlH8dNAaT0z1+INzNiBAZ8SazZA4p9g==
-----END AGE ENCRYPTED FILE-----
- recipient: age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBjYk1SOVZxUTBWYUw4aUhy
WG93N2xVWDlEWnBqQ0JTRWcxc2tXUnppSFF3Cmp6L1g4OUliVlNFYUo2bEtNOHhm
aytHK0RFWmNpSWFMaGc1cnNXNlEwbTQKLS0tIHMxU3VXYTZHNWk3cWZFNXBNRElD
bE1CUHc1TFlsbGU2aTJjSmhwZENuUDgKvGHg2Df/eBw5akRPYFLvXhzh7P6jTOgj
E56n29EJ+p4kTkC8yVBci01qpMioL+Wx2Rt+X+0LWrGBzu5fic2U5g==
-----END AGE ENCRYPTED FILE-----
- recipient: age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAwSGdBYzlsYmc4dVJ5ZE8r
Q1FMR1NQL2lzZ1lmaTg1bGROa0hOcVk2ZTJvCllFMUtJdG5JVFhseWc5eXFsYk9p
Q2tIVm5yelAreVRrVkQrTnJ1N3FLclEKLS0tIGQ5dTdqZ3lKWU1KcHdHK1UwajIw
Znh5aXkwNmdyVi9EZlBzMUpTTW1VdjgKVVNGjiiKw3nLxq4YsCWoTS4R8wUD8wqD
awXSIOvZj5xiz6NvFk1X5T3H4XeEm6tKKzOpBvoVn5yl3zgn/bh5pA==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-05-21T20:13:20Z"
mac: ENC[AES256_GCM,data:bi4o17skIOtZoxJGzLFJ7IAv+X265qIhgnQz6wr4bch2xofwPZZfzECck0I0xDhcwxcDtx3VZ5E2VBdvHWk+iSan+clRq7735k9+DIpjdWaGxIHuylmelZl6aE5kE36UO7fQdnwmiqCPHxiVq/sKuqknFXkl1+FvO1ryrtSHQsU=,iv:xxm+5rK7o2c43iGS1j2Q274QCRsbDn+k+wskpp5tzeU=,tag:sZHk9eth/TwqM8tIWSrv3w==,type:str]
pgp: []
unencrypted_suffix: _unencrypted
version: 3.9.4

View File

@@ -0,0 +1,30 @@
api_key: ENC[AES256_GCM,data:ps6aTT939SWpgyUoMMIj6YsJG3UnJFBdRKuESpoMeyphB9QnrkqSb5Y/kXI=,iv:6KxZTpPUR2GmPzr5opMP4Wp/yPIQqE9AWtAO5xBDGTI=,tag:qfRt1AlQ97PeHMsGmXnqsQ==,type:str]
sops:
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
age:
- recipient: age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBUZEU0QWtxb0RMMkNqTHI3
d25ONHF2Z2F6L0c4VWd6amMvYkQ0NU5YeEh3ClpBSEt5bXNJVmF5Q2pzNGdzVWVC
MzFDSTk2YlpyWkxIdDg0RmFxR21EMzgKLS0tIE1GRW52Wk10eXppM05XSDMrc3RH
eWQ5TFR0R29RWUxsbVhlWm1TVkFvb0UKwv6OafGdw06J21tQoUlvNI0aLVvl2CVm
nwbltuhQvWdFPVBRP/Cx0eK7Vh80tJrnOfXeGpNVgiCs2lpxBlUR2g==
-----END AGE ENCRYPTED FILE-----
- recipient: age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBMZVdubENvWkxGWmNnVks0
NFJYQVBKMnJJZ3FEUDVJWXl2WitMTDFYdEd3CmJQaSt4bTZyUVlBZHgybXRhbWky
UGIzNkwzUzNZaWN3WmNKSUkxQkl0OTAKLS0tIENyNlphYmVRV3BUNU1FQzd0QStN
V0JLNU5RT09RVjd2OTJUTWMvek9NaTQK0R0OfQnm7AhIeroefldUNDsgDHX1cDmk
GybdGEoM8j8Is8GLc6Gk+yQmz7yNvVfYBkaaFsH3Crz3HupuyXI59g==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-05-30T23:08:58Z"
mac: ENC[AES256_GCM,data:68i7qPK7zJXDspmVYfTVJGd141ldXeiPEHK/tsX3p9QHBSP8otXwAzaEqcjNG83dXhrse9CRzzyJmrLm7EO714hZyHKh6MKZb/BOuiwnMCZse0qbK55ghkzTQLMkeSuIM2KsN396mlH/Ehd/hQV6oC6OPq2RQUs/aJt55ESutLc=,iv:kGPKf9e6w1vz7soEyDL7mGQCBkrxiG0Wvm7FHEYp3jc=,tag:zHd7qoIx9Fei2Aabgsztuw==,type:str]
pgp: []
unencrypted_suffix: _unencrypted
version: 3.9.4

View File

@@ -0,0 +1,39 @@
password: ENC[AES256_GCM,data:C9uA7LHAc/SmWJsICGMNLu2OOYyowIhLq/sTfuNo/w==,iv:211oFm7MdphnXwYszRxWrHLeKw7qeh0BNzP0yomTUTk=,tag:zfhfkzLm0AivKwD7TZTEqw==,type:str]
sops:
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
age:
- recipient: age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBiaFhuUlZJMkxERlR1T2xL
dnVKMXNMNVBIdDFuYzJtTjJWZE9JZnlaQ1dJCnNNbzJKc3VXOUZ1QUFzRWhSUkdF
WVJlNUtJSytSS2h5NGQyMHA4d2U0c28KLS0tIFVhSGJQOWlEQlp6SlRmS0ZMZzJ4
Y2ZoVVVkeVNiYUNwWFFLd2VGaTR1VUkK9Dpk0kjuKoUh3zfVQV7qs/YTTg2BaOkg
kNYY01k9MftEwdtvpKk1ogzdHyhGFJ1yEepwK7se6W5KHDJXyVfA8w==
-----END AGE ENCRYPTED FILE-----
- recipient: age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA4N2xmZEM3R2tMQjA4a1Fr
cWZ5a2lwUWlJZDdVSExnaHA3cEpubTVMWG1BCjNjTEJzL01PWm9xRWlEWUlWend5
TUdMMjUzNE1RN0h5bzJuQnl5QkJXVzAKLS0tIDZ6aVh6dXlxTTVPZDRKQUkyZmlk
a2FLZ2s1NTA1ZUljeFBhSDZEcGZLNlkKcX5b3dXcJZejeSP4TLr1cOXQj7YEjj0G
znXBdDt6c42qE5XS+LGciunf3MmYxt0xXDb11cnSSEVgn0VyqE2x6w==
-----END AGE ENCRYPTED FILE-----
- recipient: age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBybWtTZEw3NTdwMWZhOUMv
L1RNWGduU1BSM0FodWMwUEtHNkxSY3MvdGhzCm1qTGlnM2ZRM2RoekhZbjFzZ2tE
Qk1DNjlnMk01R1ZzUDBubTMvR2hPNmcKLS0tIGsxZ2lQMi9vY0Zpb3pwRHBSSm5t
OWtkeCtNNnpTVXV6S1p2Y2E0V2toL1EK3RI0RgM0SudRguOpOimke7niuX3cIIVi
X+zK75QbvLN7pe1pIIC2UOEmRB9BhgGMVssHxnLzTJv0LUKjDMGdbw==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-05-21T20:13:20Z"
mac: ENC[AES256_GCM,data:PXXEpDbJIoIwWuNwMetpALtxrcB7yJhDYqp6LSKV8WFQI2TJSDgDHiNQ2cngfB9PXKmUNNhlvhmB52Hi6WQSWQdXjiy79T1fOjMhbUAKmykTaZDrwvlrDGkPgiZ1cFSb8+hx/5aZw7YhdQO9+7LAmymeIldZIFEpEO2pvuPZ6MM=,iv:mJb3K8TnsnY183OX7o9pF7oVMDcwVU/rOgX74KTRpr4=,tag:f+M+FT0Vjozm7a8LFDI8lA==,type:str]
pgp: []
unencrypted_suffix: _unencrypted
version: 3.9.4

74
vps/management.json.tmpl Normal file
View File

@@ -0,0 +1,74 @@
{
"Stuns": [
{"Proto": "udp", "URI": "stun:stun.l.google.com:19302", "Username": "", "Password": null},
{"Proto": "udp", "URI": "stun:stun1.l.google.com:19302", "Username": "", "Password": null},
{"Proto": "udp", "URI": "stun:stun.cloudflare.com:3478", "Username": "", "Password": null}
],
"TURNConfig": {
"Turns": [
{"Proto": "tcp", "URI": "turn:netbird.hubris.network:3478?transport=tcp", "Username": "netbird", "Password": "{{TURN_PASSWORD}}"}
],
"CredentialsTTL": "12h",
"Secret": "not-used-when-time-based-false",
"TimeBasedCredentials": false
},
"Relay": {
"Addresses": ["rels://netbird.hubris.network:443"],
"CredentialsTTL": "24h",
"Secret": "f6vaBSTqv53Jl9Fr+zUkzJ6iIsKv0RoYt++hKRqq58Q"
},
"Signal": {
"Proto": "https",
"URI": "netbird.hubris.network:443",
"Username": "",
"Password": null
},
"ReverseProxy": {
"TrustedHTTPProxies": ["172.30.0.0/24"],
"TrustedHTTPProxiesCount": 0,
"TrustedPeers": ["0.0.0.0/0"]
},
"Datadir": "",
"DataStoreEncryptionKey": "U60qK19PEpe6LSocYs1OR+qeoE2rUq6tN+W20NAC+gs=",
"StoreConfig": {"Engine": "sqlite"},
"DisableDefaultPolicy": false,
"HttpConfig": {
"Address": "0.0.0.0:80",
"AuthIssuer": "https://auth.hubris.network/application/o/netbird/",
"AuthAudience": "netbird-dashboard",
"AuthUserIDClaim": "sub",
"AuthKeysLocation": "https://auth.hubris.network/application/o/netbird/jwks/",
"OIDCConfigEndpoint": "https://auth.hubris.network/application/o/netbird/.well-known/openid-configuration",
"IdpSignKeyRefreshEnabled": true,
"CertFile": "",
"CertKey": ""
},
"IdpManagerConfig": {
"ManagerType": "none"
},
"DeviceAuthorizationFlow": {
"Provider": "hosted",
"ProviderConfig": {
"ClientID": "netbird-dashboard",
"Audience": "netbird-dashboard",
"Domain": "auth.hubris.network",
"TokenEndpoint": "https://auth.hubris.network/application/o/token/",
"DeviceAuthEndpoint": "https://auth.hubris.network/application/o/device/",
"Scope": "openid profile email offline_access",
"UseIDToken": false
}
},
"PKCEAuthorizationFlow": {
"ProviderConfig": {
"ClientID": "netbird-dashboard",
"ClientSecret": "{{AUTHENTIK_CLIENT_SECRET}}",
"Audience": "netbird-dashboard",
"Domain": "auth.hubris.network",
"TokenEndpoint": "https://auth.hubris.network/application/o/token/",
"AuthorizationEndpoint": "https://auth.hubris.network/application/o/authorize/",
"Scope": "openid profile email offline_access",
"UseIDToken": false,
"RedirectURLs": ["http://localhost:53000/"]
}
}
}

23
vps/turnserver.conf.tmpl Normal file
View File

@@ -0,0 +1,23 @@
# coturn for netbird symmetric-NAT peers — rendered from this template by
# `homelab render-vps-configs`. DO NOT edit /etc/turnserver.conf on the VPS
# directly; that file is recreated from this template on each render.
listening-port=3478
listening-ip=0.0.0.0
relay-ip=82.165.190.79
external-ip=82.165.190.79
min-port=49152
max-port=49999
fingerprint
lt-cred-mech
realm=netbird.hubris.network
user=netbird:{{TURN_PASSWORD}}
no-stun
no-multicast-peers
no-cli
no-loopback-peers
no-tlsv1
no-tlsv1_1
no-udp