Merge branch 'main' of git-ssh.hubris.network:dtoro/Homelab-Docs

# Conflicts:
#	.sops.yaml
#	containers/107-dns.md
#	infrastructure/dns.md
This commit is contained in:
2026-06-03 22:02:52 +02:00
63 changed files with 2086 additions and 152 deletions

View File

@@ -23,7 +23,8 @@ creation_rules:
age: >-
age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6,
age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0,
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6,
age1z62ff2ak9zj5ctcvaxwyyhedwjvlwgm2dkn9nk3wrwk8fkavcpmsqwc2vs
- path_regex: ^secrets/gitea-pat\.yaml$
# Write-scoped Gitea PAT (dtoro user). Same recipient list as hello.yaml
@@ -32,7 +33,8 @@ creation_rules:
age: >-
age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6,
age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0,
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6,
age1z62ff2ak9zj5ctcvaxwyyhedwjvlwgm2dkn9nk3wrwk8fkavcpmsqwc2vs
- path_regex: ^secrets/gitea-tokens\.yaml$
# Workstations only.
@@ -52,7 +54,8 @@ creation_rules:
age: >-
age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6,
age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0,
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6,
age1z62ff2ak9zj5ctcvaxwyyhedwjvlwgm2dkn9nk3wrwk8fkavcpmsqwc2vs
- path_regex: ^secrets/netbird-authentik-oidc\.yaml$
# Authentik OIDC client secret for the netbird-dashboard provider.
@@ -61,7 +64,8 @@ creation_rules:
age: >-
age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6,
age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0,
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6,
age1z62ff2ak9zj5ctcvaxwyyhedwjvlwgm2dkn9nk3wrwk8fkavcpmsqwc2vs
- path_regex: ^secrets/netbird-pat\.yaml$
# NetBird API Personal Access Token. Consumed by the dns-sync job on the
@@ -70,5 +74,18 @@ creation_rules:
age: >-
age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6,
age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0,
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6,
age1z62ff2ak9zj5ctcvaxwyyhedwjvlwgm2dkn9nk3wrwk8fkavcpmsqwc2vs
- 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,
age1z62ff2ak9zj5ctcvaxwyyhedwjvlwgm2dkn9nk3wrwk8fkavcpmsqwc2vs
>>>>>>> ce1bf4d0d229b6e76c04e848cb0bb01d76dd5d37
# webhook noop 2026-05-20T18:16:57+02:00

View File

@@ -83,7 +83,28 @@ Grep is fine for browsing or when MCP is unreachable.
- **Wiki updates**: same-session rule applies to any meaningful state change
this client makes.
## 6. When in doubt
## 6. Auto-setup mechanism
The homelab-context repo ships tooling that gets automatically installed
on every client after `git pull`. This is handled by `tools/post-pull.sh`
(replaces the raw git pull in the sync timer) which runs any script matching
`tools/*.setup.sh` after pull.
Currently auto-setup:
- **Caveman + templates** (`tools/setup-caveman.sh`): Installs Caveman npm
package, wrapper scripts, and compact output templates for token-efficient
CLI output. Wrapper at `~/bin/caveman_wrapper.sh`.
- **Hermes agent persona** (`tools/setup-hermes-soul.sh`): Provisions
`~/.hermes/SOUL.md` from `HERMES.md` on Hermes agents. This ensures every
Hermes agent follows the canonical homelab persona (token efficiency, source
of truth hierarchy). No-op on non-Hermes agents.
To add a new auto-setup, create `tools/<name>.setup.sh` in the repo,
commit and push. All enrolled clients pick it up within 5 minutes.
To trigger sync manually: `sudo homelab sync` or wait for the 5-min timer.
## 7. When in doubt
Run `homelab mcp search_docs <query>` or `homelab mcp get_host <name>`.
The clone is the fallback; MCP is the index.

View File

@@ -67,6 +67,30 @@ Links to nodes that host or depend on this.
## Changelog
```
### Plan (`plans/YYYY-MM-DD-slug.md`)
```markdown
# YYYY-MM-DD — <title>
## Goal
What this change achieves and why.
## Current topology / state
Diagram or description of what exists now.
## Target topology / state
What it looks like after.
## Pre-flight checklist
## Step-by-step procedure
## Verification
## Post-migration
Changelog entries to write, index status to update.
```
### Investigation (`investigations/YYYY-MM-DD-slug.md`)
```markdown
@@ -89,6 +113,7 @@ Links to nodes that host or depend on this.
- Every container page links to every cross-cutting page it participates in.
- Every cross-cutting page lists the nodes that participate.
- Every investigation links to the nodes it implicates *and* gets back-linked from each node's changelog.
- Every plan links to the infrastructure pages it affects. When done, update the plan's status in `plans/index.md` and write changelog entries on affected node pages.
## Changelog hygiene

83
HERMES.md Normal file
View File

@@ -0,0 +1,83 @@
# HERMES.md — Agent persona for homelab clients
This file is the canonical agent persona for **all** AI agents running on
machines in the **hubris** homelab. It prescribes behaviour, token-efficiency
conventions, and the source-of-truth hierarchy.
## Source of truth
The homelab-context repo at `/opt/homelab-context/` is the single source of
truth for:
- Fleet topology (`inventory.yaml`, `hosts/*.yaml`)
- Service endpoints and credentials (via `homelab secret`)
- Agent behaviour and conventions
- Everything in this file
When in doubt, check `/opt/homelab-context/` first.
## Agent type — how this file gets loaded
| Agent | Loading mechanism |
|-------|------------------|
| **Hermes** | `tools/setup-hermes-soul.sh` (auto-setup) → provisions `~/.hermes/SOUL.md` from this file |
| **Goose** | `.goosehints` symlink at `~/.config/goose/.goosehints``/opt/homelab-context/HERMES.md` |
| **Claude Code / Codex** | Symlink or copy this file into the project's `CLAUDES.md` / `.claude` instructions |
**Do not edit SOUL.md or .goosehints directly.** Edit this file in the
homelab-context repo instead. Changes propagate to all clients on the next
sync (`sudo homelab sync`).
---
## Token efficiency (caveman skill)
All homelab agents use the **Caveman + RTK** token optimization approach from
https://github.com/adityahimaone/hermes-agent-rtk-caveman.
### Before running any CLI command, ask:
1. **Is there a caveman wrapper equivalent?** Use the wrapper for token-efficient
output. Available wrappers (installed at `~/bin/caveman_wrapper.sh`):
- `~/bin/caveman_wrapper.sh git-status` — compact git status
- `~/bin/caveman_wrapper.sh git-log [n]` — compact git log
- `~/bin/caveman_wrapper.sh lint [target]` — compact lint results
- `~/bin/caveman_wrapper.sh test-results [cmd]` — compact test results
2. **If no caveman wrapper exists, pipe through `rtk`** to compress output:
```
rtk <command>
```
RTK (Rust Token Killer) strips redundant whitespace, trims long paths, and
deduplicates repeated lines. This reduces token usage by 60-90% on CLI
operations.
3. **For homelab operations**, prefer the `homelab` CLI or MCP tools over
raw SSH/shell — they're already token-optimized.
### Templates
Caveman templates live at `~/templates/`:
- `git_status.txt` — compact git status format
- `git_log.txt` — compact git log format
- `lint_results.txt` — compact ESLint format
- `test_results.txt` — compact vitest/jest format
### When to skip caveman/rtk
- Interactive commands (editors, prompts) — let human-readable output pass
- Commands with no output — skip entirely
- When you need the exact raw output for post-processing
### Verification
```bash
ls ~/bin/caveman_wrapper.sh && echo "caveman ready"
```
## Important note for Hermes agents
If you are reading this as a Hermes agent, your SOUL.md was auto-provisioned
by `tools/setup-hermes-soul.sh`. This file is the canonical original — you
can verify the content matches or re-provision by running:
bash /opt/homelab-context/tools/setup-hermes-soul.sh

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 "$@"

View File

@@ -199,6 +199,12 @@ SHARED_SECRETS = [
("secrets/netbird-authentik-oidc.yaml", "^secrets/netbird-authentik-oidc\\.yaml$"),
]
# Secrets granted only to hosts that opt into running the Hermes agent
# (via `homelab client add --finalize-pubkey ... --with-hermes`).
HERMES_SECRETS = [
("secrets/openrouter-api-key.yaml", "^secrets/openrouter-api-key\\.yaml$"),
]
# -------- comment-preserving inventory.yaml edits --------
# yaml.safe_load + safe_dump round-trips strip every comment, which is fine
@@ -360,11 +366,15 @@ def _add_recipient_to_sops_policy(sops_path: Path, path_regex_pattern: str, pubk
return True
def _grant_shared_secrets(pubkey: str) -> None:
"""Add `pubkey` to the recipient list of every shared secret + re-key."""
def _grant_shared_secrets(pubkey: str, secrets: list[tuple[str, str]] = SHARED_SECRETS) -> None:
"""Add `pubkey` to the recipient list of every listed secret + re-key.
`secrets` defaults to SHARED_SECRETS; pass HERMES_SECRETS to grant the
Hermes-only set.
"""
sops_path = CONTEXT / ".sops.yaml"
env = {**os.environ, "SOPS_AGE_KEY_FILE": str(AGE_KEY)}
for rel_path, pattern in SHARED_SECRETS:
for rel_path, pattern in secrets:
target = CONTEXT / rel_path
if not target.exists():
print(f" skipping {rel_path}: file does not exist yet")
@@ -440,11 +450,14 @@ def _remove_recipient_from_sops_policy(sops_path: Path, path_regex_pattern: str,
return True
def _revoke_shared_secrets(pubkey: str) -> None:
"""Remove `pubkey` from every shared-secret rule + re-key the files."""
def _revoke_shared_secrets(pubkey: str, secrets: list[tuple[str, str]] = SHARED_SECRETS) -> None:
"""Remove `pubkey` from every listed secret rule + re-key the files.
Defaults to SHARED_SECRETS; pass HERMES_SECRETS to revoke the Hermes-only set.
"""
sops_path = CONTEXT / ".sops.yaml"
env = {**os.environ, "SOPS_AGE_KEY_FILE": str(AGE_KEY)}
for rel_path, pattern in SHARED_SECRETS:
for rel_path, pattern in secrets:
target = CONTEXT / rel_path
if not target.exists():
continue
@@ -504,6 +517,19 @@ def cmd_ssh(args: argparse.Namespace) -> int:
os.execvp(cmd[0], cmd)
def cmd_ssh_config(args: argparse.Namespace) -> int:
"""Generate SSH config from inventory.yaml."""
script = CONTEXT / "ssh" / "gen-config.py"
if not script.exists():
die(f"ssh-config generator not found: {script}")
cmd = [sys.executable or "python3", str(script)]
if args.install:
cmd.append("--install")
return subprocess.call(cmd)
def cmd_pct(args: argparse.Namespace) -> int:
lxc = args.lxc
inv = inventory()
@@ -1033,7 +1059,8 @@ def cmd_client_add(args: argparse.Namespace) -> int:
return subprocess.call(["sudo", "-E", sys.argv[0], "client", "add"]
+ ([args.name] if args.name else [])
+ (["--finalize-pubkey", args.finalize_pubkey]
if args.finalize_pubkey else []))
if args.finalize_pubkey else [])
+ (["--with-hermes"] if args.with_hermes else []))
name = args.name
inv = inventory()
if not args.finalize_pubkey:
@@ -1054,6 +1081,7 @@ def cmd_client_add(args: argparse.Namespace) -> int:
print(f" 2. On {name}: curl -fsSL <gitea>/dtoro/Homelab-Docs/raw/main/bootstrap.sh | sudo bash")
print(f" 3. bootstrap prints an age pubkey — bring it back here and run:")
print(f" homelab client add {name} --finalize-pubkey <age1...>")
print(f" (append --with-hermes to also grant the Hermes agent's OpenRouter key.)")
return 0
# finalize_pubkey path
@@ -1065,8 +1093,13 @@ def cmd_client_add(args: argparse.Namespace) -> int:
print(f"set age_pubkey for {name}")
print("granting shared secrets...")
_grant_shared_secrets(pubkey)
commit_subject = f"client-add: {name} (finalize age_pubkey + grant shared secrets)"
if args.with_hermes:
print("granting hermes-only secrets...")
_grant_shared_secrets(pubkey, HERMES_SECRETS)
commit_subject = f"client-add: {name} (finalize age_pubkey + grant shared + hermes secrets)"
push_inventory(
f"client-add: {name} (finalize age_pubkey + grant shared secrets)",
commit_subject,
extra_paths=[".sops.yaml", "secrets/"],
)
print(f"finalized {name}.")
@@ -1103,6 +1136,9 @@ def cmd_client_remove(args: argparse.Namespace) -> int:
if pubkey:
print("revoking shared secrets...")
_revoke_shared_secrets(pubkey)
# Also revoke from hermes-only secrets; idempotent if the pubkey
# was never on those rules (logs a "not present" warning, no harm).
_revoke_shared_secrets(pubkey, HERMES_SECRETS)
else:
print(f" note: no age_pubkey recorded for {name} — skipping sops re-key")
@@ -1436,6 +1472,12 @@ def main() -> int:
sp.add_argument("command", nargs=argparse.REMAINDER)
sp.set_defaults(func=cmd_ssh)
sp = sub.add_parser("ssh-config",
help="generate ~/.ssh/config.d/homelab from inventory.yaml")
sp.add_argument("--install", "-i", action="store_true",
help=f"write to ~/.ssh/config.d/homelab and wire Include into main config")
sp.set_defaults(func=cmd_ssh_config)
sp = sub.add_parser("pct", help="proxy pct commands via ssh to hubris")
sp.add_argument("lxc")
sp.add_argument("action")
@@ -1518,6 +1560,10 @@ def main() -> int:
csub_add.add_argument("name")
csub_add.add_argument("--finalize-pubkey", default=None,
help="set/update age_pubkey for an already-added client")
csub_add.add_argument("--with-hermes", action="store_true",
help="also grant secrets/openrouter-api-key.yaml so this "
"host can run the Hermes agent (see "
"operations/hermes-agent.md). Combine with --finalize-pubkey.")
csub_add.set_defaults(func=cmd_client_add)
csub_rm = csub.add_parser("remove")
csub_rm.add_argument("name")

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
#
@@ -22,9 +23,12 @@ REPO_HTTPS="${HOMELAB_REPO_URL:-https://git.hubris.network/dtoro/Homelab-Docs.gi
CLONE_DIR="${HOMELAB_CONTEXT_DIR:-/opt/homelab-context}"
ISSUANCE_URL_NETBIRD="${HOMELAB_ISSUANCE_NETBIRD:-https://secrets.hubris.network/issue}"
ISSUANCE_URL_TAILSCALE="${HOMELAB_ISSUANCE_TAILSCALE:-https://secrets.hubris.network/issue}"
MCP_URL="${HOMELAB_MCP_URL:-https://mcp.hubris.network/sse}"
MCP_URL="${HOMELAB_MCP_URL:-https://mcp.hubris.network/mcp}"
HERMES_MCP_URI="${HOMELAB_HERMES_MCP_URI:-https://mcp.hubris.network/mcp}"
HERMES_MODEL="${HOMELAB_HERMES_MODEL:-nousresearch/hermes-4-405b}"
WITH_MCP=0
WITH_HERMES=0
DRY_RUN=0
NO_SECRETS=0
GITEA_TOKEN="${HOMELAB_GITEA_TOKEN:-}"
@@ -34,6 +38,7 @@ GITEA_USER="${HOMELAB_GITEA_USER:-dtoro}"
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 ;;
@@ -403,6 +408,116 @@ 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

