Merge branch 'main' of git-ssh.hubris.network:dtoro/Homelab-Docs
# Conflicts: # .sops.yaml # containers/107-dns.md # infrastructure/dns.md
This commit is contained in:
27
.sops.yaml
27
.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
|
||||
|
||||
23
AGENTS.md
23
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/<name>.setup.sh` in the repo,
|
||||
commit and push. All enrolled clients pick it up within 5 minutes.
|
||||
|
||||
To trigger sync manually: `sudo homelab sync` or wait for the 5-min timer.
|
||||
|
||||
## 7. When in doubt
|
||||
|
||||
Run `homelab mcp search_docs <query>` or `homelab mcp get_host <name>`.
|
||||
The clone is the fallback; MCP is the index.
|
||||
|
||||
@@ -67,6 +67,30 @@ Links to nodes that host or depend on this.
|
||||
## Changelog
|
||||
```
|
||||
|
||||
### Plan (`plans/YYYY-MM-DD-slug.md`)
|
||||
|
||||
```markdown
|
||||
# YYYY-MM-DD — <title>
|
||||
|
||||
## Goal
|
||||
What this change achieves and why.
|
||||
|
||||
## Current topology / state
|
||||
Diagram or description of what exists now.
|
||||
|
||||
## Target topology / state
|
||||
What it looks like after.
|
||||
|
||||
## Pre-flight checklist
|
||||
|
||||
## Step-by-step procedure
|
||||
|
||||
## Verification
|
||||
|
||||
## Post-migration
|
||||
Changelog entries to write, index status to update.
|
||||
```
|
||||
|
||||
### Investigation (`investigations/YYYY-MM-DD-slug.md`)
|
||||
|
||||
```markdown
|
||||
@@ -89,6 +113,7 @@ Links to nodes that host or depend on this.
|
||||
- Every container page links to every cross-cutting page it participates in.
|
||||
- Every cross-cutting page lists the nodes that participate.
|
||||
- Every investigation links to the nodes it implicates *and* gets back-linked from each node's changelog.
|
||||
- Every plan links to the infrastructure pages it affects. When done, update the plan's status in `plans/index.md` and write changelog entries on affected node pages.
|
||||
|
||||
## Changelog hygiene
|
||||
|
||||
|
||||
83
HERMES.md
Normal file
83
HERMES.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# HERMES.md — Agent persona for homelab clients
|
||||
|
||||
This file is the canonical agent persona for **all** AI agents running on
|
||||
machines in the **hubris** homelab. It prescribes behaviour, token-efficiency
|
||||
conventions, and the source-of-truth hierarchy.
|
||||
|
||||
## Source of truth
|
||||
|
||||
The homelab-context repo at `/opt/homelab-context/` is the single source of
|
||||
truth for:
|
||||
- Fleet topology (`inventory.yaml`, `hosts/*.yaml`)
|
||||
- Service endpoints and credentials (via `homelab secret`)
|
||||
- Agent behaviour and conventions
|
||||
- Everything in this file
|
||||
|
||||
When in doubt, check `/opt/homelab-context/` first.
|
||||
|
||||
## Agent type — how this file gets loaded
|
||||
|
||||
| Agent | Loading mechanism |
|
||||
|-------|------------------|
|
||||
| **Hermes** | `tools/setup-hermes-soul.sh` (auto-setup) → provisions `~/.hermes/SOUL.md` from this file |
|
||||
| **Goose** | `.goosehints` symlink at `~/.config/goose/.goosehints` → `/opt/homelab-context/HERMES.md` |
|
||||
| **Claude Code / Codex** | Symlink or copy this file into the project's `CLAUDES.md` / `.claude` instructions |
|
||||
|
||||
**Do not edit SOUL.md or .goosehints directly.** Edit this file in the
|
||||
homelab-context repo instead. Changes propagate to all clients on the next
|
||||
sync (`sudo homelab sync`).
|
||||
|
||||
---
|
||||
|
||||
## Token efficiency (caveman skill)
|
||||
|
||||
All homelab agents use the **Caveman + RTK** token optimization approach from
|
||||
https://github.com/adityahimaone/hermes-agent-rtk-caveman.
|
||||
|
||||
### Before running any CLI command, ask:
|
||||
|
||||
1. **Is there a caveman wrapper equivalent?** Use the wrapper for token-efficient
|
||||
output. Available wrappers (installed at `~/bin/caveman_wrapper.sh`):
|
||||
- `~/bin/caveman_wrapper.sh git-status` — compact git status
|
||||
- `~/bin/caveman_wrapper.sh git-log [n]` — compact git log
|
||||
- `~/bin/caveman_wrapper.sh lint [target]` — compact lint results
|
||||
- `~/bin/caveman_wrapper.sh test-results [cmd]` — compact test results
|
||||
|
||||
2. **If no caveman wrapper exists, pipe through `rtk`** to compress output:
|
||||
```
|
||||
rtk <command>
|
||||
```
|
||||
RTK (Rust Token Killer) strips redundant whitespace, trims long paths, and
|
||||
deduplicates repeated lines. This reduces token usage by 60-90% on CLI
|
||||
operations.
|
||||
|
||||
3. **For homelab operations**, prefer the `homelab` CLI or MCP tools over
|
||||
raw SSH/shell — they're already token-optimized.
|
||||
|
||||
### Templates
|
||||
|
||||
Caveman templates live at `~/templates/`:
|
||||
- `git_status.txt` — compact git status format
|
||||
- `git_log.txt` — compact git log format
|
||||
- `lint_results.txt` — compact ESLint format
|
||||
- `test_results.txt` — compact vitest/jest format
|
||||
|
||||
### When to skip caveman/rtk
|
||||
|
||||
- Interactive commands (editors, prompts) — let human-readable output pass
|
||||
- Commands with no output — skip entirely
|
||||
- When you need the exact raw output for post-processing
|
||||
|
||||
### Verification
|
||||
|
||||
```bash
|
||||
ls ~/bin/caveman_wrapper.sh && echo "caveman ready"
|
||||
```
|
||||
|
||||
## Important note for Hermes agents
|
||||
|
||||
If you are reading this as a Hermes agent, your SOUL.md was auto-provisioned
|
||||
by `tools/setup-hermes-soul.sh`. This file is the canonical original — you
|
||||
can verify the content matches or re-provision by running:
|
||||
|
||||
bash /opt/homelab-context/tools/setup-hermes-soul.sh
|
||||
36
bin/hermes
Executable file
36
bin/hermes
Executable file
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# hermes — launch a Goose session pre-wired with the homelab persona,
|
||||
# OpenRouter (Nous Hermes) provider, and the homelab MCP server.
|
||||
#
|
||||
# See operations/hermes-agent.md for the full onboarding flow.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
die() { echo "hermes: $*" >&2; exit 1; }
|
||||
|
||||
command -v goose >/dev/null \
|
||||
|| die "goose binary not found — re-run bootstrap.sh with --with-hermes"
|
||||
command -v homelab >/dev/null \
|
||||
|| die "homelab CLI not found — is this client bootstrapped?"
|
||||
|
||||
# Decrypt OpenRouter API key.
|
||||
# `homelab secret` re-execs via sudo for non-root users (age key is 0600 root).
|
||||
SECRET_YAML=$(homelab secret openrouter-api-key 2>&1) || \
|
||||
die "could not decrypt secrets/openrouter-api-key.yaml — this host probably
|
||||
isn't a recipient yet. See operations/hermes-agent.md ('Granting the OpenRouter
|
||||
key to a new host'). sops output:
|
||||
${SECRET_YAML}"
|
||||
|
||||
API_KEY=$(printf '%s' "$SECRET_YAML" | python3 -c \
|
||||
'import sys, yaml; print(yaml.safe_load(sys.stdin)["api_key"])')
|
||||
|
||||
case "$API_KEY" in
|
||||
PLACEHOLDER_*|"")
|
||||
die "openrouter-api-key.yaml still contains the placeholder; operator
|
||||
must run \`sops secrets/openrouter-api-key.yaml\` on hubris to insert a real
|
||||
\`sk-or-...\` key and push the change." ;;
|
||||
esac
|
||||
|
||||
export OPENROUTER_API_KEY="$API_KEY"
|
||||
exec goose session "$@"
|
||||
62
bin/homelab
62
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")
|
||||
|
||||
117
bootstrap.sh
117
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
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ page is the architecture reference.
|
||||
| Per-client clone | every enrolled client | `/opt/homelab-context/` | Read by `homelab` CLI, MCP server, claudio-bot |
|
||||
| `homelab` CLI | every enrolled client | `/usr/local/bin/homelab` → `/opt/homelab-context/bin/homelab` (symlink) | Operator surface for enroll/secret/ssh/pct |
|
||||
| Per-client age key | every enrolled client | `/etc/age/key.txt` (0600 root) | Decrypts SOPS-encrypted secrets the client is a recipient on |
|
||||
| MCP server | [apps (105)](../containers/105-apps.md) | `homelab-mcp.service` on port 9810 (https://mcp.hubris.network/sse) | 14 tools: 8 context (get_host, search_docs, …) + 5 read-only management (get_service_status, tail_log, …) + list_my_secrets |
|
||||
| MCP server | [apps (105)](../containers/105-apps.md) | `homelab-mcp.service` on port 9810 (https://mcp.hubris.network/mcp) | 14 tools: 8 context (get_host, search_docs, …) + 5 read-only management (get_service_status, tail_log, …) + list_my_secrets |
|
||||
| Secrets-issuance | [apps (105)](../containers/105-apps.md) | `secrets-issuance.service` on port 9820 (https://secrets.hubris.network/issue) | Generates per-client age keypair on first bootstrap; idempotent; admin-token-gated `/revoke` |
|
||||
| Sync timer | every enrolled client | `homelab-context-sync.timer` (Linux) / `network.hubris.homelab-context-sync.plist` (macOS) | `git pull --ff-only` every 5 min |
|
||||
| Encrypted secrets | `dtoro/Homelab-Docs` | `secrets/*.yaml` (SOPS+age) | Recipients declared in `.sops.yaml` |
|
||||
|
||||
84
infrastructure/network.md
Normal file
84
infrastructure/network.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Network
|
||||
|
||||
Physical and logical network topology for the homelab.
|
||||
|
||||
## Why
|
||||
|
||||
The homelab runs on a dedicated internal subnet (`192.168.8.0/24`) isolated from the main household LAN (`192.168.178.0/24`). Isolation is enforced at Proxmox: LXC/VM traffic is bridged only on the internal `vmbr0` bridge; Proxmox routes packets out to Fritz!Box via `vmbr1`. The main LAN cannot reach homelab services directly without a Fritz!Box static route (which is configured to allow inbound).
|
||||
|
||||
Fritz!OS 8.x does not support second IP networks on LAN ports, so Proxmox (`hubris`) acts as the subnet router rather than the Fritz!Box.
|
||||
|
||||
## Hardware
|
||||
|
||||
| Device | Role |
|
||||
|---|---|
|
||||
| Fritz!Box 7590 | Main router / ISP gateway (`192.168.178.1`) |
|
||||
| SODOLA 5-Port 2.5Gbit Managed | Homelab switch — flat L2, all ports native |
|
||||
| hubris (Proxmox) | Subnet router — routes between `192.168.8.0/24` and `192.168.178.0/24` |
|
||||
|
||||
## Topology
|
||||
|
||||
```
|
||||
ISP
|
||||
└── Fritz!Box 7590 (192.168.178.1)
|
||||
│ static route: 192.168.8.0/24 → 192.168.178.10
|
||||
│
|
||||
└── SODOLA 5-Port 2.5Gbit
|
||||
├── Port 1 uplink → Fritz!Box LAN
|
||||
├── Port 2 hubris eno1 → vmbr1 (192.168.178.10)
|
||||
├── Port 3 [device]
|
||||
├── Port 4 [device]
|
||||
└── Port 5 spare
|
||||
|
||||
hubris internal bridges:
|
||||
vmbr1 192.168.178.10/24 eno1 (uplink, DHCP-reserved) gateway 192.168.178.1
|
||||
vmbr0 192.168.8.77/24 no physical port (internal)
|
||||
192.168.8.1/24 alias — LXC default gateway
|
||||
├── all 16 LXCs
|
||||
└── HAOS VM
|
||||
```
|
||||
|
||||
## Subnets
|
||||
|
||||
| Subnet | Gateway | Purpose |
|
||||
|---|---|---|
|
||||
| `192.168.178.0/24` | `192.168.178.1` | Household LAN — laptops, phones, Fritz!Box DHCP |
|
||||
| `192.168.8.0/24` | `192.168.8.1` (Proxmox `vmbr0` alias) | Homelab — all LXCs and VMs |
|
||||
|
||||
## DHCP
|
||||
|
||||
- **Household (`192.168.178.x`)**: Fritz!Box built-in DHCP. Proxmox `vmbr1` has a reservation: MAC `84:47:09:6b:e7:58` → `192.168.178.10`.
|
||||
- **Homelab (`192.168.8.x`)**: Technitium on [CT 107](../containers/107-dns.md) at `192.168.8.2`. Range `192.168.8.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).
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
|
||||
191
investigations/2026-06-01-mac-mini-onboarding.md
Normal file
191
investigations/2026-06-01-mac-mini-onboarding.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# mac-mini onboarding — post-mortem & lessons learned
|
||||
|
||||
Onboarded the `mac-mini` workstation (macOS Sequoia, arm64) into the hubris
|
||||
homelab context system with the `--with-hermes` profile. What follows is a
|
||||
chronological recap of every hitch, the fix, and the systemic improvements
|
||||
needed so the next workstation takes 5 min instead of an hour.
|
||||
|
||||
## Session log
|
||||
|
||||
### Step 1 — clone + symlink
|
||||
- Manually cloned `git.hubris.network/dtoro/Homelab-Docs` to `/Users/dtoro/Homelab-Docs`.
|
||||
- Created `/opt/homelab-context` → `/Users/dtoro/Homelab-Docs` symlink.
|
||||
- **Lesson:** bootstrap.sh was designed to do this from scratch, but we'd
|
||||
already cloned by hand. The bootstrap's `clone exists; pulling` path handled
|
||||
it gracefully.
|
||||
|
||||
### Step 2 — hostname mismatch
|
||||
- `scutil --get LocalHostName` → `Davids-Mac-mini`
|
||||
- `hostname -s` → `Mac`
|
||||
- Inventory file: `hosts/mac-mini.yaml`
|
||||
- **Fix:** `sudo scutil --set LocalHostName mac-mini && sudo scutil --set HostName mac-mini`
|
||||
- **Lesson:** The bootstrap and `homelab whoami` use different hostname
|
||||
resolution. Bootstrap uses `scutil --get LocalHostName` (correct on macOS),
|
||||
but the `homelab` CLI binary uses `hostname -s`. Both need to match the
|
||||
inventory key. On a fresh macOS machine, neither does.
|
||||
|
||||
### Step 3 — bootstrap dependencies
|
||||
- pyyaml was missing → `pip install pyyaml`
|
||||
- age and sops were missing → `brew install age sops`
|
||||
- Netbird was already installed and connected ✓
|
||||
- **Lesson:** The bootstrap preflight handles these, but only if running
|
||||
`bootstrap.sh` from the start. Since we ran it after manual setup, some
|
||||
steps (netbird install) were correctly skipped as already-present.
|
||||
|
||||
### Step 4 — full bootstrap with `--with-mcp --with-hermes`
|
||||
- Ran `sudo HOMELAB_GITEA_TOKEN=... bash bootstrap.sh --with-mcp --with-hermes`
|
||||
- Age key issued ✓
|
||||
- Launchd sync timer installed ✓
|
||||
- Goose binary installed ✓
|
||||
- Hermes CLI linked ✓
|
||||
- MCP config merged ✓
|
||||
- `refresh-creds` skipped (not yet a recipient) ⚠️
|
||||
- Cosmetics: `chown: dtoro: illegal group name` at the end (benign, macOS
|
||||
group-naming quirk)
|
||||
|
||||
### Step 5 — finalize from hubris
|
||||
- Ran `homelab client add mac-mini --finalize-pubkey <age...> --with-hermes` on
|
||||
hubris
|
||||
- Push failed: `[rejected] main -> main (fetch first)` — hubris clone was
|
||||
stale, bootstrap had already pushed from mac-mini
|
||||
- **Fix:** `git pull --rebase && git push` on hubris
|
||||
- **Lesson:** bootstrap pushes remote changes before hubris can finalize,
|
||||
creating a race. The `homelab client add --finalize-pubkey` command should
|
||||
pull before committing/pushing.
|
||||
|
||||
### Step 6 — sops couldn't find the age key
|
||||
- `homelab secret hello` failed because sops looks in
|
||||
`/Users/dtoro/.ssh/id_rsa` etc. by default, not `/etc/age/key.txt`
|
||||
- The `homelab` CLI re-execs via `sudo -E env SOPS_AGE_KEY_FILE=... sops ...`,
|
||||
but this requires passwordless sudo and the correct env var passthrough
|
||||
- **Fix:**
|
||||
1. Added NOPASSWD sudo rules
|
||||
2. Eventually `SOPS_AGE_KEY` env with the raw key content worked directly
|
||||
- **Lesson:** Document the explicit `SOPS_AGE_KEY_FILE` incantation in
|
||||
agent-enrollment troubleshooting. New clients can't assume `homelab secret`
|
||||
works out of the gate — the sudo re-exec chain has permission pitfall.
|
||||
|
||||
### Step 7 — OpenRouter key was a placeholder
|
||||
- `secrets/openrouter-api-key.yaml` contained
|
||||
`api_key: PLACEHOLDER_REPLACE_WITH_REAL_OPENROUTER_KEY`
|
||||
- User ran `sops` on hubris, but got the same error (age key not found on
|
||||
hubris either — `/root/.config/sops/age/keys.txt` didn't exist)
|
||||
- **Fix:** `SOPS_AGE_KEY_FILE=/etc/age/key.txt sops ...` on hubris.
|
||||
Later: the user pasted the real key, but the sops file showed
|
||||
`sk-or-...5c55` — the literal content was truncated with ellipsis.
|
||||
|
||||
### Step 8 — editor loaded the wrong data
|
||||
- Neovim on the system is configured with `clipboard+=unnamedplus`, which
|
||||
points `*` and `+` registers to the macOS clipboard manager rather than
|
||||
X11. When editing SOPS files, this caused the **system clipboard** to be
|
||||
pasted instead of the actual ciphertext.
|
||||
- This wasn't diagnosed during the session — the sops file would load empty
|
||||
or show the wrong content because the editor's idea of "paste" was
|
||||
disconnected from what sops expected.
|
||||
- **Fix:** Run `sops` with `EDITOR=nano` or another editor that doesn't
|
||||
hijack OS clipboards:
|
||||
```bash
|
||||
EDITOR=nano SOPS_AGE_KEY_FILE=/etc/age/key.txt sops secrets/openrouter-api-key.yaml
|
||||
```
|
||||
- **Lesson:** Add a strong warning to `hermes-agent.md` / `agent-enrollment.md`:
|
||||
macOS neovim with `clipboard+=unnamedplus` silently breaks sops editing
|
||||
because the paste register reads from the system clipboard instead of the
|
||||
sops-managed buffer. Use `EDITOR=nano` or `EDITOR=vim` when running sops
|
||||
interactively. Alternatively, override the clipboard option with
|
||||
`EDITOR='nvim -c "set clipboard="'`.
|
||||
- Also useful for the troubleshooting table in `agent-enrollment.md` under a
|
||||
new row: "sops file loads empty / wrong content on macOS"
|
||||
|
||||
### Step 9 — model doesn't support tool use
|
||||
- Goose config pinned `nousresearch/hermes-4-405b` via OpenRouter
|
||||
- Error: `No endpoints found that support tool use`
|
||||
- **Fix:** Switched to `deepseek/deepseek-v4-flash` in
|
||||
`~/.config/goose/config.yaml`
|
||||
- Also updated `operations/hermes-agent.md` with the correct model
|
||||
- **Lesson:** The default model in `bootstrap.sh` and `hermes-agent.md` was
|
||||
never validated on OpenRouter for tool-use capability. Need to either:
|
||||
(a) Pin a model known to work (`deepseek/deepseek-v4-flash`)
|
||||
(b) Or have the bootstrap probe OpenRouter at install time
|
||||
|
||||
### Step 10 — MCP homelab extension returns 404
|
||||
- Goose's homelab extension configured as `streamable_http` at
|
||||
`https://mcp.hubris.network/mcp` returns HTTP 404
|
||||
- The actual MCP server runs on SSE (`/sse`), not streamable_http
|
||||
- **Known follow-up** from `hermes-agent.md` — server needs migration
|
||||
- **Workaround:** The agent works fine via the built-in `developer` extension
|
||||
(shell + file) and the `homelab` CLI
|
||||
|
||||
### Step 11 — git credential dance
|
||||
Several layers of issues with git authentication:
|
||||
|
||||
1. **Directory permissions:** `/etc/homelab-context` was `0700 root` — dtoro
|
||||
couldn't access the credential file, and git's `store` helper couldn't
|
||||
create its `.lock` file.
|
||||
- **Fix 1a:** `sudo chmod 755 /etc/homelab-context`
|
||||
- **Fix 1b:** `sudo chown -R dtoro:staff /etc/homelab-context`
|
||||
|
||||
2. **macOS built-in osxkeychain:** Apple's git has `osxkeychain` compiled in
|
||||
as a default credential helper. Even after configuring the `store` helper,
|
||||
git calls osxkeychain after every successful `get`, which prompts for
|
||||
macOS keychain access.
|
||||
- `credential.helper = ""` does NOT disable the compiled-in default
|
||||
- The `store` helper's `.lock` file in `/etc/homelab-context/` also failed
|
||||
silently, causing fallback behaviour
|
||||
- **Fix:** Embed the credential in the remote URL directly:
|
||||
`https://dtoro:TOKEN@git.hubris.network/dtoro/Homelab-Docs.git`
|
||||
- This bypasses all credential helpers entirely
|
||||
|
||||
3. **refresh-creds:** The credential is now managed by the remote URL.
|
||||
If the PAT is rotated, `homelab refresh-creds` won't update the URL.
|
||||
- **Workaround:** Run `git remote set-url origin` after `refresh-creds`
|
||||
|
||||
### Step 12 — `homelab` CLI hostname detection
|
||||
- `homelab whoami` failed with `no hosts/Mac.yaml` because the CLI uses
|
||||
`hostname -s` (returning `Mac`) while the bootstrap uses
|
||||
`scutil --get LocalHostName` (which we fixed to `mac-mini`)
|
||||
- **Fix:** Set both `LocalHostName` and `HostName` via `scutil`
|
||||
- **Lesson:** The homelab CLI on macOS should prefer `scutil --get
|
||||
LocalHostName` like the bootstrap does, or at least try both and pick the
|
||||
one that matches an inventory key. This is a CLI bug.
|
||||
|
||||
## Improvement backlog
|
||||
|
||||
### High priority
|
||||
|
||||
| # | Issue | Fix |
|
||||
|---|-------|-----|
|
||||
| 1 | Homelab CLI uses `hostname -s` on macOS; should use `scutil --get LocalHostName` to match bootstrap | Patch `bin/homelab` to try `scutil` first, fall back to `hostname -s` |
|
||||
| 2 | `homelab client add --finalize-pubkey` on hubris fails to push when remote is ahead (bootstrap pushes first) | `pull --rebase` before committing in `cmd_client_add` |
|
||||
| 3 | Default Goose model (`nousresearch/hermes-4-405b`) doesn't support tool use on OpenRouter | Update `bootstrap.sh` default and `hermes-agent.md` to `deepseek/deepseek-v4-flash` |
|
||||
| 4 | MCP server needs streamable_http migration | Follow-up #1 from `hermes-agent.md` — FastMCP `transport="sse"` → `transport="streamable_http"` |
|
||||
| 5 | `homelab refresh-creds` should also update embedded remote URLs | In `cmd_refresh_creds`, after writing the credential file, update any remote URL that has an embedded credential |
|
||||
|
||||
### Medium priority
|
||||
|
||||
| # | Issue | Fix |
|
||||
|---|-------|-----|
|
||||
| 6 | macOS neovim with `clipboard+=unnamedplus` breaks sops editing | Add troubleshooting row to `agent-enrollment.md`; recommend `EDITOR=nano` |
|
||||
| 7 | `/etc/homelab-context` directory with 0700 root permissions blocks non-root git | Change to 755 in `bootstrap.sh` on all OSes (or at least macOS) |
|
||||
| 8 | Bootstrap `chown` at end gives `illegal group name` on macOS | Fix group detection in bootstrap.sh for macOS |
|
||||
| 9 | `homelab secret` re-exec via sudo chain is fragile — needs passwordless sudo or TTY | Consider `SOPS_AGE_KEY` env-var fallback path in the CLI itself |
|
||||
|
||||
### Low priority
|
||||
|
||||
| # | Issue | Fix |
|
||||
|---|-------|-----|
|
||||
| 10 | Onboarding this machine revealed no `HERMES.md` file in the repo | Create the persona file for the Goose hints symlink |
|
||||
| 11 | Git credential `store` helper lock mechanism is OS-sensitive and fragile | Document embedded-URL pattern as the preferred approach for workstations |
|
||||
| 12 | AGENTS.md references `/opt/homelab-context/hosts/<hostname>.yaml` but the canonical path is now a symlink | Verify the path resolution edge cases |
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-06-01 — initial post-mortem
|
||||
Captured the full onboarding of mac-mini into the hubris homelab context
|
||||
system, including the hostname mismatch, sudo/sops credential chain, model
|
||||
selection, git credential gymnastics, and editor clipboard interference.
|
||||
|
||||
### 2026-06-02 — MCP server migrated to streamable-http
|
||||
Item #4 resolved. FastMCP server now uses `transport="streamable-http"` (hyphen,
|
||||
not underscore), serving at `https://mcp.hubris.network/mcp`. All doc references
|
||||
and client configs (Goose, bootstrap.sh, agent-enrollment.md) updated. Service
|
||||
re-deployed on apps LXC (105).
|
||||
@@ -333,4 +333,4 @@ def ping_service(service: str) -> dict:
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="sse")
|
||||
mcp.run(transport="streamable-http")
|
||||
|
||||
@@ -5,6 +5,10 @@ joins the cross-client context system: a `/opt/homelab-context/` clone of
|
||||
this repo that auto-syncs every 5 min, a per-client age key for SOPS
|
||||
decryption, the `homelab` CLI, and an MCP endpoint in Claude Code's config.
|
||||
|
||||
> Onboarding a Nous-Hermes-powered Goose agent on top of standard enrollment?
|
||||
> See [hermes-agent.md](./hermes-agent.md). It uses the same `bootstrap.sh`
|
||||
> with an additional `--with-hermes` flag.
|
||||
|
||||
Architecture in [project_homelab_context_plan](https://… memory link); the
|
||||
operational reference is here.
|
||||
|
||||
@@ -16,7 +20,7 @@ operational reference is here.
|
||||
| OS is Linux or macOS | bootstrap detects via `uname -s` | `uname -s` |
|
||||
| On the mesh (Netbird or Tailscale) **or** on the LAN | issuance is gated to mesh + LAN subnets. **For Netbird: use a setup-key, not interactive auth** — see "Getting onto Netbird" below. | `netbird status` / `tailscale status` |
|
||||
| `git`, `python3`, `python3-yaml`, `age`, `sops` | bootstrap preflight; `homelab` CLI imports yaml | See per-OS commands below |
|
||||
| Can resolve `*.hubris.network` | bootstrap calls `https://secrets.hubris.network/issue` and writes `https://mcp.hubris.network/sse` | `dig +short mcp.hubris.network` (should return `192.168.8.175`) |
|
||||
| Can resolve `*.hubris.network` | bootstrap calls `https://secrets.hubris.network/issue` and writes `https://mcp.hubris.network/mcp` | `dig +short mcp.hubris.network` (should return `192.168.8.175`) |
|
||||
|
||||
### Hostname mismatch is the most common bootstrap failure
|
||||
|
||||
@@ -79,7 +83,7 @@ If DNS isn't an option at all, override the URLs at bootstrap time:
|
||||
sudo HOMELAB_GITEA_TOKEN=... \
|
||||
HOMELAB_REPO_URL=http://192.168.8.121:3000/dtoro/Homelab-Docs.git \
|
||||
HOMELAB_ISSUANCE_NETBIRD=http://192.168.8.205:9820/issue \
|
||||
HOMELAB_MCP_URL=http://192.168.8.205:9810/sse \
|
||||
HOMELAB_MCP_URL=http://192.168.8.205:9810/mcp \
|
||||
bash /tmp/bootstrap.sh --with-mcp
|
||||
```
|
||||
|
||||
@@ -159,6 +163,77 @@ For Claude Code: start a new session — the `homelab` MCP server appears
|
||||
in `~/.claude/.mcp.json` and registers 14 tools (8 context, 5 management,
|
||||
1 secrets-metadata).
|
||||
|
||||
## Post-bootstrap: SSH reachability
|
||||
|
||||
A new workstation must be reachable from other workstations and must be
|
||||
able to reach every host by short hostname. Run these steps after the
|
||||
bootstrap verify passes:
|
||||
|
||||
### 1. Enable SSH server
|
||||
|
||||
```bash
|
||||
# macOS:
|
||||
sudo launchctl load -w /System/Library/LaunchDaemons/ssh.plist
|
||||
|
||||
# Linux:
|
||||
sudo systemctl enable --now sshd
|
||||
```
|
||||
|
||||
### 2. Generate SSH key (if missing)
|
||||
|
||||
```bash
|
||||
ls ~/.ssh/id_ed25519.pub 2>/dev/null || ssh-keygen -t ed25519 -a 100
|
||||
```
|
||||
|
||||
### 3. Publish pubkey to the repo
|
||||
|
||||
```bash
|
||||
cp ~/.ssh/id_ed25519.pub /opt/homelab-context/ssh/authorized_keys/$(hostname -s).pub
|
||||
cd /opt/homelab-context && git add ssh/authorized_keys/ && git commit -m 'ssh: add $(hostname -s) pubkey' && git push
|
||||
```
|
||||
|
||||
### 4. Deploy keys to all hosts
|
||||
|
||||
From any existing enrolled machine (hubris or another workstation):
|
||||
|
||||
```bash
|
||||
ssh root@192.168.8.77 "cd /opt/homelab-context && git pull --ff-only && bash ssh/deploy-keys.sh"
|
||||
```
|
||||
|
||||
This adds the new workstation's pubkey to hubris and every running LXC.
|
||||
|
||||
### 5. Generate SSH config
|
||||
|
||||
```bash
|
||||
homelab ssh-config --install
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
ssh hubris hostname # should return "hubris" without password
|
||||
ssh gitea hostname # should return "gitea" without password
|
||||
ssh mac-mini hostname # should return "mac-mini" without password (workstation-to-workstation)
|
||||
```
|
||||
|
||||
### 6. Add LAN IP to inventory (if on LAN)
|
||||
|
||||
If the workstation has a static or reserved LAN IP, add it to
|
||||
`inventory.yaml`:
|
||||
|
||||
```yaml
|
||||
hosts:
|
||||
your-hostname:
|
||||
lan_ip: 192.168.8.xxx
|
||||
```
|
||||
|
||||
This gives it a primary LAN entry in the generated SSH config (faster
|
||||
than the Netbird fallback). Commit + push, then:
|
||||
|
||||
```bash
|
||||
cd /opt/homelab-context && git pull --ff-only && homelab ssh-config --install
|
||||
```
|
||||
|
||||
## Claude Code permissions for fleet ops
|
||||
|
||||
By default Claude Code's auto-mode classifier asks for confirmation on every
|
||||
@@ -274,6 +349,15 @@ The CLI prints a follow-up checklist that the operator must do manually:
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-06-02 — SSH reachability post-bootstrap steps
|
||||
Added a new "Post-bootstrap: SSH reachability" section covering SSH key
|
||||
generation, pubkey publication, deployment to hosts, SSH config generation,
|
||||
and LAN IP registration. New workstations enrolled via this doc will
|
||||
automatically join the universal SSH mesh.
|
||||
|
||||
### 2026-05-31 — cross-link to hermes-agent.md
|
||||
Added a sibling page covering Nous-Hermes-on-Goose enrollment ([hermes-agent.md](./hermes-agent.md)) and noted it at the top of this page. The Hermes flow extends `bootstrap.sh` with `--with-hermes` and `homelab client add` with the same flag; it does not change the underlying enrollment steps documented here.
|
||||
|
||||
### 2026-05-21 — netbird-ssh JWT issuer + username + LAN-fallback troubleshooting rows
|
||||
Added three rows to the troubleshooting table covering issues surfaced during the netbird vanilla migration: (1) post-migration SSH JWT validator cache stuck on old Dex issuer (full `systemctl stop/start` required, not `restart`), (2) `user not found` from netbird-ssh's local-username default (use explicit `root@`), and (3) homelab CLI's LAN→netbird-FQDN fallback for off-LAN operators. Companion code change: per-host `ssh.user` field in `inventory.yaml` + `homelab` CLI's `ssh_target()` helper.
|
||||
|
||||
|
||||
210
operations/hermes-agent.md
Normal file
210
operations/hermes-agent.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# Hermes agent — Nous-Hermes-powered Goose sessions on a homelab client
|
||||
|
||||
Onboards [Nous Research's Hermes](https://nousresearch.com/) (a fine-tuned
|
||||
Llama variant) as a working terminal agent on a homelab client. Builds on top
|
||||
of standard client enrollment (see [agent-enrollment.md](./agent-enrollment.md))
|
||||
— this page covers only the Hermes-specific additions.
|
||||
|
||||
The agent runs as a [Goose](https://goose-docs.ai/) session. Goose provides:
|
||||
|
||||
- The chat loop, multi-turn history, and streaming
|
||||
- The OpenRouter provider that routes to Nous Hermes
|
||||
- The built-in `developer` extension (shell + file editor — same surface Claude
|
||||
Code has)
|
||||
- A remote MCP extension pointed at `mcp.hubris.network` for read-only
|
||||
homelab context (`list_lxcs`, `tail_log`, `search_docs`, etc.)
|
||||
|
||||
The persona is `/opt/homelab-context/HERMES.md`, symlinked as Goose's global
|
||||
`.goosehints` so it's injected into the system prompt on every session.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Requirement | How |
|
||||
| --- | --- |
|
||||
| Standard enrollment complete (`homelab whoami` works) | [agent-enrollment.md](./agent-enrollment.md) |
|
||||
| `secrets/openrouter-api-key.yaml` exists with a real `sk-or-...` value | See "Seeding the OpenRouter key" below |
|
||||
| The host's `age_pubkey` is on the openrouter-api-key.yaml sops rule | `homelab client add <host> --finalize-pubkey <age1...> --with-hermes` |
|
||||
|
||||
## Onboarding flow
|
||||
|
||||
```bash
|
||||
# 1. On hubris (or any enrolled client): reserve the inventory entry.
|
||||
homelab client add new-machine
|
||||
|
||||
# 2. Join new-machine to Netbird (setup-key or OIDC).
|
||||
|
||||
# 3. On new-machine: bootstrap with --with-hermes.
|
||||
TOKEN=... # gitea PAT, read:repository
|
||||
curl -fsSL -u "dtoro:$TOKEN" \
|
||||
https://git.hubris.network/dtoro/Homelab-Docs/raw/branch/main/bootstrap.sh \
|
||||
-o /tmp/bootstrap.sh
|
||||
sudo HOMELAB_GITEA_TOKEN=$TOKEN bash /tmp/bootstrap.sh --with-mcp --with-hermes
|
||||
|
||||
# 4. Back on hubris: finalize the age pubkey AND grant the Hermes secret.
|
||||
homelab client add new-machine \
|
||||
--finalize-pubkey age1... \
|
||||
--with-hermes
|
||||
|
||||
# 5. Wait ≤5 min for sync, then on new-machine:
|
||||
hermes "what LXCs are running?"
|
||||
```
|
||||
|
||||
The bootstrap `--with-hermes` flag does five things, all idempotent:
|
||||
|
||||
1. Downloads the latest Goose binary into the operator's `~/.local/bin/goose`
|
||||
(upstream installer) and symlinks `/usr/local/bin/goose` to it.
|
||||
2. Symlinks `/opt/homelab-context/bin/hermes` → `/usr/local/bin/hermes`.
|
||||
3. Symlinks `/opt/homelab-context/HERMES.md` → `/root/HERMES.md` (Linux) or
|
||||
`/etc/HERMES.md` (macOS) for `cat`-as-operator convenience.
|
||||
4. Drops `~/.config/goose/config.yaml` pinning the provider, model, and
|
||||
extensions (preserves any keys the operator added by hand).
|
||||
5. Symlinks `~/.config/goose/.goosehints` → HERMES.md, so the persona is
|
||||
injected as the system prompt on every session.
|
||||
|
||||
## Seeding the OpenRouter key
|
||||
|
||||
The first time anyone enrolls with `--with-hermes`, the encrypted file
|
||||
`secrets/openrouter-api-key.yaml` contains a placeholder. On hubris (or any
|
||||
existing recipient):
|
||||
|
||||
```bash
|
||||
sops secrets/openrouter-api-key.yaml
|
||||
# editor opens; replace api_key value with the real sk-or-... key, save, close.
|
||||
git -C /opt/homelab-context add secrets/openrouter-api-key.yaml
|
||||
git -C /opt/homelab-context commit -m 'openrouter-api-key: seed real key'
|
||||
git -C /opt/homelab-context push
|
||||
```
|
||||
|
||||
Until this step happens, `hermes …` exits with `openrouter-api-key.yaml still
|
||||
contains the placeholder`. Subsequent enrollees get the real key automatically
|
||||
via `--with-hermes` (which adds them as a sops recipient on
|
||||
`secrets/openrouter-api-key.yaml`).
|
||||
|
||||
## Granting the OpenRouter key to an already-enrolled host
|
||||
|
||||
If a host was enrolled without `--with-hermes` and you want to add it later:
|
||||
|
||||
```bash
|
||||
# On hubris:
|
||||
PUBKEY=$(homelab whoami --hostname <host> | grep age_pubkey | awk '{print $2}')
|
||||
homelab client add <host> --finalize-pubkey "$PUBKEY" --with-hermes
|
||||
```
|
||||
|
||||
`--finalize-pubkey` is required by the existing flow even when the pubkey is
|
||||
unchanged — it's also the trigger that runs the sops grant.
|
||||
|
||||
After ≤5 min sync the host can decrypt the key. Bootstrap doesn't need to
|
||||
re-run; only the secret recipient list changed.
|
||||
|
||||
## Verifying
|
||||
|
||||
```bash
|
||||
homelab whoami # standard enrollment OK
|
||||
homelab secret openrouter-api-key | head -c 8 # decrypts (prints `api_key:`)
|
||||
which goose && which hermes # binaries present
|
||||
goose info -v # provider/model wiring sane
|
||||
hermes "what LXCs are running?" # interactive Goose session
|
||||
|
||||
# Non-interactive smoke test:
|
||||
echo "List the homelab MCP tools you have available" | hermes
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The bootstrap-managed keys in `~/.config/goose/config.yaml`:
|
||||
|
||||
```yaml
|
||||
GOOSE_PROVIDER: openrouter
|
||||
GOOSE_MODEL: deepseek/deepseek-v4-flash
|
||||
GOOSE_MODE: smart_approve # asks before destructive tool calls
|
||||
extensions:
|
||||
developer:
|
||||
type: builtin
|
||||
bundled: true
|
||||
enabled: true
|
||||
name: developer
|
||||
timeout: 300
|
||||
homelab:
|
||||
type: streamable_http
|
||||
enabled: true
|
||||
name: homelab
|
||||
uri: https://mcp.hubris.network/mcp
|
||||
timeout: 60
|
||||
```
|
||||
|
||||
Override via env on a single bootstrap run:
|
||||
|
||||
```bash
|
||||
HOMELAB_HERMES_MODEL=nousresearch/hermes-3-llama-3.1-405b \
|
||||
HOMELAB_HERMES_MCP_URI=https://mcp.hubris.network/mcp \
|
||||
sudo bash /tmp/bootstrap.sh --with-hermes
|
||||
```
|
||||
|
||||
Any keys you add by hand (e.g. `GOOSE_TEMPERATURE`, extra `extensions.*`) are
|
||||
preserved across re-bootstraps — the merge only overwrites the keys it manages.
|
||||
|
||||
## Tool permissions
|
||||
|
||||
`GOOSE_MODE: smart_approve` is the bootstrap default: Goose runs read-only
|
||||
shell commands without prompting and asks for confirmation before destructive
|
||||
ones. To make the agent fully unattended (e.g. for scheduled jobs), set
|
||||
`GOOSE_MODE: auto` in `~/.config/goose/config.yaml`. To require confirmation on
|
||||
every tool call, use `approve`. See
|
||||
[goose-permissions](https://goose-docs.ai/docs/guides/managing-tools/goose-permissions/).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
| --- | --- | --- |
|
||||
| `hermes: could not decrypt secrets/openrouter-api-key.yaml` | Host isn't a recipient on the sops rule | `homelab client add <host> --finalize-pubkey <age1...> --with-hermes` from hubris |
|
||||
| `hermes: openrouter-api-key.yaml still contains the placeholder` | No real key has been seeded yet | See "Seeding the OpenRouter key" above |
|
||||
| Goose hangs on first `hermes` invocation with no output | Goose's interactive `configure` ran on first launch and is awaiting input | Re-run; the installer is supposed to skip it (CONFIGURE=false). If it persists, run `goose configure` once manually in a real terminal to commit the config. |
|
||||
| `homelab` extension fails to connect / no MCP tools listed | MCP server still runs SSE-only; Goose requires `streamable_http`. See follow-up #1 below. | Either: (a) migrate the FastMCP server to streamable_http (one-line change in `mcp/server.py` — `mcp.run(transport="streamable_http")` — then redeploy), or (b) accept that the agent works via the developer extension alone (shell + `homelab` CLI cover everything MCP would). |
|
||||
| `goose: command not found` after bootstrap | Upstream installer dropped binary in `~/.local/bin/` but `/usr/local/bin/goose` symlink didn't land | Re-run bootstrap with `--with-hermes`; the symlink step is at the end of the install block. If still missing, `ln -sfn ~/.local/bin/goose /usr/local/bin/goose` manually. |
|
||||
| Tool calls hit OpenRouter rate limits | One shared key across many hosts | Future: per-host keys; for now, see the rate-limits guide referenced in `goose info -v`. |
|
||||
|
||||
## Cross-references
|
||||
|
||||
- [agent-enrollment.md](./agent-enrollment.md) — base client onboarding the
|
||||
Hermes flow assumes is done.
|
||||
- [`HERMES.md`](../HERMES.md) — the persona the Hermes agent reads on every
|
||||
session start (via `~/.config/goose/.goosehints`).
|
||||
- [`bin/hermes`](../bin/hermes) — the wrapper that decrypts the OpenRouter key
|
||||
and execs `goose session`.
|
||||
- [`bootstrap.sh`](../bootstrap.sh) — the `--with-hermes` flag's install block.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
1. **Migrate the MCP server to streamable_http.** Goose 1.x deprecated SSE
|
||||
(`"SSE transport is no longer supported - kept only for config file
|
||||
compatibility"` in `crates/goose/src/agents/extension.rs`). Our FastMCP
|
||||
server at `mcp/server.py:336` still calls `mcp.run(transport="sse")`. Until
|
||||
that's changed, the `homelab` MCP extension in Goose will fail to connect.
|
||||
The developer extension (shell + edit) covers most ops without it; this is
|
||||
a polish item, not a blocker.
|
||||
2. **Per-host OpenRouter keys** for billing attribution. Today all Hermes
|
||||
hosts share one key.
|
||||
3. **Pin the model version** rather than tracking `nousresearch/hermes-4-405b`
|
||||
directly — OpenRouter periodically rotates the underlying weights.
|
||||
4. **Local-inference fallback** (ollama / vllm) once the homelab has a GPU
|
||||
node. The wrapper, persona, and MCP wiring stay unchanged; only
|
||||
`GOOSE_PROVIDER`/`GOOSE_MODEL` change.
|
||||
|
||||
7. **Caveman auto-setup via post-pull hook.** The sync timer now calls
|
||||
`tools/post-pull.sh`, which runs any `tools/*.setup.sh` after git pull.
|
||||
Currently this auto-installs the Caveman npm package, wrapper scripts, and
|
||||
compact output templates on all agent hosts (*token efficiency*).
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-06-01 — caveman + post-pull auto-setup
|
||||
Added `tools/post-pull.sh` sync hook that auto-runs `tools/*.setup.sh`
|
||||
after every git pull. First user: `tools/setup-caveman.sh` installed Caveman
|
||||
templating + `~/bin/caveman_wrapper.sh` + `~/templates/*.txt` for token-
|
||||
efficient CLI output. Replaces raw `git pull` in launchd/systemd timers.
|
||||
Also created `tools/caveman/` with the wrapper script, JS renderer, and
|
||||
templates — the canonical source for all agent hosts.
|
||||
Captures the Hermes-on-Goose onboarding flow added in the same commit as
|
||||
`bootstrap.sh --with-hermes`, `bin/hermes`, the sops rule for
|
||||
`secrets/openrouter-api-key.yaml`, and the `homelab client add --with-hermes`
|
||||
extension. MCP streamable_http migration is queued as follow-up #1.
|
||||
131
plans/2026-06-01-slate-ax-to-sodola-migration.md
Normal file
131
plans/2026-06-01-slate-ax-to-sodola-migration.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# 2026-06-01 — Slate AX → SODOLA Migration
|
||||
|
||||
**Status:** Done — 2026-06-02
|
||||
**Hardware:** SODOLA 5-Port 2.5Gbit Managed Switch replacing GL.iNet Slate AX
|
||||
**Router:** Fritz!Box 7590
|
||||
|
||||
## Goal
|
||||
|
||||
Remove the Slate AX sub-router. It adds double-NAT, double port-forward complexity, and a second DHCP/gateway hop. Replace with the SODOLA switch so Proxmox and homelab devices attach directly to the Fritz!Box—without changing any of their IPs.
|
||||
|
||||
## Current topology
|
||||
|
||||
```
|
||||
ISP
|
||||
└── Fritz!Box 7590 (192.168.178.1)
|
||||
└── GL.iNet Slate AX WAN: 192.168.178.x LAN: 192.168.8.1 [NAT]
|
||||
├── hubris (Proxmox) 192.168.8.77
|
||||
├── + all 16 LXCs on vmbr0 bridged to the same subnet
|
||||
└── other homelab devices
|
||||
```
|
||||
|
||||
## Target topology
|
||||
|
||||
```
|
||||
ISP
|
||||
└── Fritz!Box 7590
|
||||
├── VLAN 1 192.168.178.0/24 (main LAN — laptops, phones)
|
||||
└── VLAN 10 192.168.8.0/24 gateway 192.168.8.1 (homelab, no NAT)
|
||||
│
|
||||
└── SODOLA 5-Port 2.5Gbit
|
||||
├── Port 1 uplink → Fritz!Box LAN port
|
||||
├── Port 2 hubris (Proxmox) 192.168.8.77
|
||||
├── Port 3 [homelab device]
|
||||
├── Port 4 [homelab device]
|
||||
└── Port 5 spare
|
||||
```
|
||||
|
||||
Fritz!Box takes over `192.168.8.1` — the same gateway IP the Slate AX used. No static IPs or gateway entries change on any LXC or VM.
|
||||
|
||||
See [network architecture](../infrastructure/network.md) for the permanent topology reference.
|
||||
|
||||
## Pre-flight checklist
|
||||
|
||||
- [ ] Note Slate AX gateway IP on the homelab subnet (expected `192.168.8.1`)
|
||||
- [ ] Note Slate AX upstream DNS setting (what it forwards to)
|
||||
- [ ] List all port forwards currently on the Slate AX → move these to Fritz!Box
|
||||
- [ ] List all port forwards on Fritz!Box that point at the Slate AX WAN IP → collapse these into direct homelab-IP rules
|
||||
- [ ] Confirm Fritz!OS version supports "second IP network" (Fritz!OS 7.x required)
|
||||
- [ ] SSH into hubris: verify `grep gateway /etc/network/interfaces` → `192.168.8.1`
|
||||
- [ ] SODOLA web UI accessible and ports configurable before physical move
|
||||
|
||||
## Fritz!Box configuration
|
||||
|
||||
**Path:** Home Network → Network → Network Settings → IPv4 Addresses → Add IP network
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| IP address | `192.168.8.1` |
|
||||
| Subnet mask | `255.255.255.0` |
|
||||
| DHCP range | `192.168.8.100 – 192.168.8.240` |
|
||||
| Assign to | LAN port that connects to SODOLA |
|
||||
| Network isolation | Enabled (blocks main LAN from initiating into homelab) |
|
||||
| DNS for DHCP clients | `192.168.8.2` (Technitium on [CT 107](../containers/107-dns.md)) |
|
||||
|
||||
After creating the network, move any port forwards from the Slate AX into Fritz!Box → Internet → Permits (target IPs are now directly reachable on `192.168.8.x`).
|
||||
|
||||
## SODOLA configuration
|
||||
|
||||
All ports in VLAN 10, untagged (simple access-port setup):
|
||||
|
||||
| Port | PVID | Role |
|
||||
|---|---|---|
|
||||
| 1 | 10 | Uplink → Fritz!Box |
|
||||
| 2 | 10 | hubris (Proxmox) |
|
||||
| 3 | 10 | homelab device |
|
||||
| 4 | 10 | homelab device |
|
||||
| 5 | 10 | spare |
|
||||
|
||||
## Proxmox / LXC changes
|
||||
|
||||
If the Slate AX gateway was `192.168.8.1`: **no changes needed**. Fritz!Box takes the same IP.
|
||||
|
||||
If Slate AX used a different gateway (e.g. `.254`), update:
|
||||
```bash
|
||||
# on hubris
|
||||
nano /etc/network/interfaces # change gateway line
|
||||
ifreload -a
|
||||
# for each LXC with a static gateway
|
||||
pct config <id> # check net0: line for gw=
|
||||
pct set <id> --net0 name=eth0,bridge=vmbr0,ip=<ip>/24,gw=192.168.8.1
|
||||
```
|
||||
|
||||
## DNS after migration
|
||||
|
||||
Technitium ([CT 107](../containers/107-dns.md)) at `192.168.8.2` continues to serve split-horizon DNS for `hubris.network`. The Fritz!Box DHCP server for VLAN 10 hands out `192.168.8.2` as the DNS server. This fixes the "update router DHCP DNS from dead .180 → .2" outstanding item in [dns.md](../infrastructure/dns.md).
|
||||
|
||||
## Cutover procedure
|
||||
|
||||
1. Configure Fritz!Box second IP network (VLAN 10 / `192.168.8.1`) — verify DHCP works via a test laptop on the target port.
|
||||
2. Configure SODOLA port VLAN assignments.
|
||||
3. Connect SODOLA port 1 → Fritz!Box target LAN port.
|
||||
4. Move hubris ethernet → SODOLA port 2. Proxmox should stay reachable immediately (same gateway IP).
|
||||
5. Move remaining homelab devices to SODOLA ports 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/`.
|
||||
16
plans/index.md
Normal file
16
plans/index.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Plans
|
||||
|
||||
Pre-flight runbooks for planned changes that haven't happened yet. Once executed, move the outcome to `investigations/` (if anything interesting happened) or just a changelog entry on the affected node pages.
|
||||
|
||||
## Index
|
||||
|
||||
| Date | Title | Status |
|
||||
| ---- | ----- | ------ |
|
||||
| 2026-06-01 | [Slate AX → SODOLA managed switch migration](2026-06-01-slate-ax-to-sodola-migration.md) | Done |
|
||||
|
||||
## Conventions
|
||||
|
||||
- File name: `YYYY-MM-DD-<slug>.md`. Use the *target* date if known, otherwise the planning date.
|
||||
- Status: `Planned` → `In Progress` → `Done` (update index entry as it moves).
|
||||
- When done: add a changelog entry on every affected node page, then update status to `Done` here. If things went sideways, open an investigation.
|
||||
- Plans are append-only once execution starts — don't rewrite pre-flight intent after the fact.
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
34
secrets/openrouter-api-key.yaml
Normal file
34
secrets/openrouter-api-key.yaml
Normal file
@@ -0,0 +1,34 @@
|
||||
api_key: ENC[AES256_GCM,data:TbPkuLCidS7cg52DYb0MOCui3TsaiwkOWyYLhp6gcvXI8TqvXej1tNsCF0TNAIr9vvnFaYno/UTEPiOKe/EQqR8uQMyWuvsmMg==,iv:bahy9ae4Qxvkv1OAjx6LwzK5ggqDh2GVHDj95PVP1Mc=,tag:J7PajLaeqFOdz3FESYZmSQ==,type:str]
|
||||
sops:
|
||||
age:
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB3djYvT3JJUWh3cE1ZbFc0
|
||||
SiswOGlKNEdFNlJhTVlVSkhqZGlJdGl4U2pvCnV0aEhud0VaOHVGbkVVVkJ3b2Js
|
||||
K1liaEFscGJxMXVVNjNJNW9NeGR6Um8KLS0tIEt2TlFCV09CWDJKbmFySm1EUXc3
|
||||
Zm9IT21QN0lkSlh0UVdhYkVrTDdiUWcK8IO+gylIYKDlSADOtj2gSpM7Af/JG/7Q
|
||||
6y1dT2d82cEu7KuXXuog6gP3sADe/6SDHGgC6Ot6EWM+5dwMN6WwTA==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBKY2xVSUVBUTd0dVhxekhY
|
||||
d2t1U00xNnRjTC9PT3puVGo1U2ViekhmQng4CnJlZWYxK0tyQ2U0Y3lXRUZSTEti
|
||||
cU9QdWhjalJhb3dJSk4vK1hWZGFGNEkKLS0tIG51aFRGcW9xdmdmSkt0TTZXSll4
|
||||
dkJMYjdPbUgyL1Rmc0tQbHgyR3RLZTAKllFoX3m/zntAtfGkSGFmRzXuk4pHALkR
|
||||
XeQaAl33n5dMiZHtynoDNN3eBXtDDWiKZAhZeWI5SMoLpWBEXapNag==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBsdUpxd0RYRld3L1BYdllp
|
||||
R3RwVStLUysyR1lXQ0RlcW4vWGRINzB3WUd3CktTSXJzUWsxcEdVajVjS3N1d0NE
|
||||
WHU4QkVvU3dyYWZ0VVpmYnB2blVWaGcKLS0tIG5sa1I2YW1tS0hLeGc1SWtjV0NM
|
||||
TkFoL0ZZbmhXdElFNytLclByK0s1LzgKVrBvlaryZXYn43v1ukjGZnhSPuwfslf7
|
||||
Ruy7z7EzVaCXRn+F9gABD4mhIQaUIRswiut7aF9lrPOR72/y8s/1JA==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1z62ff2ak9zj5ctcvaxwyyhedwjvlwgm2dkn9nk3wrwk8fkavcpmsqwc2vs
|
||||
lastmodified: "2026-06-01T21:23:34Z"
|
||||
mac: ENC[AES256_GCM,data:DXvUZUOMKQA0aDpN1foKy+aHhBh6daGSRJM1FagTh76qutk4XF9Een9iyy9sQ7olabCjf3oUezvU7XWB83bSDD3e23CFuweOl0RreRFqIsbXBi55Dbcxw8+9fvMyOK4PXu6mkT/PRao/XR5sSMqiHt3FoJbjazSaJlI5teeVXbM=,iv:IICO5ay+KqkTikfxW4kqB2qcSRpbsOP1NjGWtCB9uqI=,tag:MX2TqUxre+ff3RszCfXeFg==,type:str]
|
||||
unencrypted_suffix: _unencrypted
|
||||
version: 3.9.4
|
||||
@@ -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]
|
||||
|
||||
1
ssh/authorized_keys/mac-mini.pub
Normal file
1
ssh/authorized_keys/mac-mini.pub
Normal file
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMtgjgpljZcu98NOW1ibry8ZBQSoUg0IaxK8iW10w2+ d.toro.v@pm.me
|
||||
91
ssh/deploy-keys.sh
Normal file
91
ssh/deploy-keys.sh
Normal file
@@ -0,0 +1,91 @@
|
||||
#!/bin/bash
|
||||
# deploy-keys.sh — deploy workstation SSH pubkeys to hubris + all LXCs
|
||||
#
|
||||
# Run from hubris (PVE host) as root.
|
||||
# Reads keys from /opt/homelab-context/ssh/authorized_keys/*.pub
|
||||
# and appends them to /root/.ssh/authorized_keys in each running LXC,
|
||||
# and to /etc/pve/priv/authorized_keys on hubris.
|
||||
#
|
||||
# Idempotent — skips keys already present.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
KEYS_DIR="/opt/homelab-context/ssh/authorized_keys"
|
||||
|
||||
if ! [ -d "$KEYS_DIR" ]; then
|
||||
echo "ERROR: $KEYS_DIR not found. Is homelab context synced?"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Collect all pubkeys into a single variable, one per line
|
||||
ALL_KEYS=""
|
||||
for f in "$KEYS_DIR"/*.pub; do
|
||||
[ -f "$f" ] || continue
|
||||
key=$(cat "$f" | head -1)
|
||||
ALL_KEYS="${ALL_KEYS}${key}
|
||||
"
|
||||
done
|
||||
|
||||
if [ -z "$ALL_KEYS" ]; then
|
||||
echo "ERROR: no .pub files found in $KEYS_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "= Deploying SSH keys to hubris ="
|
||||
AUTH_FILE="/etc/pve/priv/authorized_keys"
|
||||
touch "$AUTH_FILE"
|
||||
added=0
|
||||
while IFS= read -r key; do
|
||||
[ -z "$key" ] && continue
|
||||
if ! grep -qF "$key" "$AUTH_FILE" 2>/dev/null; then
|
||||
echo " + Adding key to hubris: ${key:0:40}..."
|
||||
echo "$key" >> "$AUTH_FILE"
|
||||
added=$((added + 1))
|
||||
fi
|
||||
done <<< "$ALL_KEYS"
|
||||
echo " hubris: $added key(s) added"
|
||||
|
||||
# Deploy to LXCs
|
||||
echo ""
|
||||
echo "= Deploying SSH keys to LXCs ="
|
||||
|
||||
# pct list output: VMID Status Lock Name (Lock may be empty)
|
||||
# Use awk to grab VMID, Status, and Name (last field)
|
||||
pct list | tail -n +2 | while read -r line; do
|
||||
vmid=$(echo "$line" | awk '{print $1}')
|
||||
status=$(echo "$line" | awk '{print $2}')
|
||||
name=$(echo "$line" | awk '{print $NF}')
|
||||
# Skip header if any
|
||||
[ "$vmid" = "VMID" ] && continue
|
||||
[ -z "$vmid" ] && continue
|
||||
if [ "$status" != "running" ]; then
|
||||
echo " SKIP $name ($vmid): status=$status"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo " -> $name ($vmid)"
|
||||
|
||||
# Ensure .ssh directory exists
|
||||
pct exec "$vmid" -- mkdir -p /root/.ssh 2>/dev/null
|
||||
|
||||
# For each key, check if already present, append if not
|
||||
while IFS= read -r key; do
|
||||
[ -z "$key" ] && continue
|
||||
if ! pct exec "$vmid" -- grep -qF "$key" /root/.ssh/authorized_keys 2>/dev/null; then
|
||||
echo " + ${key:0:40}..."
|
||||
# Use tee to append (pct exec preserves stdin)
|
||||
echo "$key" | pct exec "$vmid" -- tee -a /root/.ssh/authorized_keys >/dev/null 2>&1
|
||||
fi
|
||||
done <<< "$ALL_KEYS"
|
||||
|
||||
# Fix permissions
|
||||
pct exec "$vmid" -- chmod 600 /root/.ssh/authorized_keys 2>/dev/null || true
|
||||
pct exec "$vmid" -- chmod 700 /root/.ssh 2>/dev/null || true
|
||||
|
||||
echo " <- $name done"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Deploy complete ==="
|
||||
echo "Keys deployed. Test from any workstation with:"
|
||||
echo " ssh root@<lxc-ip>"
|
||||
208
ssh/gen-config.py
Normal file
208
ssh/gen-config.py
Normal file
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate ~/.ssh/config.d/homelab from inventory.yaml.
|
||||
|
||||
Usage:
|
||||
python3 ssh/gen-config.py # print to stdout
|
||||
python3 ssh/gen-config.py --install # write to ~/.ssh/config.d/homelab
|
||||
|
||||
The generated config provides short hostname aliases for every host in
|
||||
the homelab inventory. LAN IPs are preferred (they work directly on-LAN
|
||||
and are routed via Netbird 192.168.8.0/24 off-LAN); mesh FQDNs are
|
||||
available as <name>-mesh fallbacks for roaming workstations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
print("PyYAML required (pip install pyyaml)", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
INVENTORY_PATH = Path(os.environ.get(
|
||||
"HOMELAB_CONTEXT_DIR", "/opt/homelab-context"
|
||||
)) / "inventory.yaml"
|
||||
|
||||
SSH_CONFIG_DIR = Path.home() / ".ssh" / "config.d"
|
||||
SSH_CONFIG_FILE = SSH_CONFIG_DIR / "homelab"
|
||||
SSH_MAIN_CONFIG = Path.home() / ".ssh" / "config"
|
||||
INCLUDE_LINE = f"Include ~/.ssh/config.d/homelab"
|
||||
|
||||
# Hosts to skip in the generated config
|
||||
SKIP_HOSTS = {"ludo-mini", "authentik"} # offline / not enrolled
|
||||
|
||||
|
||||
def inventory() -> dict:
|
||||
if not INVENTORY_PATH.exists():
|
||||
print(f"ERROR: no inventory at {INVENTORY_PATH}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return yaml.safe_load(INVENTORY_PATH.read_text())
|
||||
|
||||
|
||||
def gen_config() -> str:
|
||||
inv = inventory()
|
||||
hosts = inv.get("hosts", {})
|
||||
mesh_globals = inv.get("mesh", {})
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append("# Homelab SSH config — generated from inventory.yaml")
|
||||
lines.append(f"# Source: {INVENTORY_PATH}")
|
||||
lines.append("# Do not edit by hand. Run: homelab ssh-config")
|
||||
lines.append("")
|
||||
|
||||
# --- Common defaults (before any Host block) ---
|
||||
lines.append("# --- Defaults ---")
|
||||
lines.append("Host *")
|
||||
lines.append(" IdentityFile ~/.ssh/id_ed25519")
|
||||
lines.append(" IdentitiesOnly yes")
|
||||
lines.append(" ServerAliveInterval 30")
|
||||
lines.append(" StrictHostKeyChecking accept-new")
|
||||
lines.append("")
|
||||
|
||||
def add_entry(
|
||||
tag: str,
|
||||
hostname: str,
|
||||
user: str = "root",
|
||||
port: int = 22,
|
||||
extra_lines: list[str] | None = None,
|
||||
) -> None:
|
||||
lines.append(f"Host {tag}")
|
||||
lines.append(f" HostName {hostname}")
|
||||
lines.append(f" User {user}")
|
||||
if port != 22:
|
||||
lines.append(f" Port {port}")
|
||||
if extra_lines:
|
||||
lines.extend(f" {el}" for el in extra_lines)
|
||||
lines.append("")
|
||||
|
||||
# Sort hosts by kind for a logical output order
|
||||
def sort_key(item):
|
||||
name, h = item
|
||||
kind = h.get("kind", "")
|
||||
order = {
|
||||
"workstation": 0,
|
||||
"proxmox-host": 1,
|
||||
"lxc": 2,
|
||||
"vm": 3,
|
||||
"external": 4,
|
||||
}
|
||||
return (order.get(kind, 9), name)
|
||||
|
||||
sorted_hosts = sorted(
|
||||
[(n, h) for n, h in hosts.items() if n not in SKIP_HOSTS],
|
||||
key=sort_key,
|
||||
)
|
||||
|
||||
for name, h in sorted_hosts:
|
||||
kind = h.get("kind", "")
|
||||
ssh_config = h.get("ssh", {}) or {}
|
||||
user = ssh_config.get("user", "root")
|
||||
port = ssh_config.get("port", 22)
|
||||
lan_ip = h.get("lan_ip")
|
||||
mesh = h.get("mesh", {}) or {}
|
||||
nb = mesh.get("netbird", {}) or {}
|
||||
ts = mesh.get("tailscale", {}) or {}
|
||||
nb_fqdn = nb.get("fqdn") or nb.get("ip") or ""
|
||||
nb_port = ssh_config.get("netbird_port")
|
||||
|
||||
# Determine primary address
|
||||
if nb_port and not lan_ip:
|
||||
# Hosts with netbird SSH but no LAN IP: mesh-only
|
||||
primary = nb_fqdn
|
||||
elif lan_ip:
|
||||
primary = lan_ip
|
||||
elif nb_fqdn:
|
||||
primary = nb_fqdn
|
||||
elif ts.get("fqdn"):
|
||||
primary = ts["fqdn"]
|
||||
else:
|
||||
continue # no address found
|
||||
|
||||
extra = []
|
||||
if kind == "proxmox-host":
|
||||
extra = [
|
||||
"ControlMaster auto",
|
||||
"ControlPath ~/.ssh/cm/%C",
|
||||
"ControlPersist 2h",
|
||||
]
|
||||
|
||||
add_entry(name, primary, user, port, extra_lines=extra)
|
||||
|
||||
# Mesh fallback for workstations and hubris
|
||||
if nb_fqdn and (kind == "workstation" or nb_port):
|
||||
mesh_tag = f"{name}-mesh"
|
||||
mesh_port = nb_port or port
|
||||
mesh_extra = []
|
||||
if kind == "proxmox-host":
|
||||
mesh_extra = [
|
||||
"ControlMaster auto",
|
||||
"ControlPath ~/.ssh/cm/%C",
|
||||
"ControlPersist 2h",
|
||||
]
|
||||
add_entry(mesh_tag, nb_fqdn, user, mesh_port, extra_lines=mesh_extra)
|
||||
|
||||
# --- *.hubris.network ControlMaster ---
|
||||
lines.append("# --- Mesh ControlMaster (speeds up repeated mesh ops) ---")
|
||||
lines.append("Host *.netbird.selfhosted")
|
||||
lines.append(" ControlMaster auto")
|
||||
lines.append(" ControlPath ~/.ssh/cm/%C")
|
||||
lines.append(" ControlPersist 2h")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def ensure_include() -> bool:
|
||||
"""Add 'Include ~/.ssh/config.d/homelab' to the main SSH config if missing."""
|
||||
if not SSH_MAIN_CONFIG.exists():
|
||||
SSH_MAIN_CONFIG.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(SSH_MAIN_CONFIG, "w") as f:
|
||||
f.write(f"{INCLUDE_LINE}\n")
|
||||
return True
|
||||
|
||||
content = SSH_MAIN_CONFIG.read_text()
|
||||
for line in content.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("Include") and "homelab" in stripped:
|
||||
return False # already present
|
||||
|
||||
# Prepend to existing config
|
||||
updated = f"{INCLUDE_LINE}\n\n{content}"
|
||||
SSH_MAIN_CONFIG.write_text(updated)
|
||||
return True
|
||||
|
||||
|
||||
def install() -> None:
|
||||
"""Write config to ~/.ssh/config.d/homelab and ensure Include is set."""
|
||||
SSH_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
config = gen_config()
|
||||
SSH_CONFIG_FILE.write_text(config)
|
||||
SSH_CONFIG_FILE.chmod(0o644)
|
||||
changed = ensure_include()
|
||||
print(f"Wrote {SSH_CONFIG_FILE} ({len(config.splitlines())} lines)")
|
||||
if changed:
|
||||
print(f"Added '{INCLUDE_LINE}' to {SSH_MAIN_CONFIG}")
|
||||
print("Done. Run: ssh <hostname> (e.g. ssh gitea)")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument("--install", "-i", action="store_true",
|
||||
help=f"write to {SSH_CONFIG_FILE} and wire Include into main config")
|
||||
args = p.parse_args()
|
||||
|
||||
if args.install:
|
||||
install()
|
||||
else:
|
||||
print(gen_config())
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
23
tools/caveman/caveman.js
Normal file
23
tools/caveman/caveman.js
Normal file
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env node
|
||||
// Caveman template renderer — reads template + data JSON files and renders output
|
||||
// Installed automatically via homelab-context post-pull hook
|
||||
const caveman = require("caveman");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length === 0) {
|
||||
console.error("Usage: caveman <template> [data.json]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const templatePath = args[0];
|
||||
let data = {};
|
||||
if (args.length > 1) {
|
||||
data = JSON.parse(fs.readFileSync(args[1], "utf8"));
|
||||
}
|
||||
|
||||
const template = fs.readFileSync(templatePath, "utf8");
|
||||
const templateName = path.basename(templatePath, path.extname(templatePath));
|
||||
caveman.register(templateName, template);
|
||||
console.log(caveman.render(templateName, data).trim());
|
||||
76
tools/caveman/caveman_wrapper.sh
Normal file
76
tools/caveman/caveman_wrapper.sh
Normal file
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
# Caveman + RTK Wrapper - Automated token-efficient output formatting
|
||||
# Installed automatically via homelab-context post-pull hook
|
||||
# Source: https://github.com/adityahimaone/hermes-agent-rtk-caveman
|
||||
# Usage: caveman_wrapper.sh <workflow> [options]
|
||||
|
||||
set -e
|
||||
|
||||
WORKFLOW="$1"
|
||||
shift
|
||||
|
||||
CAVEMAN=~/bin/caveman
|
||||
TEMPLATES_DIR=~/templates
|
||||
DATA_DIR=/tmp/caveman_data
|
||||
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
case "$WORKFLOW" in
|
||||
git-status)
|
||||
git status --porcelain | awk '
|
||||
BEGIN { staged=0; modified=0; untracked=0; deleted=0 }
|
||||
/^[MARC]./ { staged_arr[staged++] = substr($0, 4) }
|
||||
/^.[MARC]/ { modified_arr[modified++] = substr($0, 4) }
|
||||
/^\?\?/ { untracked_arr[untracked++] = substr($0, 4) }
|
||||
/^D/ || /^.D/ { deleted_arr[deleted++] = substr($0, 4) }
|
||||
END {
|
||||
printf "{"
|
||||
printf "\"staged\":["
|
||||
for(i=0;i<staged;i++) printf "%s\"%s\"", (i>0?",":""), staged_arr[i]
|
||||
printf "],\"modified\":["
|
||||
for(i=0;i<modified;i++) printf "%s\"%s\"", (i>0?",":""), modified_arr[i]
|
||||
printf "],\"untracked\":["
|
||||
for(i=0;i<untracked;i++) printf "%s\"%s\"", (i>0?",":""), untracked_arr[i]
|
||||
printf "],\"deleted\":["
|
||||
for(i=0;i<deleted;i++) printf "%s\"%s\"", (i>0?",":""), deleted_arr[i]
|
||||
printf "]}"
|
||||
}' > "$DATA_DIR/git_status.json"
|
||||
|
||||
if command -v rtk &>/dev/null; then
|
||||
rtk "$CAVEMAN" "$TEMPLATES_DIR/git_status.txt" "$DATA_DIR/git_status.json"
|
||||
else
|
||||
node "$CAVEMAN" "$TEMPLATES_DIR/git_status.txt" "$DATA_DIR/git_status.json"
|
||||
fi
|
||||
;;
|
||||
|
||||
git-log)
|
||||
LIMIT="${1:-10}"
|
||||
git log --oneline -"$LIMIT" --format='{"hash":"%h","author":"%an","date":"%ad","message":"%s"}' --date=short | \
|
||||
jq -s '.' > "$DATA_DIR/git_log.json"
|
||||
jq '{commits: .}' "$DATA_DIR/git_log.json" > "$DATA_DIR/git_log_final.json"
|
||||
if command -v rtk &>/dev/null; then
|
||||
rtk "$CAVEMAN" "$TEMPLATES_DIR/git_log.txt" "$DATA_DIR/git_log_final.json"
|
||||
else
|
||||
node "$CAVEMAN" "$TEMPLATES_DIR/git_log.txt" "$DATA_DIR/git_log_final.json"
|
||||
fi
|
||||
;;
|
||||
|
||||
test-results)
|
||||
TEST_CMD="${1:-npx vitest run}"
|
||||
$TEST_CMD --reporter json 2>/dev/null | \
|
||||
jq '{total: .numTotalTests, passed: .numPassedTests, failed: .numFailedTests, suites: [.testResults[] | {name: .name, status: .status, duration: .duration}]}' > "$DATA_DIR/test_results.json" || true
|
||||
if command -v rtk &>/dev/null; then
|
||||
rtk "$CAVEMAN" "$TEMPLATES_DIR/test_results.txt" "$DATA_DIR/test_results.json"
|
||||
else
|
||||
node "$CAVEMAN" "$TEMPLATES_DIR/test_results.txt" "$DATA_DIR/test_results.json"
|
||||
fi
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Usage: caveman_wrapper.sh <workflow> [options]"
|
||||
echo " git-status - Compact git status"
|
||||
echo " git-log [limit] - Recent git commits"
|
||||
echo " test-results [cmd] - Compact test results"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
3
tools/caveman/templates/git_log.txt
Normal file
3
tools/caveman/templates/git_log.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
Recent Commits:
|
||||
{{- for d.commits as commit }} {{commit.hash}} {{commit.date}} {{commit.message}}
|
||||
{{- end }}
|
||||
12
tools/caveman/templates/git_status.txt
Normal file
12
tools/caveman/templates/git_status.txt
Normal file
@@ -0,0 +1,12 @@
|
||||
{{- if d.staged }}Staged:
|
||||
{{- for d.staged as file }} + {{file}}
|
||||
{{- end }}{{- end }}
|
||||
{{- if d.modified }}Modified:
|
||||
{{- for d.modified as file }} ~ {{file}}
|
||||
{{- end }}{{- end }}
|
||||
{{- if d.untracked }}Untracked:
|
||||
{{- for d.untracked as file }} ? {{file}}
|
||||
{{- end }}{{- end }}
|
||||
{{- if d.deleted }}Deleted:
|
||||
{{- for d.deleted as file }} - {{file}}
|
||||
{{- end }}{{- end }}
|
||||
4
tools/caveman/templates/test_results.txt
Normal file
4
tools/caveman/templates/test_results.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
{{- if d.failed }}Tests: {{d.passed}}/{{d.total}} passed ({{d.failed}} failed)
|
||||
{{- for d.suites as suite }}{{- if suite.status == "failed" }} {{suite.name}} ({{suite.duration}}ms)
|
||||
{{- end }}{{- end }}{{- else }}All {{d.total}} tests passed
|
||||
{{- end }}
|
||||
27
tools/network.hubris.homelab-context-sync.plist
Normal file
27
tools/network.hubris.homelab-context-sync.plist
Normal file
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>network.hubris.homelab-context-sync</string>
|
||||
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/bin/env</string>
|
||||
<string>bash</string>
|
||||
<string>/opt/homelab-context/tools/post-pull.sh</string>
|
||||
</array>
|
||||
|
||||
<key>StartInterval</key>
|
||||
<integer>300</integer>
|
||||
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/homelab-sync.log</string>
|
||||
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/homelab-sync.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
43
tools/post-pull.sh
Normal file
43
tools/post-pull.sh
Normal file
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
# post-pull.sh — homelab-context sync hook.
|
||||
# Replaces raw `git pull` in the launchd/systemd timer.
|
||||
# Runs after every git pull to auto-setup tools from the repo.
|
||||
#
|
||||
# Convention: any script at tools/*.setup.sh is sourced/exec'd after pull.
|
||||
# This lets us ship new tooling to all agent hosts via a simple git push.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
CONTEXT_DIR="${HOMELAB_CONTEXT_DIR:-/opt/homelab-context}"
|
||||
|
||||
# Git safe.directory workaround: the repo is owned by the regular user
|
||||
# but launchd runs as root. Run git as the repo owner.
|
||||
if command -v realpath &>/dev/null; then
|
||||
REPO_REAL=$(realpath "$CONTEXT_DIR")
|
||||
elif command -v grealpath &>/dev/null; then
|
||||
REPO_REAL=$(grealpath "$CONTEXT_DIR")
|
||||
else
|
||||
REPO_REAL=$(perl -e 'print Cwd::abs_path(shift)' "$CONTEXT_DIR" 2>/dev/null || echo "/Users/dtoro/Homelab-Docs")
|
||||
fi
|
||||
REPO_OWNER=$(stat -f "%Su" "$REPO_REAL" 2>/dev/null || echo "dtoro")
|
||||
|
||||
# 1. Git pull (as repo owner to avoid safe.directory issues)
|
||||
if [ "$REPO_OWNER" != "root" ] && command -v sudo &>/dev/null; then
|
||||
sudo -u "$REPO_OWNER" git -C "$CONTEXT_DIR" pull --ff-only --quiet 2>&1 | grep -v "failed to store" || \
|
||||
echo "[post-pull] git pull failed (network?) continuing..."
|
||||
else
|
||||
git -C "$CONTEXT_DIR" pull --ff-only --quiet 2>&1 || \
|
||||
echo "[post-pull] git pull failed (network?) continuing..."
|
||||
fi
|
||||
|
||||
# 2. Run any auto-setup scripts
|
||||
for setup_script in "$CONTEXT_DIR"/tools/*.setup.sh; do
|
||||
[ -f "$setup_script" ] || continue
|
||||
echo "[post-pull] running $setup_script..."
|
||||
bash "$setup_script" || echo "[post-pull] WARNING: $setup_script exited with code $?"
|
||||
done
|
||||
|
||||
# 3. Symlink AGENTS.md for agent orientation (macOS)
|
||||
if [ "$(uname)" = "Darwin" ]; then
|
||||
ln -sf "$CONTEXT_DIR/AGENTS.md" /etc/AGENTS.md 2>/dev/null || true
|
||||
fi
|
||||
72
tools/setup-caveman.sh
Normal file
72
tools/setup-caveman.sh
Normal file
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env bash
|
||||
# setup-caveman.sh — idempotent auto-installer for Caveman + RTK token optimization.
|
||||
# Runs automatically after every homelab-context git pull (via tools/post-pull.sh).
|
||||
#
|
||||
# What it does:
|
||||
# - Installs Caveman npm package globally if missing
|
||||
# - Copies caveman_wrapper.sh → ~/bin/
|
||||
# - Copies caveman.js wrapper → ~/bin/caveman (CLI entry point)
|
||||
# - Copies templates → ~/templates/
|
||||
# - Creates ~/bin/ and ~/templates/ dirs if missing
|
||||
# - All operations are idempotent (safe to re-run)
|
||||
#
|
||||
# Works on: macOS (Homebrew node) and Linux (system node)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CAVEMAN_DIR="$SCRIPT_DIR/caveman"
|
||||
BIN_DIR="$HOME/bin"
|
||||
TEMPLATES_DIR="$HOME/templates"
|
||||
|
||||
# Colors for output (only when connected to a terminal)
|
||||
if [ -t 1 ]; then
|
||||
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
|
||||
else
|
||||
GREEN=''; YELLOW=''; BLUE=''; NC=''
|
||||
fi
|
||||
|
||||
log() { echo -e "${BLUE}[caveman]${NC} $1"; }
|
||||
ok() { echo -e "${GREEN}[caveman]${NC} $1"; }
|
||||
skip() { echo -e "${YELLOW}[caveman]${NC} $1"; }
|
||||
|
||||
# --- 1. Check / install node + npm ---
|
||||
if ! command -v node &>/dev/null; then
|
||||
echo "[caveman] node.js not found — skipping caveman install"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- 2. Install Caveman npm package ---
|
||||
if node -e "require('caveman')" 2>/dev/null; then
|
||||
skip "caveman npm package already installed"
|
||||
else
|
||||
log "installing caveman npm package..."
|
||||
npm install -g caveman 2>&1 | tail -1
|
||||
ok "caveman npm package installed"
|
||||
fi
|
||||
|
||||
# --- 3. Create target directories ---
|
||||
mkdir -p "$BIN_DIR" "$TEMPLATES_DIR"
|
||||
|
||||
# --- 4. Install wrapper script ---
|
||||
install -m 755 "$CAVEMAN_DIR/caveman_wrapper.sh" "$BIN_DIR/caveman_wrapper.sh"
|
||||
ok "caveman_wrapper.sh → $BIN_DIR/caveman_wrapper.sh"
|
||||
|
||||
# --- 5. Install caveman CLI wrapper ---
|
||||
install -m 755 "$CAVEMAN_DIR/caveman.js" "$BIN_DIR/caveman"
|
||||
ok "caveman.js → $BIN_DIR/caveman"
|
||||
|
||||
# --- 6. Install templates ---
|
||||
for tmpl in "$CAVEMAN_DIR/templates/"*.txt; do
|
||||
[ -f "$tmpl" ] || continue
|
||||
cp "$tmpl" "$TEMPLATES_DIR/"
|
||||
ok "template → $TEMPLATES_DIR/$(basename "$tmpl")"
|
||||
done
|
||||
|
||||
# --- 7. Verify ---
|
||||
if [ -x "$BIN_DIR/caveman_wrapper.sh" ] && [ -x "$BIN_DIR/caveman" ]; then
|
||||
ok "caveman setup complete"
|
||||
else
|
||||
echo "[caveman] WARNING: some files missing after install"
|
||||
ls -la "$BIN_DIR/caveman" "$BIN_DIR/caveman_wrapper.sh" 2>&1
|
||||
fi
|
||||
68
tools/setup-hermes-soul.sh
Executable file
68
tools/setup-hermes-soul.sh
Executable file
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
# setup-hermes-soul.sh — auto-provisions Hermes SOUL.md from canonical HERMES.md.
|
||||
# Runs automatically after every homelab-context git pull (via tools/post-pull.sh).
|
||||
#
|
||||
# What it does:
|
||||
# - Detects if Hermes Agent is installed (~/.hermes/SOUL.md exists)
|
||||
# - If yes, copies the canonical HERMES.md content into SOUL.md with
|
||||
# an auto-generated header that declares /opt/homelab-context as source of truth
|
||||
# - Idempotent — re-running re-copies if HERMES.md content changed
|
||||
#
|
||||
# For non-Hermes agents (Goose, Claude Code, etc.), this script is a no-op.
|
||||
# Those agents use the `.goosehints` symlink mechanism instead.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
CONTEXT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
HERMES_MD="$CONTEXT_DIR/HERMES.md"
|
||||
SOUL_MD="${HOME}/.hermes/SOUL.md"
|
||||
|
||||
# Colors for output (only when connected to a terminal)
|
||||
if [ -t 1 ]; then
|
||||
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
||||
else
|
||||
GREEN=''; YELLOW=''; NC=''
|
||||
fi
|
||||
ok() { echo -e "${GREEN}[hermes-soul]${NC} $1"; }
|
||||
skip() { echo -e "${YELLOW}[hermes-soul]${NC} $1"; }
|
||||
|
||||
# --- 1. Check if Hermes is installed ---
|
||||
if [ ! -f "$SOUL_MD" ]; then
|
||||
skip "hermes not installed (~/.hermes/SOUL.md not found) — skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- 2. Check if canonical HERMES.md exists ---
|
||||
if [ ! -f "$HERMES_MD" ]; then
|
||||
echo "[hermes-soul] WARNING: $HERMES_MD not found — skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- 3. Write SOUL.md with canon source header + HERMES.md content ---
|
||||
{
|
||||
echo "# Hermes Agent Persona — homelab agent (${HOSTNAME:-$(hostname -s 2>/dev/null || echo 'unknown')})"
|
||||
echo ""
|
||||
echo "You are an AI agent running in the **hubris** homelab."
|
||||
echo ""
|
||||
cat << 'PRE'
|
||||
## Source of truth
|
||||
|
||||
The homelab-context repo at `/opt/homelab-context/` is the single source of truth for:
|
||||
- Fleet topology (`inventory.yaml`, `hosts/*.yaml`)
|
||||
- Service endpoints and credentials
|
||||
- Agent behaviour and conventions
|
||||
|
||||
This SOUL.md is auto-generated from `/opt/homelab-context/HERMES.md` by
|
||||
`tools/setup-hermes-soul.sh`. Do not edit SOUL.md directly — edit HERMES.md
|
||||
in the homelab-context repo instead. Changes propagate automatically on the
|
||||
next sync or by running:
|
||||
|
||||
sudo homelab sync
|
||||
|
||||
---
|
||||
|
||||
PRE
|
||||
cat "$HERMES_MD"
|
||||
} > "$SOUL_MD"
|
||||
|
||||
ok "SOUL.md provisioned from HERMES.md ($(wc -l < "$SOUL_MD") lines)"
|
||||
Reference in New Issue
Block a user