Phase 1: cross-client homelab context + MCP scaffolding

Add the foundation for distributing homelab context to every client
(LXCs, VMs, workstations including republic-laptop, mac-mini, ludo-mini)
with a single source of truth, structured query layer (MCP), and per-client
age-key issuance for secrets:

- inventory.yaml — canonical topology (hosts, services, mesh addresses)
- hosts/*.yaml — per-host identity files generated from inventory by
  mcp/build_host_files.py; do not edit by hand
- AGENTS.md — orientation doc symlinked to /root/AGENTS.md on every client
- bootstrap.sh — one-shot enroll (Linux + macOS), clones repo, fetches age
  key from issuance, installs sync timer/launchd job, drops the homelab CLI
- bin/homelab — single-binary Python CLI: whoami, list, ssh, pct, logs,
  restart, open, status, secret, sync, mcp, client add/remove, nuke
- mcp/server.py — FastMCP server: context tools + read-only management
  tools (no mutations exposed); shell-outs use mcp-reader restricted ssh key
- mcp/deploy/ — claudio-monitor-style gitea webhook deploy scaffold for the
  MCP service on LXC 105 (ports 9810 mcp, 9811 webhook)
- secrets-issuance/ — per-client age key auto-provisioning over the mesh;
  source-IP gated against inventory, with denylist for revoked clients
  (ports 9820 issue, 9821 webhook)
- secrets/, .sops.yaml — SOPS recipient scaffolding; the operator fills in
  age public keys after Phase 3a generates them
- scripts/sync/ — systemd timer (Linux) + launchd plist (macOS) pulling
  /opt/homelab-context every 5 min

Mesh: both Netbird (preferred, 100.122.0.0/16) and Tailscale accepted
during the in-flight migration; no client is gated on completing the move.

Plan reference: /root/.claude/plans/lets-make-a-plan-fluttering-trinket.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-05-20 15:47:48 +02:00
parent 8f598a0e7a
commit 3c25f936d3
44 changed files with 3180 additions and 0 deletions

36
.sops.yaml Normal file
View File

@@ -0,0 +1,36 @@
# SOPS recipient rules.
#
# Each rule pins one or more age public keys as recipients for files matching
# the path_regex. The build_host_files.py generator doesn't read this file;
# `sops` does — to encrypt a new secret, run `sops -e secrets/<name>.yaml`
# from the repo root and SOPS will pick the matching rule below.
#
# To grant a secret to a new client: add their age public key (from
# inventory.yaml `hosts.<name>.age_pubkey`) to the relevant rule below, then
# run `sops updatekeys secrets/<name>.yaml` to re-encrypt without rotating
# the ciphertext payload.
#
# To revoke: remove the recipient from the relevant rule and run
# `sops updatekeys` (this is what `homelab client remove` calls). Past
# ciphertext the client already decrypted is not affected — rotate the
# underlying credential if compromise is suspected.
creation_rules:
- path_regex: ^secrets/hello\.yaml$
# The "hello" secret is encrypted to every enrolled client so the bootstrap
# decrypt test works for everyone. Populated by the operator with the
# age_pubkey from each hosts/<name>.yaml after Phase 3a generates keys.
age: >-
# placeholder — operator fills in after first issuance run.
# comma-separated age public keys, e.g.:
# age1xxx,age1yyy,age1zzz
- path_regex: ^secrets/gitea-tokens\.yaml$
# Workstations only.
age: >-
# placeholder — fill with age_pubkey of: republic-laptop, mac-mini, ludo-mini, hubris
- path_regex: ^secrets/webhook-hmacs\.yaml$
# LXCs that run a webhook receiver.
age: >-
# placeholder — fill with age_pubkey of: apps, caddy, claudio-bot, claudio-monitor host

89
AGENTS.md Normal file
View File

@@ -0,0 +1,89 @@
# AGENTS.md — orientation for any agent on a homelab client
You are running on a machine that is part of the **hubris** homelab. The full
context is in this checkout at `/opt/homelab-context/`. This file is the entry
point. Read it once at start, then keep working.
## 1. Who you are
Run `hostname` (Linux) or `scutil --get LocalHostName` (macOS), then read:
/opt/homelab-context/hosts/<your-hostname>.yaml
That file tells you your role, your peers, what's mounted, and what services
you host. If it does not exist, this client was not enrolled — stop and tell
the operator to run `homelab client add <hostname>` from an existing client.
## 2. The topology
- `/opt/homelab-context/inventory.yaml` — every host, LXC, VM, and workstation
with their mesh addresses, roles, and service mappings. Treat this file as
authoritative; anything you read in narrative pages should agree with it.
- `/opt/homelab-context/infrastructure/mesh.md` — Tailscale → Netbird state.
Both meshes are accepted today; Netbird is preferred for new traffic.
- `/opt/homelab-context/infrastructure/dns.md` — split-horizon DNS via
dnsmasq on LXC 124. `*.hubris.network` resolves to 192.168.x.x on the LAN
and to mesh addresses off-LAN.
- `/opt/homelab-context/operations/commands.md` — the operator's cheatsheet
for pct, caddy, dnsmasq. Use these verbs when you take actions.
## 3. The MCP server
The homelab exposes a Model Context Protocol server with structured tools.
Endpoint is in `inventory.yaml` under `services.homelab_mcp.endpoint`.
Available tools:
Context (pure read):
get_host(name), list_services(), find_service(name_or_role),
get_topology(), search_docs(query), get_page(path),
get_changelog(page, since?), whoami(hostname),
list_my_secrets(caller_pubkey?)
Management (read-only):
get_service_status(service), tail_log(service, lines=200),
list_lxcs(), get_lxc_state(lxc), ping_service(service)
Mutations are **not** exposed via MCP. Use the `homelab` CLI for those, with
operator confirmation.
**When to prefer MCP over grepping the clone:** any time you need to resolve a
name to an address, look up service status, or search the wiki by content.
Grep is fine for browsing or when MCP is unreachable.
## 4. Wiki conventions
- Pages live under `containers/`, `hosts/`, `vms/`, `infrastructure/`,
`investigations/`, `operations/`. Cross-link liberally; orphans are bugs.
- Every page ends with a `## Changelog` section, entries in reverse-chrono
order:
### YYYY-MM-DD — short title
one or two lines describing what changed and why.
- Investigation files are dated and slugged: `YYYY-MM-DD-slug.md`.
- Live state takes precedence over docs. If you observe a discrepancy, update
the docs *in the same session* (per the same-session update rule).
## 5. Acting on the homelab
- **Read state**: prefer MCP tools, then files, then shell. Examples:
`homelab whoami`, `homelab list`, `homelab status`, `homelab logs caddy`.
- **Cross-host actions** (caddy reload, pct exec, etc.): use the `homelab`
CLI — it resolves hostname → mesh address → ssh / pct path for you. Direct
SSH still works; the CLI just removes the lookup burden.
- **Secrets**: never hardcode. Call `homelab secret <name>` to decrypt on
demand using the per-client age key at `/etc/age/key.txt`. Secrets ARE
available in this system — `list_my_secrets()` (MCP) shows what you can
decrypt.
- **Mutations** (restart, edit configs, etc.): the `homelab` CLI's mutating
subcommands ask for confirmation. For ad-hoc work, SSH and edit directly —
but commit changes that touch tracked configs (caddy, gitea custom,
artifacto, mule-image, etc.; see `infrastructure/auto-deploy.md`).
- **Wiki updates**: same-session rule applies to any meaningful state change
this client makes.
## 6. When in doubt
Run `homelab mcp search_docs <query>` or `homelab mcp get_host <name>`.
The clone is the fallback; MCP is the index.

494
bin/homelab Executable file
View File

@@ -0,0 +1,494 @@
#!/usr/bin/env python3
"""
homelab — single-binary CLI for managing the hubris homelab from any client.
Reads /opt/homelab-context/inventory.yaml as the source of truth, resolves
hostnames to mesh addresses, and wraps the common ops (ssh, pct, logs, restart,
status, open, secret, client add/remove, sync, mcp).
Mutating subcommands prompt unless -y / --yes is passed.
Run `homelab --help` or `homelab <subcommand> --help` for usage.
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
import urllib.request
from pathlib import Path
try:
import yaml
except ImportError:
print("PyYAML required (apt install python3-yaml or pip install pyyaml)", file=sys.stderr)
sys.exit(2)
CONTEXT = Path(os.environ.get("HOMELAB_CONTEXT_DIR", "/opt/homelab-context"))
INVENTORY = CONTEXT / "inventory.yaml"
HOSTS_DIR = CONTEXT / "hosts"
AGE_KEY = Path(os.environ.get("SOPS_AGE_KEY_FILE", "/etc/age/key.txt"))
# ---------- helpers ----------
def die(msg: str, code: int = 1) -> None:
print(f"homelab: {msg}", file=sys.stderr)
sys.exit(code)
def inventory() -> dict:
if not INVENTORY.exists():
die(f"no inventory at {INVENTORY} — has bootstrap run?")
return yaml.safe_load(INVENTORY.read_text())
def host(name: str) -> dict:
inv = inventory()
if name not in inv.get("hosts", {}):
die(f"unknown host: {name}")
return inv["hosts"][name]
def host_address(name: str, prefer_lan: bool = False) -> str:
h = host(name)
mesh = h.get("mesh", {})
candidates = []
if prefer_lan and h.get("lan_ip"):
candidates.append(h["lan_ip"])
candidates.extend([
mesh.get("netbird", {}).get("fqdn") if isinstance(mesh.get("netbird"), dict) else None,
mesh.get("netbird", {}).get("ip") if isinstance(mesh.get("netbird"), dict) else None,
h.get("lan_ip"),
mesh.get("tailscale", {}).get("fqdn") if isinstance(mesh.get("tailscale"), dict) else None,
mesh.get("tailscale", {}).get("ip") if isinstance(mesh.get("tailscale"), dict) else None,
])
for c in candidates:
if c:
return c
die(f"no reachable address for host {name}")
def hubris_ssh() -> list[str]:
"""SSH base command for hubris, honoring its non-default Netbird SSH port."""
h = host("hubris")
port = h.get("ssh", {}).get("netbird_port", 22)
return ["ssh", "-p", str(port), f"root@{host_address('hubris')}"]
def confirm(question: str, default_no: bool = True) -> bool:
suffix = "[y/N]" if default_no else "[Y/n]"
try:
answer = input(f"{question} {suffix} ").strip().lower()
except (EOFError, KeyboardInterrupt):
return False
if not answer:
return not default_no
return answer in ("y", "yes")
def service(name: str) -> dict:
inv = inventory()
svc = inv.get("services", {}).get(name)
if not svc:
die(f"unknown service: {name}")
return svc
def service_backend_host(name: str) -> str:
return service(name)["backend"]
def push_inventory(message: str) -> None:
"""Stage + commit + push inventory + regenerated hosts/."""
subprocess.run(["python3", str(CONTEXT / "mcp" / "build_host_files.py")],
check=True, cwd=CONTEXT)
subprocess.run(["git", "add", "inventory.yaml", "hosts/"], check=True, cwd=CONTEXT)
if subprocess.run(["git", "diff", "--cached", "--quiet"], cwd=CONTEXT).returncode == 0:
print("(no changes to commit)")
return
subprocess.run(["git", "commit", "-m", message], check=True, cwd=CONTEXT)
subprocess.run(["git", "push"], check=True, cwd=CONTEXT)
# ---------- subcommands ----------
def cmd_whoami(args: argparse.Namespace) -> int:
name = args.hostname or os.uname().nodename.split(".")[0]
path = HOSTS_DIR / f"{name}.yaml"
if not path.exists():
die(f"no hosts/{name}.yaml — has this client been enrolled?")
print(path.read_text())
return 0
def cmd_list(args: argparse.Namespace) -> int:
inv = inventory()
print(f"{'NAME':<22} {'KIND':<14} {'OS':<6} {'ROLE':<22} ADDRESS")
for name, entry in inv.get("hosts", {}).items():
addr = ""
try:
addr = host_address(name)
except SystemExit:
addr = "?"
print(f"{name:<22} {entry.get('kind','?'):<14} {entry.get('os','?'):<6} "
f"{entry.get('role','?'):<22} {addr}")
print()
print("Services:")
for svc, entry in inv.get("services", {}).items():
url = entry.get("url") or entry.get("endpoint") or ""
backend = entry.get("backend", "?")
print(f" {svc:<22} backend={backend:<15} {url}")
return 0
def cmd_ssh(args: argparse.Namespace) -> int:
name = args.host
h = host(name)
addr = host_address(name)
cmd = ["ssh"]
# hubris uses a non-default Netbird SSH port for the mesh path
if name == "hubris":
port = h.get("ssh", {}).get("netbird_port", 22)
cmd.extend(["-p", str(port)])
user = args.user or "root"
cmd.append(f"{user}@{addr}")
if args.command:
cmd.append(" ".join(args.command))
os.execvp(cmd[0], cmd)
def cmd_pct(args: argparse.Namespace) -> int:
lxc = args.lxc
inv = inventory()
entry = inv.get("hosts", {}).get(lxc)
if not entry or entry.get("kind") != "lxc":
die(f"{lxc} is not an LXC in inventory")
pve_id = str(entry["pve_id"])
action = args.action
if action == "exec":
if not args.rest:
die("pct exec needs a command")
remote_cmd = ["pct", "exec", pve_id, "--"] + args.rest
elif action in ("status", "config", "start", "stop", "reboot", "shutdown"):
remote_cmd = ["pct", action, pve_id]
elif action == "enter":
remote_cmd = ["pct", "enter", pve_id]
else:
die(f"unknown pct action: {action}")
if action in ("stop", "reboot", "shutdown") and not args.yes:
if not confirm(f"run 'pct {action} {pve_id}' (={lxc}) on hubris?"):
return 1
cmd = hubris_ssh() + ["--"] + remote_cmd
return subprocess.call(cmd)
def cmd_logs(args: argparse.Namespace) -> int:
svc = args.service
host_name = service_backend_host(svc)
unit = service(svc).get("systemd_unit", svc)
if host_name == "hubris":
base = hubris_ssh()
else:
base = ["ssh", f"root@{host_address(host_name)}"]
remote = ["journalctl", "-u", unit, "-n", str(args.lines), "--no-pager"]
if args.follow:
remote.append("-f")
return subprocess.call(base + ["--"] + remote)
def cmd_restart(args: argparse.Namespace) -> int:
svc = args.service
host_name = service_backend_host(svc)
unit = service(svc).get("systemd_unit", svc)
if not args.yes:
if not confirm(f"restart systemd unit '{unit}' on {host_name}?"):
return 1
if host_name == "hubris":
base = hubris_ssh()
else:
base = ["ssh", f"root@{host_address(host_name)}"]
return subprocess.call(base + ["--", "systemctl", "restart", unit])
def cmd_open(args: argparse.Namespace) -> int:
url = service(args.service).get("url")
if not url:
die(f"service {args.service} has no url in inventory")
opener = "open" if sys.platform == "darwin" else "xdg-open"
if shutil.which(opener) is None:
print(url)
return 0
return subprocess.call([opener, url])
def cmd_status(args: argparse.Namespace) -> int:
inv = inventory()
print(f"{'NAME':<22} REACH")
for name, entry in inv.get("hosts", {}).items():
try:
addr = host_address(name)
except SystemExit:
print(f"{name:<22} no-address")
continue
# Quick ping-ish check via mesh.
proc = subprocess.run(
["ping", "-c", "1", "-W", "2", addr],
capture_output=True, text=True,
)
reach = "ok" if proc.returncode == 0 else "down"
print(f"{name:<22} {reach:<5} {addr}")
print()
print(f"{'SERVICE':<22} URL HEALTH")
for svc, entry in inv.get("services", {}).items():
url = entry.get("url") or entry.get("endpoint")
if not url:
continue
proc = subprocess.run(
["curl", "-sS", "-o", "/dev/null", "-w", "%{http_code}",
"--max-time", "3", url],
capture_output=True, text=True,
)
code = proc.stdout.strip() or "---"
print(f"{svc:<22} {url:<50} {code}")
return 0
def cmd_secret(args: argparse.Namespace) -> int:
name = args.name
path = CONTEXT / "secrets" / f"{name}.yaml"
if not path.exists():
die(f"no secret '{name}' (looked for {path})")
if not AGE_KEY.exists():
die(f"no age key at {AGE_KEY} — has bootstrap run?")
env = {**os.environ, "SOPS_AGE_KEY_FILE": str(AGE_KEY)}
return subprocess.call(["sops", "-d", str(path)], env=env)
def cmd_sync(args: argparse.Namespace) -> int:
if sys.platform == "darwin":
return subprocess.call(
["sudo", "launchctl", "kickstart", "-k",
"system/network.hubris.homelab-context-sync"]
)
return subprocess.call(
["sudo", "systemctl", "start", "homelab-context-sync.service"]
)
def cmd_mcp(args: argparse.Namespace) -> int:
svc = service("homelab_mcp")
endpoint = svc.get("endpoint")
if not endpoint:
die("no MCP endpoint in inventory")
# Minimal SSE invocation via mcp-cli if available; otherwise instruct.
if shutil.which("mcp"):
return subprocess.call(["mcp", "call", endpoint, args.tool, *(args.args or [])])
die("'mcp' CLI not installed. Install with: pip install 'mcp[cli]'")
def cmd_client_add(args: argparse.Namespace) -> int:
name = args.name
inv = inventory()
if not args.finalize_pubkey:
if name in inv["hosts"]:
die(f"{name} already in inventory (use --finalize-pubkey to update age_pubkey)")
print(f"Adding new client '{name}' to inventory.yaml.")
kind = input(" kind [workstation/lxc/vm] (default: workstation): ").strip() or "workstation"
os_name = input(f" os [linux/macos] (default: linux): ").strip() or "linux"
netbird_fqdn = input(f" netbird FQDN (default: {name}.netbird.selfhosted): ").strip() \
or f"{name}.netbird.selfhosted"
role = input(" role (e.g. primary-dev, dev): ").strip() or "dev"
entry = {
"kind": kind,
"os": os_name,
"role": role,
"mesh": {"netbird": {"fqdn": netbird_fqdn}},
"age_pubkey": "",
}
inv["hosts"][name] = entry
INVENTORY.write_text(yaml.safe_dump(inv, sort_keys=False))
push_inventory(f"client-add: {name}")
print()
print("Next steps:")
print(f" 1. Join {name} to Netbird (out-of-band, Netbird console / setup key).")
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...>")
return 0
# finalize_pubkey path
if name not in inv["hosts"]:
die(f"{name} not in inventory — run 'homelab client add {name}' first (no --finalize-pubkey)")
inv["hosts"][name]["age_pubkey"] = args.finalize_pubkey
INVENTORY.write_text(yaml.safe_dump(inv, sort_keys=False))
push_inventory(f"client-add: {name} (finalize age_pubkey)")
print(f"finalized age_pubkey for {name}.")
return 0
def cmd_client_remove(args: argparse.Namespace) -> int:
name = args.name
inv = inventory()
if name not in inv["hosts"]:
die(f"{name} not in inventory")
if not args.yes:
print(f"This will:")
print(f" 1. Remove {name} from inventory.yaml and hosts/")
print(f" 2. Re-encrypt every secret without {name} as recipient")
print(f" 3. Revoke {name}'s age key on the issuance server (shred + denylist)")
print(f" 4. Commit + push the change")
print(f"After: rotate any credentials inside secrets {name} previously had access to,")
print(f" and revoke {name}'s Netbird peer in the console.")
if not confirm(f"proceed removing {name}?"):
return 1
# 1. Inventory
del inv["hosts"][name]
INVENTORY.write_text(yaml.safe_dump(inv, sort_keys=False))
# 2. SOPS — remove recipient. Requires `sops updatekeys` after we edit .sops.yaml.
# We don't try to programmatically edit .sops.yaml because the recipient list
# there is keyed by path-glob rules; the operator must remove the pubkey line
# if it's listed by-name. We'll trigger updatekeys after the operator confirms.
secrets_dir = CONTEXT / "secrets"
if secrets_dir.exists():
print()
print("[remove] re-encrypting secrets without removed recipient (sops updatekeys)")
sops_yaml = CONTEXT / ".sops.yaml"
if sops_yaml.exists():
print(f" Note: review {sops_yaml} for hard-coded recipients of '{name}' "
"and remove them before sops updatekeys.")
for f in sorted(secrets_dir.glob("*.yaml")):
subprocess.run(["sops", "updatekeys", "-y", str(f)], check=False)
# 3. Revoke on issuance server
admin_token_url = inv["services"].get("secrets_issuance", {}).get("endpoint", "").replace("/issue", "")
if admin_token_url:
revoke_url = admin_token_url.rstrip("/") + "/revoke"
token = os.environ.get("HOMELAB_ISSUANCE_ADMIN_TOKEN", "")
if not token:
print(f" WARNING: set HOMELAB_ISSUANCE_ADMIN_TOKEN to call {revoke_url} — skipping revocation")
else:
req = urllib.request.Request(
revoke_url, data=json.dumps({"hostname": name}).encode(),
headers={"X-Admin-Token": token, "Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
print(f" issuance revoke: {resp.status} {resp.read().decode().strip()}")
except Exception as e:
print(f" issuance revoke failed: {e}")
# 4. Commit + push
push_inventory(f"client-remove: {name}")
print()
print("Follow-up checklist (the CLI cannot do these automatically):")
print(f" [ ] Revoke {name}'s Netbird peer in the Netbird console.")
print(f" [ ] Rotate any credentials whose ciphertext {name} already has on disk")
print(f" — the only real revocation for past-disclosed secrets is rotation.")
print(f" [ ] If the machine is reachable and decommissioned, run:")
print(f" homelab nuke {name} (shreds /etc/age/key.txt, removes /opt/homelab-context)")
return 0
def cmd_nuke(args: argparse.Namespace) -> int:
name = args.name
if not args.yes:
if not confirm(f"destroy /etc/age/key.txt + /opt/homelab-context on {name}?"):
return 1
addr = host_address(name)
remote = ("set -euo pipefail; "
"shred -u /etc/age/key.txt 2>/dev/null || true; "
"rm -rf /opt/homelab-context; "
"systemctl disable --now homelab-context-sync.timer 2>/dev/null || true; "
"launchctl bootout system/network.hubris.homelab-context-sync 2>/dev/null || true; "
"echo nuked")
return subprocess.call(["ssh", f"root@{addr}", remote])
# ---------- argparse ----------
def main() -> int:
p = argparse.ArgumentParser(prog="homelab", description=__doc__)
sub = p.add_subparsers(dest="cmd", required=True)
sp = sub.add_parser("whoami", help="print this host's hosts/<hostname>.yaml")
sp.add_argument("hostname", nargs="?")
sp.set_defaults(func=cmd_whoami)
sp = sub.add_parser("list", help="list hosts and services from inventory")
sp.set_defaults(func=cmd_list)
sp = sub.add_parser("ssh", help="ssh to a host via mesh")
sp.add_argument("host")
sp.add_argument("--user", "-u", default=None)
sp.add_argument("command", nargs=argparse.REMAINDER)
sp.set_defaults(func=cmd_ssh)
sp = sub.add_parser("pct", help="proxy pct commands via ssh to hubris")
sp.add_argument("lxc")
sp.add_argument("action")
sp.add_argument("rest", nargs=argparse.REMAINDER)
sp.add_argument("--yes", "-y", action="store_true")
sp.set_defaults(func=cmd_pct)
sp = sub.add_parser("logs", help="journalctl for a service")
sp.add_argument("service")
sp.add_argument("--lines", "-n", type=int, default=200)
sp.add_argument("--follow", "-f", action="store_true")
sp.set_defaults(func=cmd_logs)
sp = sub.add_parser("restart", help="restart a service")
sp.add_argument("service")
sp.add_argument("--yes", "-y", action="store_true")
sp.set_defaults(func=cmd_restart)
sp = sub.add_parser("open", help="open a service's URL in browser")
sp.add_argument("service")
sp.set_defaults(func=cmd_open)
sp = sub.add_parser("status", help="ping every host + HTTP-check every service")
sp.set_defaults(func=cmd_status)
sp = sub.add_parser("secret", help="decrypt a secret (sops -d wrapper)")
sp.add_argument("name")
sp.set_defaults(func=cmd_secret)
sp = sub.add_parser("sync", help="manually trigger homelab-context-sync")
sp.set_defaults(func=cmd_sync)
sp = sub.add_parser("mcp", help="call an MCP tool (requires 'mcp' CLI installed)")
sp.add_argument("tool")
sp.add_argument("args", nargs=argparse.REMAINDER)
sp.set_defaults(func=cmd_mcp)
sp = sub.add_parser("nuke", help="shred /etc/age/key.txt + /opt/homelab-context on a host")
sp.add_argument("name")
sp.add_argument("--yes", "-y", action="store_true")
sp.set_defaults(func=cmd_nuke)
client = sub.add_parser("client", help="client lifecycle (add/remove)")
csub = client.add_subparsers(dest="action", required=True)
csub_add = csub.add_parser("add")
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.set_defaults(func=cmd_client_add)
csub_rm = csub.add_parser("remove")
csub_rm.add_argument("name")
csub_rm.add_argument("--yes", "-y", action="store_true")
csub_rm.set_defaults(func=cmd_client_remove)
args = p.parse_args()
return args.func(args)
if __name__ == "__main__":
sys.exit(main())

266
bootstrap.sh Executable file
View File

@@ -0,0 +1,266 @@
#!/usr/bin/env bash
# bootstrap.sh — enroll a new client into the homelab context system.
#
# Usage:
# 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 -- --dry-run # show what would happen
# curl ... | sudo bash -s -- --no-secrets # skip age-key issuance
#
# Prerequisites the script verifies:
# - running as root
# - OS is Linux or macOS
# - git, age, sops are installed
# - at least one mesh (netbird OR tailscale) is connected
# - this host has an `hosts/<hostname>.yaml` entry in the repo (or refuses)
set -euo pipefail
# -------- defaults --------
REPO_HTTPS="${HOMELAB_REPO_URL:-https://git.hubris.network/dtoro/Homelab-Docs.git}"
CLONE_DIR="${HOMELAB_CONTEXT_DIR:-/opt/homelab-context}"
ISSUANCE_URL_NETBIRD="${HOMELAB_ISSUANCE_NETBIRD:-http://apps.netbird.selfhosted:9820/issue}"
ISSUANCE_URL_TAILSCALE="${HOMELAB_ISSUANCE_TAILSCALE:-http://apps.ts:9820/issue}"
MCP_URL="${HOMELAB_MCP_URL:-http://apps.netbird.selfhosted:9810/sse}"
WITH_MCP=0
DRY_RUN=0
NO_SECRETS=0
# -------- flag parsing --------
while [ $# -gt 0 ]; do
case "$1" in
--with-mcp) WITH_MCP=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
--no-secrets) NO_SECRETS=1; shift ;;
--help|-h)
sed -n '2,11p' "$0" | sed 's/^# *//'
exit 0
;;
*)
echo "unknown flag: $1" >&2; exit 2 ;;
esac
done
run() {
if [ "$DRY_RUN" -eq 1 ]; then
printf '+ %s\n' "$*"
else
eval "$*"
fi
}
# -------- preflight --------
if [ "$(id -u)" -ne 0 ]; then
echo "bootstrap.sh must run as root (use sudo)." >&2
exit 1
fi
OS="$(uname -s)"
case "$OS" in
Linux|Darwin) ;;
*) echo "unsupported OS: $OS" >&2; exit 1 ;;
esac
# Resolve hostname; on macOS prefer LocalHostName if set.
if [ "$OS" = "Darwin" ]; then
HNAME="$(scutil --get LocalHostName 2>/dev/null || hostname -s)"
HNAME_ALT="$(hostname -s)"
if [ "$HNAME" != "$HNAME_ALT" ]; then
echo "note: scutil LocalHostName=$HNAME differs from hostname=$HNAME_ALT"
echo " using LocalHostName for inventory lookup."
fi
else
HNAME="$(hostname -s)"
fi
echo "[bootstrap] hostname: $HNAME"
# Check dependencies.
missing=()
for cmd in git; do command -v "$cmd" >/dev/null || missing+=("$cmd"); done
if [ "$NO_SECRETS" -eq 0 ]; then
for cmd in age sops; do command -v "$cmd" >/dev/null || missing+=("$cmd"); done
fi
if [ "${#missing[@]}" -gt 0 ]; then
echo "missing required tools: ${missing[*]}" >&2
if [ "$OS" = "Darwin" ]; then
echo " brew install ${missing[*]}"
else
echo " apt install -y ${missing[*]} (or platform equivalent)"
fi
exit 1
fi
# Mesh check — accept either Netbird OR Tailscale.
MESH_CONNECTED=""
if command -v netbird >/dev/null && netbird status 2>/dev/null | grep -q "Management: Connected"; then
MESH_CONNECTED="netbird"
elif command -v tailscale >/dev/null && tailscale status >/dev/null 2>&1; then
MESH_CONNECTED="tailscale"
fi
if [ -z "$MESH_CONNECTED" ] && [ "$NO_SECRETS" -eq 0 ]; then
echo "neither netbird nor tailscale is connected; cannot bootstrap." >&2
echo "either bring up the mesh first, or pass --no-secrets to skip issuance." >&2
exit 1
fi
echo "[bootstrap] mesh: ${MESH_CONNECTED:-none (skipped, --no-secrets)}"
# -------- clone --------
if [ -d "$CLONE_DIR/.git" ]; then
existing_remote="$(git -C "$CLONE_DIR" remote get-url origin 2>/dev/null || true)"
if [ -n "$existing_remote" ] && [ "$existing_remote" != "$REPO_HTTPS" ]; then
echo "$CLONE_DIR already exists with a different remote ($existing_remote);" >&2
echo "refusing to overwrite. Move it aside or set HOMELAB_REPO_URL." >&2
exit 1
fi
echo "[bootstrap] clone exists; pulling"
run "git -C '$CLONE_DIR' pull --ff-only --quiet"
else
echo "[bootstrap] cloning to $CLONE_DIR"
run "git clone --quiet '$REPO_HTTPS' '$CLONE_DIR'"
fi
# -------- identity check --------
HOST_YAML="$CLONE_DIR/hosts/$HNAME.yaml"
if [ ! -f "$HOST_YAML" ]; then
cat >&2 <<EOF
[bootstrap] no hosts/$HNAME.yaml in the repo.
This client has not been enrolled yet. From any existing client, run:
homelab client add $HNAME
then re-run this bootstrap. (If the hostname here is wrong, fix it first:
'sudo hostnamectl set-hostname <name>' on Linux, or System Preferences →
Sharing on macOS.)
EOF
exit 1
fi
# -------- secrets issuance --------
if [ "$NO_SECRETS" -eq 0 ]; then
if [ "$MESH_CONNECTED" = "netbird" ]; then
URL="$ISSUANCE_URL_NETBIRD"
else
URL="$ISSUANCE_URL_TAILSCALE"
fi
KEY_FILE=/etc/age/key.txt
run "mkdir -p /etc/age && chmod 0700 /etc/age"
if [ -f "$KEY_FILE" ]; then
echo "[bootstrap] age key already exists at $KEY_FILE — verifying with issuance"
fi
echo "[bootstrap] requesting age key from $URL"
if [ "$DRY_RUN" -eq 0 ]; then
# The issuance endpoint identifies us by source mesh IP. No body needed.
HTTP_CODE=$(curl -sS -o /tmp/homelab-age-key -w '%{http_code}' \
-X POST -H "Content-Type: application/json" \
-d "{\"hostname\":\"$HNAME\"}" \
"$URL") || HTTP_CODE=000
case "$HTTP_CODE" in
200)
mv /tmp/homelab-age-key "$KEY_FILE"
chmod 0600 "$KEY_FILE"
echo "[bootstrap] age key installed at $KEY_FILE"
# Capture the PR snippet (if returned in a sidecar header) so the
# operator knows the pubkey to add to inventory.yaml. The server
# includes it in the JSON response when generating a new key.
if grep -q '"pubkey"' "$KEY_FILE" 2>/dev/null; then
# Shouldn't happen — server should return raw key, not JSON.
echo "[bootstrap] unexpected: key file contains JSON, please inspect" >&2
fi
;;
403)
echo "[bootstrap] issuance returned 403 — caller not recognized" >&2
echo "is this peer in the Netbird/Tailscale console? is the hostname" >&2
echo "above ('$HNAME') matching the inventory entry?" >&2
exit 1
;;
*)
echo "[bootstrap] issuance failed (HTTP $HTTP_CODE)" >&2
cat /tmp/homelab-age-key >&2 || true
exit 1
;;
esac
fi
fi
# -------- install sync timer / launchd plist --------
echo "[bootstrap] installing sync mechanism for $OS"
run "bash '$CLONE_DIR/scripts/sync/install.sh'"
# -------- install homelab CLI --------
echo "[bootstrap] installing homelab CLI to /usr/local/bin/homelab"
run "install -m 0755 '$CLONE_DIR/bin/homelab' /usr/local/bin/homelab"
# -------- AGENTS.md symlink --------
case "$OS" in
Linux)
AGENTS_LINK=/root/AGENTS.md
;;
Darwin)
AGENTS_LINK=/etc/AGENTS.md
;;
esac
run "ln -sfn '$CLONE_DIR/AGENTS.md' '$AGENTS_LINK'"
echo "[bootstrap] linked AGENTS.md → $AGENTS_LINK"
# -------- MCP wiring --------
if [ "$WITH_MCP" -eq 1 ]; then
# Pick the right user's home — when invoked via sudo, SUDO_USER is set.
if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ]; then
USER_HOME=$(eval echo "~$SUDO_USER")
else
USER_HOME="$HOME"
fi
MCP_CONFIG="$USER_HOME/.claude/.mcp.json"
run "mkdir -p '$USER_HOME/.claude'"
# Merge endpoint into existing config (or create new). Use python for the merge
# because shell JSON juggling is error-prone.
PY_MERGE=$(cat <<PYEOF
import json, os, sys
path = "$MCP_CONFIG"
url = "$MCP_URL"
cfg = {}
if os.path.exists(path):
with open(path) as f:
try:
cfg = json.load(f)
except Exception:
cfg = {}
cfg.setdefault("mcpServers", {})
cfg["mcpServers"]["homelab"] = {"type": "sse", "url": url}
with open(path, "w") as f:
json.dump(cfg, f, indent=2)
print("[bootstrap] merged MCP server 'homelab' into", path)
PYEOF
)
if [ "$DRY_RUN" -eq 1 ]; then
echo "+ would merge homelab MCP server into $MCP_CONFIG"
else
python3 -c "$PY_MERGE"
if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ]; then
chown "$SUDO_USER" "$MCP_CONFIG"
fi
fi
fi
# -------- done --------
cat <<EOF
[bootstrap] done.
Identity: $CLONE_DIR/hosts/$HNAME.yaml
Sync: 5-minute interval ($([ "$OS" = "Darwin" ] && echo launchd || echo systemd))
Manual pull: homelab sync (or 'systemctl start homelab-context-sync' / 'launchctl kickstart')
CLI: /usr/local/bin/homelab (try 'homelab whoami')
AGENTS.md: $AGENTS_LINK
EOF
if [ "$NO_SECRETS" -eq 0 ]; then
echo "Secrets: sops -d $CLONE_DIR/secrets/<name>.yaml (key at $KEY_FILE)"
fi
if [ "$WITH_MCP" -eq 1 ]; then
echo "MCP: merged into $MCP_CONFIG"
fi