View File

@@ -60,7 +60,7 @@ is `dtoro/Homelab-Docs/mcp/server.py`; service unit
disabled at the FastMCP layer because mesh+LAN gating is the actual
trust boundary.
- Endpoint: `https://mcp.hubris.network/sse` (Caddy → `:9810`).
- Endpoint: `https://mcp.hubris.network/mcp` (Caddy → `:9810`). StreamableHTTP transport (POST `/mcp`).
- 14 tools registered: `get_host`, `list_services`, `find_service`,
`get_topology`, `search_docs`, `get_page`, `get_changelog`, `whoami`,
`list_my_secrets` (context); `get_service_status`, `tail_log`,

View File

@@ -24,12 +24,23 @@ Authoritative split-horizon DNS for `hubris.network` on the LAN/mesh, plus recur
- API: `http://192.168.8.2:5380/api/...` (token via `/api/user/login`). Zone was built via the API.
## Who points here
- **NetBird mesh peers:** resolve via the **NetBird managed DNS zone**, which is kept in sync *from* this Technitium (see dns-sync below). The `home-lab-dns` nameserver group (`→ 192.168.8.2`) is a thin fallback forwarder.
- **Plain LAN clients:** router DHCP should hand out `192.168.8.2` (the old `.180`/dnsmasq is gone).
- **NetBird mesh peers:** resolve via the **NetBird managed DNS zone**, kept in sync *from* this Technitium (see dns-sync below). The `home-lab-dns` nameserver group (`→ 192.168.8.2`) is a thin fallback forwarder.
- **Homelab DHCP clients:** Technitium's own DHCP scope hands out `192.168.8.2` as the DNS server for `192.168.8.x` leases (see DHCP section below).
- **Plain LAN clients (`192.168.178.x`):** Fritz!Box DHCP still hands out Fritz!Box itself (`192.168.178.1`) as DNS — no split-horizon for non-mesh clients. Changing this requires a secondary DNS fallback, which Fritz!OS 8.x doesn't expose in a single DHCP field.
## dns-sync (Technitium = authoring source)
`/opt/dns-sync/sync.py` (cron `*/10`, logs `/var/log/dns-sync.log`) reconciles this zone's named A-records → the NetBird managed DNS zone via the NetBird API (`/api/dns/zones/{id}/records`). Token at `/opt/dns-sync/netbird-token` (mode 600; source of truth in sops `secrets/netbird-pat.yaml`). **Edit DNS only here**; the sync propagates to the mesh. It deletes NetBird records absent from Technitium. Tracked: [scripts/dns-sync.py](../scripts/dns-sync.py). *Why this exists:* NetBird won't forward to Technitium for mesh peers (self-IP / nameserver-group quirks), so we sync into the managed zone instead — see [dns.md](../infrastructure/dns.md).
## DHCP
Technitium also runs a DHCP server for the homelab subnet (enabled 2026-06-02):
- **Scope:** `homelab``192.168.8.100 192.168.8.240`
- **Gateway:** `192.168.8.1` (Proxmox `vmbr0` alias)
- **DNS:** `192.168.8.2` (self)
- **Lease time:** 24 h
Replaces the DHCP that was previously served by the Slate AX router. Static-IP LXCs (below `.100`) are unaffected.
## Related
- [124 — authentik](124-authentik.md) — retired host of the old dnsmasq
- [DNS split-horizon](../infrastructure/dns.md)
@@ -40,5 +51,8 @@ Authoritative split-horizon DNS for `hubris.network` on the LAN/mesh, plus recur
### 2026-06-03 — dns-sync added (Technitium → NetBird managed zone)
This Technitium became the single DNS authoring source; `/opt/dns-sync/sync.py` (cron */10) reconciles named A-records into the NetBird managed zone via the API. Fixed previously-broken mesh names (`sso`, `nfs-export`, `mcp`, `secrets`) by adding them to the managed zone; reaped obsolete `files`/`photos-new`. See [dns.md](../infrastructure/dns.md).
### 2026-06-02 — DHCP server enabled; replaces Slate AX DHCP
Enabled Technitium's built-in DHCP server for `192.168.8.0/24` (scope `homelab`, range `.100.240`, gateway `192.168.8.1`, DNS self). Previously the Slate AX sub-router served DHCP for the homelab subnet. With the Slate AX retired and Proxmox now the subnet router, Technitium takes over DHCP. Configured via the Technitium API (`/api/dhcp/scopes/set`). DHCP LXCs kept their Slate AX leases until expiry, then renewed from Technitium.
### 2026-06-01 — created; replaced dnsmasq on 124
Stood up Technitium at `192.168.8.2`, imported the split-horizon zone (specific A + wildcard + MX/SPF/CAA), made it the primary nameserver in the NetBird `home-lab-dns` group. Verified all names resolve with dnsmasq/124 stopped; [LXC 124 retired](124-authentik.md).

View File

@@ -65,6 +65,9 @@ Gitea webhook id 2 on `dtoro/caddy-conf`. Receiver, deploy script, install scrip
## Changelog
### 2026-06-02 — caddy.service unit missing; recreated
After the Slate AX → SODOLA network migration, Caddy was not listening (ports 80/443 dead). Root cause: the custom hubris1 Debian package (`caddy_1:2.11.3-hubris1_amd64`) does not ship a systemd service unit file. The unit had previously existed but was lost (likely on a package reinstall). Recreated at `/lib/systemd/system/caddy.service` with standard Caddy service config + `EnvironmentFile=/etc/caddy/caddy.env` (already present in `caddy.service.d/override.conf`). **Risk:** the unit will be lost again if the package is reinstalled without the file being tracked. Fix: add the service unit to the `caddy-conf` repo or rebuild the hubris1 package to include it.
### 2026-04-28 — wiki entry created
Initial documentation. 16 active sites at this date.

View File

