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>
9.7 KiB
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.
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 | 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.yamlon hubris, regeneratehosts/*.yaml, push. The next sync (≤5 min) propagates.
DNS prerequisite
*.hubris.network resolves via the split-horizon dnsmasq on LXC 124
(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.networkat LXC 124. - Hack-fix anywhere: append to
/etc/hosts:(192.168.8.175 = caddy on LXC 121, terminates all192.168.8.175 mcp.hubris.network secrets.hubris.network 192.168.8.175 git.hubris.network*.hubris.network.)
If DNS isn't an option at all, override the URLs at bootstrap time:
sudo HOMELAB_GITEA_TOKEN=... \
HOMELAB_REPO_URL=http://192.168.8.121:3000/dtoro/Homelab-Docs.git \
HOMELAB_ISSUANCE_NETBIRD=http://192.168.8.205:9820/issue \
HOMELAB_MCP_URL=http://192.168.8.205:9810/sse \
bash /tmp/bootstrap.sh --with-mcp
Install dependencies
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
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).
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
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).
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.
# 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:
- Edit
.sops.yamlat the repo root, add the client'sage_pubkeyto the matchingcreation_rulesblock. - Re-key the existing ciphertext for the new recipient list:
sops updatekeys -y secrets/hello.yaml - Commit + push. On the next sync (≤5 min), the client can decrypt.
Removing a client
# From any existing client:
homelab client remove my-old-machine
This:
- Removes the inventory entry and
hosts/my-old-machine.yaml. - Runs
sops updatekeys -yagainst every file insecrets/(operator must first remove the pubkey from.sops.yamlrules). - Calls
secrets-issuance/revoke(admin-token-gated, on LXC 105) to shred the key file and add the hostname to the denylist. - 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-machineSSHes 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. |
Changelog
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.