50
hosts/apps.yaml Normal file
View File

@@ -0,0 +1,50 @@
# Generated by mcp/build_host_files.py from inventory.yaml.
# Do NOT edit by hand — your changes will be overwritten.
# Source of truth: ../inventory.yaml
name: apps
kind: lxc
os: linux
role: docker-apps
host: hubris
pve_id: 105
lan_ip: 192.168.8.205
mesh:
netbird:
fqdn: apps.netbird.selfhosted
mesh_globals:
primary: netbird
accepted:
- netbird
- tailscale
mounts:
- /mnt/library
public_hosts:
- artifacto.hubris.network
runs:
- artifacto
- plantuml
- homelab-mcp
- secrets-issuance
- artifacto
- homelab_mcp
- secrets_issuance
services_hosted:
- name: artifacto
backend: apps
url: https://artifacto.hubris.network
- name: homelab_mcp
backend: apps
port: 9810
netbird_host: apps.netbird.selfhosted
endpoint: http://apps.netbird.selfhosted:9810/sse
note: MCP server. Read-only context + management. Mesh-only.
- name: secrets_issuance
backend: apps
port: 9820
netbird_host: apps.netbird.selfhosted
endpoint: http://apps.netbird.selfhosted:9820/issue
note: Issues per-client age private keys. Mesh-only.
see_also:
- containers/105-apps.md
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue

