diff --git a/.sops.yaml b/.sops.yaml index 671c1f6..13ffccb 100644 --- a/.sops.yaml +++ b/.sops.yaml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 31888bf..94c1ad0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/.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 ` or `homelab mcp get_host `. The clone is the fallback; MCP is the index. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 30fb393..1cfd2d7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 — + +## 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 diff --git a/HERMES.md b/HERMES.md new file mode 100644 index 0000000..bb5cbf2 --- /dev/null +++ b/HERMES.md @@ -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 \ No newline at end of file diff --git a/bin/hermes b/bin/hermes new file mode 100755 index 0000000..596029a --- /dev/null +++ b/bin/hermes @@ -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 "$@" diff --git a/bin/homelab b/bin/homelab index aa3ef04..d1c3a94 100755 --- a/bin/homelab +++ b/bin/homelab @@ -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") diff --git a/bootstrap.sh b/bootstrap.sh index ec6b275..643171f 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -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 diff --git a/containers/105-apps.md b/containers/105-apps.md index f7fc582..1477f10 100644 --- a/containers/105-apps.md +++ b/containers/105-apps.md @@ -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`, diff --git a/containers/107-dns.md b/containers/107-dns.md index 38172e2..51440e2 100644 --- a/containers/107-dns.md +++ b/containers/107-dns.md @@ -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). diff --git a/containers/121-caddy.md b/containers/121-caddy.md index bda0433..ceda2eb 100644 --- a/containers/121-caddy.md +++ b/containers/121-caddy.md @@ -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. diff --git a/containers/122-arriman.md b/containers/122-arriman.md index 04f9542..7ad2583 100644 --- a/containers/122-arriman.md +++ b/containers/122-arriman.md @@ -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. diff --git a/hosts/apps.yaml b/hosts/apps.yaml index 4a47624..7e3ee4c 100644 --- a/hosts/apps.yaml +++ b/hosts/apps.yaml @@ -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 diff --git a/hosts/arriman.yaml b/hosts/arriman.yaml index 8f5df9a..15cdede 100644 --- a/hosts/arriman.yaml +++ b/hosts/arriman.yaml @@ -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 diff --git a/hosts/authentik.yaml b/hosts/authentik.yaml index 9329b85..5a050bc 100644 --- a/hosts/authentik.yaml +++ b/hosts/authentik.yaml @@ -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 diff --git a/hosts/caddy.yaml b/hosts/caddy.yaml index 76780e8..cc0adcc 100644 --- a/hosts/caddy.yaml +++ b/hosts/caddy.yaml @@ -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 diff --git a/hosts/claudio-bot.yaml b/hosts/claudio-bot.yaml index e463c33..bfce5bd 100644 --- a/hosts/claudio-bot.yaml +++ b/hosts/claudio-bot.yaml @@ -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 diff --git a/hosts/elementsynapse.yaml b/hosts/elementsynapse.yaml index 32838b4..a162061 100644 --- a/hosts/elementsynapse.yaml +++ b/hosts/elementsynapse.yaml @@ -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 diff --git a/hosts/gitea.yaml b/hosts/gitea.yaml index 49ee4bf..87f25a2 100644 --- a/hosts/gitea.yaml +++ b/hosts/gitea.yaml @@ -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 diff --git a/hosts/haos.yaml b/hosts/haos.yaml index 9490069..40e3e9c 100644 --- a/hosts/haos.yaml +++ b/hosts/haos.yaml @@ -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 diff --git a/hosts/hubris.md b/hosts/hubris.md index ec259e8..4bde448 100644 --- a/hosts/hubris.md +++ b/hosts/hubris.md @@ -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). diff --git a/hosts/hubris.yaml b/hosts/hubris.yaml index 89aff22..bb156ce 100644 --- a/hosts/hubris.yaml +++ b/hosts/hubris.yaml @@ -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 diff --git a/hosts/jellyfin.yaml b/hosts/jellyfin.yaml index f77a5e1..c95f5a1 100644 --- a/hosts/jellyfin.yaml +++ b/hosts/jellyfin.yaml @@ -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 diff --git a/hosts/ludo-mini.yaml b/hosts/ludo-mini.yaml index 341d59c..cc44feb 100644 --- a/hosts/ludo-mini.yaml +++ b/hosts/ludo-mini.yaml @@ -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 diff --git a/hosts/mac-mini.yaml b/hosts/mac-mini.yaml index ddd5ad3..829ebd6 100644 --- a/hosts/mac-mini.yaml +++ b/hosts/mac-mini.yaml @@ -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 diff --git a/hosts/mule-images.yaml b/hosts/mule-images.yaml index d402715..75b8633 100644 --- a/hosts/mule-images.yaml +++ b/hosts/mule-images.yaml @@ -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 diff --git a/hosts/netbird-vps.yaml b/hosts/netbird-vps.yaml index 8cc3fee..713a0bb 100644 --- a/hosts/netbird-vps.yaml +++ b/hosts/netbird-vps.yaml @@ -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 diff --git a/hosts/nextcloud.yaml b/hosts/nextcloud.yaml index d8782cb..a0f5a39 100644 --- a/hosts/nextcloud.yaml +++ b/hosts/nextcloud.yaml @@ -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 diff --git a/hosts/nfs-export.yaml b/hosts/nfs-export.yaml index 8381943..a38c1b4 100644 --- a/hosts/nfs-export.yaml +++ b/hosts/nfs-export.yaml @@ -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 diff --git a/hosts/paperless.yaml b/hosts/paperless.yaml index 43a8fa4..71b5372 100644 --- a/hosts/paperless.yaml +++ b/hosts/paperless.yaml @@ -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 diff --git a/hosts/plato.yaml b/hosts/plato.yaml index 318da3a..e391ff2 100644 --- a/hosts/plato.yaml +++ b/hosts/plato.yaml @@ -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 diff --git a/hosts/republic-laptop.yaml b/hosts/republic-laptop.yaml index 1962ddd..23fad6a 100644 --- a/hosts/republic-laptop.yaml +++ b/hosts/republic-laptop.yaml @@ -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 diff --git a/hosts/sophia.yaml b/hosts/sophia.yaml index 62d08c0..3f28389 100644 --- a/hosts/sophia.yaml +++ b/hosts/sophia.yaml @@ -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 diff --git a/hosts/zimaos.yaml b/hosts/zimaos.yaml index ff105a7..8b2a553 100644 --- a/hosts/zimaos.yaml +++ b/hosts/zimaos.yaml @@ -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 diff --git a/infrastructure/dns.md b/infrastructure/dns.md index a6c4ddb..9cd9e89 100644 --- a/infrastructure/dns.md +++ b/infrastructure/dns.md @@ -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. diff --git a/infrastructure/homelab-context.md b/infrastructure/homelab-context.md index 8dc40ec..c147607 100644 --- a/infrastructure/homelab-context.md +++ b/infrastructure/homelab-context.md @@ -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` | diff --git a/infrastructure/network.md b/infrastructure/network.md new file mode 100644 index 0000000..44cbab8 --- /dev/null +++ b/infrastructure/network.md @@ -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.100–192.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.100–240`. 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). diff --git a/infrastructure/ssh-access.md b/infrastructure/ssh-access.md index 0cb907b..5a89f04 100644 --- a/infrastructure/ssh-access.md +++ b/infrastructure/ssh-access.md @@ -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`. \ No newline at end of file diff --git a/inventory.yaml b/inventory.yaml index c8ef328..9d1e724 100644 --- a/inventory.yaml +++ b/inventory.yaml @@ -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 diff --git a/investigations/2026-06-01-mac-mini-onboarding.md b/investigations/2026-06-01-mac-mini-onboarding.md new file mode 100644 index 0000000..a7eb631 --- /dev/null +++ b/investigations/2026-06-01-mac-mini-onboarding.md @@ -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). diff --git a/mcp/server.py b/mcp/server.py index f895991..b08700a 100755 --- a/mcp/server.py +++ b/mcp/server.py @@ -333,4 +333,4 @@ def ping_service(service: str) -> dict: if __name__ == "__main__": - mcp.run(transport="sse") + mcp.run(transport="streamable-http") diff --git a/operations/agent-enrollment.md b/operations/agent-enrollment.md index 7140cc8..a615c00 100644 --- a/operations/agent-enrollment.md +++ b/operations/agent-enrollment.md @@ -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. diff --git a/operations/hermes-agent.md b/operations/hermes-agent.md new file mode 100644 index 0000000..2f87838 --- /dev/null +++ b/operations/hermes-agent.md @@ -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. diff --git a/plans/2026-06-01-slate-ax-to-sodola-migration.md b/plans/2026-06-01-slate-ax-to-sodola-migration.md new file mode 100644 index 0000000..8f67eae --- /dev/null +++ b/plans/2026-06-01-slate-ax-to-sodola-migration.md @@ -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 3–5. +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/`. diff --git a/plans/index.md b/plans/index.md new file mode 100644 index 0000000..9aab22c --- /dev/null +++ b/plans/index.md @@ -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. diff --git a/scripts/sync/linux/homelab-context-sync.service b/scripts/sync/linux/homelab-context-sync.service index adc650b..5ee58ab 100644 --- a/scripts/sync/linux/homelab-context-sync.service +++ b/scripts/sync/linux/homelab-context-sync.service @@ -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 \ No newline at end of file diff --git a/scripts/sync/macos/network.hubris.homelab-context-sync.plist b/scripts/sync/macos/network.hubris.homelab-context-sync.plist index 814dc6e..a95dbc1 100644 --- a/scripts/sync/macos/network.hubris.homelab-context-sync.plist +++ b/scripts/sync/macos/network.hubris.homelab-context-sync.plist @@ -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> \ No newline at end of file diff --git a/secrets/gitea-pat.yaml b/secrets/gitea-pat.yaml index 0c2117e..3b8dd91 100644 --- a/secrets/gitea-pat.yaml +++ b/secrets/gitea-pat.yaml @@ -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] diff --git a/secrets/hello.yaml b/secrets/hello.yaml index 5769f8c..3c12c7c 100644 --- a/secrets/hello.yaml +++ b/secrets/hello.yaml @@ -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] diff --git a/secrets/netbird-authentik-oidc.yaml b/secrets/netbird-authentik-oidc.yaml index cb04de5..190ef0c 100644 --- a/secrets/netbird-authentik-oidc.yaml +++ b/secrets/netbird-authentik-oidc.yaml @@ -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] diff --git a/secrets/openrouter-api-key.yaml b/secrets/openrouter-api-key.yaml new file mode 100644 index 0000000..f37ffa1 --- /dev/null +++ b/secrets/openrouter-api-key.yaml @@ -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 diff --git a/secrets/turn-shared-secret.yaml b/secrets/turn-shared-secret.yaml index 3278bb4..bb15c65 100644 --- a/secrets/turn-shared-secret.yaml +++ b/secrets/turn-shared-secret.yaml @@ -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] diff --git a/ssh/authorized_keys/mac-mini.pub b/ssh/authorized_keys/mac-mini.pub new file mode 100644 index 0000000..1eb1922 --- /dev/null +++ b/ssh/authorized_keys/mac-mini.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMtgjgpljZcu98NOW1ibry8ZBQSoUg0IaxK8iW10w2+ d.toro.v@pm.me diff --git a/ssh/deploy-keys.sh b/ssh/deploy-keys.sh new file mode 100644 index 0000000..790a8a8 --- /dev/null +++ b/ssh/deploy-keys.sh @@ -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>" \ No newline at end of file diff --git a/ssh/gen-config.py b/ssh/gen-config.py new file mode 100644 index 0000000..8a1a5e1 --- /dev/null +++ b/ssh/gen-config.py @@ -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()) diff --git a/tools/caveman/caveman.js b/tools/caveman/caveman.js new file mode 100644 index 0000000..6f17d44 --- /dev/null +++ b/tools/caveman/caveman.js @@ -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()); \ No newline at end of file diff --git a/tools/caveman/caveman_wrapper.sh b/tools/caveman/caveman_wrapper.sh new file mode 100644 index 0000000..3690996 --- /dev/null +++ b/tools/caveman/caveman_wrapper.sh @@ -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 \ No newline at end of file diff --git a/tools/caveman/templates/git_log.txt b/tools/caveman/templates/git_log.txt new file mode 100644 index 0000000..e129fb6 --- /dev/null +++ b/tools/caveman/templates/git_log.txt @@ -0,0 +1,3 @@ +Recent Commits: +{{- for d.commits as commit }} {{commit.hash}} {{commit.date}} {{commit.message}} +{{- end }} \ No newline at end of file diff --git a/tools/caveman/templates/git_status.txt b/tools/caveman/templates/git_status.txt new file mode 100644 index 0000000..d13e2f9 --- /dev/null +++ b/tools/caveman/templates/git_status.txt @@ -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 }} \ No newline at end of file diff --git a/tools/caveman/templates/test_results.txt b/tools/caveman/templates/test_results.txt new file mode 100644 index 0000000..92b4d5d --- /dev/null +++ b/tools/caveman/templates/test_results.txt @@ -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 }} \ No newline at end of file diff --git a/tools/network.hubris.homelab-context-sync.plist b/tools/network.hubris.homelab-context-sync.plist new file mode 100644 index 0000000..92b043e --- /dev/null +++ b/tools/network.hubris.homelab-context-sync.plist @@ -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> diff --git a/tools/post-pull.sh b/tools/post-pull.sh new file mode 100644 index 0000000..ccbc3e6 --- /dev/null +++ b/tools/post-pull.sh @@ -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 \ No newline at end of file diff --git a/tools/setup-caveman.sh b/tools/setup-caveman.sh new file mode 100644 index 0000000..d5d1365 --- /dev/null +++ b/tools/setup-caveman.sh @@ -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 \ No newline at end of file diff --git a/tools/setup-hermes-soul.sh b/tools/setup-hermes-soul.sh new file mode 100755 index 0000000..ab2eb44 --- /dev/null +++ b/tools/setup-hermes-soul.sh @@ -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)" \ No newline at end of file