@@ -101,6 +101,16 @@ Member of [media GID 10000](../infrastructure/media-permissions.md). The LXC has
## Changelog
### 2026-06-02 — ProtonVPN added (gluetun); LXC IP set static
- Added `gluetun` container to compose as a WireGuard VPN sidecar (ProtonVPN, server AL#57, located in Tirana, Albania)
- **qbittorrent** and **sabnzbd** now use `network_mode: service:gluetun` — all traffic routes through the VPN
- Ports 8080 (qBit WebUI), 6881 tcp/udp (qBit BT), 8081 (SAB WebUI) exposed through gluetun
- gluetun config at `gluetun-config/wireguard/wg0.conf` (read-only mount)
- Healthcheck on gluetun; qBit/SAB wait for `service_healthy` before starting
- LXC IP changed from DHCP to static (`192.168.8.132`) via `pct set` + `/etc/network/interfaces`
- **After first start:** Sonarr/Radarr/Lidarr download client host needs updating from `qbittorrent``gluetun` (SAB similarly `sabnzbd``gluetun`)
- **Also fixed:** 7 other DHCP LXCs (101 jellyfin, 103 paperless, 104 gitea, 105 apps, 114 nextcloud, 118 elementsynapse, 120 mule-images, 121 caddy) set to static IPs to prevent floating on reboot. See infrastructure/dns.md.
### 2026-04-28 — wiki entry created
Initial documentation.

View File

@@ -38,7 +38,7 @@ services_hosted:
port: 9810
systemd_unit: homelab-mcp
public_host: mcp.hubris.network
endpoint: https://mcp.hubris.network/sse
endpoint: https://mcp.hubris.network/mcp
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
@@ -51,5 +51,5 @@ services_hosted:
age_pubkey: age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0
see_also:
- containers/105-apps.md
mcp_endpoint: https://mcp.hubris.network/sse
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -30,5 +30,5 @@ services_hosted:
note: jellyseerr / qbit / sab on docker compose
see_also:
- containers/122-arriman.md
mcp_endpoint: https://mcp.hubris.network/sse
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -28,5 +28,5 @@ notes:
- 'Also hosts split-horizon dnsmasq: /etc/dnsmasq.d/hubris-split.conf'
see_also:
- containers/124-authentik.md
mcp_endpoint: https://mcp.hubris.network/sse
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -28,5 +28,5 @@ notes:
- /etc/caddy is a git checkout of dtoro/caddy-conf
see_also:
- containers/121-caddy.md
mcp_endpoint: https://mcp.hubris.network/sse
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -18,5 +18,5 @@ notes:
age_pubkey: age1xmkeq968areza2necqyq0065dpeegngzyr6dhagh0n6pl33lccfqe5mqn9
see_also:
- containers/123-claudio-bot.md
mcp_endpoint: https://mcp.hubris.network/sse
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -25,5 +25,5 @@ services_hosted:
backend: elementsynapse
see_also:
- containers/118-elementsynapse.md
mcp_endpoint: https://mcp.hubris.network/sse
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -30,5 +30,5 @@ notes:
- Bare repos live at /mnt/library/repos/dtoro/*.git
see_also:
- containers/104-gitea.md
mcp_endpoint: https://mcp.hubris.network/sse
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -23,5 +23,5 @@ services_hosted:
backend: haos
see_also:
- vms/108-haos.md
mcp_endpoint: https://mcp.hubris.network/sse
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -6,8 +6,9 @@ Single-node Proxmox VE running 1 VM and 13 LXC containers. The whole homelab.
- **Role:** Proxmox VE 9.1.2 hypervisor (kernel `6.14.11-4-pve`)
- **Hardware:** GMKtec NucBox M6 Ultra — AMD Ryzen 5 7640HS (Phoenix APU), 12 vCPU / ~28 GiB RAM, 2× Samsung 990 EVO Plus NVMe (one SSD primary, one for `library` LVM). 2× Realtek RTL8125 NICs (`r8169`).
- **BIOS:** 1.02 (2025-08-06) — vendor not on LVFS, no automated update path. See [investigations](../investigations/2026-04-21-hubris-crash-loop.md).
- **LAN (primary):** `192.168.8.77/24` on bridge `vmbr0` (slave: `eno1`), gateway `192.168.8.1`. Default route metric 0.
- **WiFi (failover):** `192.168.8.141/24` on `wlp3s0` (MediaTek MT7922, AX), DHCP from the same router. Default route metric 200. See [Phase 1 WiFi failover](#phase-1-wifi-failover) below.
- **Uplink:** `vmbr1` (slave: `eno1`) → SODOLA switch → Fritz!Box 7590. DHCP-reserved `192.168.178.10/24`, gateway `192.168.178.1`.
- **Homelab bridge:** `vmbr0` — portless internal bridge, `192.168.8.77/24` + `192.168.8.1/24` alias (LXC default gateway). All 16 LXCs and the HAOS VM are on `vmbr0`. Proxmox routes between `vmbr0` and `vmbr1`; Fritz!Box has a static route `192.168.8.0/24 → 192.168.178.10`.
- **WiFi:** disabled 2026-06-02 — `wlp3s0` removed from `/etc/network/interfaces`, wpa config deleted. Was used as a failover to the now-retired Slate AX AP.
- **Mesh:** Netbird `wt0` `100.122.38.109/16`. Resolver: `100.122.38.109` (the local netbird daemon, which forwards to LAN/upstream and learns `*.hubris.network` answers via that path). See [mesh](../infrastructure/mesh.md).
- **UI:** `https://proxmox.hubris.network` (via [caddy](../containers/121-caddy.md)) or `https://192.168.8.77:8006`.
@@ -36,17 +37,6 @@ See [containers/index](../containers/index.md). 13 active (109 syncthing destroy
- **Crash capture:** `/etc/sysctl.d/60-crash-capture.conf` (panic on oops/hardlockup/softlockup/rcu, auto-reboot 10 s), `/etc/modprobe.d/softdog.conf` (`soft_panic=1 soft_margin=60`), `/etc/systemd/system.conf.d/watchdog.conf` (`RuntimeWatchdogSec=15s`). Pstore traces collected to `/var/lib/systemd/pstore/` by `systemd-pstore.service`. **Caveat:** silent CPU lockups leave pstore empty.
- **`rasdaemon`** (Debian pkg) collects MCE / memory / PCIe AER / thermal events to `/var/lib/rasdaemon/ras-mc_event.db`. Query with `ras-mc-ctl --summary` / `--errors`. (mcelog is retired in Debian 13 — don't go looking for it.)
## Phase 1 WiFi failover
Host is dual-homed on LAN (`eno1`/`vmbr0`) and WiFi (`wlp3s0`) so management/SSH stay reachable when LAN drops. **Guests are not yet failed over** — the LXC fleet remains on `vmbr0`/`eno1`. Phase 2 will migrate guest networking off the bridge so the homelab survives full LAN loss.
- WiFi creds in `/etc/wpa_supplicant/wpa_supplicant-wlp3s0.conf` (hashed PSK, mode 600). SSID lives in `/etc/network/interfaces` as `wpa-conf`.
- Both interfaces sit on the same `192.168.8.0/24`; cross-talk avoided with `arp_ignore=1` + `arp_announce=2` on `eno1`/`vmbr0`/`wlp3s0` (set via `post-up` in `/etc/network/interfaces`).
- A second default route at metric 200 is added on `wlp3s0` (post-up). LAN wins while up.
- **Carrier-based failover:** `vmbr0`'s carrier follows the LXC veth members, so it stays `1` even when `eno1` loses link. `ignore_routes_with_linkdown` is therefore not enough on its own. `wan-failover.service` (`/usr/local/sbin/wan-failover.sh`) watches `/sys/class/net/eno1/carrier` via `ip monitor link` and removes/restores the `vmbr0` default route on transitions. Logs to `journalctl -t wan-failover`.
- Failover verified 2026-04-28: `ip link set eno1 down` → outbound HTTP keeps working via WiFi; `ip link set eno1 up` → vmbr0 default restored.
- Reachable on `192.168.8.77` (LAN) and `192.168.8.141` (WiFi); SSH works on either.
## Network performance baseline (2026-05-14)
| Path | Throughput | Notes |
@@ -99,6 +89,9 @@ OpenSSH on `0.0.0.0:22`. Netbird's built-in SSH server is on `100.122.38.109:220
## Changelog
### 2026-06-02 — Slate AX retired; SODOLA switch added; network restructured
Replaced GL.iNet Slate AX sub-router with SODOLA 5-Port 2.5Gbit managed switch. Fritz!OS 8.x lacks second-IP-network support on LAN ports, so Proxmox now acts as the subnet router: `vmbr1` (eno1 → SODOLA → Fritz!Box) is the uplink at `192.168.178.10/24`; `vmbr0` is a portless internal bridge holding all LXCs/VMs with `192.168.8.1` as an alias (unchanged LXC gateway). Fritz!Box static route `192.168.8.0/24 → 192.168.178.10` enables inbound routing. No LXC configs changed. Eliminated double-NAT. WiFi (`wlp3s0`) also removed — was pointing at the Slate AX SSID, no longer useful. See [network](../infrastructure/network.md) and [migration plan](../plans/2026-06-01-slate-ax-to-sodola-migration.md).
### 2026-05-14 — LXC 109 (syncthing) decommissioned
User destroyed the syncthing LXC (had been stopped since 2026-04-21, never re-enabled). `pct destroy 109 --purge` cleaned `vm-109-disk-0` on `local-lvm` and the `/etc/pve/lxc/109.conf` entry. Data subtree `/mnt/library/syncthing` was already empty and retained as an empty dir. No DNS, Caddy, NFS-export, or claudio-monitor references to clean up. Entry moved to the "recently destroyed" table in [containers/index](../containers/index.md#recently-destroyed-kept-for-archaeology); references stripped from [README](../README.md), [media-permissions](../infrastructure/media-permissions.md), [vms/100-zimaos](../vms/100-zimaos.md), and [containers/102-nfs-export](../containers/102-nfs-export.md).

View File

@@ -31,5 +31,5 @@ services_hosted:
age_pubkey: age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6
see_also:
- hosts/hubris.md
mcp_endpoint: https://mcp.hubris.network/sse
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -27,5 +27,5 @@ services_hosted:
backend: jellyfin
see_also:
- containers/101-jellyfin.md
mcp_endpoint: https://mcp.hubris.network/sse
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

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

View File

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

View File

@@ -27,5 +27,5 @@ services_hosted:
backend: mule-images
see_also:
- containers/120-mule-images.md
mcp_endpoint: https://mcp.hubris.network/sse
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -25,5 +25,5 @@ notes:
- Configs rendered by `homelab render-vps-configs` from vps/turnserver.conf.tmpl + vps/management.json.tmpl,
with secrets decrypted from secrets/turn-shared-secret.yaml + secrets/netbird-authentik-oidc.yaml on
hubris.
mcp_endpoint: https://mcp.hubris.network/sse
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -27,5 +27,5 @@ services_hosted:
backend: nextcloud
see_also:
- containers/114-nextcloud.md
mcp_endpoint: https://mcp.hubris.network/sse
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -15,5 +15,5 @@ mesh_globals:
- tailscale
see_also:
- containers/102-nfs-export.md
mcp_endpoint: https://mcp.hubris.network/sse
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -27,5 +27,5 @@ services_hosted:
backend: paperless
see_also:
- containers/103-paperless.md
mcp_endpoint: https://mcp.hubris.network/sse
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -24,5 +24,5 @@ services_hosted:
backend: plato
see_also:
- containers/126-plato.md
mcp_endpoint: https://mcp.hubris.network/sse
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -15,5 +15,5 @@ mesh_globals:
- tailscale
ssh:
user: dtoro
mcp_endpoint: https://mcp.hubris.network/sse
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -20,5 +20,5 @@ mounts:
- /mnt/library
see_also:
- containers/119-sophia.md
mcp_endpoint: https://mcp.hubris.network/sse
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -22,5 +22,5 @@ services_hosted:
backend: zimaos
see_also:
- vms/100-zimaos.md
mcp_endpoint: https://mcp.hubris.network/sse
mcp_endpoint: https://mcp.hubris.network/mcp
secrets_issuance_endpoint: https://secrets.hubris.network/issue

View File

@@ -91,6 +91,9 @@ The "delete NetBird managed zone → forward everything to Technitium" plan was
> Reference: [scripts/dns-sync.py](../scripts/dns-sync.py). The sync's source of truth is Technitium; it **deletes** NetBird records absent from Technitium (so obsolete names like `files`, `photos-new` get reaped).
### 2026-06-02 — 8 LXCs moved from DHCP to static IP
All LXCs that Caddy reverse-proxies to by IP were on `ip=dhcp` and could float on reboot (arriman got a different lease mid-session and broke). Fixed via `pct set` + in-LXC `/etc/network/interfaces`. Affected: 101 jellyfin, 103 paperless, 104 gitea, 105 apps, 114 nextcloud, 118 elementsynapse, 120 mule-images, 121 caddy, 122 arriman. See [arriman changelog](../containers/122-arriman.md#changelog).
### 2026-06-01 — dnsmasq replaced by Technitium on [dns (107)](../containers/107-dns.md); LXC 124 retired
Split-horizon DNS moved off [124](../containers/124-authentik.md) to a dedicated **Technitium** LXC at **`192.168.8.2`** (zone: specific A overrides + wildcard→VPS + replicated MX/SPF/CAA). NetBird `home-lab-dns` nameserver group cut over to `192.168.8.2` (with `.180` as a now-dead fallback). dnsmasq stopped, all names verified via Technitium, **LXC 124 shut down**. **Caveat:** the [NetBird managed DNS zone](../containers/124-authentik.md) still answers most app names *directly* (bypassing the nameserver group) — three overlapping DNS sources remain; see the single-source-of-truth decision (Phase 4). **Action needed:** update router DHCP DNS from the dead `.180` → `192.168.8.2` for any plain-LAN (non-mesh) clients.

View File

@@ -16,7 +16,7 @@ page is the architecture reference.
| 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 |
| MCP server | [apps (105)](../containers/105-apps.md) | `homelab-mcp.service` on port 9810 (https://mcp.hubris.network/mcp) | 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` |

84
infrastructure/network.md Normal file
View File

@@ -0,0 +1,84 @@
# Network
Physical and logical network topology for the homelab.
## Why
The homelab runs on a dedicated internal subnet (`192.168.8.0/24`) isolated from the main household LAN (`192.168.178.0/24`). Isolation is enforced at Proxmox: LXC/VM traffic is bridged only on the internal `vmbr0` bridge; Proxmox routes packets out to Fritz!Box via `vmbr1`. The main LAN cannot reach homelab services directly without a Fritz!Box static route (which is configured to allow inbound).
Fritz!OS 8.x does not support second IP networks on LAN ports, so Proxmox (`hubris`) acts as the subnet router rather than the Fritz!Box.
## Hardware
| Device | Role |
|---|---|
| Fritz!Box 7590 | Main router / ISP gateway (`192.168.178.1`) |
| SODOLA 5-Port 2.5Gbit Managed | Homelab switch — flat L2, all ports native |
| hubris (Proxmox) | Subnet router — routes between `192.168.8.0/24` and `192.168.178.0/24` |
## Topology
```
ISP
└── Fritz!Box 7590 (192.168.178.1)
│ static route: 192.168.8.0/24 → 192.168.178.10
└── SODOLA 5-Port 2.5Gbit
├── Port 1 uplink → Fritz!Box LAN
├── Port 2 hubris eno1 → vmbr1 (192.168.178.10)
├── Port 3 [device]
├── Port 4 [device]
└── Port 5 spare
hubris internal bridges:
vmbr1 192.168.178.10/24 eno1 (uplink, DHCP-reserved) gateway 192.168.178.1
vmbr0 192.168.8.77/24 no physical port (internal)
192.168.8.1/24 alias — LXC default gateway
├── all 16 LXCs
└── HAOS VM
```
## Subnets
| Subnet | Gateway | Purpose |
|---|---|---|
| `192.168.178.0/24` | `192.168.178.1` | Household LAN — laptops, phones, Fritz!Box DHCP |
| `192.168.8.0/24` | `192.168.8.1` (Proxmox `vmbr0` alias) | Homelab — all LXCs and VMs |
## DHCP
- **Household (`192.168.178.x`)**: Fritz!Box built-in DHCP. Proxmox `vmbr1` has a reservation: MAC `84:47:09:6b:e7:58``192.168.178.10`.
- **Homelab (`192.168.8.x`)**: Technitium on [CT 107](../containers/107-dns.md) at `192.168.8.2`. Range `192.168.8.100192.168.8.240`, gateway `192.168.8.1`, DNS `192.168.8.2`.
Most homelab LXCs use static IPs below `.100`. DHCP only covers new/transient containers.
## DNS
Split-horizon DNS for `*.hubris.network` served by Technitium on [CT 107](../containers/107-dns.md) at `192.168.8.2:53`. See [dns.md](dns.md) for full detail.
## Routing
Proxmox has `net.ipv4.ip_forward=1` (already enabled by PVE). Packets from LXCs on `vmbr0` destined for the internet exit via `vmbr1` → Fritz!Box. Fritz!Box masquerades all outbound WAN traffic. Fritz!Box has a static route (`192.168.8.0/24 → 192.168.178.10`) so return traffic reaches the LXCs.
No NAT on Proxmox — traffic flows without double-NAT.
## Remote access
- **NetBird mesh** — primary path for remote administration. Authenticated via [Authentik on the VPS](../vps/).
- **Tailscale** — legacy, being phased out. See [mesh.md](mesh.md).
## Related
- [DNS](dns.md) — split-horizon config and entry list
- [Ingress](ingress.md) — public entry points via VPS traefik
- [Mesh](mesh.md) — NetBird / Tailscale VPN overlay
- [hosts/hubris.md](../hosts/hubris.md) — Proxmox host (vmbr0/vmbr1 config)
- [CT 107 — dns](../containers/107-dns.md) — Technitium DNS + DHCP server
## Changelog
### 2026-06-02 — Executed migration; Proxmox as subnet router
Fritz!OS 8.x does not support second IP networks on LAN ports, so the final design uses Proxmox as the router: `vmbr1` (eno1 → SODOLA → Fritz!Box) is the uplink at `192.168.178.10`; `vmbr0` is a portless internal bridge with `192.168.8.1` alias as the LXC gateway. Technitium DHCP enabled for `192.168.8.100240`. Caddy service unit was missing and recreated. See [migration plan](../plans/2026-06-01-slate-ax-to-sodola-migration.md).
### 2026-06-01 — Initial network doc; Slate AX retired; SODOLA switch added
Replaced the GL.iNet Slate AX sub-router with the SODOLA 5-Port 2.5Gbit managed switch. Eliminated double-NAT. See [migration plan](../plans/2026-06-01-slate-ax-to-sodola-migration.md).

View File

@@ -1,45 +1,200 @@
# SSH access
How to reach hubris and the VPS over SSH, and the dual-server gotcha.
How to reach every host in the fleet from any workstation, with LAN as
the primary path and Netbird as the automatic backup.
## Hubris
## Architecture
Two SSH endpoints — easy to hit the wrong one.
SSH access relies on three layers:
| Server | Listen | Auth | Notes |
| -------------- | ---------------------------- | --------------------------------- | ----- |
| OpenSSH | `0.0.0.0:22` | `authorized_keys` at `/etc/pve/priv/authorized_keys` (Proxmox cluster-synced; symlinked from `/root/.ssh/authorized_keys`) | Standard. |
| Netbird SSH | `100.122.38.109:22022` | OIDC / browser auth — bypasses `authorized_keys` | If a client lands here it'll open a browser tab to authenticate, then sometimes hang. Force port 22 or use the LAN IP. |
1. **Homelab inventory (`inventory.yaml`)** — the single source of truth
for every host's LAN IP, Netbird addresses, SSH user, and port.
2. **Key distribution (`ssh/deploy-keys.sh`)** — deploys workstation SSH
public keys to hubris and every running LXC, so any key-authorized
workstation can log in anywhere.
3. **Config generation (`homelab ssh-config --install`)** — generates
`~/.ssh/config.d/homelab` with short hostname aliases for every host,
using LAN IPs (routed via Netbird's `192.168.8.0/24` subnet route when
off-LAN) with Netbird FQDN fallbacks (`<name>-mesh`) for roaming
workstations.
### Authorized root keys
- `root@hubris` (self, RSA) — original.
- `d.toro.v@pm.me` (ed25519) — user's iMac (`mac-mini.netbird.selfhosted`, LAN `192.168.8.174`), added 2026-04-22.
### How it works
### Notes
- Password auth is enabled on hubris but the root password is **not** the one the user expects. Prefer key flows; don't try `ssh-copy-id` blind.
- Off-LAN access from the iMac uses the LAN path. As of 2026-04-22 the iMac's Netbird tunnel to hubris was P2P healthy but no packets were captured on `wt0`; needs revisit if remote access becomes critical.
- **From on-LAN:** `ssh gitea` resolves to `192.168.8.121` directly.
- **From off-LAN (Netbird):** The same `192.168.8.121` works because
hubris routes the `192.168.8.0/24` subnet through Netbird.
- **Roaming workstations:** `ssh mac-mini-mesh` or `ssh republic-laptop-mesh`
uses the Netbird FQDN as a fallback when the workstation is off its
home subnet.
## VPS (`82.165.190.79` / `100.122.165.149`)
The `homelab ssh <host>` CLI command also has built-in LAN probing:
it tries a 1.5s TCP connect to the LAN IP, and if that fails, falls
back to the Netbird FQDN.
- **Mesh-only.** Public `:22` is dropped by the nftables firewall. SSH reaches the VPS only over `wt0`.
- Key-only (`PasswordAuthentication no`, `PermitRootLogin prohibit-password`) via drop-in at `/etc/ssh/sshd_config.d/10-hubris-hardening.conf`. Original config backed up at `/etc/ssh/sshd_config.bak.<ts>`.
- `ListenAddress` is still 0.0.0.0; gating is firewall-layer.
- Authorized root keys: PVE (`root@hubris`), iMac (`d.toro.v@pm.me`). Add a new device with `ssh-copy-id root@100.122.165.149` from a mesh peer before disabling its access paths.
## Key distribution
See [VPS hardening](vps-hardening.md) for the firewall + fail2ban rules and recovery paths.
Each workstation's SSH public key lives in the repo at:
`ssh/authorized_keys/<hostname>.pub`
To deploy or re-deploy all workstation keys to hubris + every running LXC:
```bash
# From hubris (or via homelab pct):
sudo bash /opt/homelab-context/ssh/deploy-keys.sh
# Or from any workstation:
ssh root@192.168.8.77 "bash /opt/homelab-context/ssh/deploy-keys.sh"
```
This script:
- Reads all `.pub` files from `ssh/authorized_keys/`
- Adds any missing keys to `/etc/pve/priv/authorized_keys` on hubris
- For each running LXC, appends keys to `/root/.ssh/authorized_keys`
- Is idempotent — skips keys already present
## Config generation
To generate the SSH config on any workstation:
```bash
homelab ssh-config --install
```
This writes to `~/.ssh/config.d/homelab` and ensures
`Include ~/.ssh/config.d/homelab` is present in `~/.ssh/config`.
The config is regenerated automatically on every `homelab sync` (which
kicks the 5-minute context sync timer).
## Adding a new workstation
When onboarding a new machine:
1. Hostname must match an entry in `inventory.yaml`.
2. If the workstation will be on the LAN, add its `lan_ip` to
`inventory.yaml` and push. This gives it a primary LAN entry in the
generated SSH config.
3. Enable SSH Remote Login:
- **macOS:** `sudo launchctl load -w /System/Library/LaunchDaemons/ssh.plist`
- **Linux:** `sudo systemctl enable --now sshd`
4. Generate an SSH keypair if one doesn't exist:
```bash
ssh-keygen -t ed25519 -a 100
```
5. Publish the public key to the repo:
```bash
cp ~/.ssh/id_ed25519.pub /opt/homelab-context/ssh/authorized_keys/<hostname>.pub
cd /opt/homelab-context && git add ssh/authorized_keys/ && git commit -m 'ssh: add <hostname> pubkey' && git push
```
6. Deploy the key to all hosts:
```bash
ssh root@192.168.8.77 "cd /opt/homelab-context && git pull --ff-only && bash ssh/deploy-keys.sh"
```
7. Generate the local SSH config:
```bash
homelab ssh-config --install
```
## Hosts
### Hubris (PVE host)
| Detail | Value |
|--------|-------|
| LAN IP | `192.168.8.77` |
| Netbird | `100.122.38.109` (FQDN: `proxmox-server.netbird.selfhosted`) |
| Netbird SSH port | `22022` (mesh-only, OIDC auth) |
| SSH user | `root` |
| Authorized keys | `/etc/pve/priv/authorized_keys` (Proxmox cluster-synced) |
Authorized root keys currently deployed:
- `root@hubris` (self, RSA)
- `d.toro.v@pm.me` (ed25519) — mac-mini
### LXCs
Every LXC at `192.168.8.x` accepts root SSH via authorized_keys. Keys
are managed by `ssh/deploy-keys.sh`. SSH user is `root`.
| LXC | Name | LAN IP | Role |
|-----|------|--------|------|
| 101 | jellyfin | `192.168.8.206` | media-server |
| 102 | nfs-export | `192.168.8.200` | storage-export |
| 103 | paperless | `192.168.8.130` | document-archive |
| 104 | gitea | `192.168.8.121` | git-server |
| 105 | apps | `192.168.8.205` | docker-apps |
| 106 | auth-outpost | `192.168.8.184` | authentik-outpost |
| 107 | dns | `192.168.8.185` | dns-helper |
| 114 | nextcloud | `192.168.8.224` | file-sync |
| 118 | elementsynapse | `192.168.8.239` | matrix-server |
| 119 | sophia | `192.168.8.157` | workshop |
| 120 | mule-images | `192.168.8.136` | photo-management |
| 121 | caddy | `192.168.8.175` | reverse-proxy |
| 122 | arriman | `192.168.8.132` | arr-stack |
| 123 | claudio-bot | `192.168.8.230` | matrix-agent |
| 126 | plato | `192.168.8.190` | app |
### Workstations
| Name | OS | LAN IP | Netbird FQDN | SSH user |
|------|----|--------|--------------|----------|
| mac-mini | macOS | `192.168.8.174` | `mac-mini-234-17.netbird.selfhosted` | `dtoro` |
| republic-laptop | Linux | TBD | `republic-laptop.netbird.selfhosted` | `dtoro` |
| ludo-mini | Linux | `192.168.8.133` | `ludo-mini.netbird.selfhosted` | TBD |
### VPS (external)
| Detail | Value |
|--------|-------|
| Public IP | `82.165.190.79` |
| Netbird | `100.122.165.149` (FQDN: `netbird-ionos.netbird.selfhosted`) |
| SSH user | `root` |
| Access | Mesh-only — public port 22 is blocked by nftables. Key-only auth. |
## VPS
Access is mesh-only. From a mesh-connected peer:
```bash
ssh root@100.122.165.149
ssh root@netbird-ionos.netbird.selfhosted
# or via homelab:
homelab ssh netbird-vps
```
## Verification
```bash
# From any workstation after running homelab ssh-config --install:
for name in hubris gitea apps sophia paperless caddy jellyfin nextcloud; do
ssh -o BatchMode=yes "$name" "hostname" && echo "$name OK"
done
```
## Related
- [Hubris host](../hosts/hubris.md)
- [Mesh migration](mesh.md)
- [VPS hardening](vps-hardening.md)
- [Agent enrollment](../operations/agent-enrollment.md)
- [Homelab CLI](../bin/homelab)
## Changelog
### 2026-06-02 — universal SSH reachability
Replaced ad-hoc per-workstation SSH configs with inventory-generated
configs (`ssh/gen-config.py`, `homelab ssh-config`). Added centralized
key distribution (`ssh/deploy-keys.sh`, `ssh/authorized_keys/`). All
LXCs now accept root SSH from any workstation whose pubkey is in the
repo. mac-mini Remote Login enabled. Netbird subnet route
(192.168.8.0/24 via hubris) provides off-LAN reachability for all LAN
IPs.
### 2026-04-28 — wiki entry created
Initial documentation.
### 2026-04-23 — VPS SSH hardened to mesh-only
Public `:22` blocked at nftables. Key-only sshd. See [VPS hardening](vps-hardening.md).
Public `:22` blocked at nftables. Key-only sshd.
### 2026-04-22 — iMac key authorized on hubris
`d.toro.v@pm.me` added to `/etc/pve/priv/authorized_keys`.
`d.toro.v@pm.me` added to `/etc/pve/priv/authorized_keys`.

View File

@@ -88,7 +88,7 @@ services:
port: 9810
systemd_unit: homelab-mcp
public_host: mcp.hubris.network
endpoint: https://mcp.hubris.network/sse
endpoint: https://mcp.hubris.network/mcp
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).
@@ -334,6 +334,7 @@ hosts:
kind: workstation
os: macos
role: dev
lan_ip: 192.168.8.174
mesh:
netbird:
fqdn: mac-mini-234-17.netbird.selfhosted
@@ -341,7 +342,7 @@ hosts:
user: dtoro
notes:
- Only macOS in the fleet. Bootstrap uses launchd.
age_pubkey: ''
age_pubkey: age1z62ff2ak9zj5ctcvaxwyyhedwjvlwgm2dkn9nk3wrwk8fkavcpmsqwc2vs
ludo-mini:
kind: workstation
os: linux

View File

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

View File

@@ -333,4 +333,4 @@ def ping_service(service: str) -> dict:
if __name__ == "__main__":
mcp.run(transport="sse")
mcp.run(transport="streamable-http")

View File

@@ -5,6 +5,10 @@ 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.
@@ -16,7 +20,7 @@ operational reference is here.
| OS is Linux or macOS | bootstrap detects via `uname -s` | `uname -s` |
| On the mesh (Netbird or Tailscale) **or** on the LAN | issuance is gated to mesh + LAN subnets. **For Netbird: use a setup-key, not interactive auth** — see "Getting onto Netbird" below. | `netbird status` / `tailscale status` |
| `git`, `python3`, `python3-yaml`, `age`, `sops` | bootstrap preflight; `homelab` CLI imports yaml | See per-OS commands below |
| Can resolve `*.hubris.network` | bootstrap calls `https://secrets.hubris.network/issue` and writes `https://mcp.hubris.network/sse` | `dig +short mcp.hubris.network` (should return `192.168.8.175`) |
| Can resolve `*.hubris.network` | bootstrap calls `https://secrets.hubris.network/issue` and writes `https://mcp.hubris.network/mcp` | `dig +short mcp.hubris.network` (should return `192.168.8.175`) |
### Hostname mismatch is the most common bootstrap failure
@@ -79,7 +83,7 @@ If DNS isn't an option at all, override the URLs at bootstrap time:
sudo HOMELAB_GITEA_TOKEN=... \
HOMELAB_REPO_URL=http://192.168.8.121:3000/dtoro/Homelab-Docs.git \
HOMELAB_ISSUANCE_NETBIRD=http://192.168.8.205:9820/issue \
HOMELAB_MCP_URL=http://192.168.8.205:9810/sse \
HOMELAB_MCP_URL=http://192.168.8.205:9810/mcp \
bash /tmp/bootstrap.sh --with-mcp
```
@@ -159,6 +163,77 @@ For Claude Code: start a new session — the `homelab` MCP server appears
in `~/.claude/.mcp.json` and registers 14 tools (8 context, 5 management,
1 secrets-metadata).
## Post-bootstrap: SSH reachability
A new workstation must be reachable from other workstations and must be
able to reach every host by short hostname. Run these steps after the
bootstrap verify passes:
### 1. Enable SSH server
```bash
# macOS:
sudo launchctl load -w /System/Library/LaunchDaemons/ssh.plist
# Linux:
sudo systemctl enable --now sshd
```
### 2. Generate SSH key (if missing)
```bash
ls ~/.ssh/id_ed25519.pub 2>/dev/null || ssh-keygen -t ed25519 -a 100
```
### 3. Publish pubkey to the repo
```bash
cp ~/.ssh/id_ed25519.pub /opt/homelab-context/ssh/authorized_keys/$(hostname -s).pub
cd /opt/homelab-context && git add ssh/authorized_keys/ && git commit -m 'ssh: add $(hostname -s) pubkey' && git push
```
### 4. Deploy keys to all hosts
From any existing enrolled machine (hubris or another workstation):
```bash
ssh root@192.168.8.77 "cd /opt/homelab-context && git pull --ff-only && bash ssh/deploy-keys.sh"
```
This adds the new workstation's pubkey to hubris and every running LXC.
### 5. Generate SSH config
```bash
homelab ssh-config --install
```
Verify:
```bash
ssh hubris hostname # should return "hubris" without password
ssh gitea hostname # should return "gitea" without password
ssh mac-mini hostname # should return "mac-mini" without password (workstation-to-workstation)
```
### 6. Add LAN IP to inventory (if on LAN)
If the workstation has a static or reserved LAN IP, add it to
`inventory.yaml`:
```yaml
hosts:
your-hostname:
lan_ip: 192.168.8.xxx
```
This gives it a primary LAN entry in the generated SSH config (faster
than the Netbird fallback). Commit + push, then:
```bash
cd /opt/homelab-context && git pull --ff-only && homelab ssh-config --install
```
## Claude Code permissions for fleet ops
By default Claude Code's auto-mode classifier asks for confirmation on every
@@ -274,6 +349,15 @@ The CLI prints a follow-up checklist that the operator must do manually:
## Changelog
### 2026-06-02 — SSH reachability post-bootstrap steps
Added a new "Post-bootstrap: SSH reachability" section covering SSH key
generation, pubkey publication, deployment to hosts, SSH config generation,
and LAN IP registration. New workstations enrolled via this doc will
automatically join the universal SSH mesh.
### 2026-05-31 — cross-link to hermes-agent.md
Added a sibling page covering Nous-Hermes-on-Goose enrollment ([hermes-agent.md](./hermes-agent.md)) and noted it at the top of this page. The Hermes flow extends `bootstrap.sh` with `--with-hermes` and `homelab client add` with the same flag; it does not change the underlying enrollment steps documented here.
### 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.

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

@@ -0,0 +1,210 @@
# 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: deepseek/deepseek-v4-flash
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.
7. **Caveman auto-setup via post-pull hook.** The sync timer now calls
`tools/post-pull.sh`, which runs any `tools/*.setup.sh` after git pull.
Currently this auto-installs the Caveman npm package, wrapper scripts, and
compact output templates on all agent hosts (*token efficiency*).
## Changelog
### 2026-06-01 — caveman + post-pull auto-setup
Added `tools/post-pull.sh` sync hook that auto-runs `tools/*.setup.sh`
after every git pull. First user: `tools/setup-caveman.sh` installed Caveman
templating + `~/bin/caveman_wrapper.sh` + `~/templates/*.txt` for token-
efficient CLI output. Replaces raw `git pull` in launchd/systemd timers.
Also created `tools/caveman/` with the wrapper script, JS renderer, and
templates — the canonical source for all agent hosts.
Captures the Hermes-on-Goose onboarding flow added in the same commit as
`bootstrap.sh --with-hermes`, `bin/hermes`, the sops rule for
`secrets/openrouter-api-key.yaml`, and the `homelab client add --with-hermes`
extension. MCP streamable_http migration is queued as follow-up #1.

View File

@@ -0,0 +1,131 @@
# 2026-06-01 — Slate AX → SODOLA Migration
**Status:** Done — 2026-06-02
**Hardware:** SODOLA 5-Port 2.5Gbit Managed Switch replacing GL.iNet Slate AX
**Router:** Fritz!Box 7590
## Goal
Remove the Slate AX sub-router. It adds double-NAT, double port-forward complexity, and a second DHCP/gateway hop. Replace with the SODOLA switch so Proxmox and homelab devices attach directly to the Fritz!Box—without changing any of their IPs.
## Current topology
```
ISP
└── Fritz!Box 7590 (192.168.178.1)
└── GL.iNet Slate AX WAN: 192.168.178.x LAN: 192.168.8.1 [NAT]
├── hubris (Proxmox) 192.168.8.77
├── + all 16 LXCs on vmbr0 bridged to the same subnet
└── other homelab devices
```
## Target topology
```
ISP
└── Fritz!Box 7590
├── VLAN 1 192.168.178.0/24 (main LAN — laptops, phones)
└── VLAN 10 192.168.8.0/24 gateway 192.168.8.1 (homelab, no NAT)
└── SODOLA 5-Port 2.5Gbit
├── Port 1 uplink → Fritz!Box LAN port
├── Port 2 hubris (Proxmox) 192.168.8.77
├── Port 3 [homelab device]
├── Port 4 [homelab device]
└── Port 5 spare
```
Fritz!Box takes over `192.168.8.1` — the same gateway IP the Slate AX used. No static IPs or gateway entries change on any LXC or VM.
See [network architecture](../infrastructure/network.md) for the permanent topology reference.
## Pre-flight checklist
- [ ] Note Slate AX gateway IP on the homelab subnet (expected `192.168.8.1`)
- [ ] Note Slate AX upstream DNS setting (what it forwards to)
- [ ] List all port forwards currently on the Slate AX → move these to Fritz!Box
- [ ] List all port forwards on Fritz!Box that point at the Slate AX WAN IP → collapse these into direct homelab-IP rules
- [ ] Confirm Fritz!OS version supports "second IP network" (Fritz!OS 7.x required)
- [ ] SSH into hubris: verify `grep gateway /etc/network/interfaces``192.168.8.1`
- [ ] SODOLA web UI accessible and ports configurable before physical move
## Fritz!Box configuration
**Path:** Home Network → Network → Network Settings → IPv4 Addresses → Add IP network
| Field | Value |
|---|---|
| IP address | `192.168.8.1` |
| Subnet mask | `255.255.255.0` |
| DHCP range | `192.168.8.100 192.168.8.240` |
| Assign to | LAN port that connects to SODOLA |
| Network isolation | Enabled (blocks main LAN from initiating into homelab) |
| DNS for DHCP clients | `192.168.8.2` (Technitium on [CT 107](../containers/107-dns.md)) |
After creating the network, move any port forwards from the Slate AX into Fritz!Box → Internet → Permits (target IPs are now directly reachable on `192.168.8.x`).
## SODOLA configuration
All ports in VLAN 10, untagged (simple access-port setup):
| Port | PVID | Role |
|---|---|---|
| 1 | 10 | Uplink → Fritz!Box |
| 2 | 10 | hubris (Proxmox) |
| 3 | 10 | homelab device |
| 4 | 10 | homelab device |
| 5 | 10 | spare |
## Proxmox / LXC changes
If the Slate AX gateway was `192.168.8.1`: **no changes needed**. Fritz!Box takes the same IP.
If Slate AX used a different gateway (e.g. `.254`), update:
```bash
# on hubris
nano /etc/network/interfaces # change gateway line
ifreload -a
# for each LXC with a static gateway
pct config <id> # check net0: line for gw=
pct set <id> --net0 name=eth0,bridge=vmbr0,ip=<ip>/24,gw=192.168.8.1
```
## DNS after migration
Technitium ([CT 107](../containers/107-dns.md)) at `192.168.8.2` continues to serve split-horizon DNS for `hubris.network`. The Fritz!Box DHCP server for VLAN 10 hands out `192.168.8.2` as the DNS server. This fixes the "update router DHCP DNS from dead .180 → .2" outstanding item in [dns.md](../infrastructure/dns.md).
## Cutover procedure
1. Configure Fritz!Box second IP network (VLAN 10 / `192.168.8.1`) — verify DHCP works via a test laptop on the target port.
2. Configure SODOLA port VLAN assignments.
3. Connect SODOLA port 1 → Fritz!Box target LAN port.
4. Move hubris ethernet → SODOLA port 2. Proxmox should stay reachable immediately (same gateway IP).
5. Move remaining homelab devices to SODOLA ports 35.
6. Decommission Slate AX.
## Verification
```bash
# internet from Proxmox
ssh proxmox "ping -c3 1.1.1.1"
# Fritz!Box main LAN reachable (routing between VLANs works)
ssh proxmox "ping -c3 192.168.178.1"
# split-horizon DNS via Technitium still resolves
ssh proxmox "dig @192.168.8.2 +short git.hubris.network"
# expected: 192.168.8.175
# Caddy reverse proxy responds
curl -sk https://git.hubris.network | head -5
# Authentik on VPS still reachable
curl -sk https://auth.hubris.network/if/flow/default-authentication-flow/ | head -5
```
## Post-migration
- Update [network.md](../infrastructure/network.md) topology to reflect new state.
- Add changelog entries to [hosts/hubris.md](../hosts/hubris.md) and any affected container pages.
- Update status in [plans/index.md](index.md) to `Done`.
- If anything went sideways, open an investigation in `investigations/`.

16
plans/index.md Normal file
View File

@@ -0,0 +1,16 @@
# Plans
Pre-flight runbooks for planned changes that haven't happened yet. Once executed, move the outcome to `investigations/` (if anything interesting happened) or just a changelog entry on the affected node pages.
## Index
| Date | Title | Status |
| ---- | ----- | ------ |
| 2026-06-01 | [Slate AX → SODOLA managed switch migration](2026-06-01-slate-ax-to-sodola-migration.md) | Done |
## Conventions
- File name: `YYYY-MM-DD-<slug>.md`. Use the *target* date if known, otherwise the planning date.
- Status: `Planned``In Progress``Done` (update index entry as it moves).
- When done: add a changelog entry on every affected node page, then update status to `Done` here. If things went sideways, open an investigation.
- Plans are append-only once execution starts — don't rewrite pre-flight intent after the fact.

View File

@@ -1,15 +1,14 @@
[Unit]
Description=Pull /opt/homelab-context from Gitea
Description=Pull /opt/homelab-context from Gitea + auto-setup tooling
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/git -C /opt/homelab-context pull --ff-only --quiet
ExecStart=/usr/bin/env bash /opt/homelab-context/tools/post-pull.sh
TimeoutStartSec=60
# Don't fail aggressively — a missed pull just retries next tick.
SuccessExitStatus=0 1
Nice=10
[Install]
WantedBy=multi-user.target
WantedBy=multi-user.target

View File

@@ -7,12 +7,9 @@
<key>ProgramArguments</key>
<array>
<string>/usr/bin/git</string>
<string>-C</string>
<string>/opt/homelab-context</string>
<string>pull</string>
<string>--ff-only</string>
<string>--quiet</string>
<string>/usr/bin/env</string>
<string>bash</string>
<string>/opt/homelab-context/tools/post-pull.sh</string>
</array>
<key>StartInterval</key>
@@ -29,4 +26,4 @@
<key>Nice</key>
<integer>10</integer>
</dict>
</plist>
</plist>

View File

@@ -13,29 +13,38 @@ sops:
- recipient: age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBQL1FueldvT09lV011Q0hz
MlJ1YkxIYXZDNDVQQWo1MFQrRlNBcXNIYVNRCllXYVZrczdBNHdPemJybUVlQkVl
d0hKUTVsTyszOUFrY1BQQllSM2JWa2sKLS0tIDFISUw3T1lLYm10YXZaeFlTMWVj
ZHFWN0ZQTlhIc0pGMThoWWViN2lmVUUKsWRBHqoRVmY2Clcro5aKhQj9yX5W4Kh3
arC5FKBoZ/8KlcS7zxC9vIISOqlQu7MCKA8L//HX/gkfcz77aS6t1w==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBXaWJhdFAxdThGSVZRN2I1
VjE1djFtdjdnSFVJU3lMZC9YSzNSeGVLaVJBCjlId3pvcFRadUdwamxaa00zNUVl
VUJFaDRRLzdSNDNVekR3eWdmREh3eUEKLS0tIEVMMmh2eUlSL0FhRzNnZXJyWk9I
MEY3dlVDQktRR1VUajM4WmVwYS80TVUKA0bkns4IE093PvF5Ka3HNWmi+Htqs66H
BBEAETnQZOdM/Ca+oySDnhLYYT4rD46m4d3H0YQfXQhlVk/h9CTshQ==
-----END AGE ENCRYPTED FILE-----
- recipient: age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBFTVB1eWh5M3RUVFRubUFE
OTRrbWRnUDhsVGlLZkFWckRXZ08xTkxCMlVNCnFHcU9aMklma3RJZnZ5OHUxQVYx
SWFZMEJSNHExT0lsdW9FNkx0T3ZNQ0UKLS0tIEpseGdCbTJudUNWUUJGYUg1STJV
Qm9QU3Z2NHhiRWh6VnY5cDRRQVk2ZUEKktovOMBi2eT4Hxwm8i8gy4GYPpOZ3nIy
cDM6yBpUQMybiY3jMji41ELg12vF2kQ286W6lQKURdl9yYoGz0SfqQ==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB5bVZma09OVWkrVmtVNEda
MnRxdDZuRTV3RmhieTB2ZXVTcjY2VWtTOW5rCkw2STNWTHluRGZhT3B4TmtFWHUv
L2hyd3QwMW1iaHRCRWMycmxHVW5qMlEKLS0tIHNrTjdYb3NCdU05WjR3RFdvdFBR
Z3NRRVRRcnBhdXVmRmprbk1iL0lWS28KTvN7Z3VyXKCnbJBD9N+FtC5UDCxofPIU
WERW/eV7+2F6SeClSu7iE+pDMbjiKrFBVKBAU8OA8yd8VmcnD8IWdw==
-----END AGE ENCRYPTED FILE-----
- recipient: age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBHd0NhOC9FWmVSL0ExKzIz
L1hiU3RhKzh4SzFCNURhWFAxZTF4QWJIdmhBClQ4SzBEY2VweUR5UnJJSkk1OUVj
MWp5WUtQcE0yNFh5R0JoTGpUaWJiU0UKLS0tIDFQbDZoUXlPSURndTNEN2d0TjJS
djVvR2hGSllUNlZJNURrMG43bmZlU0EKl7SfN6/JGbzDA3YVrRnua5QK8xEdkWoB
ey5Yf4Zg0obqYIS5SX8QC/YtoeMANVkKtqVTOVE2PYLJtrC6l7vKxQ==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBublRwajRDeUtmdmtpN3pj
VVE0VWJhVWRXeE1UM0xHdEJKUVhHVWpNTG00CnZjNUg3ZkUvcFZTRHc4UDMrOVhV
QTNPMmphTkhxZjgzNXN6REtTTTZUU00KLS0tIEk0MXFBNS8wVmpMZFhoYUpsOGlG
ZE1OaXdhZVVGQXUrdUhJT09UU21DNkUK4DcLYPqf3ojRotaa95KIUhKYQwWorsum
IletEBZtyJdOJgpwN/eZLe12a4E5thI245jSjQlw2B8RKFqOCDyASw==
-----END AGE ENCRYPTED FILE-----
- recipient: age1z62ff2ak9zj5ctcvaxwyyhedwjvlwgm2dkn9nk3wrwk8fkavcpmsqwc2vs
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBPVEdGR0VwUjk3MUROOHRJ
aXdFMTFDRFZBek5FSWJSbWJDU2F2ODFyTVFzCndTbStrQmFSSWJwbFc0M1Nia2FS
eGZEMGsrYkNnT3M2aEhOS0IzOVNOVncKLS0tIENtOEx4SVJQTFcxR3NIM0NHVDI5
Z1RIMWRBNGhCOFF4MkdHRlpya25ybGMKsyLYsmqxuXvJ4ZF97Jh5D8BoepehSdKi
yGzLaaQo0gW/wu7n0fq7S7HhbiTcPZ8lQboVvhYWU2lTx1p8npUsAQ==
-----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]

View File

@@ -9,29 +9,38 @@ sops:
- recipient: age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBCbWhQa2RreXl3Ni9zZExQ
dzB5Q1hSZzJSb1NlaEx3bEFCK2x6Ty82ZEJ3Cng3Q1NmKzhpdmpvTWFOL0lUb3F6
emtvNCt3elQwTndoNzRIM2FoZTlpTVUKLS0tIER6STdwVUtnL0lreFh3Z09FYlBo
azJuWjVuNGJURTNPYU1aWWZGM0dpc2cKFM8AjHFg68fedY0Xrg2U69+Qxc5Pgn6f
QipSsaXrCohzNKieqCG9maKuLcGrgMQ1OJqZQm+hfit6CRKUmVHRkw==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBUanM5MzFZYnkxVHVHSlYy
U1Q1ejNJb3ZORklKM2xjK0p2cytRU0NNRUZJClBTUDJ5dFM2eUFjV0t1YzBCcXcv
cmE3czJST1RuMHJVN0tmVTZNdGNRS1kKLS0tIFUydktvK0tBU2U3czRhWHVySGZR
ck9KTVR6RS9pOHQ4WkZneG1Tbk5OSWMKwOQ+CWkuLSqfhle9fgDw4XXIp0ojZssw
9YWK/suEAb6u9nzbw7zuEmZxhhDV0Y61UAeSSbSmygy7MD7dvxrvgQ==
-----END AGE ENCRYPTED FILE-----
- recipient: age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB2TVpWeC9FcmUxckNadmtK
d3BwZnExR3VqeXBQSGhNdStPVXM2MkdjQXhBCkpHMmxPZ3JWaVVvUGwrVjZadXlR
V3VaR2l5Zm9zb1VrREphM09RMU5mR0UKLS0tIGlYZm5XbTA2WUcvWU9FWlZYSWNZ
L1JYYmJzLyt6bzJqdlk2TjYvNUdVVTgKlj1b9zQ9cBG3YQI2t4fjz3t9ySBxQ15w
qxcyGspEQ9F+4OtoRQP0FCfmJIBNxvAoT2I5TjspH6dl2o1ID+332Q==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBCbm5MOG1Bd0FZbVZTbUU1
VGpGVWNnUk03RURPNURNNjdQejY2WkxkNzBRClpuMGFaQzZhaENJckZvbUI3VmUy
ekV0eFMza1p4S2w5L0hvNndjN3RpdTQKLS0tIDRTdExxTlZQK0FKV294dnh4dmd4
a21yK0VabzhOcDhRajlxbVE1dTdtcWcKQorqkwUs7lppaDJuCDn+KtH+76xPIvsr
9axkrDLumeY9LtDhunlaEpax7zdvvAiC4DzJdbJX0LgFCaurtgubOA==
-----END AGE ENCRYPTED FILE-----
- recipient: age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBFa1NwSGlhUzgxdjI2V0Ew
VVNRS1o2L00wM0QvdTFXelFLVnBrQ2ZHRHprCks0Z2ZQbzdQNG5hVFlCZXFrSG5B
cG5Qb2JUbnk4ekdJSER2MU5NRGZ6NEUKLS0tIDF4cjNzWnBLaXJNS1RHbVozVUtp
bTkzM21jNndEdDV0RmtxSVZ5SkdQcGMK+MZNNAfnnXq7VjIvaDtJEaImk0mecErp
gzV3b0MOKc2eTiuaWRNH1yAhJOFTK9iQfC7dDUI8npE+zLOgpLEZ9w==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBaS1JrL3lpa1c4QUwxWlZT
VWQ1ZDVPVjN6QTdFNGQzNkV5WllOK3dYWHhzCllTbVdzRVFTbDZ4TU9PZWpKNnJu
cWJ6YWs5amVsR2xZZFpaQlErOXBKZVEKLS0tIFFzVG9QWmdNWGl5UEkxaXZvMUxx
SExXL1gxNzVud2JrZ04zTnI4cUlNSVEKPF2zoSnYEt/zeG8QW1454Mcr8u8JTCl7
jDyghcRw94enbPbA43zsKGn6QALQ40PfXmZ/MlEbdnf6U3zI7zvygQ==
-----END AGE ENCRYPTED FILE-----
- recipient: age1z62ff2ak9zj5ctcvaxwyyhedwjvlwgm2dkn9nk3wrwk8fkavcpmsqwc2vs
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAwMlVSajFiT2loclFzeEQ5
ekxqUjBkSEh5M2FJMXA2VGkycllYa1R1Qm40Clh0L0RwSzZqeXlQVXMvRVlsZ05L
RHkyMlJlc1BmV0tjQk1uaWJLemR2cmMKLS0tIDlXT1dhRkpUdDRpRkJhL2NHSjN5
QU5Pc25lSUV4OWROZENXQzB4Y0o2Y1kKzhOY5jN4gi+u5tl9rAHGRb8Bh9DESl1K
qNiD+wauApal8iRrIkGdGsrWrWThSwy7vjnUsoB8JDrBaqLj2usuFA==
-----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]

View File

@@ -8,29 +8,38 @@ sops:
- recipient: age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBzVVZIQzlPak4xTVVPMm9w
M2tFbTFoQmpRUGFETjF1ZTQ3ajlEdGxWQmtZCkdkZzdhblFybGV3OEUrWERBWFUv
bTdXSnZFSU8vY1JJVTJjL3dLdWpiNGsKLS0tIHVlVmcrcTNZUGs4MmJtWkR0aTh3
Z0pTOWhsZWYyVW5TeXk5ZHhaQnZvZ3MKrnBt5T7WjSxGYvRc1olfhuMN6nOEJbbX
xoliPcKkGsBExXVgkpood+OdlH8dNAaT0z1+INzNiBAZ8SazZA4p9g==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAvdUxUdzd2VmN1MWU2dXdM
N0FWcXkveVdHYk5aYzFrOVZaRzVYZWs1RFZBCkFwRy9xVFBrVjU0NmhkMlpyN0pp
ZlZKenhoNFVRdGg4Q0ZTMS9YUDFKMHcKLS0tIG1GYkZaYkhJNk5zdWZBMEtNL041
MWhEQ1RmQ1k0d2Y2dzh2YnRvRm1IOEUKJ64/tNyLe2qBIL4CetRlpLaxhfOL93D5
BjeHLwtfXB2fNCJSdCY8H4KsQP+epwriUQPtZVId32n9xDEt71obSw==
-----END AGE ENCRYPTED FILE-----
- recipient: age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBjYk1SOVZxUTBWYUw4aUhy
WG93N2xVWDlEWnBqQ0JTRWcxc2tXUnppSFF3Cmp6L1g4OUliVlNFYUo2bEtNOHhm
aytHK0RFWmNpSWFMaGc1cnNXNlEwbTQKLS0tIHMxU3VXYTZHNWk3cWZFNXBNRElD
bE1CUHc1TFlsbGU2aTJjSmhwZENuUDgKvGHg2Df/eBw5akRPYFLvXhzh7P6jTOgj
E56n29EJ+p4kTkC8yVBci01qpMioL+Wx2Rt+X+0LWrGBzu5fic2U5g==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBRNi9kMkt5Vk4rS0pqY2ZP
ODErMlRxNmlWaHBvYjNaQ3lRT1RSQitMVlFBCk5HQ2o0QUg4dWFodTNEVGJFTlF0
Y2JIVEFPZGVUdXpNS2lGOVQzeFRvMFEKLS0tIGtQY0dicE91R0J3eEM3bEdMZHJ4
TndiYVFQWGZyRkF1bWYrRlZ6N0JBRm8KEUKY3chev13KjnGKdTR8tvyYV3s0W1rI
8LeJSIocSX58PexqgcKsT8pGpuIiOetEzjzv2WPa0MzEwtFCVgwKYQ==
-----END AGE ENCRYPTED FILE-----
- recipient: age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAwSGdBYzlsYmc4dVJ5ZE8r
Q1FMR1NQL2lzZ1lmaTg1bGROa0hOcVk2ZTJvCllFMUtJdG5JVFhseWc5eXFsYk9p
Q2tIVm5yelAreVRrVkQrTnJ1N3FLclEKLS0tIGQ5dTdqZ3lKWU1KcHdHK1UwajIw
Znh5aXkwNmdyVi9EZlBzMUpTTW1VdjgKVVNGjiiKw3nLxq4YsCWoTS4R8wUD8wqD
awXSIOvZj5xiz6NvFk1X5T3H4XeEm6tKKzOpBvoVn5yl3zgn/bh5pA==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBFcktzeGx5VWZaeFVNc2Mv
aWt1dzNUVko5YytOTTdxMWVEVm85N29TMDBrClc3ODdVVDRVRFI3K3BiUGd1TFp1
SE5nalY0RkwzNmZQOWU1THhGQ3Q0UkUKLS0tIHNZdHhxdDY0a1AyY0g3WUx3bUJh
U0RqRWRFRXNLTWFwemNOYk8vTmxZeTAKaZI6WSNM022xtZQx5yOYXj5mesAjWsu/
ZIDG6MayoyrhwAkuZsWOKtKp6zbRCcnm7s9OHdlNsx7PWG1ODEFpOQ==
-----END AGE ENCRYPTED FILE-----
- recipient: age1z62ff2ak9zj5ctcvaxwyyhedwjvlwgm2dkn9nk3wrwk8fkavcpmsqwc2vs
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBVbFFxSkhscUV2bm93Z01O
WDl3MU11K2lrNzAwUVN5MDhodDlveFZ6QlYwCjNaNjd3bG9QMTQxcVA5TTNQU3Za
bWdZTDdGZzJObnJ6NlBxKzVYcmpVNk0KLS0tIEJTcFVTRmkxQlJWSzVIM0ErOVNN
c290WkI1ZzBlYkg3QzJJUlQwOFZPYmMKQFp4vqFwh0GpYOur4kGMCeENnuNZnN0x
sbNmM9rRm/10T4tHF9aR2/WIjEqm6M6+yXd3phhB8rIggDPlVk2bCg==
-----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]

View File

@@ -0,0 +1,34 @@
api_key: ENC[AES256_GCM,data:TbPkuLCidS7cg52DYb0MOCui3TsaiwkOWyYLhp6gcvXI8TqvXej1tNsCF0TNAIr9vvnFaYno/UTEPiOKe/EQqR8uQMyWuvsmMg==,iv:bahy9ae4Qxvkv1OAjx6LwzK5ggqDh2GVHDj95PVP1Mc=,tag:J7PajLaeqFOdz3FESYZmSQ==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB3djYvT3JJUWh3cE1ZbFc0
SiswOGlKNEdFNlJhTVlVSkhqZGlJdGl4U2pvCnV0aEhud0VaOHVGbkVVVkJ3b2Js
K1liaEFscGJxMXVVNjNJNW9NeGR6Um8KLS0tIEt2TlFCV09CWDJKbmFySm1EUXc3
Zm9IT21QN0lkSlh0UVdhYkVrTDdiUWcK8IO+gylIYKDlSADOtj2gSpM7Af/JG/7Q
6y1dT2d82cEu7KuXXuog6gP3sADe/6SDHGgC6Ot6EWM+5dwMN6WwTA==
-----END AGE ENCRYPTED FILE-----
recipient: age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBKY2xVSUVBUTd0dVhxekhY
d2t1U00xNnRjTC9PT3puVGo1U2ViekhmQng4CnJlZWYxK0tyQ2U0Y3lXRUZSTEti
cU9QdWhjalJhb3dJSk4vK1hWZGFGNEkKLS0tIG51aFRGcW9xdmdmSkt0TTZXSll4
dkJMYjdPbUgyL1Rmc0tQbHgyR3RLZTAKllFoX3m/zntAtfGkSGFmRzXuk4pHALkR
XeQaAl33n5dMiZHtynoDNN3eBXtDDWiKZAhZeWI5SMoLpWBEXapNag==
-----END AGE ENCRYPTED FILE-----
recipient: age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBsdUpxd0RYRld3L1BYdllp
R3RwVStLUysyR1lXQ0RlcW4vWGRINzB3WUd3CktTSXJzUWsxcEdVajVjS3N1d0NE
WHU4QkVvU3dyYWZ0VVpmYnB2blVWaGcKLS0tIG5sa1I2YW1tS0hLeGc1SWtjV0NM
TkFoL0ZZbmhXdElFNytLclByK0s1LzgKVrBvlaryZXYn43v1ukjGZnhSPuwfslf7
Ruy7z7EzVaCXRn+F9gABD4mhIQaUIRswiut7aF9lrPOR72/y8s/1JA==
-----END AGE ENCRYPTED FILE-----
recipient: age1z62ff2ak9zj5ctcvaxwyyhedwjvlwgm2dkn9nk3wrwk8fkavcpmsqwc2vs
lastmodified: "2026-06-01T21:23:34Z"
mac: ENC[AES256_GCM,data:DXvUZUOMKQA0aDpN1foKy+aHhBh6daGSRJM1FagTh76qutk4XF9Een9iyy9sQ7olabCjf3oUezvU7XWB83bSDD3e23CFuweOl0RreRFqIsbXBi55Dbcxw8+9fvMyOK4PXu6mkT/PRao/XR5sSMqiHt3FoJbjazSaJlI5teeVXbM=,iv:IICO5ay+KqkTikfxW4kqB2qcSRpbsOP1NjGWtCB9uqI=,tag:MX2TqUxre+ff3RszCfXeFg==,type:str]
unencrypted_suffix: _unencrypted
version: 3.9.4

View File

@@ -8,29 +8,38 @@ sops:
- recipient: age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBiaFhuUlZJMkxERlR1T2xL
dnVKMXNMNVBIdDFuYzJtTjJWZE9JZnlaQ1dJCnNNbzJKc3VXOUZ1QUFzRWhSUkdF
WVJlNUtJSytSS2h5NGQyMHA4d2U0c28KLS0tIFVhSGJQOWlEQlp6SlRmS0ZMZzJ4
Y2ZoVVVkeVNiYUNwWFFLd2VGaTR1VUkK9Dpk0kjuKoUh3zfVQV7qs/YTTg2BaOkg
kNYY01k9MftEwdtvpKk1ogzdHyhGFJ1yEepwK7se6W5KHDJXyVfA8w==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBGbTFyeCtJVWNOUXlSNXQ0
MEF6aG84NEVNUzlrMnoxcEF0ZFpEVC9LK1ZvCk04TkNkVWtVMWhhL2QwVEVEMi9X
M3Jkd1NoVFBFaDczMGhtN0svR284UzgKLS0tIE5OdTlXWXptTS9TZWdUNzFKU1VW
QXl2N2I3dFFORlFEbUVSK2ppSmNHSk0KzeS7uJCvQLd97XI/MfKuhJi4pQ/jKx2d
5veiGfnmI/j/WCBdH35PMTEotbBBQ26uFt+uvcShK2gO62MVbht8PQ==
-----END AGE ENCRYPTED FILE-----
- recipient: age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA4N2xmZEM3R2tMQjA4a1Fr
cWZ5a2lwUWlJZDdVSExnaHA3cEpubTVMWG1BCjNjTEJzL01PWm9xRWlEWUlWend5
TUdMMjUzNE1RN0h5bzJuQnl5QkJXVzAKLS0tIDZ6aVh6dXlxTTVPZDRKQUkyZmlk
a2FLZ2s1NTA1ZUljeFBhSDZEcGZLNlkKcX5b3dXcJZejeSP4TLr1cOXQj7YEjj0G
znXBdDt6c42qE5XS+LGciunf3MmYxt0xXDb11cnSSEVgn0VyqE2x6w==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBjWG9PME1XQ1dSNkVEME9N
ZlBNL0FnclpoeEVLTVk5emlnTmpFVWtLcVF3CmQrY1ExOG5wMmRoMkV4Y3hXSjU5
OTZidFVWcklmZ0dHU0pJeTdiUTM5RzQKLS0tIHBGbncxRkpnUTVlNitRZkhyRjBE
bjdVSkZRZ241OE9Xd240OExWVkFrczAK+loPUAs5aBMC6XRY+yu0r3bqnWozldP6
m6mnRhUl2+JGm5RHglDWibUwPZ9I1EVlufDwDCABBnoXnY/HWuOlAw==
-----END AGE ENCRYPTED FILE-----
- recipient: age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBybWtTZEw3NTdwMWZhOUMv
L1RNWGduU1BSM0FodWMwUEtHNkxSY3MvdGhzCm1qTGlnM2ZRM2RoekhZbjFzZ2tE
Qk1DNjlnMk01R1ZzUDBubTMvR2hPNmcKLS0tIGsxZ2lQMi9vY0Zpb3pwRHBSSm5t
OWtkeCtNNnpTVXV6S1p2Y2E0V2toL1EK3RI0RgM0SudRguOpOimke7niuX3cIIVi
X+zK75QbvLN7pe1pIIC2UOEmRB9BhgGMVssHxnLzTJv0LUKjDMGdbw==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAxOG96YzNTQk1sQitQNnV3
V3FsUEN5b00wcys5TmJSY0lTTm1qaEV1MGtZCnlYa3ZMNTFTdkliM0NjVW9BLzhj
ZytCMDQrcjVmdEx0cjhNRllZY2xuWDAKLS0tIHhoMVR0KzhZb1dVMldIUjUyc2JS
OHBSbVliajNSTDR5VW9odkdpN01iT0UKzifxPVQc98lEB4sXoDcDw7t3R396iwa7
RENR54u5GYUHHySq14v4k+7zhV42+xaXW45ZPhcA4BKuaAw8ygSDGQ==
-----END AGE ENCRYPTED FILE-----
- recipient: age1z62ff2ak9zj5ctcvaxwyyhedwjvlwgm2dkn9nk3wrwk8fkavcpmsqwc2vs
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB5R1EvVlZOOVMxSSsya2Fn
Zm5iWWN6d2RFNzU5aXBNbHNkcnYwM1VLY1Z3CjlEazNpcnhQdTRFMTVxdWliV0Ju
eEZMMnliS3BnU0RMSy9lNDZhVUw4K28KLS0tIG4vN01xZjRNSmVMVWx0VEJhd29Q
QTR3cEhvTVowU2ZXNDY3ZjhSVktSdEEKTxL2M/OgOMcptliInoED6aRDEQaqGnXF
N7dg2UnQsuFplJIfsW8KRoDQkcuXbdjn/W5aQh76+OC8wuRPVNWcEA==
-----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]

View File

@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMtgjgpljZcu98NOW1ibry8ZBQSoUg0IaxK8iW10w2+ d.toro.v@pm.me

91
ssh/deploy-keys.sh Normal file
View File

@@ -0,0 +1,91 @@
#!/bin/bash
# deploy-keys.sh — deploy workstation SSH pubkeys to hubris + all LXCs
#
# Run from hubris (PVE host) as root.
# Reads keys from /opt/homelab-context/ssh/authorized_keys/*.pub
# and appends them to /root/.ssh/authorized_keys in each running LXC,
# and to /etc/pve/priv/authorized_keys on hubris.
#
# Idempotent — skips keys already present.
set -euo pipefail
KEYS_DIR="/opt/homelab-context/ssh/authorized_keys"
if ! [ -d "$KEYS_DIR" ]; then
echo "ERROR: $KEYS_DIR not found. Is homelab context synced?"
exit 1
fi
# Collect all pubkeys into a single variable, one per line
ALL_KEYS=""
for f in "$KEYS_DIR"/*.pub; do
[ -f "$f" ] || continue
key=$(cat "$f" | head -1)
ALL_KEYS="${ALL_KEYS}${key}
"
done
if [ -z "$ALL_KEYS" ]; then
echo "ERROR: no .pub files found in $KEYS_DIR"
exit 1
fi
echo "= Deploying SSH keys to hubris ="
AUTH_FILE="/etc/pve/priv/authorized_keys"
touch "$AUTH_FILE"
added=0
while IFS= read -r key; do
[ -z "$key" ] && continue
if ! grep -qF "$key" "$AUTH_FILE" 2>/dev/null; then
echo " + Adding key to hubris: ${key:0:40}..."
echo "$key" >> "$AUTH_FILE"
added=$((added + 1))
fi
done <<< "$ALL_KEYS"
echo " hubris: $added key(s) added"
# Deploy to LXCs
echo ""
echo "= Deploying SSH keys to LXCs ="
# pct list output: VMID Status Lock Name (Lock may be empty)
# Use awk to grab VMID, Status, and Name (last field)
pct list | tail -n +2 | while read -r line; do
vmid=$(echo "$line" | awk '{print $1}')
status=$(echo "$line" | awk '{print $2}')
name=$(echo "$line" | awk '{print $NF}')
# Skip header if any
[ "$vmid" = "VMID" ] && continue
[ -z "$vmid" ] && continue
if [ "$status" != "running" ]; then
echo " SKIP $name ($vmid): status=$status"
continue
fi
echo " -> $name ($vmid)"
# Ensure .ssh directory exists
pct exec "$vmid" -- mkdir -p /root/.ssh 2>/dev/null
# For each key, check if already present, append if not
while IFS= read -r key; do
[ -z "$key" ] && continue
if ! pct exec "$vmid" -- grep -qF "$key" /root/.ssh/authorized_keys 2>/dev/null; then
echo " + ${key:0:40}..."
# Use tee to append (pct exec preserves stdin)
echo "$key" | pct exec "$vmid" -- tee -a /root/.ssh/authorized_keys >/dev/null 2>&1
fi
done <<< "$ALL_KEYS"
# Fix permissions
pct exec "$vmid" -- chmod 600 /root/.ssh/authorized_keys 2>/dev/null || true
pct exec "$vmid" -- chmod 700 /root/.ssh 2>/dev/null || true
echo " <- $name done"
done
echo ""
echo "=== Deploy complete ==="
echo "Keys deployed. Test from any workstation with:"
echo " ssh root@<lxc-ip>"

208
ssh/gen-config.py Normal file
View File

@@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""Generate ~/.ssh/config.d/homelab from inventory.yaml.
Usage:
python3 ssh/gen-config.py # print to stdout
python3 ssh/gen-config.py --install # write to ~/.ssh/config.d/homelab
The generated config provides short hostname aliases for every host in
the homelab inventory. LAN IPs are preferred (they work directly on-LAN
and are routed via Netbird 192.168.8.0/24 off-LAN); mesh FQDNs are
available as <name>-mesh fallbacks for roaming workstations.
"""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
try:
import yaml
except ImportError:
print("PyYAML required (pip install pyyaml)", file=sys.stderr)
sys.exit(2)
INVENTORY_PATH = Path(os.environ.get(
"HOMELAB_CONTEXT_DIR", "/opt/homelab-context"
)) / "inventory.yaml"
SSH_CONFIG_DIR = Path.home() / ".ssh" / "config.d"
SSH_CONFIG_FILE = SSH_CONFIG_DIR / "homelab"
SSH_MAIN_CONFIG = Path.home() / ".ssh" / "config"
INCLUDE_LINE = f"Include ~/.ssh/config.d/homelab"
# Hosts to skip in the generated config
SKIP_HOSTS = {"ludo-mini", "authentik"} # offline / not enrolled
def inventory() -> dict:
if not INVENTORY_PATH.exists():
print(f"ERROR: no inventory at {INVENTORY_PATH}", file=sys.stderr)
sys.exit(1)
return yaml.safe_load(INVENTORY_PATH.read_text())
def gen_config() -> str:
inv = inventory()
hosts = inv.get("hosts", {})
mesh_globals = inv.get("mesh", {})
lines: list[str] = []
lines.append("# Homelab SSH config — generated from inventory.yaml")
lines.append(f"# Source: {INVENTORY_PATH}")
lines.append("# Do not edit by hand. Run: homelab ssh-config")
lines.append("")
# --- Common defaults (before any Host block) ---
lines.append("# --- Defaults ---")
lines.append("Host *")
lines.append(" IdentityFile ~/.ssh/id_ed25519")
lines.append(" IdentitiesOnly yes")
lines.append(" ServerAliveInterval 30")
lines.append(" StrictHostKeyChecking accept-new")
lines.append("")
def add_entry(
tag: str,
hostname: str,
user: str = "root",
port: int = 22,
extra_lines: list[str] | None = None,
) -> None:
lines.append(f"Host {tag}")
lines.append(f" HostName {hostname}")
lines.append(f" User {user}")
if port != 22:
lines.append(f" Port {port}")
if extra_lines:
lines.extend(f" {el}" for el in extra_lines)
lines.append("")
# Sort hosts by kind for a logical output order
def sort_key(item):
name, h = item
kind = h.get("kind", "")
order = {
"workstation": 0,
"proxmox-host": 1,
"lxc": 2,
"vm": 3,
"external": 4,
}
return (order.get(kind, 9), name)
sorted_hosts = sorted(
[(n, h) for n, h in hosts.items() if n not in SKIP_HOSTS],
key=sort_key,
)
for name, h in sorted_hosts:
kind = h.get("kind", "")
ssh_config = h.get("ssh", {}) or {}
user = ssh_config.get("user", "root")
port = ssh_config.get("port", 22)
lan_ip = h.get("lan_ip")
mesh = h.get("mesh", {}) or {}
nb = mesh.get("netbird", {}) or {}
ts = mesh.get("tailscale", {}) or {}
nb_fqdn = nb.get("fqdn") or nb.get("ip") or ""
nb_port = ssh_config.get("netbird_port")
# Determine primary address
if nb_port and not lan_ip:
# Hosts with netbird SSH but no LAN IP: mesh-only
primary = nb_fqdn
elif lan_ip:
primary = lan_ip
elif nb_fqdn:
primary = nb_fqdn
elif ts.get("fqdn"):
primary = ts["fqdn"]
else:
continue # no address found
extra = []
if kind == "proxmox-host":
extra = [
"ControlMaster auto",
"ControlPath ~/.ssh/cm/%C",
"ControlPersist 2h",
]
add_entry(name, primary, user, port, extra_lines=extra)
# Mesh fallback for workstations and hubris
if nb_fqdn and (kind == "workstation" or nb_port):
mesh_tag = f"{name}-mesh"
mesh_port = nb_port or port
mesh_extra = []
if kind == "proxmox-host":
mesh_extra = [
"ControlMaster auto",
"ControlPath ~/.ssh/cm/%C",
"ControlPersist 2h",
]
add_entry(mesh_tag, nb_fqdn, user, mesh_port, extra_lines=mesh_extra)
# --- *.hubris.network ControlMaster ---
lines.append("# --- Mesh ControlMaster (speeds up repeated mesh ops) ---")
lines.append("Host *.netbird.selfhosted")
lines.append(" ControlMaster auto")
lines.append(" ControlPath ~/.ssh/cm/%C")
lines.append(" ControlPersist 2h")
lines.append("")
return "\n".join(lines)
def ensure_include() -> bool:
"""Add 'Include ~/.ssh/config.d/homelab' to the main SSH config if missing."""
if not SSH_MAIN_CONFIG.exists():
SSH_MAIN_CONFIG.parent.mkdir(parents=True, exist_ok=True)
with open(SSH_MAIN_CONFIG, "w") as f:
f.write(f"{INCLUDE_LINE}\n")
return True
content = SSH_MAIN_CONFIG.read_text()
for line in content.splitlines():
stripped = line.strip()
if stripped.startswith("Include") and "homelab" in stripped:
return False # already present
# Prepend to existing config
updated = f"{INCLUDE_LINE}\n\n{content}"
SSH_MAIN_CONFIG.write_text(updated)
return True
def install() -> None:
"""Write config to ~/.ssh/config.d/homelab and ensure Include is set."""
SSH_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
config = gen_config()
SSH_CONFIG_FILE.write_text(config)
SSH_CONFIG_FILE.chmod(0o644)
changed = ensure_include()
print(f"Wrote {SSH_CONFIG_FILE} ({len(config.splitlines())} lines)")
if changed:
print(f"Added '{INCLUDE_LINE}' to {SSH_MAIN_CONFIG}")
print("Done. Run: ssh <hostname> (e.g. ssh gitea)")
def main() -> int:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--install", "-i", action="store_true",
help=f"write to {SSH_CONFIG_FILE} and wire Include into main config")
args = p.parse_args()
if args.install:
install()
else:
print(gen_config())
return 0
if __name__ == "__main__":
sys.exit(main())

23
tools/caveman/caveman.js Normal file
View File

@@ -0,0 +1,23 @@
#!/usr/bin/env node
// Caveman template renderer — reads template + data JSON files and renders output
// Installed automatically via homelab-context post-pull hook
const caveman = require("caveman");
const fs = require("fs");
const path = require("path");
const args = process.argv.slice(2);
if (args.length === 0) {
console.error("Usage: caveman <template> [data.json]");
process.exit(1);
}
const templatePath = args[0];
let data = {};
if (args.length > 1) {
data = JSON.parse(fs.readFileSync(args[1], "utf8"));
}
const template = fs.readFileSync(templatePath, "utf8");
const templateName = path.basename(templatePath, path.extname(templatePath));
caveman.register(templateName, template);
console.log(caveman.render(templateName, data).trim());

View File

@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# Caveman + RTK Wrapper - Automated token-efficient output formatting
# Installed automatically via homelab-context post-pull hook
# Source: https://github.com/adityahimaone/hermes-agent-rtk-caveman
# Usage: caveman_wrapper.sh <workflow> [options]
set -e
WORKFLOW="$1"
shift
CAVEMAN=~/bin/caveman
TEMPLATES_DIR=~/templates
DATA_DIR=/tmp/caveman_data
mkdir -p "$DATA_DIR"
case "$WORKFLOW" in
git-status)
git status --porcelain | awk '
BEGIN { staged=0; modified=0; untracked=0; deleted=0 }
/^[MARC]./ { staged_arr[staged++] = substr($0, 4) }
/^.[MARC]/ { modified_arr[modified++] = substr($0, 4) }
/^\?\?/ { untracked_arr[untracked++] = substr($0, 4) }
/^D/ || /^.D/ { deleted_arr[deleted++] = substr($0, 4) }
END {
printf "{"
printf "\"staged\":["
for(i=0;i<staged;i++) printf "%s\"%s\"", (i>0?",":""), staged_arr[i]
printf "],\"modified\":["
for(i=0;i<modified;i++) printf "%s\"%s\"", (i>0?",":""), modified_arr[i]
printf "],\"untracked\":["
for(i=0;i<untracked;i++) printf "%s\"%s\"", (i>0?",":""), untracked_arr[i]
printf "],\"deleted\":["
for(i=0;i<deleted;i++) printf "%s\"%s\"", (i>0?",":""), deleted_arr[i]
printf "]}"
}' > "$DATA_DIR/git_status.json"
if command -v rtk &>/dev/null; then
rtk "$CAVEMAN" "$TEMPLATES_DIR/git_status.txt" "$DATA_DIR/git_status.json"
else
node "$CAVEMAN" "$TEMPLATES_DIR/git_status.txt" "$DATA_DIR/git_status.json"
fi
;;
git-log)
LIMIT="${1:-10}"
git log --oneline -"$LIMIT" --format='{"hash":"%h","author":"%an","date":"%ad","message":"%s"}' --date=short | \
jq -s '.' > "$DATA_DIR/git_log.json"
jq '{commits: .}' "$DATA_DIR/git_log.json" > "$DATA_DIR/git_log_final.json"
if command -v rtk &>/dev/null; then
rtk "$CAVEMAN" "$TEMPLATES_DIR/git_log.txt" "$DATA_DIR/git_log_final.json"
else
node "$CAVEMAN" "$TEMPLATES_DIR/git_log.txt" "$DATA_DIR/git_log_final.json"
fi
;;
test-results)
TEST_CMD="${1:-npx vitest run}"
$TEST_CMD --reporter json 2>/dev/null | \
jq '{total: .numTotalTests, passed: .numPassedTests, failed: .numFailedTests, suites: [.testResults[] | {name: .name, status: .status, duration: .duration}]}' > "$DATA_DIR/test_results.json" || true
if command -v rtk &>/dev/null; then
rtk "$CAVEMAN" "$TEMPLATES_DIR/test_results.txt" "$DATA_DIR/test_results.json"
else
node "$CAVEMAN" "$TEMPLATES_DIR/test_results.txt" "$DATA_DIR/test_results.json"
fi
;;
*)
echo "Usage: caveman_wrapper.sh <workflow> [options]"
echo " git-status - Compact git status"
echo " git-log [limit] - Recent git commits"
echo " test-results [cmd] - Compact test results"
exit 1
;;
esac

View File

@@ -0,0 +1,3 @@
Recent Commits:
{{- for d.commits as commit }} {{commit.hash}} {{commit.date}} {{commit.message}}
{{- end }}

View File

@@ -0,0 +1,12 @@
{{- if d.staged }}Staged:
{{- for d.staged as file }} + {{file}}
{{- end }}{{- end }}
{{- if d.modified }}Modified:
{{- for d.modified as file }} ~ {{file}}
{{- end }}{{- end }}
{{- if d.untracked }}Untracked:
{{- for d.untracked as file }} ? {{file}}
{{- end }}{{- end }}
{{- if d.deleted }}Deleted:
{{- for d.deleted as file }} - {{file}}
{{- end }}{{- end }}

View File

@@ -0,0 +1,4 @@
{{- if d.failed }}Tests: {{d.passed}}/{{d.total}} passed ({{d.failed}} failed)
{{- for d.suites as suite }}{{- if suite.status == "failed" }} {{suite.name}} ({{suite.duration}}ms)
{{- end }}{{- end }}{{- else }}All {{d.total}} tests passed
{{- end }}

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>network.hubris.homelab-context-sync</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/env</string>
<string>bash</string>
<string>/opt/homelab-context/tools/post-pull.sh</string>
</array>
<key>StartInterval</key>
<integer>300</integer>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/homelab-sync.log</string>
<key>StandardErrorPath</key>
<string>/tmp/homelab-sync.log</string>
</dict>
</plist>

43
tools/post-pull.sh Normal file
View File

@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# post-pull.sh — homelab-context sync hook.
# Replaces raw `git pull` in the launchd/systemd timer.
# Runs after every git pull to auto-setup tools from the repo.
#
# Convention: any script at tools/*.setup.sh is sourced/exec'd after pull.
# This lets us ship new tooling to all agent hosts via a simple git push.
set -euo pipefail
CONTEXT_DIR="${HOMELAB_CONTEXT_DIR:-/opt/homelab-context}"
# Git safe.directory workaround: the repo is owned by the regular user
# but launchd runs as root. Run git as the repo owner.
if command -v realpath &>/dev/null; then
REPO_REAL=$(realpath "$CONTEXT_DIR")
elif command -v grealpath &>/dev/null; then
REPO_REAL=$(grealpath "$CONTEXT_DIR")
else
REPO_REAL=$(perl -e 'print Cwd::abs_path(shift)' "$CONTEXT_DIR" 2>/dev/null || echo "/Users/dtoro/Homelab-Docs")
fi
REPO_OWNER=$(stat -f "%Su" "$REPO_REAL" 2>/dev/null || echo "dtoro")
# 1. Git pull (as repo owner to avoid safe.directory issues)
if [ "$REPO_OWNER" != "root" ] && command -v sudo &>/dev/null; then
sudo -u "$REPO_OWNER" git -C "$CONTEXT_DIR" pull --ff-only --quiet 2>&1 | grep -v "failed to store" || \
echo "[post-pull] git pull failed (network?) continuing..."
else
git -C "$CONTEXT_DIR" pull --ff-only --quiet 2>&1 || \
echo "[post-pull] git pull failed (network?) continuing..."
fi
# 2. Run any auto-setup scripts
for setup_script in "$CONTEXT_DIR"/tools/*.setup.sh; do
[ -f "$setup_script" ] || continue
echo "[post-pull] running $setup_script..."
bash "$setup_script" || echo "[post-pull] WARNING: $setup_script exited with code $?"
done
# 3. Symlink AGENTS.md for agent orientation (macOS)
if [ "$(uname)" = "Darwin" ]; then
ln -sf "$CONTEXT_DIR/AGENTS.md" /etc/AGENTS.md 2>/dev/null || true
fi

72
tools/setup-caveman.sh Normal file
View File

@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# setup-caveman.sh — idempotent auto-installer for Caveman + RTK token optimization.
# Runs automatically after every homelab-context git pull (via tools/post-pull.sh).
#
# What it does:
# - Installs Caveman npm package globally if missing
# - Copies caveman_wrapper.sh → ~/bin/
# - Copies caveman.js wrapper → ~/bin/caveman (CLI entry point)
# - Copies templates → ~/templates/
# - Creates ~/bin/ and ~/templates/ dirs if missing
# - All operations are idempotent (safe to re-run)
#
# Works on: macOS (Homebrew node) and Linux (system node)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CAVEMAN_DIR="$SCRIPT_DIR/caveman"
BIN_DIR="$HOME/bin"
TEMPLATES_DIR="$HOME/templates"
# Colors for output (only when connected to a terminal)
if [ -t 1 ]; then
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
else
GREEN=''; YELLOW=''; BLUE=''; NC=''
fi
log() { echo -e "${BLUE}[caveman]${NC} $1"; }
ok() { echo -e "${GREEN}[caveman]${NC} $1"; }
skip() { echo -e "${YELLOW}[caveman]${NC} $1"; }
# --- 1. Check / install node + npm ---
if ! command -v node &>/dev/null; then
echo "[caveman] node.js not found — skipping caveman install"
exit 0
fi
# --- 2. Install Caveman npm package ---
if node -e "require('caveman')" 2>/dev/null; then
skip "caveman npm package already installed"
else
log "installing caveman npm package..."
npm install -g caveman 2>&1 | tail -1
ok "caveman npm package installed"
fi
# --- 3. Create target directories ---
mkdir -p "$BIN_DIR" "$TEMPLATES_DIR"
# --- 4. Install wrapper script ---
install -m 755 "$CAVEMAN_DIR/caveman_wrapper.sh" "$BIN_DIR/caveman_wrapper.sh"
ok "caveman_wrapper.sh → $BIN_DIR/caveman_wrapper.sh"
# --- 5. Install caveman CLI wrapper ---
install -m 755 "$CAVEMAN_DIR/caveman.js" "$BIN_DIR/caveman"
ok "caveman.js → $BIN_DIR/caveman"
# --- 6. Install templates ---
for tmpl in "$CAVEMAN_DIR/templates/"*.txt; do
[ -f "$tmpl" ] || continue
cp "$tmpl" "$TEMPLATES_DIR/"
ok "template → $TEMPLATES_DIR/$(basename "$tmpl")"
done
# --- 7. Verify ---
if [ -x "$BIN_DIR/caveman_wrapper.sh" ] && [ -x "$BIN_DIR/caveman" ]; then
ok "caveman setup complete"
else
echo "[caveman] WARNING: some files missing after install"
ls -la "$BIN_DIR/caveman" "$BIN_DIR/caveman_wrapper.sh" 2>&1
fi

68
tools/setup-hermes-soul.sh Executable file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# setup-hermes-soul.sh — auto-provisions Hermes SOUL.md from canonical HERMES.md.
# Runs automatically after every homelab-context git pull (via tools/post-pull.sh).
#
# What it does:
# - Detects if Hermes Agent is installed (~/.hermes/SOUL.md exists)
# - If yes, copies the canonical HERMES.md content into SOUL.md with
# an auto-generated header that declares /opt/homelab-context as source of truth
# - Idempotent — re-running re-copies if HERMES.md content changed
#
# For non-Hermes agents (Goose, Claude Code, etc.), this script is a no-op.
# Those agents use the `.goosehints` symlink mechanism instead.
set -euo pipefail
CONTEXT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
HERMES_MD="$CONTEXT_DIR/HERMES.md"
SOUL_MD="${HOME}/.hermes/SOUL.md"
# Colors for output (only when connected to a terminal)
if [ -t 1 ]; then
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
else
GREEN=''; YELLOW=''; NC=''
fi
ok() { echo -e "${GREEN}[hermes-soul]${NC} $1"; }
skip() { echo -e "${YELLOW}[hermes-soul]${NC} $1"; }
# --- 1. Check if Hermes is installed ---
if [ ! -f "$SOUL_MD" ]; then
skip "hermes not installed (~/.hermes/SOUL.md not found) — skipping"
exit 0
fi
# --- 2. Check if canonical HERMES.md exists ---
if [ ! -f "$HERMES_MD" ]; then
echo "[hermes-soul] WARNING: $HERMES_MD not found — skipping"
exit 0
fi
# --- 3. Write SOUL.md with canon source header + HERMES.md content ---
{
echo "# Hermes Agent Persona — homelab agent (${HOSTNAME:-$(hostname -s 2>/dev/null || echo 'unknown')})"
echo ""
echo "You are an AI agent running in the **hubris** homelab."
echo ""
cat << 'PRE'
## Source of truth
The homelab-context repo at `/opt/homelab-context/` is the single source of truth for:
- Fleet topology (`inventory.yaml`, `hosts/*.yaml`)
- Service endpoints and credentials
- Agent behaviour and conventions
This SOUL.md is auto-generated from `/opt/homelab-context/HERMES.md` by
`tools/setup-hermes-soul.sh`. Do not edit SOUL.md directly — edit HERMES.md
in the homelab-context repo instead. Changes propagate automatically on the
next sync or by running:
sudo homelab sync
---
PRE
cat "$HERMES_MD"
} > "$SOUL_MD"
ok "SOUL.md provisioned from HERMES.md ($(wc -l < "$SOUL_MD") lines)"