34
hosts/arriman.yaml Normal file
View File

@@ -0,0 +1,34 @@
# Generated by mcp/build_host_files.py from inventory.yaml.
# Do NOT edit by hand — your changes will be overwritten.
# Source of truth: ../inventory.yaml
name: arriman
kind: lxc
os: linux
role: arr-stack
host: hubris
pve_id: 122
lan_ip: 192.168.8.132
mesh:
netbird:
fqdn: arriman.netbird.selfhosted
mesh_globals:
primary: netbird
accepted:
- netbird
- tailscale
mounts:
- /mnt/library
public_hosts:
- jellyseerr.hubris.network
- qbit.hubris.network
- sab.hubris.network
runs:
- arr_stack
services_hosted:
- name: arr_stack
backend: arriman
note: jellyseerr / qbit / sab on docker compose
see_also:
- containers/122-arriman.md
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue

35
hosts/authentik.yaml Normal file
View File

@@ -0,0 +1,35 @@
# Generated by mcp/build_host_files.py from inventory.yaml.
# Do NOT edit by hand — your changes will be overwritten.
# Source of truth: ../inventory.yaml
name: authentik
kind: lxc
os: linux
role: idp
host: hubris
pve_id: 124
lan_ip: 192.168.8.180
mesh:
netbird:
fqdn: authentik.netbird.selfhosted
mesh_globals:
primary: netbird
accepted:
- netbird
- tailscale
public_host: auth.hubris.network
runs:
- authentik
- dnsmasq
services_hosted:
- name: authentik
url: https://auth.hubris.network
backend: authentik
- name: dnsmasq
backend: authentik
note: split-horizon DNS, /etc/dnsmasq.d/hubris-split.conf
notes:
- 'Also hosts split-horizon dnsmasq: /etc/dnsmasq.d/hubris-split.conf'
see_also:
- containers/124-authentik.md
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue

35
hosts/caddy.yaml Normal file
View File

@@ -0,0 +1,35 @@
# Generated by mcp/build_host_files.py from inventory.yaml.
# Do NOT edit by hand — your changes will be overwritten.
# Source of truth: ../inventory.yaml
name: caddy
kind: lxc
os: linux
role: reverse-proxy
host: hubris
pve_id: 121
lan_ip: 192.168.8.175
mesh:
netbird:
fqdn: caddy.netbird.selfhosted
mesh_globals:
primary: netbird
accepted:
- netbird
- tailscale
peers:
- authentik
- gitea
runs:
- caddy
services_hosted:
- name: caddy
backend: caddy
role: reverse-proxy
note: terminates all *.hubris.network
notes:
- Terminates all *.hubris.network
- /etc/caddy is a git checkout of dtoro/caddy-conf
see_also:
- containers/121-caddy.md
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue

24
hosts/claudio-bot.yaml Normal file
View File

@@ -0,0 +1,24 @@
# Generated by mcp/build_host_files.py from inventory.yaml.
# Do NOT edit by hand — your changes will be overwritten.
# Source of truth: ../inventory.yaml
name: claudio-bot
kind: lxc
os: linux
role: matrix-agent
host: hubris
pve_id: 123
lan_ip: 192.168.8.230
mesh:
netbird:
fqdn: claudio-bot.netbird.selfhosted
mesh_globals:
primary: netbird
accepted:
- netbird
- tailscale
notes:
- Reads /opt/homelab-context/ on startup
see_also:
- containers/123-claudio-bot.md
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue

29
hosts/elementsynapse.yaml Normal file
View File

@@ -0,0 +1,29 @@
# Generated by mcp/build_host_files.py from inventory.yaml.
# Do NOT edit by hand — your changes will be overwritten.
# Source of truth: ../inventory.yaml
name: elementsynapse
kind: lxc
os: linux
role: matrix-server
host: hubris
pve_id: 118
lan_ip: 192.168.8.239
mesh:
netbird:
fqdn: elementsynapse.netbird.selfhosted
mesh_globals:
primary: netbird
accepted:
- netbird
- tailscale
public_host: matrix.hubris.network
runs:
- matrix
services_hosted:
- name: matrix
url: https://matrix.hubris.network
backend: elementsynapse
see_also:
- containers/118-elementsynapse.md
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue

34
hosts/gitea.yaml Normal file
View File

@@ -0,0 +1,34 @@
# Generated by mcp/build_host_files.py from inventory.yaml.
# Do NOT edit by hand — your changes will be overwritten.
# Source of truth: ../inventory.yaml
name: gitea
kind: lxc
os: linux
role: git-server
host: hubris
pve_id: 104
lan_ip: 192.168.8.121
mesh:
netbird:
fqdn: gitea.netbird.selfhosted
mesh_globals:
primary: netbird
accepted:
- netbird
- tailscale
mounts:
- /mnt/library
public_host: git.hubris.network
runs:
- gitea
services_hosted:
- name: gitea
url: https://git.hubris.network
backend: gitea
backend_url: http://192.168.8.121:3000
notes:
- Bare repos live at /mnt/library/repos/dtoro/*.git
see_also:
- containers/104-gitea.md
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue

26
hosts/haos.yaml Normal file
View File

@@ -0,0 +1,26 @@
# Generated by mcp/build_host_files.py from inventory.yaml.
# Do NOT edit by hand — your changes will be overwritten.
# Source of truth: ../inventory.yaml
name: haos
kind: vm
os: linux
role: home-automation
host: hubris
pve_id: 108
mesh:
netbird:
fqdn: haos.netbird.selfhosted
mesh_globals:
primary: netbird
accepted:
- netbird
- tailscale
runs:
- haos
services_hosted:
- name: haos
backend: haos
see_also:
- vms/108-haos.md
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue

33
hosts/hubris.yaml Normal file
View File

@@ -0,0 +1,33 @@
# Generated by mcp/build_host_files.py from inventory.yaml.
# Do NOT edit by hand — your changes will be overwritten.
# Source of truth: ../inventory.yaml
name: hubris
kind: proxmox-host
os: linux
role: hypervisor
lan_ip: 192.168.8.77
mesh:
netbird:
ip: 100.122.38.109
fqdn: proxmox-server.netbird.selfhosted
mesh_globals:
primary: netbird
accepted:
- netbird
- tailscale
mounts:
- /mnt/library
ssh:
port: 22
netbird_port: 22022
runs:
- proxmox_ui
services_hosted:
- name: proxmox_ui
url: https://proxmox.hubris.network
backend: hubris
port: 8006
see_also:
- hosts/hubris.md
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue

31
hosts/jellyfin.yaml Normal file
View File

@@ -0,0 +1,31 @@
# Generated by mcp/build_host_files.py from inventory.yaml.
# Do NOT edit by hand — your changes will be overwritten.
# Source of truth: ../inventory.yaml
name: jellyfin
kind: lxc
os: linux
role: media-server
host: hubris
pve_id: 101
lan_ip: 192.168.8.206
mesh:
netbird:
fqdn: jellyfin.netbird.selfhosted
mesh_globals:
primary: netbird
accepted:
- netbird
- tailscale
mounts:
- /mnt/library
public_host: media.hubris.network
runs:
- jellyfin
services_hosted:
- name: jellyfin
url: https://media.hubris.network
backend: jellyfin
see_also:
- containers/101-jellyfin.md
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue

17
hosts/ludo-mini.yaml Normal file
View File

@@ -0,0 +1,17 @@
# Generated by mcp/build_host_files.py from inventory.yaml.
# Do NOT edit by hand — your changes will be overwritten.
# Source of truth: ../inventory.yaml
name: ludo-mini
kind: workstation
os: linux
role: dev
mesh:
netbird:
fqdn: ludo-mini.netbird.selfhosted
mesh_globals:
primary: netbird
accepted:
- netbird
- tailscale
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue

19
hosts/mac-mini.yaml Normal file
View File

@@ -0,0 +1,19 @@
# Generated by mcp/build_host_files.py from inventory.yaml.
# Do NOT edit by hand — your changes will be overwritten.
# Source of truth: ../inventory.yaml
name: mac-mini
kind: workstation
os: macos
role: dev
mesh:
netbird:
fqdn: mac-mini.netbird.selfhosted
mesh_globals:
primary: netbird
accepted:
- netbird
- tailscale
notes:
- Only macOS in the fleet. Bootstrap uses launchd.
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue

31
hosts/mule-images.yaml Normal file
View File

@@ -0,0 +1,31 @@
# Generated by mcp/build_host_files.py from inventory.yaml.
# Do NOT edit by hand — your changes will be overwritten.
# Source of truth: ../inventory.yaml
name: mule-images
kind: lxc
os: linux
role: photo-management
host: hubris
pve_id: 120
lan_ip: 192.168.8.136
mesh:
netbird:
fqdn: mule-images.netbird.selfhosted
mesh_globals:
primary: netbird
accepted:
- netbird
- tailscale
mounts:
- /mnt/library
public_host: photos.hubris.network
runs:
- photos
services_hosted:
- name: photos
url: https://photos.hubris.network
backend: mule-images
see_also:
- containers/120-mule-images.md
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue

View File

@@ -0,0 +1,29 @@
# Generated by mcp/build_host_files.py from inventory.yaml.
# Do NOT edit by hand — your changes will be overwritten.
# Source of truth: ../inventory.yaml
name: mule-photos-new
kind: lxc
os: linux
role: photo-management
host: hubris
pve_id: 127
lan_ip: 192.168.8.181
mesh:
netbird:
fqdn: mule-photos-new.netbird.selfhosted
mesh_globals:
primary: netbird
accepted:
- netbird
- tailscale
public_host: photos-new.hubris.network
runs:
- photos_new
services_hosted:
- name: photos_new
url: https://photos-new.hubris.network
backend: mule-photos-new
see_also:
- containers/127-mule-photos-new.md
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue

31
hosts/nextcloud.yaml Normal file
View File

@@ -0,0 +1,31 @@
# Generated by mcp/build_host_files.py from inventory.yaml.
# Do NOT edit by hand — your changes will be overwritten.
# Source of truth: ../inventory.yaml
name: nextcloud
kind: lxc
os: linux
role: file-sync
host: hubris
pve_id: 114
lan_ip: 192.168.8.224
mesh:
netbird:
fqdn: nextcloud.netbird.selfhosted
mesh_globals:
primary: netbird
accepted:
- netbird
- tailscale
mounts:
- /mnt/library
public_host: cloud.hubris.network
runs:
- nextcloud
services_hosted:
- name: nextcloud
url: https://cloud.hubris.network
backend: nextcloud
see_also:
- containers/114-nextcloud.md
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue

22
hosts/nfs-export.yaml Normal file
View File

@@ -0,0 +1,22 @@
# Generated by mcp/build_host_files.py from inventory.yaml.
# Do NOT edit by hand — your changes will be overwritten.
# Source of truth: ../inventory.yaml
name: nfs-export
kind: lxc
os: linux
role: storage-export
host: hubris
pve_id: 102
lan_ip: 192.168.8.200
mesh:
netbird:
fqdn: nfs-export.netbird.selfhosted
mesh_globals:
primary: netbird
accepted:
- netbird
- tailscale
see_also:
- containers/102-nfs-export.md
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue

31
hosts/paperless.yaml Normal file
View File

@@ -0,0 +1,31 @@
# Generated by mcp/build_host_files.py from inventory.yaml.
# Do NOT edit by hand — your changes will be overwritten.
# Source of truth: ../inventory.yaml
name: paperless
kind: lxc
os: linux
role: document-archive
host: hubris
pve_id: 103
lan_ip: 192.168.8.130
mesh:
netbird:
fqdn: paperless.netbird.selfhosted
mesh_globals:
primary: netbird
accepted:
- netbird
- tailscale
mounts:
- /mnt/library
public_host: paperless.hubris.network
runs:
- paperless
services_hosted:
- name: paperless
url: https://paperless.hubris.network
backend: paperless
see_also:
- containers/103-paperless.md
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue

31
hosts/plato.yaml Normal file
View File

@@ -0,0 +1,31 @@
# Generated by mcp/build_host_files.py from inventory.yaml.
# Do NOT edit by hand — your changes will be overwritten.
# Source of truth: ../inventory.yaml
name: plato
kind: lxc
os: linux
role: app
host: hubris
pve_id: 126
lan_ip: 192.168.8.190
mesh:
netbird:
fqdn: plato.netbird.selfhosted
mesh_globals:
primary: netbird
accepted:
- netbird
- tailscale
mounts:
- /mnt/library/documents/plato
public_host: plato.hubris.network
runs:
- plato
services_hosted:
- name: plato
url: https://plato.hubris.network
backend: plato
see_also:
- containers/126-plato.md
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue

View File

@@ -0,0 +1,17 @@
# Generated by mcp/build_host_files.py from inventory.yaml.
# Do NOT edit by hand — your changes will be overwritten.
# Source of truth: ../inventory.yaml
name: republic-laptop
kind: workstation
os: linux
role: primary-dev
mesh:
netbird:
fqdn: republic-laptop.netbird.selfhosted
mesh_globals:
primary: netbird
accepted:
- netbird
- tailscale
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue

24
hosts/sophia.yaml Normal file
View File

@@ -0,0 +1,24 @@
# Generated by mcp/build_host_files.py from inventory.yaml.
# Do NOT edit by hand — your changes will be overwritten.
# Source of truth: ../inventory.yaml
name: sophia
kind: lxc
os: linux
role: workshop
host: hubris
pve_id: 119
lan_ip: 192.168.8.157
mesh:
netbird:
fqdn: sophia.netbird.selfhosted
mesh_globals:
primary: netbird
accepted:
- netbird
- tailscale
mounts:
- /mnt/library
see_also:
- containers/119-sophia.md
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue

28
hosts/zimaos.yaml Normal file
View File

@@ -0,0 +1,28 @@
# Generated by mcp/build_host_files.py from inventory.yaml.
# Do NOT edit by hand — your changes will be overwritten.
# Source of truth: ../inventory.yaml
name: zimaos
kind: vm
os: linux
role: nas-frontend-eval
host: hubris
pve_id: 100
mesh:
netbird:
fqdn: zimaos.netbird.selfhosted
mesh_globals:
primary: netbird
accepted:
- netbird
- tailscale
public_host: zimaos.hubris.network
runs:
- zimaos
services_hosted:
- name: zimaos
url: https://zimaos.hubris.network
backend: zimaos
see_also:
- vms/100-zimaos.md
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue

365
inventory.yaml Normal file
View File

@@ -0,0 +1,365 @@
# Homelab inventory — canonical structured topology
#
# This file is the single source of truth for hosts, services, and mesh
# addresses. Everything in hosts/*.yaml is generated from this file by
# mcp/build_host_files.py — do not edit hosts/*.yaml by hand.
#
# Conventions:
# - hostname keys here MUST match the actual `hostname` of the machine
# (or, on macOS, `scutil --get LocalHostName` if set).
# - `os:` one of: linux, macos
# - `kind:` one of: proxmox-host, lxc, vm, workstation
# - `mesh:` lists addresses the host is reachable at. Both `netbird` and
# `tailscale` are accepted during the in-flight migration
# (see infrastructure/mesh.md). Prefer netbird FQDNs over raw IPs.
# - `age_pubkey:` is provisioned by the secrets-issuance service on first
# bootstrap and committed back here. New entries leave it empty.
# - When a service moves hosts, update only the `services:` section here;
# never duplicate the address elsewhere.
# ---------------------------------------------------------------------------
# Static config (mesh + service endpoints)
# ---------------------------------------------------------------------------
mesh:
primary: netbird # preferred for new clients
accepted: [netbird, tailscale]
netbird_subnet: 100.122.0.0/16
netbird_domain: netbird.selfhosted
# Top-level service registry. Each entry resolves to a host above plus a URL.
services:
proxmox_ui:
url: https://proxmox.hubris.network
backend: hubris
port: 8006
gitea:
url: https://git.hubris.network
backend: gitea
backend_url: http://192.168.8.121:3000
caddy:
backend: caddy
role: reverse-proxy
note: terminates all *.hubris.network
authentik:
url: https://auth.hubris.network
backend: authentik
dnsmasq:
backend: authentik
note: split-horizon DNS, /etc/dnsmasq.d/hubris-split.conf
jellyfin:
url: https://media.hubris.network
backend: jellyfin
nextcloud:
url: https://cloud.hubris.network
backend: nextcloud
paperless:
url: https://paperless.hubris.network
backend: paperless
matrix:
url: https://matrix.hubris.network
backend: elementsynapse
photos:
url: https://photos.hubris.network
backend: mule-images
photos_new:
url: https://photos-new.hubris.network
backend: mule-photos-new
plato:
url: https://plato.hubris.network
backend: plato
arr_stack:
backend: arriman
note: jellyseerr / qbit / sab on docker compose
artifacto:
backend: apps
url: https://artifacto.hubris.network
zimaos:
url: https://zimaos.hubris.network
backend: zimaos
haos:
backend: haos
# New services introduced by this plan.
homelab_mcp:
backend: apps
port: 9810
netbird_host: apps.netbird.selfhosted
endpoint: http://apps.netbird.selfhosted:9810/sse
note: MCP server. Read-only context + management. Mesh-only.
secrets_issuance:
backend: apps
port: 9820
netbird_host: apps.netbird.selfhosted
endpoint: http://apps.netbird.selfhosted:9820/issue
note: Issues per-client age private keys. Mesh-only.
# ---------------------------------------------------------------------------
# Hosts (Proxmox host + LXCs + VMs + workstations)
# ---------------------------------------------------------------------------
hosts:
# ----- The hypervisor -----
hubris:
kind: proxmox-host
os: linux
role: hypervisor
lan_ip: 192.168.8.77
mesh:
netbird:
ip: 100.122.38.109
fqdn: proxmox-server.netbird.selfhosted
ssh:
port: 22 # OpenSSH
netbird_port: 22022 # see infrastructure/ssh-access.md
mounts:
- /mnt/library # shared lvmthin
age_pubkey: "" # filled by issuance on first bootstrap
# ----- LXC containers (all on hubris) -----
jellyfin:
kind: lxc
pve_id: 101
host: hubris
os: linux
role: media-server
lan_ip: 192.168.8.206
public_host: media.hubris.network
mesh: {netbird: {fqdn: jellyfin.netbird.selfhosted}}
mounts: [/mnt/library]
age_pubkey: ""
nfs-export:
kind: lxc
pve_id: 102
host: hubris
os: linux
role: storage-export
lan_ip: 192.168.8.200
mesh: {netbird: {fqdn: nfs-export.netbird.selfhosted}}
age_pubkey: ""
paperless:
kind: lxc
pve_id: 103
host: hubris
os: linux
role: document-archive
lan_ip: 192.168.8.130
public_host: paperless.hubris.network
mesh: {netbird: {fqdn: paperless.netbird.selfhosted}}
mounts: [/mnt/library]
age_pubkey: ""
gitea:
kind: lxc
pve_id: 104
host: hubris
os: linux
role: git-server
lan_ip: 192.168.8.121
public_host: git.hubris.network
backend_port: 3000
mesh: {netbird: {fqdn: gitea.netbird.selfhosted}}
mounts: [/mnt/library]
notes:
- "Bare repos live at /mnt/library/repos/dtoro/*.git"
age_pubkey: ""
apps:
kind: lxc
pve_id: 105
host: hubris
os: linux
role: docker-apps
lan_ip: 192.168.8.205
public_hosts: [artifacto.hubris.network]
mesh: {netbird: {fqdn: apps.netbird.selfhosted}}
mounts: [/mnt/library]
runs:
- artifacto
- plantuml
- homelab-mcp # introduced by this plan
- secrets-issuance # introduced by this plan
age_pubkey: ""
nextcloud:
kind: lxc
pve_id: 114
host: hubris
os: linux
role: file-sync
lan_ip: 192.168.8.224
public_host: cloud.hubris.network
mesh: {netbird: {fqdn: nextcloud.netbird.selfhosted}}
mounts: [/mnt/library]
age_pubkey: ""
elementsynapse:
kind: lxc
pve_id: 118
host: hubris
os: linux
role: matrix-server
lan_ip: 192.168.8.239
public_host: matrix.hubris.network
mesh: {netbird: {fqdn: elementsynapse.netbird.selfhosted}}
age_pubkey: ""
sophia:
kind: lxc
pve_id: 119
host: hubris
os: linux
role: workshop
lan_ip: 192.168.8.157
mesh: {netbird: {fqdn: sophia.netbird.selfhosted}}
mounts: [/mnt/library]
age_pubkey: ""
mule-images:
kind: lxc
pve_id: 120
host: hubris
os: linux
role: photo-management
lan_ip: 192.168.8.136
public_host: photos.hubris.network
mesh: {netbird: {fqdn: mule-images.netbird.selfhosted}}
mounts: [/mnt/library]
age_pubkey: ""
caddy:
kind: lxc
pve_id: 121
host: hubris
os: linux
role: reverse-proxy
lan_ip: 192.168.8.175
notes:
- "Terminates all *.hubris.network"
- "/etc/caddy is a git checkout of dtoro/caddy-conf"
peers: [authentik, gitea]
mesh: {netbird: {fqdn: caddy.netbird.selfhosted}}
age_pubkey: ""
arriman:
kind: lxc
pve_id: 122
host: hubris
os: linux
role: arr-stack
lan_ip: 192.168.8.132
public_hosts: [jellyseerr.hubris.network, qbit.hubris.network, sab.hubris.network]
mesh: {netbird: {fqdn: arriman.netbird.selfhosted}}
mounts: [/mnt/library]
age_pubkey: ""
claudio-bot:
kind: lxc
pve_id: 123
host: hubris
os: linux
role: matrix-agent
lan_ip: 192.168.8.230
mesh: {netbird: {fqdn: claudio-bot.netbird.selfhosted}}
notes:
- "Reads /opt/homelab-context/ on startup"
age_pubkey: ""
authentik:
kind: lxc
pve_id: 124
host: hubris
os: linux
role: idp
lan_ip: 192.168.8.180
public_host: auth.hubris.network
notes:
- "Also hosts split-horizon dnsmasq: /etc/dnsmasq.d/hubris-split.conf"
mesh: {netbird: {fqdn: authentik.netbird.selfhosted}}
age_pubkey: ""
plato:
kind: lxc
pve_id: 126
host: hubris
os: linux
role: app
lan_ip: 192.168.8.190
public_host: plato.hubris.network
mesh: {netbird: {fqdn: plato.netbird.selfhosted}}
mounts: [/mnt/library/documents/plato]
age_pubkey: ""
mule-photos-new:
kind: lxc
pve_id: 127
host: hubris
os: linux
role: photo-management
lan_ip: 192.168.8.181
public_host: photos-new.hubris.network
mesh: {netbird: {fqdn: mule-photos-new.netbird.selfhosted}}
age_pubkey: ""
# ----- VMs (on hubris) -----
zimaos:
kind: vm
pve_id: 100
host: hubris
os: linux
role: nas-frontend-eval
public_host: zimaos.hubris.network
mesh: {netbird: {fqdn: zimaos.netbird.selfhosted}}
age_pubkey: ""
haos:
kind: vm
pve_id: 108
host: hubris
os: linux
role: home-automation
mesh: {netbird: {fqdn: haos.netbird.selfhosted}}
age_pubkey: ""
# ----- Workstations (roaming, primary agent clients) -----
republic-laptop:
kind: workstation
os: linux
role: primary-dev
mesh: {netbird: {fqdn: republic-laptop.netbird.selfhosted}}
age_pubkey: ""
mac-mini:
kind: workstation
os: macos
role: dev
mesh: {netbird: {fqdn: mac-mini.netbird.selfhosted}}
notes:
- "Only macOS in the fleet. Bootstrap uses launchd."
age_pubkey: ""
ludo-mini:
kind: workstation
os: linux
role: dev
mesh: {netbird: {fqdn: ludo-mini.netbird.selfhosted}}
age_pubkey: ""

158
mcp/build_host_files.py Executable file
View File

@@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""
Generate hosts/<name>.yaml from inventory.yaml.
Run from the repo root:
python3 mcp/build_host_files.py # writes files, exits non-zero on diff
python3 mcp/build_host_files.py --check # exits non-zero if any output differs
Designed to be wired into a pre-commit hook or Gitea Action so generated
hosts/*.yaml never drift from inventory.yaml.
"""
from __future__ import annotations
import argparse
import difflib
import os
import sys
from pathlib import Path
try:
import yaml
except ImportError: # pragma: no cover
print("PyYAML is required: pip install pyyaml", file=sys.stderr)
sys.exit(2)
REPO = Path(__file__).resolve().parent.parent
INVENTORY = REPO / "inventory.yaml"
HOSTS_DIR = REPO / "hosts"
GENERATED_BANNER = (
"# Generated by mcp/build_host_files.py from inventory.yaml.\n"
"# Do NOT edit by hand — your changes will be overwritten.\n"
"# Source of truth: ../inventory.yaml\n"
)
def narrative_page(name: str, kind: str, pve_id: int | None) -> str | None:
"""Best-guess path to the human-authored narrative page for this host."""
if kind == "proxmox-host":
candidate = REPO / "hosts" / f"{name}.md"
elif kind == "lxc":
candidate = REPO / "containers" / f"{pve_id}-{name}.md"
elif kind == "vm":
candidate = REPO / "vms" / f"{pve_id}-{name}.md"
else:
return None
if candidate.exists():
return str(candidate.relative_to(REPO))
return None
def build_one(name: str, entry: dict, inventory: dict) -> dict:
"""Project the entry for a single host into a per-host yaml record."""
services = inventory.get("services", {})
mesh = inventory.get("mesh", {})
pve_id = entry.get("pve_id")
# Services this host runs: scan inventory.services for matching backend.
runs_services = sorted(
svc for svc, sentry in services.items()
if isinstance(sentry, dict) and sentry.get("backend") == name
)
record = {
"name": name,
"kind": entry.get("kind"),
"os": entry.get("os"),
"role": entry.get("role"),
"host": entry.get("host"),
"pve_id": pve_id,
"lan_ip": entry.get("lan_ip"),
"mesh": entry.get("mesh", {}),
"mesh_globals": {
"primary": mesh.get("primary"),
"accepted": mesh.get("accepted"),
},
"peers": entry.get("peers", []),
"mounts": entry.get("mounts", []),
"public_host": entry.get("public_host"),
"public_hosts": entry.get("public_hosts", []),
"ssh": entry.get("ssh", {}),
"runs": entry.get("runs", []) + runs_services,
"services_hosted": [
{"name": svc, **services[svc]} for svc in runs_services
],
"notes": entry.get("notes", []),
"age_pubkey": entry.get("age_pubkey", ""),
"see_also": [
page for page in [narrative_page(name, entry.get("kind", ""), pve_id)]
if page
],
"mcp_endpoint": services.get("homelab_mcp", {}).get("endpoint"),
"secrets_issuance_endpoint": (
services.get("secrets_issuance", {}).get("endpoint")
),
}
# Strip None and empty containers so the file stays readable.
return {k: v for k, v in record.items() if v not in (None, {}, [], "")}
def serialize(record: dict) -> str:
return GENERATED_BANNER + yaml.safe_dump(
record, sort_keys=False, default_flow_style=False, width=100
)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--check", action="store_true",
help="exit 1 if any output would change (don't write)")
args = parser.parse_args()
inventory = yaml.safe_load(INVENTORY.read_text())
hosts = inventory.get("hosts", {})
HOSTS_DIR.mkdir(exist_ok=True)
desired: dict[Path, str] = {}
for name, entry in hosts.items():
desired[HOSTS_DIR / f"{name}.yaml"] = serialize(build_one(name, entry, inventory))
diff_count = 0
for path, content in desired.items():
existing = path.read_text() if path.exists() else ""
if existing != content:
diff_count += 1
if args.check:
diff = difflib.unified_diff(
existing.splitlines(keepends=True),
content.splitlines(keepends=True),
fromfile=str(path),
tofile=str(path) + " (generated)",
)
sys.stdout.writelines(diff)
else:
path.write_text(content)
print(f"wrote {path.relative_to(REPO)}")
# Clean up orphans (file exists but host removed from inventory).
for existing_path in HOSTS_DIR.glob("*.yaml"):
if existing_path not in desired:
diff_count += 1
if args.check:
print(f"orphan: {existing_path.relative_to(REPO)} (would delete)")
else:
existing_path.unlink()
print(f"deleted orphan {existing_path.relative_to(REPO)}")
if args.check and diff_count > 0:
print(f"\n{diff_count} file(s) would change. Run without --check to write.",
file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())

46
mcp/deploy/deploy.sh Executable file
View File

@@ -0,0 +1,46 @@
#!/bin/bash
# Install/update homelab-mcp on LXC 105 (apps). Idempotent.
# Triggered by the gitea webhook or run by hand.
set -euo pipefail
REPO_DIR=${REPO_DIR:-/opt/homelab-mcp}
cd "$REPO_DIR"
echo "[deploy] git pull"
git pull --ff-only
echo "[deploy] ensure python venv + deps"
if [ ! -d /opt/homelab-mcp/.venv ]; then
python3 -m venv /opt/homelab-mcp/.venv
fi
/opt/homelab-mcp/.venv/bin/pip install --quiet --upgrade pip
/opt/homelab-mcp/.venv/bin/pip install --quiet "mcp[cli]" pyyaml
echo "[deploy] install systemd units"
install -m 644 mcp/deploy/homelab-mcp.service \
/etc/systemd/system/homelab-mcp.service
install -m 644 mcp/deploy/webhook/homelab-mcp-deploy.service \
/etc/systemd/system/homelab-mcp-deploy.service
echo "[deploy] context clone for the MCP server"
# The MCP server reads from a local clone of Homelab-Docs at /opt/homelab-context
# (the same path every client uses). Bootstrap should already have created this;
# if not, fail loudly.
if [ ! -d /opt/homelab-context/.git ]; then
echo " /opt/homelab-context is not a git clone — run bootstrap.sh first." >&2
exit 1
fi
systemctl daemon-reload
if systemctl is-active --quiet homelab-mcp.service; then
systemctl restart homelab-mcp.service
fi
if systemctl is-active --quiet homelab-mcp-deploy.service; then
systemctl restart homelab-mcp-deploy.service
fi
echo "[deploy] done"
echo "First-time enable:"
echo " systemctl enable --now homelab-mcp.service homelab-mcp-deploy.service"
echo "Webhook first-time setup (generates secret):"
echo " $REPO_DIR/mcp/deploy/webhook/install.sh"

View File

@@ -0,0 +1,24 @@
[Unit]
Description=Homelab MCP server (read-only context + management)
After=network-online.target homelab-context-sync.service
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/opt/homelab-context
Environment=HOMELAB_CONTEXT_DIR=/opt/homelab-context
Environment=HOMELAB_MCP_SSH_KEY=/etc/homelab-mcp/mcp-reader.key
Environment=HOMELAB_MCP_SSH_USER=mcp-reader
ExecStart=/opt/homelab-mcp/.venv/bin/python /opt/homelab-mcp/mcp/server.py
Restart=on-failure
RestartSec=5
# Stay confined.
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
NoNewPrivileges=true
ReadOnlyPaths=/opt/homelab-context /opt/homelab-mcp
ReadWritePaths=/var/log/homelab-mcp
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,13 @@
[Unit]
Description=Gitea deploy webhook for dtoro/Homelab-Docs → homelab-mcp
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 /opt/homelab-mcp/mcp/deploy/webhook/webhook.py
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target

31
mcp/deploy/webhook/install.sh Executable file
View File

@@ -0,0 +1,31 @@
#!/bin/bash
# First-time setup for the homelab-mcp deploy webhook. Generates a secret,
# installs the systemd unit, and starts it. Re-run is safe (won't regenerate
# the secret if one exists).
set -euo pipefail
SECRET_DIR=/etc/homelab-mcp-deploy
SECRET=$SECRET_DIR/secret
UNIT=homelab-mcp-deploy.service
install -d -m 700 "$SECRET_DIR"
if [ ! -s "$SECRET" ]; then
head -c 32 /dev/urandom | base64 > "$SECRET"
chmod 600 "$SECRET"
echo "[install] generated webhook secret at $SECRET"
fi
systemctl daemon-reload
systemctl enable --now "$UNIT"
systemctl status "$UNIT" --no-pager | head -10
cat <<EOF
[install] webhook listening on :9811/deploy.
Configure Gitea (dtoro/Homelab-Docs → Settings → Webhooks → Add Webhook → Gitea):
Target URL: http://<lxc-105-mesh-ip>:9811/deploy
HTTP Method: POST
Content-Type: application/json
Secret: $(cat $SECRET)
Trigger: Push events
EOF

95
mcp/deploy/webhook/webhook.py Executable file
View File

@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Deploy webhook for dtoro/Homelab-Docs on LXC 105 (apps).
Listens on 0.0.0.0:9811/deploy. Validates Gitea HMAC, runs deploy.sh.
Port 9811: claudio-bot-deploy=9797, backup-library-deploy=9798,
claudio-monitor=9799, homelab-mcp=9811, secrets-issuance=9821.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
import os
import subprocess
import sys
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
BIND_HOST = "0.0.0.0"
BIND_PORT = 9811
SECRET_PATH = "/etc/homelab-mcp-deploy/secret"
DEPLOY_CMD = ["/opt/homelab-mcp/mcp/deploy/deploy.sh"]
TARGET_REF = "refs/heads/main"
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("homelab-mcp-deploy")
_deploy_lock = threading.Lock()
def load_secret() -> bytes:
with open(SECRET_PATH, "rb") as f:
return f.read().strip()
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
log.info("%s - %s", self.address_string(), fmt % args)
def _reply(self, status: int, msg: str = "") -> None:
self.send_response(status)
self.send_header("Content-Type", "text/plain")
self.end_headers()
if msg:
self.wfile.write(msg.encode())
def do_POST(self) -> None:
if self.path != "/deploy":
self._reply(404, "not found")
return
length = int(self.headers.get("Content-Length", "0"))
body = self.rfile.read(length) if length else b""
sig_header = self.headers.get("X-Gitea-Signature", "")
secret = load_secret()
expected = hmac.new(secret, body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig_header):
log.warning("signature mismatch")
self._reply(403, "bad signature")
return
try:
payload = json.loads(body)
except json.JSONDecodeError:
self._reply(400, "bad json")
return
if payload.get("ref", "") != TARGET_REF:
self._reply(204)
return
if not _deploy_lock.acquire(blocking=False):
self._reply(202, "already running")
return
try:
log.info("running deploy: %s", DEPLOY_CMD)
result = subprocess.run(DEPLOY_CMD, capture_output=True, text=True, timeout=180)
if result.returncode != 0:
log.error("deploy failed: %s\n%s", result.stdout, result.stderr)
self._reply(500, "deploy failed")
return
self._reply(204)
finally:
_deploy_lock.release()
def main() -> int:
if not os.path.exists(SECRET_PATH):
log.error("secret file missing: %s", SECRET_PATH)
return 1
server = HTTPServer((BIND_HOST, BIND_PORT), Handler)
log.info("listening on %s:%d", BIND_HOST, BIND_PORT)
server.serve_forever()
return 0
if __name__ == "__main__":
sys.exit(main())

310
mcp/server.py Executable file
View File

@@ -0,0 +1,310 @@
#!/usr/bin/env python3
"""
Homelab MCP server.
Reads the canonical state from /opt/homelab-context/ (a git clone of
dtoro/Homelab-Docs) and exposes structured tools to any MCP-capable agent.
Two tool groups:
- Context (pure read of the clone, no shell-outs)
- Management (read-only ssh/pct/systemctl/curl, NO mutations)
Mutations live in the `homelab` CLI on each client, behind operator
confirmation. The MCP server never restarts, edits, or executes arbitrary
commands.
"""
from __future__ import annotations
import os
import re
import shlex
import subprocess
from functools import lru_cache
from pathlib import Path
from typing import Any
import yaml
from mcp.server.fastmcp import FastMCP
CONTEXT_DIR = Path(os.environ.get("HOMELAB_CONTEXT_DIR", "/opt/homelab-context"))
INVENTORY = CONTEXT_DIR / "inventory.yaml"
HOSTS_DIR = CONTEXT_DIR / "hosts"
SSH_IDENTITY = os.environ.get("HOMELAB_MCP_SSH_KEY", "/etc/homelab-mcp/mcp-reader.key")
SSH_USER = os.environ.get("HOMELAB_MCP_SSH_USER", "mcp-reader")
SSH_TIMEOUT = int(os.environ.get("HOMELAB_MCP_SSH_TIMEOUT", "10"))
mcp = FastMCP("homelab")
def _load_inventory() -> dict:
if not INVENTORY.exists():
raise RuntimeError(f"inventory not found: {INVENTORY}")
return yaml.safe_load(INVENTORY.read_text())
@lru_cache(maxsize=1)
def _inventory_cache_key() -> int:
"""Cache buster keyed on mtime so we re-read after each sync pull."""
return INVENTORY.stat().st_mtime_ns
def inventory() -> dict:
_inventory_cache_key.cache_clear()
_ = _inventory_cache_key() # warm
return _load_inventory()
def _host_ssh_target(name: str) -> str:
"""Return the ssh target (user@host) the MCP reader uses for `name`."""
inv = inventory()
h = inv.get("hosts", {}).get(name)
if h is None:
raise ValueError(f"unknown host: {name}")
mesh = h.get("mesh", {})
# Netbird FQDN if available, then lan_ip, then tailscale.
target = (
mesh.get("netbird", {}).get("fqdn")
or mesh.get("netbird", {}).get("ip")
or h.get("lan_ip")
or mesh.get("tailscale", {}).get("fqdn")
)
if not target:
raise ValueError(f"no reachable address for host {name}")
return f"{SSH_USER}@{target}"
def _ssh(host: str, *cmd: str, timeout: int | None = None) -> subprocess.CompletedProcess:
"""Run a command on a remote host as the restricted mcp-reader user."""
target = _host_ssh_target(host)
full = [
"ssh", "-i", SSH_IDENTITY, "-o", "BatchMode=yes",
"-o", "StrictHostKeyChecking=accept-new",
"-o", f"ConnectTimeout={SSH_TIMEOUT}",
target, *cmd,
]
return subprocess.run(full, capture_output=True, text=True,
timeout=timeout or SSH_TIMEOUT * 3)
def _service_to_host(service: str) -> str:
"""Resolve a service name to its backend host name."""
svc = inventory().get("services", {}).get(service)
if not svc:
raise ValueError(f"unknown service: {service}")
return svc["backend"]
# ---------------------------------------------------------------------------
# Context tools
# ---------------------------------------------------------------------------
@mcp.tool()
def get_host(name: str) -> dict:
"""Return the structured record for a host (LXC, VM, workstation, or hubris)."""
inv = inventory()
entry = inv.get("hosts", {}).get(name)
if entry is None:
raise ValueError(f"unknown host: {name}")
out = {"name": name, **entry}
yaml_path = HOSTS_DIR / f"{name}.yaml"
if yaml_path.exists():
out["host_yaml_path"] = str(yaml_path)
return out
@mcp.tool()
def list_services() -> dict:
"""List every service registered in inventory.yaml."""
return inventory().get("services", {})
@mcp.tool()
def find_service(query: str) -> list[dict]:
"""Find services by name substring, role, or backend host name."""
q = query.lower()
inv = inventory()
out = []
for name, entry in inv.get("services", {}).items():
haystack = " ".join([
name,
str(entry.get("backend", "")),
str(entry.get("role", "")),
str(entry.get("note", "")),
]).lower()
if q in haystack:
out.append({"service": name, **entry})
return out
@mcp.tool()
def get_topology() -> dict:
"""Return the full inventory (hosts + services + mesh)."""
return inventory()
@mcp.tool()
def search_docs(query: str, max_results: int = 10) -> list[dict]:
"""Ripgrep the markdown wiki for query, return file:line hits."""
rg = subprocess.run(
["rg", "--no-heading", "-n", "-i", "--type", "md", "-m", "5", query, str(CONTEXT_DIR)],
capture_output=True, text=True, timeout=10,
)
results = []
for line in rg.stdout.splitlines()[: max_results * 5]:
m = re.match(r"^(.+?):(\d+):(.*)$", line)
if m:
results.append({
"path": str(Path(m.group(1)).relative_to(CONTEXT_DIR)),
"line": int(m.group(2)),
"text": m.group(3).strip(),
})
if len(results) >= max_results:
break
return results
@mcp.tool()
def get_page(path: str) -> str:
"""Return a markdown page verbatim. Path is relative to the repo root."""
full = (CONTEXT_DIR / path).resolve()
if not str(full).startswith(str(CONTEXT_DIR.resolve())):
raise ValueError("path escapes repo")
if not full.exists():
raise ValueError(f"no such page: {path}")
return full.read_text()
@mcp.tool()
def get_changelog(path: str, since: str | None = None) -> str:
"""Extract the trailing ## Changelog section from a page; optionally filter."""
text = get_page(path)
m = re.search(r"^##\s+Changelog\s*\n(.*)", text, re.MULTILINE | re.DOTALL)
if not m:
return ""
body = m.group(1).strip()
if since:
kept = []
for entry in re.split(r"^###\s+", body, flags=re.MULTILINE):
if not entry.strip():
continue
head = entry.split("\n", 1)[0]
date_m = re.match(r"(\d{4}-\d{2}-\d{2})", head)
if not date_m or date_m.group(1) >= since:
kept.append("### " + entry.rstrip())
return "\n\n".join(kept).strip()
return body
@mcp.tool()
def whoami(hostname: str) -> dict:
"""Given a hostname, return that host's full yaml record from hosts/<name>.yaml."""
candidate = HOSTS_DIR / f"{hostname}.yaml"
if not candidate.exists():
raise ValueError(f"no host record for {hostname}")
return yaml.safe_load(candidate.read_text())
@mcp.tool()
def list_my_secrets(caller_pubkey: str) -> list[str]:
"""Return the names of secrets the caller (identified by age pubkey) can decrypt.
Metadata only — the server never returns plaintext. The client decrypts
locally with its own /etc/age/key.txt.
"""
if not caller_pubkey:
return []
secrets_dir = CONTEXT_DIR / "secrets"
if not secrets_dir.exists():
return []
out = []
for path in secrets_dir.glob("*.yaml"):
try:
data = yaml.safe_load(path.read_text()) or {}
except yaml.YAMLError:
continue
recipients = (
data.get("sops", {})
.get("age", [])
)
for r in recipients:
if r.get("recipient") == caller_pubkey:
out.append(path.stem)
break
return sorted(out)
# ---------------------------------------------------------------------------
# Management tools (read-only)
# ---------------------------------------------------------------------------
@mcp.tool()
def get_service_status(service: str) -> dict:
"""systemctl is-active + is-enabled for the named service on its backend host."""
host = _service_to_host(service)
inv_svc = inventory()["services"][service]
unit = inv_svc.get("systemd_unit", service)
active = _ssh(host, "systemctl", "is-active", unit)
enabled = _ssh(host, "systemctl", "is-enabled", unit)
return {
"service": service,
"host": host,
"unit": unit,
"active": active.stdout.strip(),
"enabled": enabled.stdout.strip(),
}
@mcp.tool()
def tail_log(service: str, lines: int = 200) -> str:
"""Last N journalctl lines for the named service on its backend host."""
host = _service_to_host(service)
inv_svc = inventory()["services"][service]
unit = inv_svc.get("systemd_unit", service)
proc = _ssh(host, "journalctl", "-u", unit, "-n", str(lines), "--no-pager")
return proc.stdout
@mcp.tool()
def list_lxcs() -> str:
"""Run `pct list` on hubris."""
return _ssh("hubris", "pct", "list").stdout
@mcp.tool()
def get_lxc_state(lxc: str) -> dict:
"""`pct status` and a quick `pct config` snapshot for the named LXC."""
inv = inventory().get("hosts", {})
if lxc not in inv:
raise ValueError(f"unknown lxc: {lxc}")
pve_id = inv[lxc].get("pve_id")
if pve_id is None:
raise ValueError(f"{lxc} has no pve_id (is it actually an LXC?)")
status = _ssh("hubris", "pct", "status", str(pve_id))
cfg = _ssh("hubris", "pct", "config", str(pve_id))
return {"lxc": lxc, "pve_id": pve_id,
"status": status.stdout.strip(),
"config": cfg.stdout}
@mcp.tool()
def ping_service(service: str) -> dict:
"""HTTP check against the service's URL (from inventory)."""
inv_svc = inventory().get("services", {}).get(service)
if not inv_svc:
raise ValueError(f"unknown service: {service}")
url = inv_svc.get("url") or inv_svc.get("endpoint") or inv_svc.get("backend_url")
if not url:
return {"service": service, "ok": False, "reason": "no URL in inventory"}
proc = subprocess.run(
["curl", "-sS", "-o", "/dev/null", "-w", "%{http_code} %{time_total}",
"--max-time", "5", url],
capture_output=True, text=True,
)
code, t = (proc.stdout.strip().split() + ["", ""])[:2]
return {"service": service, "url": url, "http_code": code, "time_s": t,
"ok": code.startswith("2") or code.startswith("3")}
if __name__ == "__main__":
mcp.run(transport="sse")

41
scripts/sync/install.sh Executable file
View File

@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Install the homelab-context sync mechanism for the current OS.
# Called by bootstrap.sh; safe to re-run (idempotent).
set -euo pipefail
REPO_DIR="${HOMELAB_CONTEXT_DIR:-/opt/homelab-context}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
case "$(uname -s)" in
Linux)
install -m 0644 "$SCRIPT_DIR/linux/homelab-context-sync.service" \
/etc/systemd/system/homelab-context-sync.service
install -m 0644 "$SCRIPT_DIR/linux/homelab-context-sync.timer" \
/etc/systemd/system/homelab-context-sync.timer
systemctl daemon-reload
systemctl enable --now homelab-context-sync.timer
# Kick once so the first pull happens immediately.
systemctl start homelab-context-sync.service || true
echo "[install.sh] linux: timer installed and started"
;;
Darwin)
PLIST=/Library/LaunchDaemons/network.hubris.homelab-context-sync.plist
install -m 0644 -o root -g wheel \
"$SCRIPT_DIR/macos/network.hubris.homelab-context-sync.plist" "$PLIST"
# Touch the log path so the daemon can write without a TOCTOU.
touch /var/log/homelab-context-sync.log
chmod 0644 /var/log/homelab-context-sync.log
# bootstrap may have loaded a previous version; reload.
launchctl bootout system "$PLIST" 2>/dev/null || true
launchctl bootstrap system "$PLIST"
launchctl enable system/network.hubris.homelab-context-sync
# Kick once.
launchctl kickstart -k system/network.hubris.homelab-context-sync || true
echo "[install.sh] macos: launchd job loaded and kicked"
;;
*)
echo "[install.sh] unsupported OS: $(uname -s)" >&2
exit 1
;;
esac

View File

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

View File

@@ -0,0 +1,11 @@
[Unit]
Description=Periodic pull of /opt/homelab-context
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
AccuracySec=30s
Unit=homelab-context-sync.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,32 @@
<?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/git</string>
<string>-C</string>
<string>/opt/homelab-context</string>
<string>pull</string>
<string>--ff-only</string>
<string>--quiet</string>
</array>
<key>StartInterval</key>
<integer>300</integer>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>/var/log/homelab-context-sync.log</string>
<key>StandardErrorPath</key>
<string>/var/log/homelab-context-sync.log</string>
<key>Nice</key>
<integer>10</integer>
</dict>
</plist>

View File

@@ -0,0 +1,57 @@
#!/bin/bash
# Install/update secrets-issuance on LXC 105 (apps). Idempotent.
set -euo pipefail
REPO_DIR=${REPO_DIR:-/opt/secrets-issuance}
cd "$REPO_DIR"
echo "[deploy] git pull"
git pull --ff-only
echo "[deploy] ensure python venv + deps"
if [ ! -d /opt/secrets-issuance/.venv ]; then
python3 -m venv /opt/secrets-issuance/.venv
fi
/opt/secrets-issuance/.venv/bin/pip install --quiet --upgrade pip
/opt/secrets-issuance/.venv/bin/pip install --quiet pyyaml
echo "[deploy] verify dependencies (age, shred)"
command -v age-keygen >/dev/null || { echo "age-keygen not installed: apt install -y age" >&2; exit 1; }
command -v shred >/dev/null || { echo "shred not installed: apt install -y coreutils" >&2; exit 1; }
echo "[deploy] state dir"
install -d -m 700 /var/lib/secrets-issuance
install -d -m 700 /var/lib/secrets-issuance/keys
echo "[deploy] admin token dir"
install -d -m 700 /etc/secrets-issuance
if [ ! -s /etc/secrets-issuance/admin-token ]; then
head -c 32 /dev/urandom | base64 > /etc/secrets-issuance/admin-token
chmod 600 /etc/secrets-issuance/admin-token
echo "[deploy] generated admin token at /etc/secrets-issuance/admin-token"
fi
echo "[deploy] install systemd units"
install -m 644 secrets-issuance/server.service \
/etc/systemd/system/secrets-issuance.service
install -m 644 secrets-issuance/deploy/webhook/secrets-issuance-deploy.service \
/etc/systemd/system/secrets-issuance-deploy.service
if [ ! -d /opt/homelab-context/.git ]; then
echo " /opt/homelab-context is not a git clone — run bootstrap.sh first." >&2
exit 1
fi
systemctl daemon-reload
if systemctl is-active --quiet secrets-issuance.service; then
systemctl restart secrets-issuance.service
fi
if systemctl is-active --quiet secrets-issuance-deploy.service; then
systemctl restart secrets-issuance-deploy.service
fi
echo "[deploy] done"
echo "First-time enable:"
echo " systemctl enable --now secrets-issuance.service secrets-issuance-deploy.service"
echo "Webhook first-time setup:"
echo " $REPO_DIR/secrets-issuance/deploy/webhook/install.sh"

View File

@@ -0,0 +1,30 @@
#!/bin/bash
# First-time setup for the secrets-issuance deploy webhook. Generates a
# secret, installs the systemd unit, and starts it.
set -euo pipefail
SECRET_DIR=/etc/secrets-issuance-deploy
SECRET=$SECRET_DIR/secret
UNIT=secrets-issuance-deploy.service
install -d -m 700 "$SECRET_DIR"
if [ ! -s "$SECRET" ]; then
head -c 32 /dev/urandom | base64 > "$SECRET"
chmod 600 "$SECRET"
echo "[install] generated webhook secret at $SECRET"
fi
systemctl daemon-reload
systemctl enable --now "$UNIT"
systemctl status "$UNIT" --no-pager | head -10
cat <<EOF
[install] webhook listening on :9821/deploy.
Configure Gitea (dtoro/Homelab-Docs → Settings → Webhooks → Add Webhook → Gitea):
Target URL: http://<lxc-105-mesh-ip>:9821/deploy
HTTP Method: POST
Content-Type: application/json
Secret: $(cat $SECRET)
Trigger: Push events
EOF

View File

@@ -0,0 +1,13 @@
[Unit]
Description=Gitea deploy webhook for dtoro/Homelab-Docs → secrets-issuance
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 /opt/secrets-issuance/secrets-issuance/deploy/webhook/webhook.py
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Deploy webhook for dtoro/Homelab-Docs → secrets-issuance on LXC 105.
Listens on 0.0.0.0:9821/deploy. Validates Gitea HMAC, runs deploy.sh.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
import os
import subprocess
import sys
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
BIND_HOST = "0.0.0.0"
BIND_PORT = 9821
SECRET_PATH = "/etc/secrets-issuance-deploy/secret"
DEPLOY_CMD = ["/opt/secrets-issuance/secrets-issuance/deploy/deploy.sh"]
TARGET_REF = "refs/heads/main"
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("secrets-issuance-deploy")
_deploy_lock = threading.Lock()
def load_secret() -> bytes:
with open(SECRET_PATH, "rb") as f:
return f.read().strip()
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
log.info("%s - %s", self.address_string(), fmt % args)
def _reply(self, status: int, msg: str = "") -> None:
self.send_response(status)
self.send_header("Content-Type", "text/plain")
self.end_headers()
if msg:
self.wfile.write(msg.encode())
def do_POST(self) -> None:
if self.path != "/deploy":
self._reply(404, "not found")
return
length = int(self.headers.get("Content-Length", "0"))
body = self.rfile.read(length) if length else b""
sig = self.headers.get("X-Gitea-Signature", "")
secret = load_secret()
expected = hmac.new(secret, body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig):
log.warning("signature mismatch")
self._reply(403, "bad signature")
return
try:
payload = json.loads(body)
except json.JSONDecodeError:
self._reply(400, "bad json")
return
if payload.get("ref", "") != TARGET_REF:
self._reply(204)
return
if not _deploy_lock.acquire(blocking=False):
self._reply(202, "already running")
return
try:
result = subprocess.run(DEPLOY_CMD, capture_output=True, text=True, timeout=180)
if result.returncode != 0:
log.error("deploy failed: %s\n%s", result.stdout, result.stderr)
self._reply(500, "deploy failed")
return
self._reply(204)
finally:
_deploy_lock.release()
def main() -> int:
if not os.path.exists(SECRET_PATH):
log.error("secret file missing: %s", SECRET_PATH)
return 1
server = HTTPServer((BIND_HOST, BIND_PORT), Handler)
log.info("listening on %s:%d", BIND_HOST, BIND_PORT)
server.serve_forever()
return 0
if __name__ == "__main__":
sys.exit(main())

266
secrets-issuance/server.py Executable file
View File

@@ -0,0 +1,266 @@
#!/usr/bin/env python3
"""
Secrets-issuance HTTP service.
Lives on LXC 105 alongside the MCP server. Mesh-bound (nftables restricts to
Netbird + Tailscale subnets). Identifies callers by source mesh IP and returns
the per-client age private key.
State:
/var/lib/secrets-issuance/keys/<hostname>.key (private, 0600 root)
/var/lib/secrets-issuance/keys/<hostname>.pub (public)
/var/lib/secrets-issuance/denylist.txt (one hostname per line)
Inventory lookup:
/opt/homelab-context/inventory.yaml — the issuance server is itself a
homelab-context client, so it sees inventory updates within 5 min.
Endpoints:
POST /issue body: {"hostname": "..."} -> raw age private key
POST /revoke body: {"hostname": "..."} -> shred key + add to denylist
(called by 'homelab client remove')
GET /health
"""
from __future__ import annotations
import ipaddress
import json
import logging
import os
import secrets as sysrandom
import subprocess
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
import yaml
BIND_HOST = "0.0.0.0"
BIND_PORT = 9820
CONTEXT_DIR = Path(os.environ.get("HOMELAB_CONTEXT_DIR", "/opt/homelab-context"))
STATE_DIR = Path(os.environ.get("SECRETS_ISSUANCE_STATE", "/var/lib/secrets-issuance"))
KEYS_DIR = STATE_DIR / "keys"
DENYLIST = STATE_DIR / "denylist.txt"
ADMIN_TOKEN_PATH = Path(os.environ.get("SECRETS_ISSUANCE_ADMIN_TOKEN",
"/etc/secrets-issuance/admin-token"))
MESH_SUBNETS = [
ipaddress.ip_network(s.strip())
for s in os.environ.get("MESH_SUBNETS", "100.64.0.0/10").split(",")
if s.strip()
]
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("secrets-issuance")
def load_inventory() -> dict:
return yaml.safe_load((CONTEXT_DIR / "inventory.yaml").read_text())
def known_hosts() -> dict[str, dict]:
return load_inventory().get("hosts", {})
def host_by_mesh_ip(ip: str) -> str | None:
"""Resolve a mesh source IP to a hostname per inventory."""
for name, entry in known_hosts().items():
mesh = entry.get("mesh", {})
for proto in ("netbird", "tailscale"):
p = mesh.get(proto, {})
if not isinstance(p, dict):
continue
if p.get("ip") == ip:
return name
return None
def in_mesh(ip: str) -> bool:
addr = ipaddress.ip_address(ip)
return any(addr in subnet for subnet in MESH_SUBNETS)
def is_denied(hostname: str) -> bool:
if not DENYLIST.exists():
return False
return hostname in {line.strip() for line in DENYLIST.read_text().splitlines() if line.strip()}
def deny(hostname: str) -> None:
DENYLIST.parent.mkdir(parents=True, exist_ok=True)
existing = set()
if DENYLIST.exists():
existing = {line.strip() for line in DENYLIST.read_text().splitlines() if line.strip()}
existing.add(hostname)
DENYLIST.write_text("\n".join(sorted(existing)) + "\n")
def generate_key(hostname: str) -> tuple[Path, str]:
"""Generate an age keypair for hostname; return (priv_path, pubkey)."""
KEYS_DIR.mkdir(parents=True, exist_ok=True)
priv = KEYS_DIR / f"{hostname}.key"
pub_path = KEYS_DIR / f"{hostname}.pub"
proc = subprocess.run(["age-keygen"], capture_output=True, text=True, check=True)
priv.write_text(proc.stdout)
priv.chmod(0o600)
# Extract the public key. age-keygen writes "# public key: ageXXX" line.
pubkey = ""
for line in proc.stdout.splitlines():
if line.startswith("# public key:"):
pubkey = line.split(":", 1)[1].strip()
if not pubkey:
priv.unlink(missing_ok=True)
raise RuntimeError("age-keygen did not emit a public key")
pub_path.write_text(pubkey + "\n")
log.info("generated new age key for %s (pubkey: %s)", hostname, pubkey)
return priv, pubkey
def existing_pubkey(hostname: str) -> str | None:
pub_path = KEYS_DIR / f"{hostname}.pub"
if pub_path.exists():
return pub_path.read_text().strip()
return None
def read_admin_token() -> str | None:
if ADMIN_TOKEN_PATH.exists():
return ADMIN_TOKEN_PATH.read_text().strip()
return None
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
log.info("%s - %s", self.address_string(), fmt % args)
def _reply(self, status: int, body: bytes | str = b"",
content_type: str = "text/plain") -> None:
self.send_response(status)
self.send_header("Content-Type", content_type)
if isinstance(body, str):
body = body.encode()
self.send_header("Content-Length", str(len(body)))
self.end_headers()
if body:
self.wfile.write(body)
def _read_json(self) -> dict | None:
length = int(self.headers.get("Content-Length", "0"))
if not length:
return {}
body = self.rfile.read(length)
try:
return json.loads(body)
except json.JSONDecodeError:
return None
def do_GET(self) -> None:
if self.path == "/health":
self._reply(200, "ok")
return
self._reply(404, "not found")
def do_POST(self) -> None:
client_ip = self.client_address[0]
if not in_mesh(client_ip):
log.warning("rejecting non-mesh source %s", client_ip)
self._reply(403, "non-mesh source")
return
if self.path == "/issue":
self._handle_issue(client_ip)
elif self.path == "/revoke":
self._handle_revoke()
else:
self._reply(404, "not found")
def _handle_issue(self, client_ip: str) -> None:
payload = self._read_json()
if payload is None:
self._reply(400, "bad json")
return
hostname = (payload.get("hostname") or "").strip()
if not hostname:
self._reply(400, "hostname required")
return
# Cross-check: does inventory list this hostname, and does its mesh IP
# match the caller (if known)?
hosts = known_hosts()
if hostname not in hosts:
log.warning("unknown hostname in /issue: %s (from %s)", hostname, client_ip)
self._reply(403, f"unknown hostname: {hostname} (run 'homelab client add {hostname}' first)")
return
if is_denied(hostname):
log.warning("denied hostname %s tried to issue (from %s)", hostname, client_ip)
self._reply(403, "hostname on denylist (was removed); operator must clear before re-enrolling")
return
inv_ip = host_by_mesh_ip(client_ip)
if inv_ip is not None and inv_ip != hostname:
log.warning("source %s maps to inventory host %s but body claims %s",
client_ip, inv_ip, hostname)
self._reply(403, "source IP / hostname mismatch")
return
if inv_ip is None:
# Inventory has no IP for this hostname yet (first-bootstrap state).
# Accept the call but log it loudly so the operator backfills.
log.info("issuing for %s (source %s) — inventory has no mesh IP yet",
hostname, client_ip)
priv = KEYS_DIR / f"{hostname}.key"
existing_pub = existing_pubkey(hostname)
if priv.exists() and existing_pub:
# Re-issue: client lost its key but is still authorized. Return
# the existing key.
log.info("returning existing key for %s", hostname)
self._reply(200, priv.read_text())
return
# Fresh provisioning.
priv, pubkey = generate_key(hostname)
# Tell the operator (via response body comment) which pubkey to commit.
body = priv.read_text()
body += f"\n# operator: add this pubkey to inventory.yaml under hosts.{hostname}.age_pubkey:\n"
body += f"# {pubkey}\n"
self._reply(200, body)
def _handle_revoke(self) -> None:
token = self.headers.get("X-Admin-Token", "")
expected = read_admin_token()
if not expected or token != expected:
self._reply(403, "admin token required")
return
payload = self._read_json()
if payload is None:
self._reply(400, "bad json")
return
hostname = (payload.get("hostname") or "").strip()
if not hostname:
self._reply(400, "hostname required")
return
priv = KEYS_DIR / f"{hostname}.key"
pub = KEYS_DIR / f"{hostname}.pub"
for p in (priv, pub):
if p.exists():
# shred-then-unlink
subprocess.run(["shred", "-u", str(p)], check=False)
if p.exists():
p.unlink(missing_ok=True)
deny(hostname)
log.warning("revoked %s (shredded keys, added to denylist)", hostname)
self._reply(200, f"revoked {hostname}\n")
def main() -> int:
KEYS_DIR.mkdir(parents=True, exist_ok=True)
server = HTTPServer((BIND_HOST, BIND_PORT), Handler)
log.info("listening on %s:%d (context=%s, state=%s)",
BIND_HOST, BIND_PORT, CONTEXT_DIR, STATE_DIR)
server.serve_forever()
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,23 @@
[Unit]
Description=Homelab secrets-issuance (per-client age key provisioning)
After=network-online.target homelab-context-sync.service
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/opt/secrets-issuance
Environment=HOMELAB_CONTEXT_DIR=/opt/homelab-context
Environment=SECRETS_ISSUANCE_STATE=/var/lib/secrets-issuance
Environment=MESH_SUBNETS=100.122.0.0/16,100.64.0.0/10
ExecStart=/opt/secrets-issuance/.venv/bin/python /opt/secrets-issuance/secrets-issuance/server.py
Restart=on-failure
RestartSec=5
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
NoNewPrivileges=true
ReadOnlyPaths=/opt/homelab-context /opt/secrets-issuance
ReadWritePaths=/var/lib/secrets-issuance
[Install]
WantedBy=multi-user.target

63
secrets/README.md Normal file
View File

@@ -0,0 +1,63 @@
# secrets/
SOPS-encrypted YAML files. The plaintext lives only in transit and in the
operator's head — committed files are always ciphertext.
## Conventions
- One file per logical grouping (e.g. `gitea-tokens.yaml`, `webhook-hmacs.yaml`,
`api-keys.yaml`).
- Recipients are declared in `../.sops.yaml` by path-regex, not per-file.
- The plaintext schema inside each file is free-form YAML; the consumer code
decides what it expects (e.g. `gitea-tokens.yaml` contains
`{"<host>": "ghp_xxx"}`).
## How to add a secret
```bash
# 1. Decide which clients should be able to decrypt it; edit ../.sops.yaml to
# list their age public keys for the new path_regex.
# 2. Create the plaintext, encrypt in place:
sops -e --in-place secrets/my-thing.yaml
# 3. Commit + push. The 5-min sync propagates to every recipient.
```
## How to consume a secret
```bash
# On any client that's a recipient:
homelab secret my-thing # prints plaintext
# Or programmatically:
sops -d /opt/homelab-context/secrets/my-thing.yaml
```
The `mcp` tool `list_my_secrets(caller_pubkey)` returns the names of secrets
the caller can decrypt. The MCP server never reads plaintext — decryption
stays client-side.
## Granting / revoking access
To grant a new recipient: edit `../.sops.yaml` to add their age pubkey, then
re-key every affected file:
```bash
sops updatekeys -y secrets/my-thing.yaml
```
To revoke: remove the recipient from `../.sops.yaml` and `sops updatekeys`
but remember this only protects future ciphertext. Past plaintext the client
already decrypted is gone from your control. Rotate the underlying credential
if compromise is suspected.
`homelab client remove <name>` does the recipient removal + `updatekeys` for
you, and prints the rotation checklist as a follow-up.
## hello.yaml — bootstrap decrypt test
`secrets/hello.yaml` is encrypted to every enrolled client. Used by Phase 3a
verification to confirm the end-to-end decrypt path works on a freshly-
bootstrapped machine. Content is intentionally trivial:
```yaml
greeting: hello from the homelab
```