Compare commits
55 Commits
feat/conte
...
hermes-onb
| Author | SHA1 | Date | |
|---|---|---|---|
| 4560e25bd7 | |||
|
|
04d1f39e7b | ||
| b34d362c19 | |||
| 0260f01b74 | |||
| b12f80933d | |||
| 8ef17dba3d | |||
| 21063015c7 | |||
| d41d73f323 | |||
| b42a986cc0 | |||
| 7a062bdb6b | |||
| 8f76338b05 | |||
| 306c397ce1 | |||
| 2ff7263f3d | |||
| 35c688c56f | |||
| dd4072f1d4 | |||
|
|
4c16de8102 | ||
|
|
419ab475b1 | ||
|
|
b4ca21b2b3 | ||
|
|
dd92c5fd88 | ||
|
|
36a686d953 | ||
|
|
e7a74f795d | ||
|
|
6ed04dd1b0 | ||
|
|
a5ee017291 | ||
|
|
3f41416805 | ||
|
|
2d2446b36e | ||
|
|
b5dfbb68ae | ||
|
|
2599c28104 | ||
|
|
aed977aa56 | ||
|
|
ea3100f091 | ||
|
|
5a8d6cfd4f | ||
|
|
098d4cfd6e | ||
|
|
e016f512b2 | ||
|
|
4316acadaa | ||
|
|
047138a81d | ||
|
|
af8961d194 | ||
|
|
ab6b8fabc4 | ||
|
|
e8c2ccf7bb | ||
|
|
0b6be9f42d | ||
|
|
65ece6f447 | ||
|
|
90a65bd5a1 | ||
|
|
ce25e73625 | ||
|
|
ae7eb8c649 | ||
|
|
df6aca888c | ||
|
|
58bd4df3b1 | ||
|
|
44d4d00aff | ||
|
|
c5fa756c7d | ||
|
|
a7d2a7a08c | ||
|
|
6848640fa4 | ||
|
|
1dc40ca715 | ||
|
|
bc578df39b | ||
|
|
1ae328f04d | ||
|
|
582823a89c | ||
|
|
3c9d2f975d | ||
|
|
de6f8bec42 | ||
|
|
385deb6dec |
49
.sops.yaml
49
.sops.yaml
@@ -18,12 +18,21 @@
|
||||
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.
|
||||
# decrypt test works for everyone. Add each new client's age_pubkey when
|
||||
# they enrol; re-key with `sops updatekeys -y secrets/hello.yaml`.
|
||||
age: >-
|
||||
# placeholder — operator fills in after first issuance run.
|
||||
# comma-separated age public keys, e.g.:
|
||||
# age1xxx,age1yyy,age1zzz
|
||||
age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6,
|
||||
age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0,
|
||||
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
|
||||
|
||||
- path_regex: ^secrets/gitea-pat\.yaml$
|
||||
# Write-scoped Gitea PAT (dtoro user). Same recipient list as hello.yaml
|
||||
# since every enrolled client should be able to push (homelab client
|
||||
# add/remove, wiki edits, etc.).
|
||||
age: >-
|
||||
age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6,
|
||||
age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0,
|
||||
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
|
||||
|
||||
- path_regex: ^secrets/gitea-tokens\.yaml$
|
||||
# Workstations only.
|
||||
@@ -34,3 +43,33 @@ creation_rules:
|
||||
# LXCs that run a webhook receiver.
|
||||
age: >-
|
||||
# placeholder — fill with age_pubkey of: apps, caddy, claudio-bot, claudio-monitor host
|
||||
|
||||
- path_regex: ^secrets/turn-shared-secret\.yaml$
|
||||
# coturn TURN long-term-credential password. Consumed by hubris (which
|
||||
# renders /etc/turnserver.conf + /opt/management.json on the VPS via
|
||||
# `homelab render-vps-configs`). Other recipients are convenience for
|
||||
# operator debugging — only hubris's pubkey is strictly required.
|
||||
age: >-
|
||||
age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6,
|
||||
age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0,
|
||||
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
|
||||
|
||||
- path_regex: ^secrets/netbird-authentik-oidc\.yaml$
|
||||
# Authentik OIDC client secret for the netbird-dashboard provider.
|
||||
# Consumed by hubris to render /opt/management.json on the VPS
|
||||
# (PKCEAuthorizationFlow.ProviderConfig.ClientSecret).
|
||||
age: >-
|
||||
age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6,
|
||||
age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0,
|
||||
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
|
||||
|
||||
- path_regex: ^secrets/openrouter-api-key\.yaml$
|
||||
# OpenRouter API key consumed by the `hermes` wrapper (bin/hermes) when
|
||||
# spawning a Goose session. Recipients are any host that should run a
|
||||
# Nous-Hermes agent. Add a host's age_pubkey here, then
|
||||
# `sops updatekeys -y secrets/openrouter-api-key.yaml`.
|
||||
# See operations/hermes-agent.md.
|
||||
age: >-
|
||||
age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6,
|
||||
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
|
||||
# webhook noop 2026-05-20T18:16:57+02:00
|
||||
|
||||
@@ -43,12 +43,14 @@ See the full table in [`containers/index.md`](containers/index.md). Quick links:
|
||||
- [Backups — restic on external drive (disabled)](infrastructure/backups.md)
|
||||
- [Auto-deploy — gitea-webhook pipelines](infrastructure/auto-deploy.md)
|
||||
- [VPS hardening — IONOS / netbird control plane](infrastructure/vps-hardening.md)
|
||||
- [Homelab context distribution](infrastructure/homelab-context.md) — cross-client `/opt/homelab-context` + MCP + secrets-issuance
|
||||
|
||||
### Investigations
|
||||
Time-stamped incident notes / experiments in [`investigations/`](investigations/index.md).
|
||||
|
||||
### Operations
|
||||
- [Command cheatsheet](operations/commands.md)
|
||||
- [Agent enrollment](operations/agent-enrollment.md) — bootstrap a new client (workstation, LXC, VM) into the homelab context system
|
||||
|
||||
## Conventions
|
||||
|
||||
|
||||
36
bin/hermes
Executable file
36
bin/hermes
Executable file
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# hermes — launch a Goose session pre-wired with the homelab persona,
|
||||
# OpenRouter (Nous Hermes) provider, and the homelab MCP server.
|
||||
#
|
||||
# See operations/hermes-agent.md for the full onboarding flow.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
die() { echo "hermes: $*" >&2; exit 1; }
|
||||
|
||||
command -v goose >/dev/null \
|
||||
|| die "goose binary not found — re-run bootstrap.sh with --with-hermes"
|
||||
command -v homelab >/dev/null \
|
||||
|| die "homelab CLI not found — is this client bootstrapped?"
|
||||
|
||||
# Decrypt OpenRouter API key.
|
||||
# `homelab secret` re-execs via sudo for non-root users (age key is 0600 root).
|
||||
SECRET_YAML=$(homelab secret openrouter-api-key 2>&1) || \
|
||||
die "could not decrypt secrets/openrouter-api-key.yaml — this host probably
|
||||
isn't a recipient yet. See operations/hermes-agent.md ('Granting the OpenRouter
|
||||
key to a new host'). sops output:
|
||||
${SECRET_YAML}"
|
||||
|
||||
API_KEY=$(printf '%s' "$SECRET_YAML" | python3 -c \
|
||||
'import sys, yaml; print(yaml.safe_load(sys.stdin)["api_key"])')
|
||||
|
||||
case "$API_KEY" in
|
||||
PLACEHOLDER_*|"")
|
||||
die "openrouter-api-key.yaml still contains the placeholder; operator
|
||||
must run \`sops secrets/openrouter-api-key.yaml\` on hubris to insert a real
|
||||
\`sk-or-...\` key and push the change." ;;
|
||||
esac
|
||||
|
||||
export OPENROUTER_API_KEY="$API_KEY"
|
||||
exec goose session "$@"
|
||||
1201
bin/homelab
1201
bin/homelab
File diff suppressed because it is too large
Load Diff
395
bootstrap.sh
395
bootstrap.sh
@@ -5,6 +5,7 @@
|
||||
# curl -fsSL https://git.hubris.network/dtoro/Homelab-Docs/raw/main/bootstrap.sh \
|
||||
# | sudo bash
|
||||
# curl ... | sudo bash -s -- --with-mcp # also wire Claude's .mcp.json
|
||||
# curl ... | sudo bash -s -- --with-hermes # also install Goose + Hermes wrapper
|
||||
# curl ... | sudo bash -s -- --dry-run # show what would happen
|
||||
# curl ... | sudo bash -s -- --no-secrets # skip age-key issuance
|
||||
#
|
||||
@@ -20,20 +21,28 @@ 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}"
|
||||
ISSUANCE_URL_NETBIRD="${HOMELAB_ISSUANCE_NETBIRD:-https://secrets.hubris.network/issue}"
|
||||
ISSUANCE_URL_TAILSCALE="${HOMELAB_ISSUANCE_TAILSCALE:-https://secrets.hubris.network/issue}"
|
||||
MCP_URL="${HOMELAB_MCP_URL:-https://mcp.hubris.network/sse}"
|
||||
HERMES_MCP_URI="${HOMELAB_HERMES_MCP_URI:-https://mcp.hubris.network/mcp}"
|
||||
HERMES_MODEL="${HOMELAB_HERMES_MODEL:-nousresearch/hermes-4-405b}"
|
||||
|
||||
WITH_MCP=0
|
||||
WITH_HERMES=0
|
||||
DRY_RUN=0
|
||||
NO_SECRETS=0
|
||||
GITEA_TOKEN="${HOMELAB_GITEA_TOKEN:-}"
|
||||
GITEA_USER="${HOMELAB_GITEA_USER:-dtoro}"
|
||||
|
||||
# -------- flag parsing --------
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--with-mcp) WITH_MCP=1; shift ;;
|
||||
--with-hermes) WITH_HERMES=1; shift ;;
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
--no-secrets) NO_SECRETS=1; shift ;;
|
||||
--gitea-token) GITEA_TOKEN="$2"; shift 2 ;;
|
||||
--gitea-user) GITEA_USER="$2"; shift 2 ;;
|
||||
--help|-h)
|
||||
sed -n '2,11p' "$0" | sed 's/^# *//'
|
||||
exit 0
|
||||
@@ -43,6 +52,29 @@ while [ $# -gt 0 ]; do
|
||||
esac
|
||||
done
|
||||
|
||||
# If a gitea token is provided, write it to the standard credential store
|
||||
# BEFORE the clone happens. The HTTPS REPO_HTTPS will then pick it up via
|
||||
# git's credential helper.
|
||||
configure_gitea_creds() {
|
||||
if [ -z "$GITEA_TOKEN" ]; then return 0; fi
|
||||
local creds_dir=/etc/homelab-context
|
||||
local creds_file=$creds_dir/git-credentials
|
||||
mkdir -p "$creds_dir"
|
||||
chmod 700 "$creds_dir"
|
||||
# Format the credential URL: <scheme>://user:token@host (scheme must match
|
||||
# the actual REPO_HTTPS — git's credential helper does exact prefix match).
|
||||
local proto host
|
||||
proto=$(echo "$REPO_HTTPS" | sed -E 's|^(https?)://.*|\1|')
|
||||
host=$(echo "$REPO_HTTPS" | sed -E 's|^https?://([^/]+).*|\1|')
|
||||
printf '%s://%s:%s@%s\n' "$proto" "$GITEA_USER" "$GITEA_TOKEN" "$host" > "$creds_file"
|
||||
chmod 600 "$creds_file"
|
||||
# Point git at this store system-wide (/etc/gitconfig) so the systemd
|
||||
# sync timer's git — which runs as root without HOME set — finds it.
|
||||
# --global writes to /root/.gitconfig which the timer doesn't read.
|
||||
git config --system credential.helper "store --file=$creds_file"
|
||||
echo "[bootstrap] wrote gitea credentials to $creds_file"
|
||||
}
|
||||
|
||||
run() {
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
printf '+ %s\n' "$*"
|
||||
@@ -78,34 +110,143 @@ echo "[bootstrap] hostname: $HNAME"
|
||||
|
||||
# Check dependencies.
|
||||
missing=()
|
||||
for cmd in git; do command -v "$cmd" >/dev/null || missing+=("$cmd"); done
|
||||
for cmd in git python3; do command -v "$cmd" >/dev/null || missing+=("$cmd"); done
|
||||
# The homelab CLI needs PyYAML.
|
||||
if ! python3 -c "import yaml" >/dev/null 2>&1; then
|
||||
missing+=("python3-yaml")
|
||||
fi
|
||||
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[*]}"
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
echo "+ would install missing tools: ${missing[*]}"
|
||||
else
|
||||
echo " apt install -y ${missing[*]} (or platform equivalent)"
|
||||
echo "[bootstrap] installing missing tools: ${missing[*]}"
|
||||
if [ "$OS" = "Darwin" ]; then
|
||||
brew_list=()
|
||||
for m in "${missing[@]}"; do
|
||||
case "$m" in
|
||||
python3-yaml) python3 -m pip install --break-system-packages pyyaml >/dev/null 2>&1 \
|
||||
|| python3 -m pip install pyyaml ;;
|
||||
*) brew_list+=("$m") ;;
|
||||
esac
|
||||
done
|
||||
[ "${#brew_list[@]}" -gt 0 ] && brew install "${brew_list[@]}"
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
dnf_list=()
|
||||
for m in "${missing[@]}"; do
|
||||
case "$m" in
|
||||
python3-yaml) dnf_list+=("python3-pyyaml") ;;
|
||||
*) dnf_list+=("$m") ;;
|
||||
esac
|
||||
done
|
||||
dnf install -y "${dnf_list[@]}"
|
||||
elif command -v apt-get >/dev/null 2>&1; then
|
||||
DEBIAN_FRONTEND=noninteractive apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y "${missing[@]}"
|
||||
else
|
||||
echo "[bootstrap] no supported package manager for: ${missing[*]}" >&2
|
||||
echo "[bootstrap] install with your package manager + re-run" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Re-verify (especially python yaml — the rename is the most common gotcha).
|
||||
for cmd in git python3; do
|
||||
command -v "$cmd" >/dev/null || { echo "[bootstrap] post-install $cmd still missing" >&2; exit 1; }
|
||||
done
|
||||
python3 -c "import yaml" 2>/dev/null \
|
||||
|| { echo "[bootstrap] post-install python3-yaml/pyyaml still missing" >&2; exit 1; }
|
||||
if [ "$NO_SECRETS" -eq 0 ]; then
|
||||
for cmd in age sops; do
|
||||
command -v "$cmd" >/dev/null || { echo "[bootstrap] post-install $cmd still missing" >&2; exit 1; }
|
||||
done
|
||||
fi
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Mesh check — accept either Netbird OR Tailscale.
|
||||
# -------- ensure netbird is installed + connected (workstation/VM hosts) --------
|
||||
# Skipped on --no-secrets (LXCs that route via the LAN already) and --dry-run.
|
||||
# Installs netbird if missing, then drives `netbird up` against the homelab
|
||||
# management server. The operator clicks the printed device-code URL once.
|
||||
if [ "$NO_SECRETS" -eq 0 ] && [ "$DRY_RUN" -eq 0 ]; then
|
||||
if ! command -v netbird >/dev/null 2>&1 && ! command -v tailscale >/dev/null 2>&1; then
|
||||
echo "[bootstrap] no mesh CLI found; installing netbird..."
|
||||
if [ "$OS" = "Darwin" ]; then
|
||||
brew install --cask netbird || { echo "[bootstrap] brew install netbird failed" >&2; exit 1; }
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
cat > /etc/yum.repos.d/netbird.repo <<'NBREPO'
|
||||
[netbird]
|
||||
name=NetBird stable
|
||||
baseurl=https://pkgs.netbird.io/yum/$basearch
|
||||
enabled=1
|
||||
gpgcheck=0
|
||||
NBREPO
|
||||
dnf install -y netbird netbird-ui || { echo "[bootstrap] dnf install netbird failed" >&2; exit 1; }
|
||||
elif command -v apt-get >/dev/null 2>&1; then
|
||||
install -d -m 0755 /usr/share/keyrings
|
||||
curl -fsSL https://pkgs.netbird.io/debian/public.key \
|
||||
| gpg --dearmor -o /usr/share/keyrings/netbird-archive-keyring.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/netbird-archive-keyring.gpg] https://pkgs.netbird.io/debian stable main" \
|
||||
> /etc/apt/sources.list.d/netbird.list
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y netbird \
|
||||
|| { echo "[bootstrap] apt install netbird failed" >&2; exit 1; }
|
||||
else
|
||||
echo "[bootstrap] can't auto-install netbird on this OS; install manually + re-run" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
# Bring netbird up if not already connected.
|
||||
if command -v netbird >/dev/null && ! netbird status 2>/dev/null | grep -q "Management: Connected"; then
|
||||
cat <<MSG
|
||||
|
||||
[bootstrap] netbird is not connected to https://netbird.hubris.network.
|
||||
[bootstrap] running 'netbird up' — a device-code URL will print below.
|
||||
[bootstrap] OPEN THAT URL in a browser and approve the device when prompted.
|
||||
[bootstrap] You may need to log in to https://auth.hubris.network first.
|
||||
|
||||
MSG
|
||||
# --ssh-jwt-cache-ttl=86400 keeps the SSO valid for 24h of subsequent ssh
|
||||
# ops into mesh peers; saves repeated browser clicks during this bootstrap.
|
||||
netbird up --management-url https://netbird.hubris.network --ssh-jwt-cache-ttl 86400 \
|
||||
|| { echo "[bootstrap] 'netbird up' failed (see error above)" >&2; exit 1; }
|
||||
# `netbird up` returns once the device-code SSO completes; give the
|
||||
# mgmt connection ~30s to settle before continuing.
|
||||
for _ in $(seq 1 10); do
|
||||
netbird status 2>/dev/null | grep -q "Management: Connected" && break
|
||||
sleep 3
|
||||
done
|
||||
if ! netbird status 2>/dev/null | grep -q "Management: Connected"; then
|
||||
echo "[bootstrap] netbird daemon not reporting Management: Connected after 30s" >&2
|
||||
echo "[bootstrap] try: 'netbird status -d' and 'sudo journalctl -u netbird -n 30'" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[bootstrap] netbird connected."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Mesh check — accept Netbird, Tailscale, or LAN reachability of the issuance
|
||||
# endpoint. LAN is fine for LXCs that don't run a mesh CLI but sit in the
|
||||
# trusted subnet (192.168.8.0/24) included in MESH_SUBNETS.
|
||||
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"
|
||||
elif curl -sf -o /dev/null --max-time 3 "${ISSUANCE_URL_NETBIRD%/issue}/health" 2>/dev/null; then
|
||||
MESH_CONNECTED="lan"
|
||||
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
|
||||
echo "no reachable issuance endpoint (no netbird/tailscale connected and" >&2
|
||||
echo "$ISSUANCE_URL_NETBIRD did not respond to /health)." >&2
|
||||
echo "either bring up the mesh first, fix DNS for *.hubris.network, or pass --no-secrets." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[bootstrap] mesh: ${MESH_CONNECTED:-none (skipped, --no-secrets)}"
|
||||
|
||||
# -------- gitea creds (if provided) --------
|
||||
configure_gitea_creds
|
||||
|
||||
# -------- clone --------
|
||||
if [ -d "$CLONE_DIR/.git" ]; then
|
||||
existing_remote="$(git -C "$CLONE_DIR" remote get-url origin 2>/dev/null || true)"
|
||||
@@ -190,8 +331,9 @@ 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"
|
||||
# Symlink rather than copy so the 5-min sync auto-updates the CLI.
|
||||
echo "[bootstrap] linking homelab CLI to /usr/local/bin/homelab"
|
||||
run "ln -sfn '$CLONE_DIR/bin/homelab' /usr/local/bin/homelab"
|
||||
|
||||
# -------- AGENTS.md symlink --------
|
||||
case "$OS" in
|
||||
@@ -205,6 +347,26 @@ esac
|
||||
run "ln -sfn '$CLONE_DIR/AGENTS.md' '$AGENTS_LINK'"
|
||||
echo "[bootstrap] linked AGENTS.md → $AGENTS_LINK"
|
||||
|
||||
# -------- auto-upgrade to write-scoped Gitea PAT --------
|
||||
# After enrollment, if this client is already a recipient on
|
||||
# secrets/gitea-pat.yaml (i.e. the operator has run
|
||||
# `homelab client add --finalize-pubkey` from another client), swap the
|
||||
# read-only bootstrap PAT for the write-scoped one. Best-effort: fails
|
||||
# silently if the client isn't yet a recipient — the operator just re-runs
|
||||
# bootstrap or `homelab refresh-creds` later.
|
||||
if [ "$NO_SECRETS" -eq 0 ] && [ "$DRY_RUN" -eq 0 ] \
|
||||
&& command -v sops >/dev/null 2>&1 \
|
||||
&& [ -f "$CLONE_DIR/secrets/gitea-pat.yaml" ]; then
|
||||
if /usr/local/bin/homelab refresh-creds >/tmp/homelab-refresh-creds.log 2>&1; then
|
||||
echo "[bootstrap] refresh-creds: write-scoped Gitea PAT installed"
|
||||
else
|
||||
echo "[bootstrap] refresh-creds: skipped (this client isn't yet a recipient"
|
||||
echo " on secrets/gitea-pat.yaml — run 'homelab client add"
|
||||
echo " $HNAME --finalize-pubkey <age...>' from an existing client,"
|
||||
echo " then re-run bootstrap or 'homelab refresh-creds')"
|
||||
fi
|
||||
fi
|
||||
|
||||
# -------- MCP wiring --------
|
||||
if [ "$WITH_MCP" -eq 1 ]; then
|
||||
# Pick the right user's home — when invoked via sudo, SUDO_USER is set.
|
||||
@@ -246,6 +408,211 @@ PYEOF
|
||||
fi
|
||||
fi
|
||||
|
||||
# -------- Hermes (Goose + Nous Hermes) wiring --------
|
||||
# Installs the Goose CLI binary system-wide, symlinks the `hermes` wrapper
|
||||
# and HERMES.md persona, and drops a Goose config that pins the OpenRouter
|
||||
# provider, the Nous Hermes model, and the homelab MCP extension.
|
||||
# See operations/hermes-agent.md.
|
||||
if [ "$WITH_HERMES" -eq 1 ]; then
|
||||
# Resolve the operator's home (SUDO_USER under `sudo bash`).
|
||||
if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ]; then
|
||||
H_USER="$SUDO_USER"
|
||||
H_HOME=$(eval echo "~$SUDO_USER")
|
||||
else
|
||||
H_USER="root"
|
||||
H_HOME="$HOME"
|
||||
fi
|
||||
|
||||
# 1. Install Goose binary at /usr/local/bin/goose (idempotent).
|
||||
if ! command -v goose >/dev/null 2>&1; then
|
||||
echo "[bootstrap] installing Goose CLI"
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
echo "+ would run upstream goose installer and symlink to /usr/local/bin/goose"
|
||||
else
|
||||
# Upstream installer drops the binary at ~/.local/bin/goose for the
|
||||
# invoking user. We run it as $H_USER then symlink system-wide.
|
||||
sudo -u "$H_USER" \
|
||||
env CONFIGURE=false \
|
||||
bash -c 'curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh | bash'
|
||||
if [ -x "$H_HOME/.local/bin/goose" ]; then
|
||||
ln -sfn "$H_HOME/.local/bin/goose" /usr/local/bin/goose
|
||||
else
|
||||
echo "[bootstrap] WARNING: goose binary not found at $H_HOME/.local/bin/goose after install" >&2
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "[bootstrap] goose already installed: $(command -v goose)"
|
||||
fi
|
||||
|
||||
# 2. Symlink hermes wrapper.
|
||||
echo "[bootstrap] linking hermes CLI to /usr/local/bin/hermes"
|
||||
run "ln -sfn '$CLONE_DIR/bin/hermes' /usr/local/bin/hermes"
|
||||
|
||||
# 3. Symlink HERMES.md persona. The hermes wrapper does not need it — the
|
||||
# Goose config below references the canonical clone path — but operators
|
||||
# frequently `cat /root/HERMES.md` to inspect the persona, mirroring the
|
||||
# AGENTS.md convention above.
|
||||
case "$OS" in
|
||||
Linux) HERMES_LINK=/root/HERMES.md ;;
|
||||
Darwin) HERMES_LINK=/etc/HERMES.md ;;
|
||||
esac
|
||||
run "ln -sfn '$CLONE_DIR/HERMES.md' '$HERMES_LINK'"
|
||||
echo "[bootstrap] linked HERMES.md → $HERMES_LINK"
|
||||
|
||||
# 4. Drop the Goose config. Idempotent YAML merge — preserves any keys the
|
||||
# operator added by hand, overwrites only the keys we manage.
|
||||
GOOSE_DIR="$H_HOME/.config/goose"
|
||||
GOOSE_CONFIG="$GOOSE_DIR/config.yaml"
|
||||
GOOSEHINTS="$GOOSE_DIR/.goosehints"
|
||||
run "mkdir -p '$GOOSE_DIR'"
|
||||
|
||||
PY_GOOSE_MERGE=$(cat <<PYEOF
|
||||
import os, sys
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
print("PyYAML required", file=sys.stderr); sys.exit(2)
|
||||
|
||||
path = "$GOOSE_CONFIG"
|
||||
mcp_uri = "$HERMES_MCP_URI"
|
||||
model = "$HERMES_MODEL"
|
||||
|
||||
cfg = {}
|
||||
if os.path.exists(path):
|
||||
with open(path) as f:
|
||||
try:
|
||||
cfg = yaml.safe_load(f) or {}
|
||||
except Exception:
|
||||
cfg = {}
|
||||
|
||||
cfg["GOOSE_PROVIDER"] = "openrouter"
|
||||
cfg["GOOSE_MODEL"] = model
|
||||
cfg.setdefault("GOOSE_MODE", "smart_approve")
|
||||
cfg.setdefault("extensions", {})
|
||||
cfg["extensions"]["developer"] = {
|
||||
"bundled": True, "enabled": True, "name": "developer",
|
||||
"timeout": 300, "type": "builtin",
|
||||
}
|
||||
cfg["extensions"]["homelab"] = {
|
||||
"enabled": True, "name": "homelab",
|
||||
"description": "Read-only homelab context tools (FastMCP).",
|
||||
"type": "streamable_http", "uri": mcp_uri, "timeout": 60,
|
||||
}
|
||||
with open(path, "w") as f:
|
||||
yaml.safe_dump(cfg, f, sort_keys=False)
|
||||
print("[bootstrap] merged Goose config at", path)
|
||||
PYEOF
|
||||
)
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
echo "+ would merge Goose config at $GOOSE_CONFIG"
|
||||
else
|
||||
python3 -c "$PY_GOOSE_MERGE"
|
||||
chown -R "$H_USER" "$GOOSE_DIR"
|
||||
fi
|
||||
|
||||
# 5. Symlink HERMES.md as the global .goosehints — Goose injects it into
|
||||
# the system prompt on every session start.
|
||||
run "ln -sfn '$CLONE_DIR/HERMES.md' '$GOOSEHINTS'"
|
||||
if [ "$DRY_RUN" -eq 0 ]; then
|
||||
chown -h "$H_USER" "$GOOSEHINTS" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# -------- netbird tuning (skip per-session SSO for ssh into mesh peers) --------
|
||||
# Apply the SSH JWT cache TTL so `ssh ... .netbird.selfhosted` doesn't trigger
|
||||
# device-code SSO on every connection. Flag added in netbird 0.71.x
|
||||
# (netbirdio/netbird#4015). It belongs on `netbird up` — putting it on the
|
||||
# daemon's ExecStart crashes the daemon with "unknown flag". After this runs the
|
||||
# FIRST ssh still prompts SSO once; subsequent sessions within 24h skip it.
|
||||
if [ "$MESH_CONNECTED" = "netbird" ]; then
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
echo "+ would: netbird down && netbird up --ssh-jwt-cache-ttl=86400"
|
||||
elif netbird up --help 2>&1 | grep -q -- "--ssh-jwt-cache-ttl"; then
|
||||
echo "[bootstrap] netbird: enabling ssh-jwt-cache-ttl=86400 (one SSO per 24h)"
|
||||
# `netbird up` short-circuits with "Already connected" — need down first.
|
||||
netbird down >/dev/null 2>&1 || true
|
||||
if ! netbird up --ssh-jwt-cache-ttl=86400; then
|
||||
echo "[bootstrap] WARNING: netbird up with --ssh-jwt-cache-ttl failed; rerun manually:"
|
||||
echo "[bootstrap] netbird down && netbird up --ssh-jwt-cache-ttl=86400"
|
||||
fi
|
||||
else
|
||||
echo "[bootstrap] netbird: --ssh-jwt-cache-ttl flag not supported (need >=0.71.x); skipping"
|
||||
fi
|
||||
fi
|
||||
|
||||
# -------- ssh ControlMaster for netbird peers (workstations) --------
|
||||
# Drop a Host block into the enrolling user's ~/.ssh/config so that ssh to
|
||||
# `*.netbird.selfhosted` multiplexes over a single connection. After one SSO
|
||||
# device-code completion, subsequent ssh / scp / `pct exec` invocations
|
||||
# (within ControlPersist=2h) reuse the master socket with no re-auth — the
|
||||
# real workaround for netbird's flaky SSH JWT cache. Skip on LXCs (no
|
||||
# outbound ssh expected from them).
|
||||
HKIND="$(python3 -c "import yaml; print(yaml.safe_load(open('$HOST_YAML')).get('kind',''))" 2>/dev/null || true)"
|
||||
if [ "$MESH_CONNECTED" = "netbird" ] && [ "$HKIND" != "lxc" ]; then
|
||||
if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ]; then
|
||||
SSH_USER_HOME=$(eval echo "~$SUDO_USER")
|
||||
SSH_OWNER="$SUDO_USER"
|
||||
else
|
||||
SSH_USER_HOME="$HOME"
|
||||
SSH_OWNER=""
|
||||
fi
|
||||
SSH_CFG="$SSH_USER_HOME/.ssh/config"
|
||||
SSH_CM_DIR="$SSH_USER_HOME/.ssh/cm"
|
||||
SENTINEL="# homelab-bootstrap: ssh ControlMaster for netbird mesh"
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
echo "+ would write Host *.netbird.selfhosted ControlMaster block into $SSH_CFG"
|
||||
elif [ -f "$SSH_CFG" ] && grep -qF "$SENTINEL" "$SSH_CFG"; then
|
||||
echo "[bootstrap] ssh ControlMaster block already present in $SSH_CFG (skip)"
|
||||
else
|
||||
mkdir -p "$SSH_USER_HOME/.ssh" "$SSH_CM_DIR"
|
||||
chmod 700 "$SSH_USER_HOME/.ssh" "$SSH_CM_DIR"
|
||||
cat >> "$SSH_CFG" <<'SSHEOF'
|
||||
|
||||
# homelab-bootstrap: ssh ControlMaster for netbird mesh
|
||||
# One SSO covers many back-to-back ssh/scp/pct ops within ControlPersist.
|
||||
Host *.netbird.selfhosted
|
||||
ControlMaster auto
|
||||
ControlPath ~/.ssh/cm/%C
|
||||
ControlPersist 2h
|
||||
SSHEOF
|
||||
chmod 600 "$SSH_CFG"
|
||||
if [ -n "$SSH_OWNER" ]; then
|
||||
chown -R "$SSH_OWNER":"$SSH_OWNER" "$SSH_USER_HOME/.ssh"
|
||||
fi
|
||||
echo "[bootstrap] ssh: installed ControlMaster block into $SSH_CFG"
|
||||
fi
|
||||
fi
|
||||
|
||||
# -------- mcp CLI install (workstations) --------
|
||||
# `homelab mcp <tool>` shells out to the `mcp` python CLI. Install it via
|
||||
# pipx for the enrolling user. Skip on LXCs / VMs.
|
||||
if [ "$HKIND" != "lxc" ] && [ "$HKIND" != "vm" ]; then
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
echo "+ would install 'mcp[cli]' via pipx for the enrolling user"
|
||||
elif command -v mcp >/dev/null 2>&1; then
|
||||
echo "[bootstrap] mcp CLI already on PATH (skip)"
|
||||
else
|
||||
# Make sure pipx is available; OS-specific install.
|
||||
if ! command -v pipx >/dev/null 2>&1; then
|
||||
if [ "$OS" = "Darwin" ] && command -v brew >/dev/null 2>&1; then
|
||||
sudo -u "${SUDO_USER:-$USER}" brew install pipx 2>&1 | tail -2 || true
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
dnf install -y pipx 2>&1 | tail -2 || true
|
||||
elif command -v apt-get >/dev/null 2>&1; then
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y pipx 2>&1 | tail -2 || true
|
||||
fi
|
||||
fi
|
||||
if command -v pipx >/dev/null 2>&1; then
|
||||
INVOKING_USER="${SUDO_USER:-$USER}"
|
||||
sudo -u "$INVOKING_USER" -- bash -lc "pipx install 'mcp[cli]'" 2>&1 | tail -3 || true
|
||||
sudo -u "$INVOKING_USER" -- bash -lc "pipx ensurepath" >/dev/null 2>&1 || true
|
||||
echo "[bootstrap] mcp CLI installed for $INVOKING_USER via pipx"
|
||||
else
|
||||
echo "[bootstrap] WARNING: pipx unavailable; install manually: pipx install 'mcp[cli]'" >&2
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# -------- done --------
|
||||
cat <<EOF
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 105 — `apps`
|
||||
|
||||
Docker host for everything that doesn't justify its own LXC. Currently runs Artifacto, Booklore, PlantUML server, Portainer (and historically WriteFreely / blog).
|
||||
Docker host for everything that doesn't justify its own LXC. Currently runs Artifacto, Booklore, PlantUML server, Portainer (and historically WriteFreely / blog), plus the [homelab-context distribution services](../infrastructure/homelab-context.md) (MCP + secrets-issuance) since 2026-05-20.
|
||||
|
||||
## At a glance
|
||||
- **Hostname:** `apps`
|
||||
@@ -19,6 +19,8 @@ Docker host for everything that doesn't justify its own LXC. Currently runs Arti
|
||||
| `artifacto.hubris.network` | Artifacto | `:3100` | Public `/p/*`, `/static/*`, `/healthz` exposed via [VPS traefik](../infrastructure/ingress.md). |
|
||||
| `blog.hubris.network` | WriteFreely | `:8080` | Native OIDC via `[oauth.generic]`. |
|
||||
| `git.hubris.network/_plantuml/*` | PlantUML server | `:8079` | Same-origin route from [gitea (104)](104-gitea.md). |
|
||||
| `mcp.hubris.network` | homelab-mcp | `:9810` | MCP server over SSE. No SSO — agent-facing, gated at source-IP layer. See [homelab-context](../infrastructure/homelab-context.md). |
|
||||
| `secrets.hubris.network` | secrets-issuance | `:9820` | Per-client age-key issuance. Mesh+LAN source-IP gated (`MESH_SUBNETS`). |
|
||||
|
||||
## Stacks
|
||||
|
||||
@@ -49,6 +51,51 @@ Native OIDC via Authentik (Settings → OIDC). Redirect URI `/oauth2-callback` (
|
||||
|
||||
> ⚠️ **Never `docker compose up` Portainer-managed stacks from the host shell.** Portainer's compose state lives at `/var/lib/docker/volumes/portainer_data/_data/compose/<N>/`. Running `docker compose up -d <svc>` from the host triggers recreates of OTHER services in the stack and silently destroys bind-mounted data. **This wiped Booklore's mariadb data on 2026-04-22.** Use the Portainer UI editor for compose changes. See [mesh migration](../infrastructure/mesh.md#critical-never-docker-compose-up-portainer-managed-stacks) for the full warning.
|
||||
|
||||
### homelab-mcp (`/opt/homelab-mcp/`)
|
||||
FastMCP server (Python venv at `/opt/homelab-mcp/.venv`). Reads from
|
||||
`/opt/homelab-context/` (this LXC is itself an enrolled
|
||||
[homelab-context](../infrastructure/homelab-context.md) client). Source
|
||||
is `dtoro/Homelab-Docs/mcp/server.py`; service unit
|
||||
`homelab-mcp.service` binds `0.0.0.0:9810`. DNS-rebinding protection
|
||||
disabled at the FastMCP layer because mesh+LAN gating is the actual
|
||||
trust boundary.
|
||||
|
||||
- Endpoint: `https://mcp.hubris.network/sse` (Caddy → `:9810`).
|
||||
- 14 tools registered: `get_host`, `list_services`, `find_service`,
|
||||
`get_topology`, `search_docs`, `get_page`, `get_changelog`, `whoami`,
|
||||
`list_my_secrets` (context); `get_service_status`, `tail_log`,
|
||||
`list_lxcs`, `get_lxc_state`, `ping_service` (read-only management).
|
||||
- Management tools shell out via SSH using a dedicated `mcp-reader` key
|
||||
(planned; not provisioned yet — management tools fail until then).
|
||||
|
||||
#### Auto-deploy pipeline (homelab-mcp)
|
||||
Receiver at `/opt/homelab-mcp/mcp/deploy/webhook/` (in-tree alongside
|
||||
the server code). Listens on `0.0.0.0:9811`, secret in
|
||||
`/etc/homelab-mcp-deploy/secret`, systemd unit
|
||||
`homelab-mcp-deploy.service`. Gitea webhook id 10 on
|
||||
`dtoro/Homelab-Docs`. Deploy script reinstalls the service unit and
|
||||
restarts on push.
|
||||
|
||||
### secrets-issuance (`/opt/secrets-issuance/`)
|
||||
Tiny HTTP service that issues per-client age keypairs the first time
|
||||
each client calls `/issue`. Idempotent: subsequent calls return the
|
||||
same key. Mesh+LAN source-IP gated via the `MESH_SUBNETS` env in
|
||||
`secrets-issuance.service`. State (per-client keys + denylist) lives at
|
||||
`/var/lib/secrets-issuance/` (NOT in the repo).
|
||||
|
||||
- Endpoint: `https://secrets.hubris.network/issue` (Caddy → `:9820`).
|
||||
- `/revoke` is admin-token-gated by `/etc/secrets-issuance/admin-token`;
|
||||
shreds the local key file and adds the hostname to the denylist.
|
||||
Called by `homelab client remove`.
|
||||
- Trust subnets today: `100.122.0.0/16` (Netbird), `100.64.0.0/10`
|
||||
(Tailscale), `192.168.8.0/24` (LAN). Tighten if the LAN gets
|
||||
untrusted devices.
|
||||
|
||||
#### Auto-deploy pipeline (secrets-issuance)
|
||||
Receiver at `/opt/secrets-issuance/secrets-issuance/deploy/webhook/`,
|
||||
port `9821`, secret in `/etc/secrets-issuance-deploy/secret`, unit
|
||||
`secrets-issuance-deploy.service`. Gitea webhook id 11.
|
||||
|
||||
### WriteFreely (`/opt/writefreely-docker/`)
|
||||
Native OIDC via `[oauth.generic]` in `config/config.ini`. `host = https://auth.hubris.network`, scope `openid profile email`, `map_user_id = sub`. Redirect URI in Authentik: `https://blog.hubris.network/oauth/callback/generic`. Container has `extra_hosts: auth.hubris.network:192.168.8.175`. First-time users with a local WF account must connect SSO via Customize/Settings → Linked Accounts.
|
||||
|
||||
@@ -66,6 +113,16 @@ Native OIDC via `[oauth.generic]` in `config/config.ini`. `host = https://auth.h
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-05-20 — homelab-mcp + secrets-issuance live
|
||||
Two new services from the [homelab-context distribution plan](../infrastructure/homelab-context.md):
|
||||
`homelab-mcp.service` on `:9810` (MCP read+management surface) and
|
||||
`secrets-issuance.service` on `:9820` (per-client age-key provisioning).
|
||||
Caddy fronts both with Let's Encrypt; new vhosts on
|
||||
[caddy](121-caddy.md), split-horizon DNS entries on
|
||||
[authentik (124)](124-authentik.md). Gitea webhook ids 10 + 11 wire
|
||||
auto-deploy. LXC is itself an enrolled context client
|
||||
(`/opt/homelab-context/`).
|
||||
|
||||
### 2026-04-28 — wiki entry created
|
||||
Initial documentation.
|
||||
|
||||
|
||||
@@ -1,68 +1,46 @@
|
||||
# 120 — `mule-images`
|
||||
|
||||
Hosts `mule-image` / "mulita" — the photos app at `photos.hubris.network`. Auto-deploys from `dtoro/mule-image` on `git push origin main`.
|
||||
Hosts `mule-image` — the photos app at `photos.hubris.network`. PhotoPrism + Go sidecar + SvelteKit, replacing the legacy FastAPI/Celery stack as of 2026-05-22 (see Changelog). Auto-deploys from `dtoro/mule-image` on `git push origin main`.
|
||||
|
||||
## At a glance
|
||||
- **Hostname:** `mule-images`
|
||||
- **IP:** `192.168.8.136`
|
||||
- **Privilege:** privileged
|
||||
- **Resources:** 4 cores / 8 GiB RAM / 60 GiB rootfs
|
||||
- **Mounts:** `/mnt/library` ↔ `/mnt/library`
|
||||
- **Public hostname:** [`photos.hubris.network`](../infrastructure/dns.md) → [caddy](121-caddy.md) → `:3000` (frontend)
|
||||
- **Resources:** 6 cores / 12 GiB RAM / 60 GiB rootfs / 2 GiB swap
|
||||
- **Mounts:** `/mnt/library` ↔ `/mnt/library`; `/dev/dri/{card0,renderD128}` passed through for VA-API video accel on the AMD Phoenix1 iGPU.
|
||||
- **Public hostname:** [`photos.hubris.network`](../infrastructure/dns.md) → [caddy](121-caddy.md) → path-routed to PhotoPrism `:2342` / sidecar `:8000` / nginx-static `:3000`.
|
||||
|
||||
## Stack (`/opt/mule-image`)
|
||||
|
||||
`/opt/mule-image` IS the working tree of `dtoro/mule-image`. Compose at `/opt/mule-image/docker-compose.yml`. Services:
|
||||
`/opt/mule-image` IS the working tree of `dtoro/mule-image`. Compose stack: base `docker-compose.yml` + LAN-port-rebind `docker-compose.override.yml` (untracked) + VA-API `docker-compose.gpu.yml`. The SvelteKit frontend is built on the host and served as a static bundle by nginx — no `vite dev` in prod.
|
||||
|
||||
| Service | Port | Notes |
|
||||
| ----------------- | ------ | ----- |
|
||||
| frontend | 3000 | Reverse-proxied by Caddy |
|
||||
| backend | 8001 | FastAPI |
|
||||
| worker-light | — | Sole celery worker — runs default/high/low queues + beat scheduler |
|
||||
| db | (pg) | postgres:16 (was pgvector/pg16 until 2026-05-14) |
|
||||
| redis | (rd) | queue |
|
||||
| Service / process | Port (LAN) | Notes |
|
||||
| ------------------------ | --------------------- | ----- |
|
||||
| pp-mariadb | (internal 3306) | MariaDB 11; holds PhotoPrism's `photoprism` DB + sidecar's `mule_sidecar.*` |
|
||||
| pp-app | `0.0.0.0:2342` | PhotoPrism `:latest`; `PHOTOPRISM_FFMPEG_ENCODER=h264_vaapi` |
|
||||
| pp-sidecar | `0.0.0.0:8000` | Go service (file rename / folder ops / heap convert / dup scan / per-photo marks); also reconciles `USER_BASEPATHS` into PhotoPrism's `auth_users` every 60 s |
|
||||
| nginx (host process) | `0.0.0.0:3000` | Serves the SvelteKit static bundle from `web/build/` |
|
||||
|
||||
**No vision / ML services.** The OpenCLIP ONNX classifier and its dedicated `worker-vision` were ripped out on 2026-05-14 — see Changelog.
|
||||
**Per-user scoping** is driven by `USER_BASEPATHS=admin:admin/files, muli:muli/files` in `.env`. Sidecar applies it to PhotoPrism's `auth_users` table on boot + every 60s, `mkdir -p`s each target so PhotoPrism's ACL filter has somewhere to point.
|
||||
|
||||
`worker-watcher` was retired 2026-05-11 — file events come from NC webhook_listeners instead of watchfiles inotify. See "Nextcloud integration" below.
|
||||
`.env` is **untracked** — `git reset --hard` won't touch it, but `git clean -fdx` would. Holds PP_*, MariaDB passwords, SIDECAR_DB_PASSWORD, OIDC_* (existing `mule-image` Authentik app, redirect URI now `/api/v1/oidc/redirect`), `USER_BASEPATHS`, and `PP_FFMPEG_ENCODER=vaapi`.
|
||||
|
||||
`.env` is **untracked** — `git checkout .env` will wipe it. Holds:
|
||||
- `PHOTO_DIRS=/mnt/library/images/`
|
||||
- `NEXTCLOUD_USERS_HOST_PATH=/mnt/library/homecloud`
|
||||
- `NEXTCLOUD_BASE_URL=https://cloud.hubris.network`
|
||||
- OIDC client secret + scopes
|
||||
- `SECRET_KEY` (generated)
|
||||
- `NEXTCLOUD_WEBHOOK_SECRET` (since 2026-05-11; bearer token NC presents on every webhook call to `/api/v1/internal/nc-webhook`)
|
||||
`docker-compose.override.yml` is also untracked — it `!override`s the upstream `127.0.0.1:port` mappings to `0.0.0.0:port` so cross-host Caddy on [LXC 121](121-caddy.md) can reach pp-app + sidecar.
|
||||
|
||||
## Nextcloud integration (since 2026-05-11)
|
||||
## Library access
|
||||
|
||||
Phase 1 + 2 of dropping the duplicated indexing work between mule and Nextcloud — see `~/.claude/projects/-root/memory/project_mule_nc_integration.md` for the full detail.
|
||||
PhotoPrism reads the library **directly off the bind-mounted filesystem** — no Nextcloud webhook integration in the new stack. The base path is `/mnt/library/homecloud` and per-user scoping comes from `USER_BASEPATHS` (see Stack section above).
|
||||
|
||||
**Thumbnails (Phase 1, commit 576b0c2):** `GET /api/v1/photos/{id}/thumb/{size}` proxies `https://cloud.hubris.network/index.php/core/preview` keyed by `photos.nextcloud_fileid` (alembic 0018), authenticated with the photo owner's encrypted NC app password. NC handles JPEG/HEIC/TIFF/RAW/DNG/Movie previews natively. `X-Mule-Thumb-Source: nextcloud|disk` on every response. Worker now only writes the `medium` size to `/data/thumbs/{user}/{photo}/medium.webp` (the vision worker still loads it from disk); `small` + `large` are NC-proxied. Backfill script: `backend/scripts/backfill_nextcloud_fileid.py` — 17,591/17,591 photos filled.
|
||||
- `dtoro` is mapped to NC user `admin` → `/mnt/library/homecloud/admin/files/`
|
||||
- `muli` is mapped to NC user `muli` → `/mnt/library/homecloud/muli/files/`
|
||||
|
||||
**File events (Phase 2, commits 362fbc6 + f657e2c):** mule no longer runs `watchfiles`. NC's `webhook_listeners` app (already installed, registered via `backend/scripts/register_nc_webhooks.py`) POSTs every `NodeCreated|NodeWritten|NodeDeleted|NodeRenamed` event to `http://192.168.8.136:8001/api/v1/internal/nc-webhook` with `Authorization: Bearer $NEXTCLOUD_WEBHOOK_SECRET`. The endpoint dispatches the same `scan_folder` / `handle_file_deletion` machinery the watcher used. **NC dispatches webhooks asynchronously via its background-job queue** — `crontab -u www-data` on LXC 114 runs `cron.php` every minute (`*/1`), so end-to-end latency is ≲60 s. Re-running the registrar script is idempotent (it deletes existing webhooks pointing at the same URL first).
|
||||
PhotoPrism's container user is `33:10000` (`www-data:media`), matching the host ownership of the NC tree. The legacy `oc_webhook_listeners` rows + `NEXTCLOUD_WEBHOOK_SECRET` are gone — when NC writes via WebDAV (still its own primary surface), PhotoPrism picks up the new file on its next index pass.
|
||||
|
||||
## Nextcloud-rooted libraries (since 2026-04-26)
|
||||
## Authentication
|
||||
|
||||
Photo libraries live under each user's Nextcloud `files/` tree, NOT in `/mnt/library/images/*`.
|
||||
- `/mnt/library/homecloud` is bind-mounted into `backend`, `worker-light`, `worker-watcher`, `worker-vision` as `/nextcloud-users`. Each NC user is `/nextcloud-users/<nc_user>/files/`.
|
||||
- Reads use that bind directly.
|
||||
- Mutations (upload, delete, rename, move) dispatch through `services/nextcloud_dav.py` (HTTP Basic auth, per-user app password Fernet-encrypted in `users.nextcloud_app_password_enc`) so Nextcloud's `oc_filecache`, trashbin, comments, and desktop-sync clients stay coherent.
|
||||
- Photo copy + cross-system moves return 501 with a "use Nextcloud's web UI" hint — defer until needed.
|
||||
- `users.nextcloud_username` overrides the default OIDC `preferred_username`. **`dtoro` (mule-image) maps to `admin` (Nextcloud)** — don't assume username equality.
|
||||
- Surviving SourceRoots in DB: `Photos` → `/nextcloud-users/admin/files/Photos`; `Memories` → `/nextcloud-users/admin/files/Memories` (both owned by `dtoro`). User `muli` has `nextcloud_username=muli` backfilled but no SourceRoot yet.
|
||||
- Pre-migration DB dump: `/root/snapshots/mulita-pre-nc-migration-20260426-075132.dump` (11 MB) on the host.
|
||||
OIDC via Authentik. App slug `mule-image`, redirect URI `https://photos.hubris.network/api/v1/oidc/redirect` (PhotoPrism's auto-derived path; PhotoPrism builds it from `PHOTOPRISM_SITE_URL`). `OIDC_REGISTER=true` auto-creates a PhotoPrism user at role `user` on first SSO login. The sidecar's basepath reconciler then assigns their scoped folder.
|
||||
|
||||
## Authentication (since 2026-04-22)
|
||||
|
||||
Native OIDC via Authentik. Code in `backend/app/auth_oidc.py`, routes `/api/v1/auth/oidc/{login,callback}`. Authentik side:
|
||||
- OAuth2/OIDC Provider, client ID `fCuHew48ONTskDjUKnMTZjFbVXuHwvQqTScQRNQ1`
|
||||
- App slug `mule-image`
|
||||
- Redirect URI: `https://photos.hubris.network/api/v1/auth/oidc/callback`
|
||||
|
||||
Backend container needs `extra_hosts: auth.hubris.network:192.168.8.175` via `docker-compose.override.yml` (gitignored). Otherwise Authlib's metadata fetch fails with `SSL: CERTIFICATE_VERIFY_FAILED: self-signed certificate` (it ends up at a random public host because LXC DNS resolves the public IONOS A record).
|
||||
|
||||
Caddyfile stays plain `reverse_proxy 192.168.8.136:3000` — no forward-auth, no `/api/*` bypass needed.
|
||||
The `mule-image` Authentik app's redirect URI was migrated from the legacy FastAPI `/api/v1/auth/oidc/callback` path on 2026-05-22 — same client ID/secret were reused. The separate `mule-photos-new` Authentik app was deleted in the same operation.
|
||||
|
||||
## Auto-deploy
|
||||
|
||||
@@ -70,11 +48,10 @@ Push to `dtoro/mule-image` `main` → gitea webhook → `http://192.168.8.136:97
|
||||
- Validates HMAC against `/etc/mule-deploy/secret`
|
||||
- Filters to `refs/heads/main`
|
||||
- Runs `/opt/mule-deploy/deploy.sh` in a daemon thread (returns 202 immediately — docker builds exceed gitea's request timeout)
|
||||
- `git pull --ff-only` + `docker compose up -d --build` + `docker image prune -f`
|
||||
- `git fetch && git reset --hard origin/main`, force-recreates **only the sidecar** image (so PhotoPrism's `Session` HMAC key in `pp/storage/config/hub.yml` doesn't rotate and invalidate every in-flight OIDC state cookie), reconciles `pp-app` + `mariadb` in place, `cd web && npm ci && npm run build`, `systemctl reload nginx`.
|
||||
- Conditionally layers `docker-compose.gpu.yml` when `/dev/dri/renderD128` exists, and always layers `docker-compose.override.yml` (the LAN-port rebind) when present.
|
||||
|
||||
Deploy tooling is **outside** the app repo: `/opt/mule-deploy/{deploy.sh,webhook.py}`, secret at `/etc/mule-deploy/secret`, unit at `/etc/systemd/system/mule-deploy-webhook.service`. Same shape as the Caddy + Artifacto pipelines. Gitea webhook id 6.
|
||||
|
||||
`app.ini` `ALLOWED_HOST_LIST` on [gitea](104-gitea.md) includes `192.168.8.136`.
|
||||
Deploy tooling is **outside** the app repo: `/opt/mule-deploy/{deploy.sh,webhook.py}`, secret at `/etc/mule-deploy/secret`. Gitea webhook id 6. `app.ini` `ALLOWED_HOST_LIST` on [gitea](104-gitea.md) includes `192.168.8.136`.
|
||||
|
||||
Logs: `pct exec 120 -- journalctl -u mule-deploy-webhook -f`.
|
||||
Manual deploy: `pct exec 120 -- /opt/mule-deploy/deploy.sh`.
|
||||
@@ -91,6 +68,53 @@ For pushes from inside the LXC, gitea creds at `/etc/mule-deploy/git-credentials
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-05-22 — Cutover to PhotoPrism stack (Mulimage 2.0)
|
||||
|
||||
The `new` branch of `dtoro/mule-image` was merged into `main` as commit
|
||||
`70dc1b6`. The merge replaces the legacy FastAPI + PostgreSQL + Celery
|
||||
+ React stack with PhotoPrism + Go sidecar + MariaDB + SvelteKit, lifted
|
||||
in from the M0 evaluation on (now-destroyed) [LXC 127](#).
|
||||
|
||||
Cutover on 120 (this session):
|
||||
- Bumped to 6 cores / 2 GB swap. `/dev/dri/{card0,renderD128}` already
|
||||
passed through; added an opt-in `docker-compose.gpu.yml` overlay that
|
||||
the deploy script layers in when the device is present. PhotoPrism now
|
||||
uses `h264_vaapi` instead of `libx264`.
|
||||
- Installed host nginx + a small `photos.conf` vhost serving the SvelteKit
|
||||
static bundle from `/opt/mule-image/web/build/` on `:3000` — no Vite
|
||||
dev server in prod. SvelteKit's `adapter-static` produces a real SPA
|
||||
bundle.
|
||||
- Replaced `/opt/mule-deploy/deploy.sh` with the 127-style multi-compose
|
||||
deploy (force-recreates only sidecar to preserve PhotoPrism's Session
|
||||
HMAC key; reconciles pp-app + mariadb in place; rebuilds web/ and
|
||||
reloads nginx).
|
||||
- `/opt/mule-image/.env` was rewritten to the PhotoPrism shape. Reused
|
||||
passwords from `/root/mule-photos-new-secrets.txt`. `USER_BASEPATHS=
|
||||
admin:admin/files, muli:muli/files` exposes both libraries.
|
||||
- `docker-compose.override.yml` (untracked) `!override`s pp-app +
|
||||
sidecar ports to `0.0.0.0` so cross-LXC caddy on [121](121-caddy.md)
|
||||
can reach them.
|
||||
- Authentik: `mule-image` app's redirect URI updated to `/api/v1/oidc/redirect`;
|
||||
the separate `mule-photos-new` app deleted.
|
||||
- Caddyfile (`dtoro/caddy-conf`): `photos.hubris.network` switched from
|
||||
plain passthrough to path-matchers (PhotoPrism API + sidecar API +
|
||||
nginx static catch-all), and `photos-new.hubris.network` was removed
|
||||
entirely.
|
||||
- Cleanups: 4 Nextcloud `oc_webhook_listeners` rows for the legacy
|
||||
`/api/v1/internal/nc-webhook` endpoint deleted; gitea webhook id 9
|
||||
(the `refs/heads/new` deploy hook for 127) deleted; `192.168.8.181`
|
||||
removed from gitea's `ALLOWED_HOST_LIST`; `photos-new.hubris.network`
|
||||
removed from dnsmasq.
|
||||
- LXC 127 destroyed via `pct destroy 127` after the new stack passed
|
||||
curl verification end-to-end (PP `/api/v1/status` returns
|
||||
`{"status":"operational"}` through caddy; sidecar `/api/sidecar/healthz`
|
||||
returns `{"ok":true,...}`).
|
||||
- Rollback safety net: legacy `mulita` postgres dump at
|
||||
`/root/backups/mulita-legacy-final-20260521-212036.sql.gz` on hubris
|
||||
(10 MB, 16,155 photo rows). The `mule-image_mule_db_data` docker
|
||||
volume on 120 retains the on-disk postgres data for at least 24 h
|
||||
before housekeeping removes it.
|
||||
|
||||
### 2026-05-14 — Drop AI/vision pipeline, plain Postgres, DB↔FS refresh
|
||||
|
||||
**AI removal (commits `a27267f` + `423a73a`).** The OpenCLIP-on-ONNX
|
||||
|
||||
@@ -100,16 +100,63 @@ API paths to bypass per app:
|
||||
| [Artifacto (105)](105-apps.md) | Forward-auth + gateway-secret auto-login | Public `/p/*` paths bypass |
|
||||
| [Home Assistant VM (108)](../vms/108-haos.md) | HACS `christiaangoossens/hass-oidc-auth` | `automatic_user_linking: true`, `default_redirect: true`. Supervisor DNS via `ha dns options`. |
|
||||
|
||||
## Netbird IdP integration — DEFERRED
|
||||
## Netbird IdP integration — LANDED 2026-05-21
|
||||
|
||||
The `netbirdio/netbird-server` combined image has no config knobs for external OIDC. Verified in `combined/cmd/config.go` on main (v0.69.0): `AuthConfig` only exposes issuer + redirect URIs; `ToManagementConfig()` hardcodes `AuthAudience="netbird-dashboard"`, `UserIDClaim="sub"`, always calls `buildEmbeddedIdPConfig()`. Code comment: `"embedded IdP is always enabled in combined server"`.
|
||||
The combined `netbirdio/netbird-server` image was replaced with the canonical vanilla stack (`mgmt + signal + relay + dashboard` 0.71.3) on the VPS so external OIDC actually works. Authentik is now the netbird dashboard's IdP. Full migration context in [mesh.md changelog](../infrastructure/mesh.md#changelog).
|
||||
|
||||
To wire Authentik into Netbird login/user-sync, must migrate to the legacy split stack (`netbirdio/management` + `signal` + `dashboard` + coturn) with the richer `management.json` schema. Sqlite management data should migrate but needs verification; peers stay connected via wireguard keys.
|
||||
**Active provider & app:**
|
||||
- Provider `NetBird` (OAuth2/OpenID), **Client type: `Public`** (PKCE-only — `Confidential` would break the dashboard SPA's token exchange).
|
||||
- Client ID: `netbird-dashboard`. Client Secret is in `/opt/management.json` `PKCEAuthorizationFlow.ProviderConfig.ClientSecret` on the VPS (TODO: sops-encrypt as `secrets/netbird-authentik-oidc.yaml`).
|
||||
- Application `NetBird`, slug `netbird`, launch URL `https://netbird.hubris.network/`.
|
||||
- Redirect URIs: `https://netbird.hubris.network/peers`, `/nb-auth`, `/nb-silent-auth`, plus `https://netbird.hubris.network/` for post-logout.
|
||||
- Scopes enabled on the provider: `openid`, `profile`, `email`.
|
||||
- Discovery URL: `https://auth.hubris.network/application/o/netbird/.well-known/openid-configuration` — netbird mgmt fetches this on startup; logs `loaded OIDC configuration from the provided IDP configuration endpoint`.
|
||||
|
||||
**Pre-work already in place (keep for re-use):**
|
||||
- Provider `Provider for Netbird` + App `netbird`. Client ID `xZwVTFCsxWdBM3uIGS15wAAcVvsJiTtWdxVCEela`. Redirect URIs for `https://netbird.hubris.network/{nb-auth,nb-silent-auth}` and `http://localhost:53000/`. Scopes: `openid profile email offline_access goauthentik.io/api`.
|
||||
- Service account `netbird-service` in `authentik Admins` group, non-expiring API token `netbird-service-api`.
|
||||
- Netbird mgmt host (`82.165.190.79`) is now a peer on its own mesh. See [mesh](../infrastructure/mesh.md).
|
||||
**Login flow:** netbird dashboard PKCE → Authentik authorize → redirect back to `/nb-auth` → JS token exchange at Authentik's `/token` endpoint → mgmt validates the bearer against Authentik's JWKS.
|
||||
|
||||
**First-time owner promotion gotcha** (write-down for future operators):
|
||||
|
||||
When a new Authentik user logs in for the first time against an account that already has peers, netbird mgmt adds them as `role=user, blocked=1, pending_approval=1`. The OLD account-owner (the one in `store.db` from before the IdP swap) can't be reached anymore, so there's no admin to approve. Recovery is a direct sqlite update on `mgmt_data`:
|
||||
|
||||
```
|
||||
docker stop netbird-mgmt
|
||||
sqlite3 /var/lib/docker/volumes/opt_mgmt_data/_data/store.db \
|
||||
"UPDATE users SET role='owner', blocked=0, pending_approval=0 WHERE id='<new authentik sub>';"
|
||||
docker start netbird-mgmt
|
||||
```
|
||||
|
||||
The Authentik sub-claim is the value of the `id` column on the newly-created user row (look for `role=user, blocked=1, pending_approval=1`).
|
||||
|
||||
### Device Code grant — configured (2026-05-21)
|
||||
|
||||
`netbird up` (interactive, without `--setup-key`) works against Authentik. The recipe:
|
||||
|
||||
1. **Flow** `default-device-code-flow` (designation: `Stage Configuration`) with 4 stage bindings in order:
|
||||
- 10: `default-authentication-identification` (username/email lookup)
|
||||
- 20: `default-authentication-password` (password validation)
|
||||
- 30: `default-authentication-login` (attach authenticated user to session)
|
||||
- 40: `default-provider-authorization-explicit-consent`'s Consent Stage (`default-provider-authorization-consent`) — the "Authorize NetBird?" approval
|
||||
2. **Brand** (System → Brands → edit the brand serving `auth.hubris.network`): set **Device code flow** field to `default-device-code-flow`.
|
||||
3. No provider-side change is required — Authentik 2026.x routes `/device` via the brand's device-code flow, not via the OAuth2/OpenID provider's `Authorization flow`.
|
||||
|
||||
**Why this matters for the lab**: Authentik 2026.x doesn't ship a default device-code flow. Without this configuration, the URL `https://auth.hubris.network/device` renders blank (the `/device` endpoint is unrouted), so `netbird up` device-codes expire without consent → only `--setup-key` works for onboarding. The above unblocks interactive onboarding.
|
||||
|
||||
**Verifying** from a browser tab: visit `https://auth.hubris.network/device`. You should see a form with one **Code** input + Continue button. Then `netbird up` (no setup-key) end-to-end:
|
||||
- CLI prints `verification_uri_complete: https://auth.hubris.network/device?code=...`
|
||||
- Open URL → identification (skipped if logged in) → password (re-auth check) → consent ("Authorize NetBird?") → Continue
|
||||
- CLI completes registration with `Connected`
|
||||
|
||||
### Self-service onboarding (not yet — future-session)
|
||||
|
||||
`auth.hubris.network` is only reachable from inside the netbird mesh (split-horizon DNS). A brand-new client that isn't on the mesh yet can't OIDC-login → setup-key is the only path. To enable self-service onboarding via Authentik from the public internet:
|
||||
- Add a Traefik route on the VPS for `auth.hubris.network` that forwards via the netbird-routed `192.168.8.0/24` to LXC 124.
|
||||
- DNS already points `auth.hubris.network → 82.165.190.79` (IONOS wildcard).
|
||||
|
||||
Tracked in homelab memory as a queued follow-up.
|
||||
|
||||
### Old pre-work to remove
|
||||
|
||||
The previous `Provider for Netbird` + `netbird` app from 2026-04-22 (client ID `xZwVTFCsxWdBM3uIGS15wAAcVvsJiTtWdxVCEela`, service account `netbird-service`) is now obsolete — replaced by `netbird-dashboard` above. Safe to delete from Authentik admin UI; nothing currently uses the old client ID. The service account + API token can also be removed unless we wire IdpManagerConfig in mgmt later (currently `ManagerType: none`).
|
||||
|
||||
## DNS responsibility
|
||||
|
||||
@@ -123,11 +170,14 @@ dnsmasq runs alongside Authentik on this LXC, listening on `192.168.8.180:53` +
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-05-21 — Netbird IdP swap landed (Phase 6 done)
|
||||
VPS migrated from combined netbird-server to vanilla mgmt+signal+relay+dashboard 0.71.3 (see [mesh.md](../infrastructure/mesh.md)), enabling Authentik as the dashboard IdP via PKCE. New Provider/App = `netbird-dashboard`, replacing the deferred pre-work. Device Code Stage still missing — interactive `netbird up` fails consent; setup-keys are the workaround until that's added.
|
||||
|
||||
### 2026-04-28 — wiki entry created
|
||||
Initial documentation.
|
||||
|
||||
### 2026-04-22 — Phase 6 (Netbird IdP swap) deferred
|
||||
Combined netbird-server image can't take an external IdP. Pre-work in Authentik kept for later. Netbird mgmt host instead joined its own mesh as a peer (`100.122.165.149`) for split-horizon DNS access.
|
||||
Combined netbird-server image couldn't take an external IdP. Pre-work in Authentik kept for later (now superseded by 2026-05-21 above). Netbird mgmt host instead joined its own mesh as a peer (`100.122.165.149`) for split-horizon DNS access.
|
||||
|
||||
### 2026-04-22 — Artifacto, mulita, WriteFreely, Portainer wired
|
||||
Native OIDC for mulita / WriteFreely / Portainer; gateway-secret auto-login pattern for Artifacto.
|
||||
|
||||
@@ -144,8 +144,23 @@ curl -sk --resolve photos-new.hubris.network:443:192.168.8.175 \
|
||||
https://photos-new.hubris.network/api/v1/oidc/login -i | head -2 # 302 → auth.hubris.network
|
||||
```
|
||||
|
||||
> **Decommissioned 2026-05-22.** The PhotoPrism + sidecar + SvelteKit stack
|
||||
> validated here was promoted into production on [LXC 120](120-mule-images.md)
|
||||
> via the `Mulimage 2.0` merge (`dtoro/mule-image` commit `70dc1b6`). This
|
||||
> page is retained for archaeology; everything below is historic. See the
|
||||
> 2026-05-22 entry in [120-mule-images.md](120-mule-images.md#changelog) for
|
||||
> the cutover detail.
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-05-22 — Destroyed
|
||||
|
||||
`pct destroy 127` after curl verification of the new 120 stack passed
|
||||
end-to-end. dnsmasq `photos-new.hubris.network` line removed; gitea
|
||||
webhook id 9 + `192.168.8.181` ALLOWED_HOST_LIST entry removed; caddy
|
||||
`photos-new.hubris.network` site block dropped from `dtoro/caddy-conf`.
|
||||
Authentik `mule-photos-new` app + provider deleted.
|
||||
|
||||
### 2026-05-18 (pm) — OIDC state-cookie fix: deploy.sh no longer recreates pp-app
|
||||
|
||||
User reported `failed to get state: securecookie: the value is not valid` after
|
||||
|
||||
@@ -11,18 +11,18 @@ All containers live on [`hubris`](../hosts/hubris.md). Each row links to the per
|
||||
| 114 | [nextcloud](114-nextcloud.md) | 192.168.8.224 | priv | 4 | 6 GiB | 25 GiB | `/mnt/library` | `cloud.hubris.network` | running |
|
||||
| 118 | [elementsynapse](118-elementsynapse.md) | 192.168.8.239 | unpriv | 1 | 2 GiB | 8 GiB | — | `matrix.hubris.network` | running |
|
||||
| 119 | [sophia](119-sophia.md) | 192.168.8.157 | priv | 2 | 1 GiB | 10 GiB | `/mnt/library` | — | running |
|
||||
| 120 | [mule-images](120-mule-images.md) | 192.168.8.136 | priv | 4 | 8 GiB | 60 GiB | `/mnt/library` | `photos.hubris.network` | running |
|
||||
| 120 | [mule-images](120-mule-images.md) | 192.168.8.136 | priv | 6 | 12 GiB | 60 GiB | `/mnt/library` + `/dev/dri` (iGPU passthrough) | `photos.hubris.network` | running |
|
||||
| 121 | [caddy](121-caddy.md) | 192.168.8.175 | unpriv | 1 | 512 MiB | 6 GiB | — | (terminates all `*.hubris.network`) | running |
|
||||
| 122 | [arriman](122-arriman.md) | 192.168.8.132 | priv | 4 | 8 GiB | 24 GiB | `/mnt/library` | `jellyseerr` / `qbit` / `sab` | running |
|
||||
| 123 | [claudio-bot](123-claudio-bot.md) | 192.168.8.230 | unpriv | 1 | 512 MiB | 8 GiB | — | — | running |
|
||||
| 124 | [authentik](124-authentik.md) | 192.168.8.180 | priv | 2 | 4 GiB | 20 GiB | — | `auth.hubris.network` | running |
|
||||
| 126 | [plato](126-plato.md) | 192.168.8.190 | priv | 2 | 2 GiB | 8 GiB | `/mnt/library/documents/plato` | `plato.hubris.network` | running |
|
||||
| 127 | [mule-photos-new](127-mule-photos-new.md) | 192.168.8.181 | unpriv | 6 | 8 GiB | 40 GiB | — (rsync scratch on rootfs) | `photos-new.hubris.network` | running |
|
||||
|
||||
## Recently destroyed (kept for archaeology)
|
||||
|
||||
| ID | Name | Destroyed | Reason |
|
||||
| --- | ---------------- | --------------- | --------------------------------------------- |
|
||||
| 127 | mule-photos-new | 2026-05-22 | PhotoPrism + sidecar + SvelteKit stack promoted to LXC 120 via Mulimage 2.0 merge (`70dc1b6`); M0 test LXC retired. Caddy + dnsmasq + gitea webhook + NC webhook listeners all cleaned up in the same cutover. |
|
||||
| 100 | arr (yunohost) | ~2026-04-28 | Migrated to docker stack on [arriman](122-arriman.md); planned retention window expired |
|
||||
| 106 | flaresolverr | ~2026-04-28 | Folded into the arriman docker compose |
|
||||
| 116 | heaper | 2026-05-14 | Decommissioned by user; data subtree at `/mnt/library/heaper` (224 MiB) retained |
|
||||
|
||||
@@ -9,8 +9,9 @@ host: hubris
|
||||
pve_id: 105
|
||||
lan_ip: 192.168.8.205
|
||||
mesh:
|
||||
netbird:
|
||||
fqdn: apps.netbird.selfhosted
|
||||
tailscale:
|
||||
ip: 100.121.171.122
|
||||
fqdn: apps
|
||||
mesh_globals:
|
||||
primary: netbird
|
||||
accepted:
|
||||
@@ -35,16 +36,20 @@ services_hosted:
|
||||
- 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.
|
||||
systemd_unit: homelab-mcp
|
||||
public_host: mcp.hubris.network
|
||||
endpoint: https://mcp.hubris.network/sse
|
||||
note: MCP server. Read-only context + management. Reachable on the LAN via Caddy and from off-LAN via
|
||||
Netbird (192.168.8.0/24 is a network resource routed through hubris).
|
||||
- name: secrets_issuance
|
||||
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.
|
||||
systemd_unit: secrets-issuance
|
||||
public_host: secrets.hubris.network
|
||||
endpoint: https://secrets.hubris.network/issue
|
||||
note: Issues per-client age private keys. Gated at source-IP layer (mesh + LAN subnets in MESH_SUBNETS).
|
||||
age_pubkey: age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0
|
||||
see_also:
|
||||
- containers/105-apps.md
|
||||
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
|
||||
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue
|
||||
mcp_endpoint: https://mcp.hubris.network/sse
|
||||
secrets_issuance_endpoint: https://secrets.hubris.network/issue
|
||||
|
||||
@@ -9,8 +9,8 @@ host: hubris
|
||||
pve_id: 122
|
||||
lan_ip: 192.168.8.132
|
||||
mesh:
|
||||
netbird:
|
||||
fqdn: arriman.netbird.selfhosted
|
||||
tailscale:
|
||||
fqdn: arr
|
||||
mesh_globals:
|
||||
primary: netbird
|
||||
accepted:
|
||||
@@ -30,5 +30,5 @@ services_hosted:
|
||||
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
|
||||
mcp_endpoint: https://mcp.hubris.network/sse
|
||||
secrets_issuance_endpoint: https://secrets.hubris.network/issue
|
||||
|
||||
@@ -8,9 +8,6 @@ role: idp
|
||||
host: hubris
|
||||
pve_id: 124
|
||||
lan_ip: 192.168.8.180
|
||||
mesh:
|
||||
netbird:
|
||||
fqdn: authentik.netbird.selfhosted
|
||||
mesh_globals:
|
||||
primary: netbird
|
||||
accepted:
|
||||
@@ -31,5 +28,5 @@ 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
|
||||
mcp_endpoint: https://mcp.hubris.network/sse
|
||||
secrets_issuance_endpoint: https://secrets.hubris.network/issue
|
||||
|
||||
@@ -8,9 +8,6 @@ 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:
|
||||
@@ -31,5 +28,5 @@ notes:
|
||||
- /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
|
||||
mcp_endpoint: https://mcp.hubris.network/sse
|
||||
secrets_issuance_endpoint: https://secrets.hubris.network/issue
|
||||
|
||||
@@ -8,9 +8,6 @@ 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:
|
||||
@@ -18,7 +15,8 @@ mesh_globals:
|
||||
- tailscale
|
||||
notes:
|
||||
- Reads /opt/homelab-context/ on startup
|
||||
age_pubkey: age1xmkeq968areza2necqyq0065dpeegngzyr6dhagh0n6pl33lccfqe5mqn9
|
||||
see_also:
|
||||
- containers/123-claudio-bot.md
|
||||
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
|
||||
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue
|
||||
mcp_endpoint: https://mcp.hubris.network/sse
|
||||
secrets_issuance_endpoint: https://secrets.hubris.network/issue
|
||||
|
||||
@@ -9,8 +9,8 @@ host: hubris
|
||||
pve_id: 118
|
||||
lan_ip: 192.168.8.239
|
||||
mesh:
|
||||
netbird:
|
||||
fqdn: elementsynapse.netbird.selfhosted
|
||||
tailscale:
|
||||
fqdn: elementsynapse
|
||||
mesh_globals:
|
||||
primary: netbird
|
||||
accepted:
|
||||
@@ -25,5 +25,5 @@ services_hosted:
|
||||
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
|
||||
mcp_endpoint: https://mcp.hubris.network/sse
|
||||
secrets_issuance_endpoint: https://secrets.hubris.network/issue
|
||||
|
||||
@@ -9,8 +9,8 @@ host: hubris
|
||||
pve_id: 104
|
||||
lan_ip: 192.168.8.121
|
||||
mesh:
|
||||
netbird:
|
||||
fqdn: gitea.netbird.selfhosted
|
||||
tailscale:
|
||||
fqdn: gitea
|
||||
mesh_globals:
|
||||
primary: netbird
|
||||
accepted:
|
||||
@@ -30,5 +30,5 @@ 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
|
||||
mcp_endpoint: https://mcp.hubris.network/sse
|
||||
secrets_issuance_endpoint: https://secrets.hubris.network/issue
|
||||
|
||||
@@ -7,9 +7,10 @@ os: linux
|
||||
role: home-automation
|
||||
host: hubris
|
||||
pve_id: 108
|
||||
lan_ip: 192.168.8.101
|
||||
mesh:
|
||||
netbird:
|
||||
fqdn: haos.netbird.selfhosted
|
||||
tailscale:
|
||||
fqdn: homeassistant
|
||||
mesh_globals:
|
||||
primary: netbird
|
||||
accepted:
|
||||
@@ -22,5 +23,5 @@ services_hosted:
|
||||
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
|
||||
mcp_endpoint: https://mcp.hubris.network/sse
|
||||
secrets_issuance_endpoint: https://secrets.hubris.network/issue
|
||||
|
||||
@@ -20,6 +20,7 @@ mounts:
|
||||
ssh:
|
||||
port: 22
|
||||
netbird_port: 22022
|
||||
user: root
|
||||
runs:
|
||||
- proxmox_ui
|
||||
services_hosted:
|
||||
@@ -27,7 +28,8 @@ services_hosted:
|
||||
url: https://proxmox.hubris.network
|
||||
backend: hubris
|
||||
port: 8006
|
||||
age_pubkey: age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6
|
||||
see_also:
|
||||
- hosts/hubris.md
|
||||
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
|
||||
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue
|
||||
mcp_endpoint: https://mcp.hubris.network/sse
|
||||
secrets_issuance_endpoint: https://secrets.hubris.network/issue
|
||||
|
||||
@@ -9,8 +9,8 @@ host: hubris
|
||||
pve_id: 101
|
||||
lan_ip: 192.168.8.206
|
||||
mesh:
|
||||
netbird:
|
||||
fqdn: jellyfin.netbird.selfhosted
|
||||
tailscale:
|
||||
fqdn: jellyfin
|
||||
mesh_globals:
|
||||
primary: netbird
|
||||
accepted:
|
||||
@@ -27,5 +27,5 @@ services_hosted:
|
||||
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
|
||||
mcp_endpoint: https://mcp.hubris.network/sse
|
||||
secrets_issuance_endpoint: https://secrets.hubris.network/issue
|
||||
|
||||
@@ -13,5 +13,5 @@ mesh_globals:
|
||||
accepted:
|
||||
- netbird
|
||||
- tailscale
|
||||
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
|
||||
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue
|
||||
mcp_endpoint: https://mcp.hubris.network/sse
|
||||
secrets_issuance_endpoint: https://secrets.hubris.network/issue
|
||||
|
||||
@@ -7,13 +7,15 @@ os: macos
|
||||
role: dev
|
||||
mesh:
|
||||
netbird:
|
||||
fqdn: mac-mini.netbird.selfhosted
|
||||
fqdn: mac-mini-234-17.netbird.selfhosted
|
||||
mesh_globals:
|
||||
primary: netbird
|
||||
accepted:
|
||||
- netbird
|
||||
- tailscale
|
||||
ssh:
|
||||
user: dtoro
|
||||
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
|
||||
mcp_endpoint: https://mcp.hubris.network/sse
|
||||
secrets_issuance_endpoint: https://secrets.hubris.network/issue
|
||||
|
||||
@@ -9,8 +9,8 @@ host: hubris
|
||||
pve_id: 120
|
||||
lan_ip: 192.168.8.136
|
||||
mesh:
|
||||
netbird:
|
||||
fqdn: mule-images.netbird.selfhosted
|
||||
tailscale:
|
||||
fqdn: muleimage
|
||||
mesh_globals:
|
||||
primary: netbird
|
||||
accepted:
|
||||
@@ -27,5 +27,5 @@ services_hosted:
|
||||
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
|
||||
mcp_endpoint: https://mcp.hubris.network/sse
|
||||
secrets_issuance_endpoint: https://secrets.hubris.network/issue
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
# 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
|
||||
29
hosts/netbird-vps.yaml
Normal file
29
hosts/netbird-vps.yaml
Normal 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: netbird-vps
|
||||
kind: external
|
||||
os: linux
|
||||
role: netbird-mgmt
|
||||
mesh:
|
||||
netbird:
|
||||
ip: 100.122.165.149
|
||||
fqdn: netbird-ionos.netbird.selfhosted
|
||||
mesh_globals:
|
||||
primary: netbird
|
||||
accepted:
|
||||
- netbird
|
||||
- tailscale
|
||||
ssh:
|
||||
user: root
|
||||
notes:
|
||||
- "Public IONOS VPS \u2014 hosts the vanilla netbird mgmt+signal+relay+dashboard stack + host coturn (see\
|
||||
\ infrastructure/vps-hardening.md + infrastructure/mesh.md changelog 2026-05-21)."
|
||||
- NOT a homelab client. No /etc/age/key.txt, no /opt/homelab-context clone. Managed via ssh from hubris;
|
||||
sshd is locked to hubris's pubkey.
|
||||
- Public IPv4 82.165.190.79. Auto-patching via unattended-upgrades.
|
||||
- Configs rendered by `homelab render-vps-configs` from vps/turnserver.conf.tmpl + vps/management.json.tmpl,
|
||||
with secrets decrypted from secrets/turn-shared-secret.yaml + secrets/netbird-authentik-oidc.yaml on
|
||||
hubris.
|
||||
mcp_endpoint: https://mcp.hubris.network/sse
|
||||
secrets_issuance_endpoint: https://secrets.hubris.network/issue
|
||||
@@ -9,8 +9,8 @@ host: hubris
|
||||
pve_id: 114
|
||||
lan_ip: 192.168.8.224
|
||||
mesh:
|
||||
netbird:
|
||||
fqdn: nextcloud.netbird.selfhosted
|
||||
tailscale:
|
||||
fqdn: nextcloud
|
||||
mesh_globals:
|
||||
primary: netbird
|
||||
accepted:
|
||||
@@ -27,5 +27,5 @@ services_hosted:
|
||||
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
|
||||
mcp_endpoint: https://mcp.hubris.network/sse
|
||||
secrets_issuance_endpoint: https://secrets.hubris.network/issue
|
||||
|
||||
@@ -8,9 +8,6 @@ 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:
|
||||
@@ -18,5 +15,5 @@ mesh_globals:
|
||||
- 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
|
||||
mcp_endpoint: https://mcp.hubris.network/sse
|
||||
secrets_issuance_endpoint: https://secrets.hubris.network/issue
|
||||
|
||||
@@ -9,8 +9,8 @@ host: hubris
|
||||
pve_id: 103
|
||||
lan_ip: 192.168.8.130
|
||||
mesh:
|
||||
netbird:
|
||||
fqdn: paperless.netbird.selfhosted
|
||||
tailscale:
|
||||
fqdn: paperless
|
||||
mesh_globals:
|
||||
primary: netbird
|
||||
accepted:
|
||||
@@ -27,5 +27,5 @@ services_hosted:
|
||||
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
|
||||
mcp_endpoint: https://mcp.hubris.network/sse
|
||||
secrets_issuance_endpoint: https://secrets.hubris.network/issue
|
||||
|
||||
@@ -8,9 +8,6 @@ role: app
|
||||
host: hubris
|
||||
pve_id: 126
|
||||
lan_ip: 192.168.8.190
|
||||
mesh:
|
||||
netbird:
|
||||
fqdn: plato.netbird.selfhosted
|
||||
mesh_globals:
|
||||
primary: netbird
|
||||
accepted:
|
||||
@@ -27,5 +24,5 @@ services_hosted:
|
||||
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
|
||||
mcp_endpoint: https://mcp.hubris.network/sse
|
||||
secrets_issuance_endpoint: https://secrets.hubris.network/issue
|
||||
|
||||
@@ -13,5 +13,7 @@ mesh_globals:
|
||||
accepted:
|
||||
- netbird
|
||||
- tailscale
|
||||
mcp_endpoint: http://apps.netbird.selfhosted:9810/sse
|
||||
secrets_issuance_endpoint: http://apps.netbird.selfhosted:9820/issue
|
||||
ssh:
|
||||
user: dtoro
|
||||
mcp_endpoint: https://mcp.hubris.network/sse
|
||||
secrets_issuance_endpoint: https://secrets.hubris.network/issue
|
||||
|
||||
@@ -9,8 +9,8 @@ host: hubris
|
||||
pve_id: 119
|
||||
lan_ip: 192.168.8.157
|
||||
mesh:
|
||||
netbird:
|
||||
fqdn: sophia.netbird.selfhosted
|
||||
tailscale:
|
||||
fqdn: sophia
|
||||
mesh_globals:
|
||||
primary: netbird
|
||||
accepted:
|
||||
@@ -20,5 +20,5 @@ 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
|
||||
mcp_endpoint: https://mcp.hubris.network/sse
|
||||
secrets_issuance_endpoint: https://secrets.hubris.network/issue
|
||||
|
||||
@@ -7,9 +7,7 @@ os: linux
|
||||
role: nas-frontend-eval
|
||||
host: hubris
|
||||
pve_id: 100
|
||||
mesh:
|
||||
netbird:
|
||||
fqdn: zimaos.netbird.selfhosted
|
||||
lan_ip: 192.168.8.195
|
||||
mesh_globals:
|
||||
primary: netbird
|
||||
accepted:
|
||||
@@ -24,5 +22,5 @@ services_hosted:
|
||||
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
|
||||
mcp_endpoint: https://mcp.hubris.network/sse
|
||||
secrets_issuance_endpoint: https://secrets.hubris.network/issue
|
||||
|
||||
@@ -40,6 +40,12 @@ The app repo at `/opt/<thing>` is the working tree, but the deploy tooling (`web
|
||||
| `dtoro/Plato` | [plato (126)](../containers/126-plato.md) `/opt/plato/app/` | B | `http://192.168.8.190:9799/deploy` | 8 | `docker compose up -d --build` |
|
||||
| `dtoro/claudio-bot` | [claudio-bot (123)](../containers/123-claudio-bot.md) `/opt/claudio-bot/` | A | `http://192.168.8.230:9797/deploy` | (orig) | `pip install` + `systemctl restart claudio-bot` |
|
||||
| `dtoro/backup-library` | [hubris host](../hosts/hubris.md) `/opt/backup-library/` | A | `http://192.168.8.77:9798/deploy` | (orig) | runs `deploy.sh` (preserves admin-edited `/etc/restic/include-*.list`) |
|
||||
| `dtoro/Homelab-Docs` → homelab-mcp | [apps (105)](../containers/105-apps.md) `/opt/homelab-mcp/` | B | `http://192.168.8.205:9811/deploy` | 10 | reinstalls `homelab-mcp.service` + restart |
|
||||
| `dtoro/Homelab-Docs` → secrets-issuance | [apps (105)](../containers/105-apps.md) `/opt/secrets-issuance/` | B | `http://192.168.8.205:9821/deploy` | 11 | reinstalls `secrets-issuance.service` + restart |
|
||||
|
||||
> Note: `dtoro/Homelab-Docs` has **two webhooks** firing on the same push.
|
||||
> Each owns its own clone on LXC 105. They don't conflict because each
|
||||
> deploy.sh only touches its own service unit + venv.
|
||||
|
||||
> **Not yet wired:** `dtoro/claudio-monitor` (push, then `/opt/claudio-monitor/scripts/deploy.sh` manually). `dtoro/authentik-conf` is reserved but the LXC stack is not git-tracked yet. The dnsmasq config on [authentik (124)](../containers/124-authentik.md) is also not tracked — if it gets a `dtoro/dnsmasq-conf`, mirror the caddy-conf pattern.
|
||||
|
||||
@@ -54,6 +60,7 @@ Always commit + push. Local-only edits drift. Common ones:
|
||||
- `/opt/plato/app/` ↔ `dtoro/Plato` (auto-deploys)
|
||||
- `/opt/claudio-bot/` ↔ `dtoro/claudio-bot` (auto-deploys)
|
||||
- `/opt/backup-library/` ↔ `dtoro/backup-library` (auto-deploys)
|
||||
- `/opt/homelab-mcp/` + `/opt/secrets-issuance/` ↔ `dtoro/Homelab-Docs` (auto-deploys both, see [homelab-context](homelab-context.md))
|
||||
|
||||
## Per-pipeline notes / gotchas
|
||||
|
||||
@@ -81,13 +88,44 @@ Always commit + push. Local-only edits drift. Common ones:
|
||||
- Currently the only deploy that targets the host directly (`192.168.8.77:9798`).
|
||||
- `deploy.sh` is careful to preserve admin edits to `/etc/restic/include-*.list` — canonical source is `config/` in the repo, but the install path is treated as authoritative once `deploy.sh` has run.
|
||||
|
||||
### homelab-mcp / secrets-issuance
|
||||
- Both ride a single push to `dtoro/Homelab-Docs`. Two clones on LXC 105
|
||||
(`/opt/homelab-mcp`, `/opt/secrets-issuance`) — each is an independent
|
||||
Shape-B target with its own webhook receiver.
|
||||
- The deploy script restarts the service it just updated. Because the
|
||||
webhook receiver itself is a separate systemd unit (`*-deploy.service`),
|
||||
it does NOT restart itself — but `deploy.sh` running `systemctl
|
||||
restart homelab-mcp-deploy.service` (or the secrets-issuance one)
|
||||
would create a kill-self loop. The current `deploy.sh` is careful
|
||||
to only restart the main service.
|
||||
- Both services consume `/opt/homelab-context` for their runtime data
|
||||
(inventory, secret recipient lookup). That clone is **the same clone
|
||||
every other client has** — kept fresh by `homelab-context-sync.timer`,
|
||||
not by these webhooks.
|
||||
|
||||
## Custom-built binaries that overlap apt-managed paths
|
||||
|
||||
If a pipeline (or any out-of-band build) drops a binary into a path that an apt package also owns — most commonly `/usr/bin/<name>` — then the next `apt upgrade` of the corresponding package will silently clobber the custom build. That's exactly how [LXC 121 caddy](../containers/121-caddy.md) went down for ~10 min on 2026-05-21: an xcaddy build with `caddy-dns/ionos` lived at `/usr/bin/caddy` and Debian's caddy 2.11.2→2.11.3 apt upgrade replaced it with a vanilla 2.11.3 that couldn't parse the Caddyfile.
|
||||
|
||||
Two patterns are acceptable, pick one when authoring a pipeline that ships a non-apt binary:
|
||||
|
||||
1. **Ship the build as a `.deb` with an epoch-bumped version.** Use `dpkg-deb --build` (or `nfpm`) to package the binary as `Package: <name>`, `Version: 1:<upstream>-hubris<n>`. The epoch (`1:`) means it beats any non-epoch upstream version regardless of point bumps, so `apt upgrade` is a no-op for that package. Used by caddy: `caddy 1:2.11.3-hubris1` (see commit `2026-05-21` in [121-caddy.md](../containers/121-caddy.md)).
|
||||
|
||||
2. **Hold the apt package.** `apt-mark hold <pkg>` on the LXC during pipeline install; apt will refuse to upgrade it. Simpler than `.deb` packaging but: (a) the hold flag isn't preserved by `dpkg -i` of a new version, (b) it's invisible unless you check `apt-mark showhold`, (c) you have to remember to `apt-mark unhold` when you intentionally want a new version. The new `homelab apt-audit` subcommand surfaces holds across the fleet so they don't get forgotten.
|
||||
|
||||
If you're not sure what's already lurking, run `homelab apt-audit --fleet` and look at the `NONAPT` column — that's a count of binaries in `/usr/bin/{caddy,docker,jellyfin}` + `/usr/local/bin/*` that no apt package owns.
|
||||
|
||||
## Related
|
||||
- [Gitea (104)](../containers/104-gitea.md) — webhook source for all of these
|
||||
- [Caddy (121)](../containers/121-caddy.md), [apps (105)](../containers/105-apps.md), [mule-images (120)](../containers/120-mule-images.md), [claudio-bot (123)](../containers/123-claudio-bot.md), [hubris host](../hosts/hubris.md) — webhook targets
|
||||
- [Backups (disabled)](backups.md)
|
||||
- [Operations cheatsheet](../operations/commands.md) — `homelab apt-audit` / `homelab apt-upgrade` reference
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-05-20 — homelab-mcp + secrets-issuance pipelines added
|
||||
Webhook ids 10 + 11 on `dtoro/Homelab-Docs` (ports `9811` + `9821` on [apps (105)](../containers/105-apps.md)). Two webhooks on one repo — each owns its own clone (`/opt/homelab-mcp`, `/opt/secrets-issuance`) and only restarts its own service. See [homelab-context](homelab-context.md) for why both services live in one repo.
|
||||
|
||||
### 2026-05-13 — Plato pipeline added
|
||||
Webhook id 8 on `dtoro/Plato` (port `9799` on [plato (126)](../containers/126-plato.md)). `app.ini` `ALLOWED_HOST_LIST` extended to include `192.168.8.190`.
|
||||
|
||||
|
||||
143
infrastructure/homelab-context.md
Normal file
143
infrastructure/homelab-context.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# Homelab context distribution
|
||||
|
||||
The cross-client context-and-secrets system that makes every agent (Claude
|
||||
Code, claudio-bot, future MCP-capable clients) on every machine in the lab
|
||||
self-locating and able to read the same source of truth.
|
||||
|
||||
Operational walkthrough for enrolling a new client lives in
|
||||
[operations/agent-enrollment.md](../operations/agent-enrollment.md); this
|
||||
page is the architecture reference.
|
||||
|
||||
## What's where
|
||||
|
||||
| Piece | Host | Path | Role |
|
||||
| --- | --- | --- | --- |
|
||||
| Source of truth | [gitea (104)](../containers/104-gitea.md) | `dtoro/Homelab-Docs.git` | Inventory + wiki + service code |
|
||||
| Per-client clone | every enrolled client | `/opt/homelab-context/` | Read by `homelab` CLI, MCP server, claudio-bot |
|
||||
| `homelab` CLI | every enrolled client | `/usr/local/bin/homelab` → `/opt/homelab-context/bin/homelab` (symlink) | Operator surface for enroll/secret/ssh/pct |
|
||||
| Per-client age key | every enrolled client | `/etc/age/key.txt` (0600 root) | Decrypts SOPS-encrypted secrets the client is a recipient on |
|
||||
| MCP server | [apps (105)](../containers/105-apps.md) | `homelab-mcp.service` on port 9810 (https://mcp.hubris.network/sse) | 14 tools: 8 context (get_host, search_docs, …) + 5 read-only management (get_service_status, tail_log, …) + list_my_secrets |
|
||||
| Secrets-issuance | [apps (105)](../containers/105-apps.md) | `secrets-issuance.service` on port 9820 (https://secrets.hubris.network/issue) | Generates per-client age keypair on first bootstrap; idempotent; admin-token-gated `/revoke` |
|
||||
| Sync timer | every enrolled client | `homelab-context-sync.timer` (Linux) / `network.hubris.homelab-context-sync.plist` (macOS) | `git pull --ff-only` every 5 min |
|
||||
| Encrypted secrets | `dtoro/Homelab-Docs` | `secrets/*.yaml` (SOPS+age) | Recipients declared in `.sops.yaml` |
|
||||
| Read-only context PAT | `dtoro` Gitea user, scope `read:repository` | given to operators out-of-band | Bootstrap-only — for the initial clone before SOPS works |
|
||||
| Write-scoped PAT | `secrets/gitea-pat.yaml` (SOPS) | `homelab refresh-creds` swaps it into `/etc/homelab-context/git-credentials` | All post-bootstrap pushes (client lifecycle, wiki edits) |
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
dtoro/Homelab-Docs (gitea)
|
||||
│
|
||||
┌────────── push ────────┤ ◀── git push (write PAT or SSH)
|
||||
│ │
|
||||
│ ┌────── push ──────┘
|
||||
│ │ │
|
||||
│ │ ▼ webhook (push event)
|
||||
│ │ ┌─── homelab-mcp-deploy ──── (LXC 105:9811)
|
||||
│ │ └─── secrets-issuance-deploy (LXC 105:9821)
|
||||
│ │ │
|
||||
│ │ ▼
|
||||
│ │ git pull → deploy.sh → restart service
|
||||
│ │
|
||||
│ └── on every client:
|
||||
│ timer (5 min) → git pull --ff-only into /opt/homelab-context
|
||||
│
|
||||
▼
|
||||
homelab CLI / MCP server reads /opt/homelab-context for everything
|
||||
```
|
||||
|
||||
## Why two clones on LXC 105
|
||||
|
||||
The MCP server and secrets-issuance each have their own clone
|
||||
(`/opt/homelab-mcp`, `/opt/secrets-issuance`) **in addition to**
|
||||
`/opt/homelab-context`. Reasons:
|
||||
|
||||
- The deploy webhook for each service updates its own clone, runs
|
||||
`deploy.sh` from there, and re-installs the systemd unit. Mixing this
|
||||
with the client-context clone would create a circular dependency
|
||||
(deploy reinstalls the unit that pulled it).
|
||||
- The MCP server reads its data from `/opt/homelab-context` (the same path
|
||||
every client uses) so changes to inventory propagate identically. Code
|
||||
changes live in `/opt/homelab-mcp` and trigger a service restart.
|
||||
|
||||
## Mesh / network gates
|
||||
|
||||
- Both services bind `0.0.0.0:<port>`. The trust boundary is
|
||||
`MESH_SUBNETS` in the service's environment + nftables (planned). Today
|
||||
`MESH_SUBNETS=100.122.0.0/16,100.64.0.0/10,192.168.8.0/24` — Netbird +
|
||||
Tailscale + the homelab LAN. Adjust if the LAN ever has untrusted
|
||||
devices.
|
||||
- Caddy fronts both with Let's Encrypt certs via the IONOS DNS challenge:
|
||||
`mcp.hubris.network` → `192.168.8.205:9810`,
|
||||
`secrets.hubris.network` → `192.168.8.205:9820`. Off-LAN clients on
|
||||
Netbird reach them via the `192.168.8.0/24` network resource routed
|
||||
through the PVE peer ([mesh.md](mesh.md)).
|
||||
- Clients with default-public DNS (workstations not on Netbird, LXCs
|
||||
using router DNS) need a `/etc/hosts` override pointing
|
||||
`mcp.hubris.network` and `secrets.hubris.network` at the caddy LXC
|
||||
(`192.168.8.175`) — same caveat as every other `*.hubris.network`
|
||||
service, see [dns.md](dns.md).
|
||||
|
||||
## Secrets model
|
||||
|
||||
- Each enrolled client gets one **age private key** issued by
|
||||
secrets-issuance on first bootstrap. The key file stays root-only on
|
||||
the client; the public key is committed to `inventory.yaml` (and
|
||||
becomes a recipient on SOPS-encrypted files via `.sops.yaml`).
|
||||
- `secrets/*.yaml` are SOPS+age. Recipients per file are pinned in
|
||||
`.sops.yaml` `creation_rules` by `path_regex`. Re-encrypting a file is
|
||||
`sops updatekeys -y secrets/<name>.yaml`.
|
||||
- The MCP server's `list_my_secrets(caller_pubkey)` tool returns only
|
||||
secret *names* a given pubkey can decrypt — the server never sees
|
||||
plaintext. Decryption is local-on-client (`homelab secret <name>`
|
||||
shells out to `sops -d` with the client's key).
|
||||
- The "all-clients" secrets (`hello.yaml` for the bootstrap decrypt
|
||||
test, `gitea-pat.yaml` for the write-scoped PAT) are auto-granted to
|
||||
every newly enrolled client by `homelab client add --finalize-pubkey`
|
||||
(which appends the pubkey to the matching `.sops.yaml` rule and runs
|
||||
`sops updatekeys`).
|
||||
- **Removal does not erase past disclosure.** Revoking a client via
|
||||
`homelab client remove` shreds the issuance-side key, denylists the
|
||||
hostname, removes them from the recipient list, and re-keys all
|
||||
shared secrets — but anything they already decrypted to disk is out of
|
||||
your control. Rotate the underlying credential if compromise is
|
||||
suspected.
|
||||
|
||||
## Why this design
|
||||
|
||||
- **One source of truth** keeps inventory, code, secrets, and docs
|
||||
versioned together. A `git log` of `inventory.yaml` is the history of
|
||||
the homelab.
|
||||
- **Per-client age keys** scale better than a shared admin secret —
|
||||
removing a client is a real revocation (for new ciphertext), not just
|
||||
removing them from a wiki page.
|
||||
- **MCP layer over the same clone** gives MCP-capable agents structured
|
||||
query (`find_service`, `search_docs`) without forcing non-MCP tools to
|
||||
go without — anything can still `cat` the markdown.
|
||||
- **Sync timer rather than push fan-out** keeps the failure mode
|
||||
contained: one client's webhook outage doesn't block a push from
|
||||
landing on the others. Sub-5-min staleness is fine for docs and rare
|
||||
enough for secrets that we don't need lower latency.
|
||||
|
||||
## Related
|
||||
|
||||
- [Operations: agent enrollment](../operations/agent-enrollment.md) — the
|
||||
step-by-step for adding a new client
|
||||
- [Auto-deploy](auto-deploy.md) — the `homelab-mcp` + `secrets-issuance`
|
||||
pipelines (and the rest of the lab's webhook pipelines)
|
||||
- [Mesh](mesh.md) — Netbird / Tailscale paths and the `192.168.8.0/24`
|
||||
network resource
|
||||
- [Apps (105)](../containers/105-apps.md) — where both services run
|
||||
- [Gitea (104)](../containers/104-gitea.md) — the source of truth
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-05-20 — system live across hubris, apps, republic-laptop
|
||||
Phase 1 of the [cross-client context plan](../README.md) merged. Three
|
||||
clients enrolled end-to-end: PAT-based bootstrap, age-key issuance, SOPS
|
||||
decrypt verified on each. Webhook auto-deploy for both LXC 105 services
|
||||
wired (hook ids 10 + 11). `homelab refresh-creds` + atomic
|
||||
`client add --finalize-pubkey` grant flow live so new clients are one
|
||||
ceremony instead of four manual steps. Outstanding: bootstrap mac-mini
|
||||
(macOS, exercises launchd) + ludo-mini + the remaining LXCs;
|
||||
claudio-bot integration so the matrix agent uses inventory at chat-time.
|
||||
@@ -8,23 +8,40 @@ The hubris fleet is migrating from Tailscale to Netbird. Netbird is the target e
|
||||
- **Netbird mgmt host** (`82.165.190.79`, FQDN `inspiring-ramanujan.netbird.selfhosted`, NB IP `100.122.165.149`) is now itself a peer on the mesh (joined 2026-04-22 via setup key, netbird 0.69.0). Routes the homelab network (`192.168.8.0/24`) via the PVE peer. This gives the mgmt host LAN access *and* split-horizon DNS for `*.hubris.network`. Useful independently of any Authentik integration.
|
||||
- **Most LXCs** still run Tailscale or use router DNS (`192.168.8.1`) / Tailscale MagicDNS (`100.100.100.100`), both of which return the *public* IONOS A record `*.hubris.network → 82.165.190.79`. The VPS only routes hostnames it actually publishes (today, `artifacto` + `blog`), so this path is a dead end for any LAN-only service.
|
||||
|
||||
## ICE / STUN — must use external STUN, not embedded
|
||||
## ICE / STUN / TURN
|
||||
|
||||
The bundled netbird-server image runs an embedded STUN listener on UDP 3478. **IONOS drops inbound UDP 3478 to the VPS upstream of the host firewall** (verified 2026-05-10 via `tcpdump -ni any udp port 3478`: 0 packets captured during external probes from hubris). Without a reachable STUN server, the management API hands peers a STUN URI nothing can talk to → no srflx candidates → ICE always fails → every peer falls back to the websocket relay (`rels://netbird.hubris.network:443`). All cross-NAT traffic is then bottlenecked by the relay/VPS bandwidth (observed ~366 kB/s for Nextcloud uploads).
|
||||
**Today** (post-2026-05-21 migration):
|
||||
|
||||
**Fix**: in `/opt/config.yaml` on the VPS, declare external STUN servers under `server:` — this disables the embedded STUN automatically:
|
||||
- External STUN servers (Google + Cloudflare) declared under `server.Stuns` in `/opt/management.json`. Embedded STUN is no longer in use.
|
||||
- coturn runs on the VPS host (apt-installed, systemd-managed). TURN URL `turn:netbird.hubris.network:3478?transport=tcp` is advertised to peers via mgmt's `TURNConfig.Turns` block. Long-term credentials at `user=netbird:<pwd from /root/turn-pass.txt>`.
|
||||
- For non-symmetric peers, ICE picks direct `srflx/srflx` (P2P). For symmetric-NAT peers, ICE falls through to TURN-relay before falling back to the WSS relay (`rels://netbird.hubris.network:443`).
|
||||
|
||||
```yaml
|
||||
server:
|
||||
stuns:
|
||||
- uri: "stun:stun.l.google.com:19302"
|
||||
- uri: "stun:stun1.l.google.com:19302"
|
||||
- uri: "stun:stun.cloudflare.com:3478"
|
||||
**IONOS port-3478 caveat** (load-bearing — undocumented before 2026-05-21):
|
||||
|
||||
IONOS upstream filters STUN-class traffic on port 3478 **for both UDP and TCP** by default. The UDP block was already known (verified 2026-05-10 with `tcpdump -ni any udp port 3478`). The TCP block was discovered 2026-05-21 when TURN-allocate retries timed out from a symmetric-NAT laptop: TCP handshake completed (carried by kernel-only SYN exchange) but data packets never reached the VPS's `ens6` interface.
|
||||
|
||||
**Resolution**: operator must add an inbound firewall exception in the IONOS dashboard (Cloud Panel → Networks → Firewall policy on the VPS) for inbound TCP 3478 (and UDP 3478 if STUN-via-VPS-listener is ever needed; we use external STUN so this is optional). Without that exception, coturn is unreachable from outside even though host nftables + listener look fine.
|
||||
|
||||
**Verifying TURN works** end-to-end from an outside peer:
|
||||
|
||||
```python
|
||||
# python3
|
||||
import socket, struct, secrets
|
||||
s = socket.create_connection(("netbird.hubris.network", 3478), timeout=10)
|
||||
tid = secrets.token_bytes(12)
|
||||
attrs = struct.pack("!HHI", 0x0019, 4, 17 << 24) # REQUESTED-TRANSPORT = UDP
|
||||
msg = struct.pack("!HHI", 0x0003, len(attrs), 0x2112A442) + tid + attrs # Allocate
|
||||
s.sendall(msg)
|
||||
print(s.recv(4096).hex()) # expect 120 bytes starting 0x0113 (Allocate Error = auth challenge)
|
||||
```
|
||||
|
||||
`docker restart netbird-server`, then `netbird down && netbird up` on each peer to force a resync. Verify with `netbird status -d` — `Connection type:` should flip from `Relayed` to `P2P` for peers that aren't behind double-NAT/CGNAT.
|
||||
A 120-byte `0x0113`-typed response means coturn is reachable and responding. An indefinite TCP timeout (after `connected from ...` prints) means the IONOS rule was reverted or narrowed.
|
||||
|
||||
If a peer is *still* relayed after this, it's a NAT-symmetry problem on its side, not a config bug — would need TURN to fix.
|
||||
**If a peer is still on the WSS relay after this** — verify in `netbird status -d`: `Connection type: P2P` with srflx/srflx is the cone-NAT happy path; `Connection type: P2P` with relay/srflx (or relay/prflx) means the peer's network is symmetric-NAT and it's using TURN. Falling back to `Relayed (rels://...)` only happens if TURN allocate also fails — investigate the IONOS exception first.
|
||||
|
||||
**Old combined-server note (history, kept for context):**
|
||||
|
||||
Pre-migration, the bundled `netbirdio/netbird-server` combined image silently ignored both `server.turns:` and top-level `TURNConfig:` YAML, so TURN was non-functional. That's why the architectural migration to the canonical multi-container stack (`mgmt + signal + relay + coturn`) happened. See the 2026-05-21 changelog entry below and the [Netbird-combined-no-TURN finding](https://github.com/netbirdio/netbird/issues) in homelab memory for the full discovery.
|
||||
|
||||
## Consequence — every LXC wired to Authentik needs an internal override
|
||||
|
||||
@@ -99,6 +116,65 @@ Recipe for container-config changes (e.g. adding `extra_hosts`) on Portainer-man
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-05-31 — Netbird mesh recovered; auth.hubris.network exposed via VPS Traefik
|
||||
|
||||
**Symptom:** `netbird-mgmt` crash-looped for ~9 days (since 2026-05-21 migration). All peers showed `Connecting`, management returned `404 (Not Found)` for gRPC → `EOF` on startup.
|
||||
|
||||
**Root cause:** The 2026-05-21 migration configured `management.json` with `OIDCConfigEndpoint: https://auth.hubris.network/...`. The public DNS for `auth.hubris.network` already pointed to `82.165.190.79` (VPS), but the VPS Traefik had no route for that host → management got EOF on every boot, crash-looped.
|
||||
|
||||
**Fix:**
|
||||
1. Added `auth-hubris` router to `/opt/traefik-dynamic.yaml` — `Host(auth.hubris.network)` with `certResolver: letsencrypt` → service `auth-authentik`.
|
||||
2. Service backend: `https://192.168.8.175` (Caddy on hubris LAN) via the Netbird mesh.
|
||||
3. ServersTransport `skip-verify` with `serverName: auth.hubris.network` + `insecureSkipVerify: true` so Traefik sends the correct SNI to Caddy.
|
||||
4. Upgraded VPS Netbird client `0.69.0 → 0.71.3` via apt (`apt install netbird=0.71.3`).
|
||||
5. Mesh fully recovered; management connected to peers within ~1 min.
|
||||
|
||||
**Bootstrap note (if mesh is dark and management must restart):** If the WireGuard tunnels are fully dead AND management needs to restart (after e.g. a VPS reboot with no peer handshakes), the Traefik backend `192.168.8.175` will be unreachable and management will crash again. Recovery: temporarily open VPS port 22 via IONOS console (`nft insert rule inet hubris-fw input iifname "ens6" tcp dport 22 accept`), SSH in, run `socat TCP-LISTEN:8443,bind=172.30.0.1,reuseaddr,fork TCP:127.0.0.1:8443 &`, then from hubris `ssh -f -N -R 127.0.0.1:8443:192.168.8.175:443 root@82.165.190.79` — this bootstraps one management start, after which the mesh self-heals.
|
||||
|
||||
**Architecture after this change:** `auth.hubris.network` is publicly accessible (HTTPS via VPS Traefik → Caddy on hubris → Authentik LXC 124). External devices authenticating to Netbird now hit this public path. Phase 6 (Authentik as Netbird IdP) is complete and live.
|
||||
|
||||
### 2026-05-21 — VPS migrated combined → vanilla netbird stack with external TURN
|
||||
The combined `netbirdio/netbird-server` image was replaced with the canonical multi-container deploy (`netbirdio/management:0.71.3` + `signal:0.71.3` + `relay:0.71.3` + `dashboard:latest` + host coturn) on `/opt/docker-compose.yml`. Driver: combined image silently ignored external `TURNConfig` so symmetric-NAT peers couldn't use TURN.
|
||||
|
||||
Same migration also swapped OIDC from the combined image's embedded Dex IdP to Authentik on [LXC 124](../containers/124-authentik.md), upgrading mgmt to 0.71.3. The `store.db` schema auto-migrated cleanly from 0.68.3 (copy-not-move from the old `opt_netbird_data` volume into the new `mgmt_data` volume). Pre-cutover backups at `/root/netbird-*.tgz` on the VPS, ~857 MB, retained for ~7d.
|
||||
|
||||
Also during this work: IONOS upstream was found to filter TCP 3478 in addition to UDP 3478. Added a TCP-3478 inbound exception in the IONOS firewall (see ICE/STUN section above for the verification probe).
|
||||
|
||||
The new Authentik provider for NetBird is `Client type: Public` (PKCE-only). Confidential would break the dashboard SPA's token exchange. The Device Code grant flow is wired (see [containers/124-authentik.md](../containers/124-authentik.md#device-code-grant--configured-2026-05-21)) so interactive `netbird up` works — `--setup-key` is no longer required for new peers.
|
||||
|
||||
**Post-migration JWT-issuer gotcha on existing peers** (cost ~30 min to diagnose 2026-05-21):
|
||||
|
||||
Existing peers — registered against the old combined image's embedded Dex IdP at `https://netbird.hubris.network/oauth2` — cache the OLD expected SSH-JWT issuer in the netbird daemon's in-memory state. After the migration, incoming `netbird ssh` connections were rejected with:
|
||||
|
||||
```
|
||||
JWT authentication failed: validate token (
|
||||
expected issuer=https://netbird.hubris.network/oauth2,
|
||||
audiences=[netbird-dashboard netbird-cli],
|
||||
actual issuer=https://auth.hubris.network/application/o/netbird/,
|
||||
audience=netbird-dashboard
|
||||
)
|
||||
```
|
||||
|
||||
Neither `systemctl restart netbird` nor `netbird down && netbird up` clears the cache. Root cause: in `client/internal/engine_ssh.go`, `updateSSH()` bails out with `if e.sshServer != nil { return nil }` whenever the SSH server is already running, so mgmt-pushed JWT config updates are silently ignored. Only a full daemon-process tear-down lets the SSH server re-initialize with the new validator config:
|
||||
|
||||
```
|
||||
sudo systemctl stop netbird
|
||||
sleep 3
|
||||
sudo systemctl start netbird
|
||||
```
|
||||
|
||||
After that, `grep -iE "issuer|audience" /var/log/netbird/client.log | tail` shows the new Authentik issuer. Run this on every existing peer (PVE host + every LXC + every workstation) once after a future IdP swap.
|
||||
|
||||
**Username gotcha (related):** `netbird ssh` defaults the remote username to the LOCAL one (e.g. `dtoro` from the operator's laptop). Hubris + the LXCs only have `root`, so the JWT is accepted but the session immediately fails with `user dtoro not found`. Always use the explicit `root@` prefix when invoking netbird-ssh manually:
|
||||
|
||||
```
|
||||
netbird ssh -p 22022 root@proxmox-server.netbird.selfhosted
|
||||
```
|
||||
|
||||
The `homelab` CLI handles this automatically via the per-host `ssh.user` field in `inventory.yaml` (defaults to `root`; set explicitly only for workstations whose login user isn't `root`).
|
||||
|
||||
Open follow-up: TURN-over-TLS on TCP 5349 (cert via certbot or extract Traefik's acme.json) for hostile-middlebox networks; plain TCP 3478 is sufficient for current usage.
|
||||
|
||||
### 2026-05-10 — ICE direct p2p restored (external STUN swap)
|
||||
All peers were `Connection type: Relayed` because the embedded STUN on the VPS was unreachable from outside (IONOS drops UDP 3478 inbound). Swapped to Google + Cloudflare STUN in `/opt/config.yaml`. After `netbird down/up`, peers now report `P2P` with srflx/host candidates. Backup of pre-change config at `/opt/config.yaml.bak-20260510-185050`. Fixes a real-world Nextcloud upload bottleneck (~366 kB/s through the relay → LAN-direct on same-network peers).
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ IONOS VPS that runs the Netbird control plane and the [public ingress traefik](i
|
||||
- **Mesh:** netbird `100.122.165.149` (peer of the lab mesh; routes `192.168.8.0/24` via [hubris](../hosts/hubris.md)).
|
||||
- **Public:** `82.165.190.79` (`ens6`).
|
||||
- **Public DNS:** IONOS wildcard `*.hubris.network → 82.165.190.79`.
|
||||
- **Docker stack** at `/opt/docker-compose.yml`: `traefik` (TLS/ACME) + `dashboard` + `mgmt` + `signal` + `relay` + `proxy` — netbird-mgmt 0.71.3 vanilla deploy since 2026-05-21 (see [mesh.md changelog](mesh.md#changelog)).
|
||||
- **Host services (outside docker):** `coturn` (TURN-TCP on :3478, long-term creds rendered into `/etc/turnserver.conf` by `homelab render-vps-configs` from sops-encrypted `secrets/turn-shared-secret.yaml`).
|
||||
- **Config rendering:** `/etc/turnserver.conf` + `/opt/management.json` are generated from templates in `vps/*.tmpl` on this repo by `homelab render-vps-configs`. Secret placeholders (`{{TURN_PASSWORD}}`, `{{AUTHENTIK_CLIENT_SECRET}}`) are substituted from sops-encrypted secrets decrypted on hubris and pushed over ssh. **Do not hand-edit those two files on the VPS** — the next render will overwrite them.
|
||||
|
||||
## SSH
|
||||
|
||||
@@ -20,8 +23,9 @@ IONOS VPS that runs the Netbird control plane and the [public ingress traefik](i
|
||||
Config at `/etc/nftables.conf`, service enabled.
|
||||
|
||||
- Public iface `ens6`. Wireguard iface `wt0`.
|
||||
- **INPUT on `ens6`** allow-list: DHCP (67→68), rate-limited ICMP/ICMPv6. Everything else drops.
|
||||
- **INPUT on `ens6`** allow-list: DHCP (67→68), rate-limited ICMP/ICMPv6, **TCP 3478** (coturn TURN-TCP, added 2026-05-21). Everything else drops.
|
||||
- `wt0` fully accepted in INPUT. `lo` accepted.
|
||||
- **IONOS upstream firewall** also gates inbound traffic before it reaches `ens6`. Open ports today: TCP 80/443 (traefik), UDP 51820 (netbird-proxy), TCP 3478 (coturn, added 2026-05-21). UDP 3478 is dropped by IONOS upstream regardless of local nftables. See [mesh.md ICE/STUN](mesh.md) for the STUN/TURN port matrix.
|
||||
- **FORWARD chain at priority `filter-10`** (runs before Docker's FORWARD) hosts the fail2ban ban enforcement — see below.
|
||||
- Set `banned4` (typed `ipv4_addr`, flag `timeout`) holds fail2ban's drops.
|
||||
- Coexists with Docker's `ip nat` / `ip filter` tables (iptables-nft compat). **Do NOT `flush ruleset`** in this config — it'll wipe Docker's state too.
|
||||
@@ -77,6 +81,9 @@ Ordered by preference:
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-05-21 — netbird stack migrated combined → vanilla; coturn added
|
||||
Replaced the `netbirdio/netbird-server` combined image with the canonical `mgmt + signal + relay + dashboard` containers (0.71.3). Added host-side `coturn` for external TURN, with nftables rule `iifname "ens6" tcp dport 3478 accept` and an IONOS upstream firewall exception. Authentik on LXC 124 now provides OIDC for the netbird dashboard. Full context in [mesh.md changelog](mesh.md#changelog).
|
||||
|
||||
### 2026-04-28 — wiki entry created
|
||||
Initial documentation.
|
||||
|
||||
|
||||
312
inventory.yaml
312
inventory.yaml
@@ -1,122 +1,106 @@
|
||||
# Homelab inventory — canonical structured topology
|
||||
# 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.
|
||||
# Single source of truth. hosts/*.yaml is generated from this file by
|
||||
# mcp/build_host_files.py; do NOT edit those by hand.
|
||||
#
|
||||
# Conventions:
|
||||
# - hostname keys here MUST match the actual `hostname` of the machine
|
||||
# (or, on macOS, `scutil --get LocalHostName` if set).
|
||||
# - hostname keys MUST match the actual `hostname` of the machine (on
|
||||
# macOS: `scutil --get LocalHostName` if set).
|
||||
# - `os:` one of: linux, macos
|
||||
# - `kind:` one of: proxmox-host, lxc, vm, workstation
|
||||
# - `kind:` one of: proxmox-host, lxc, vm, workstation, external
|
||||
# ("external" is reserved for hosts the homelab CLI manages via ssh but
|
||||
# that aren't homelab clients themselves — e.g. the IONOS netbird VPS
|
||||
# with no /etc/age/key.txt and no /opt/homelab-context clone.)
|
||||
# - `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.
|
||||
# `tailscale` are accepted during the migration (see infrastructure/mesh.md).
|
||||
# Prefer netbird FQDNs over raw IPs.
|
||||
# - `age_pubkey:` provisioned by secrets-issuance on first bootstrap and
|
||||
# committed back via `homelab client add --finalize-pubkey <key>`.
|
||||
# - When a service moves hosts, update only the `services:` section here;
|
||||
# never duplicate the address elsewhere.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Static config (mesh + service endpoints)
|
||||
# ---------------------------------------------------------------------------
|
||||
# never duplicate addresses elsewhere.
|
||||
# - `ssh.user:` per-host login user. Default is `root` if omitted (matches
|
||||
# every LXC + the PVE host). Set explicitly for workstations whose login
|
||||
# user differs from `root`. Used by the `homelab` CLI to build
|
||||
# `user@host` and to inform anyone running raw `netbird ssh` (which
|
||||
# defaults to the LOCAL username — the gotcha that creates "user not
|
||||
# found" errors when ssh'ing INTO machines that only have `root`).
|
||||
#
|
||||
# `homelab client add/remove` does surgical line-edits — comments survive.
|
||||
# Avoid round-tripping the file through yaml.safe_dump (it strips comments).
|
||||
|
||||
mesh:
|
||||
primary: netbird # preferred for new clients
|
||||
accepted: [netbird, tailscale]
|
||||
primary: netbird
|
||||
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.
|
||||
|
||||
systemd_unit: homelab-mcp
|
||||
public_host: mcp.hubris.network
|
||||
endpoint: https://mcp.hubris.network/sse
|
||||
note: MCP server. Read-only context + management. Reachable on the LAN via Caddy
|
||||
and from off-LAN via Netbird (192.168.8.0/24 is a network resource routed through
|
||||
hubris).
|
||||
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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
systemd_unit: secrets-issuance
|
||||
public_host: secrets.hubris.network
|
||||
endpoint: https://secrets.hubris.network/issue
|
||||
note: Issues per-client age private keys. Gated at source-IP layer (mesh + LAN
|
||||
subnets in MESH_SUBNETS).
|
||||
hosts:
|
||||
|
||||
# ----- The hypervisor -----
|
||||
hubris:
|
||||
kind: proxmox-host
|
||||
os: linux
|
||||
@@ -127,13 +111,12 @@ hosts:
|
||||
ip: 100.122.38.109
|
||||
fqdn: proxmox-server.netbird.selfhosted
|
||||
ssh:
|
||||
port: 22 # OpenSSH
|
||||
netbird_port: 22022 # see infrastructure/ssh-access.md
|
||||
port: 22
|
||||
netbird_port: 22022
|
||||
user: root
|
||||
mounts:
|
||||
- /mnt/library # shared lvmthin
|
||||
age_pubkey: "" # filled by issuance on first bootstrap
|
||||
|
||||
# ----- LXC containers (all on hubris) -----
|
||||
- /mnt/library
|
||||
age_pubkey: age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6
|
||||
jellyfin:
|
||||
kind: lxc
|
||||
pve_id: 101
|
||||
@@ -142,10 +125,12 @@ hosts:
|
||||
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: ""
|
||||
|
||||
mesh:
|
||||
tailscale:
|
||||
fqdn: jellyfin
|
||||
mounts:
|
||||
- /mnt/library
|
||||
age_pubkey: ''
|
||||
nfs-export:
|
||||
kind: lxc
|
||||
pve_id: 102
|
||||
@@ -153,9 +138,6 @@ hosts:
|
||||
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
|
||||
@@ -164,10 +146,12 @@ hosts:
|
||||
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: ""
|
||||
|
||||
mesh:
|
||||
tailscale:
|
||||
fqdn: paperless
|
||||
mounts:
|
||||
- /mnt/library
|
||||
age_pubkey: ''
|
||||
gitea:
|
||||
kind: lxc
|
||||
pve_id: 104
|
||||
@@ -177,12 +161,14 @@ hosts:
|
||||
lan_ip: 192.168.8.121
|
||||
public_host: git.hubris.network
|
||||
backend_port: 3000
|
||||
mesh: {netbird: {fqdn: gitea.netbird.selfhosted}}
|
||||
mounts: [/mnt/library]
|
||||
mesh:
|
||||
tailscale:
|
||||
fqdn: gitea
|
||||
mounts:
|
||||
- /mnt/library
|
||||
notes:
|
||||
- "Bare repos live at /mnt/library/repos/dtoro/*.git"
|
||||
age_pubkey: ""
|
||||
|
||||
- Bare repos live at /mnt/library/repos/dtoro/*.git
|
||||
age_pubkey: ''
|
||||
apps:
|
||||
kind: lxc
|
||||
pve_id: 105
|
||||
@@ -190,16 +176,20 @@ hosts:
|
||||
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]
|
||||
public_hosts:
|
||||
- artifacto.hubris.network
|
||||
mesh:
|
||||
tailscale:
|
||||
ip: 100.121.171.122
|
||||
fqdn: apps
|
||||
mounts:
|
||||
- /mnt/library
|
||||
runs:
|
||||
- artifacto
|
||||
- plantuml
|
||||
- homelab-mcp # introduced by this plan
|
||||
- secrets-issuance # introduced by this plan
|
||||
age_pubkey: ""
|
||||
|
||||
- artifacto
|
||||
- plantuml
|
||||
- homelab-mcp
|
||||
- secrets-issuance
|
||||
age_pubkey: age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0
|
||||
nextcloud:
|
||||
kind: lxc
|
||||
pve_id: 114
|
||||
@@ -208,10 +198,12 @@ hosts:
|
||||
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: ""
|
||||
|
||||
mesh:
|
||||
tailscale:
|
||||
fqdn: nextcloud
|
||||
mounts:
|
||||
- /mnt/library
|
||||
age_pubkey: ''
|
||||
elementsynapse:
|
||||
kind: lxc
|
||||
pve_id: 118
|
||||
@@ -220,9 +212,9 @@ hosts:
|
||||
role: matrix-server
|
||||
lan_ip: 192.168.8.239
|
||||
public_host: matrix.hubris.network
|
||||
mesh: {netbird: {fqdn: elementsynapse.netbird.selfhosted}}
|
||||
age_pubkey: ""
|
||||
|
||||
mesh:
|
||||
tailscale:
|
||||
fqdn: elementsynapse
|
||||
sophia:
|
||||
kind: lxc
|
||||
pve_id: 119
|
||||
@@ -230,10 +222,12 @@ hosts:
|
||||
os: linux
|
||||
role: workshop
|
||||
lan_ip: 192.168.8.157
|
||||
mesh: {netbird: {fqdn: sophia.netbird.selfhosted}}
|
||||
mounts: [/mnt/library]
|
||||
age_pubkey: ""
|
||||
|
||||
mesh:
|
||||
tailscale:
|
||||
fqdn: sophia
|
||||
mounts:
|
||||
- /mnt/library
|
||||
age_pubkey: ''
|
||||
mule-images:
|
||||
kind: lxc
|
||||
pve_id: 120
|
||||
@@ -242,10 +236,12 @@ hosts:
|
||||
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: ""
|
||||
|
||||
mesh:
|
||||
tailscale:
|
||||
fqdn: muleimage
|
||||
mounts:
|
||||
- /mnt/library
|
||||
age_pubkey: ''
|
||||
caddy:
|
||||
kind: lxc
|
||||
pve_id: 121
|
||||
@@ -254,12 +250,11 @@ hosts:
|
||||
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: ""
|
||||
|
||||
- Terminates all *.hubris.network
|
||||
- /etc/caddy is a git checkout of dtoro/caddy-conf
|
||||
peers:
|
||||
- authentik
|
||||
- gitea
|
||||
arriman:
|
||||
kind: lxc
|
||||
pve_id: 122
|
||||
@@ -267,11 +262,16 @@ hosts:
|
||||
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: ""
|
||||
|
||||
public_hosts:
|
||||
- jellyseerr.hubris.network
|
||||
- qbit.hubris.network
|
||||
- sab.hubris.network
|
||||
mesh:
|
||||
tailscale:
|
||||
fqdn: arr
|
||||
mounts:
|
||||
- /mnt/library
|
||||
age_pubkey: ''
|
||||
claudio-bot:
|
||||
kind: lxc
|
||||
pve_id: 123
|
||||
@@ -279,11 +279,9 @@ hosts:
|
||||
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: ""
|
||||
|
||||
- Reads /opt/homelab-context/ on startup
|
||||
age_pubkey: age1xmkeq968areza2necqyq0065dpeegngzyr6dhagh0n6pl33lccfqe5mqn9
|
||||
authentik:
|
||||
kind: lxc
|
||||
pve_id: 124
|
||||
@@ -293,10 +291,7 @@ hosts:
|
||||
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: ""
|
||||
|
||||
- 'Also hosts split-horizon dnsmasq: /etc/dnsmasq.d/hubris-split.conf'
|
||||
plato:
|
||||
kind: lxc
|
||||
pve_id: 126
|
||||
@@ -305,61 +300,76 @@ hosts:
|
||||
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) -----
|
||||
mounts:
|
||||
- /mnt/library/documents/plato
|
||||
age_pubkey: ''
|
||||
zimaos:
|
||||
kind: vm
|
||||
pve_id: 100
|
||||
host: hubris
|
||||
os: linux
|
||||
role: nas-frontend-eval
|
||||
lan_ip: 192.168.8.195
|
||||
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) -----
|
||||
lan_ip: 192.168.8.101
|
||||
mesh:
|
||||
tailscale:
|
||||
fqdn: homeassistant
|
||||
republic-laptop:
|
||||
kind: workstation
|
||||
os: linux
|
||||
role: primary-dev
|
||||
mesh: {netbird: {fqdn: republic-laptop.netbird.selfhosted}}
|
||||
age_pubkey: ""
|
||||
|
||||
mesh:
|
||||
netbird:
|
||||
fqdn: republic-laptop.netbird.selfhosted
|
||||
ssh:
|
||||
user: dtoro
|
||||
mac-mini:
|
||||
kind: workstation
|
||||
os: macos
|
||||
role: dev
|
||||
mesh: {netbird: {fqdn: mac-mini.netbird.selfhosted}}
|
||||
mesh:
|
||||
netbird:
|
||||
fqdn: mac-mini-234-17.netbird.selfhosted
|
||||
ssh:
|
||||
user: dtoro
|
||||
notes:
|
||||
- "Only macOS in the fleet. Bootstrap uses launchd."
|
||||
age_pubkey: ""
|
||||
|
||||
- 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: ""
|
||||
mesh:
|
||||
netbird:
|
||||
fqdn: ludo-mini.netbird.selfhosted
|
||||
# ssh.user defaults to root; uncomment + set to the actual login user
|
||||
# before relying on `homelab ssh ludo-mini` or netbird-ssh INTO this host.
|
||||
# ssh: { user: ludo }
|
||||
netbird-vps:
|
||||
kind: external
|
||||
os: linux
|
||||
role: netbird-mgmt
|
||||
mesh:
|
||||
netbird:
|
||||
ip: 100.122.165.149
|
||||
fqdn: netbird-ionos.netbird.selfhosted
|
||||
ssh:
|
||||
user: root
|
||||
notes:
|
||||
- Public IONOS VPS — hosts the vanilla netbird mgmt+signal+relay+dashboard
|
||||
stack + host coturn (see infrastructure/vps-hardening.md +
|
||||
infrastructure/mesh.md changelog 2026-05-21).
|
||||
- NOT a homelab client. No /etc/age/key.txt, no /opt/homelab-context
|
||||
clone. Managed via ssh from hubris; sshd is locked to hubris's pubkey.
|
||||
- Public IPv4 82.165.190.79. Auto-patching via unattended-upgrades.
|
||||
- Configs rendered by `homelab render-vps-configs` from
|
||||
vps/turnserver.conf.tmpl + vps/management.json.tmpl, with secrets
|
||||
decrypted from secrets/turn-shared-secret.yaml +
|
||||
secrets/netbird-authentik-oidc.yaml on hubris.
|
||||
|
||||
@@ -8,7 +8,12 @@ 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
|
||||
# SSH user is `root` on hubris — the restricted shell at command="..." in
|
||||
# authorized_keys (see mcp/mcp-reader-shell) provides the access boundary,
|
||||
# not a separate user account.
|
||||
Environment=HOMELAB_MCP_SSH_USER=root
|
||||
Environment=HOMELAB_MCP_HUBRIS_HOST=192.168.8.77
|
||||
Environment=HOMELAB_MCP_SSH_KNOWN_HOSTS=/etc/homelab-mcp/known_hosts
|
||||
ExecStart=/opt/homelab-mcp/.venv/bin/python /opt/homelab-mcp/mcp/server.py
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
62
mcp/mcp-reader-shell
Executable file
62
mcp/mcp-reader-shell
Executable file
@@ -0,0 +1,62 @@
|
||||
#!/bin/bash
|
||||
# mcp-reader-shell — restricted SSH command for the homelab-mcp service.
|
||||
#
|
||||
# Authorized in /root/.ssh/authorized_keys on hubris via:
|
||||
# command="/usr/local/bin/mcp-reader-shell",restrict ssh-ed25519 AAAA... mcp-reader@homelab-mcp
|
||||
#
|
||||
# `restrict` disables PTY/agent/forwarding/X11. This wrapper then validates
|
||||
# $SSH_ORIGINAL_COMMAND against a strict read-only allowlist before running
|
||||
# it. Anything outside the allowlist (interactive shell, file writes, pct
|
||||
# start/stop/destroy, etc.) is refused.
|
||||
#
|
||||
# Distributed via the homelab-context sync — symlink:
|
||||
# /usr/local/bin/mcp-reader-shell -> /opt/homelab-context/mcp/mcp-reader-shell
|
||||
# so updates land on the next 5-min pull without a manual re-install.
|
||||
#
|
||||
# Argument shapes allowed (Bash glob, after rejecting shell metacharacters):
|
||||
# systemctl is-active <unit>
|
||||
# systemctl is-enabled <unit>
|
||||
# journalctl -u <unit> [-n N] [--no-pager]
|
||||
# pct list
|
||||
# pct status <id>
|
||||
# pct config <id>
|
||||
# pct exec <id> -- systemctl is-active <unit>
|
||||
# pct exec <id> -- systemctl is-enabled <unit>
|
||||
# pct exec <id> -- journalctl -u <unit> [-n N] [--no-pager]
|
||||
#
|
||||
# Logs each call to syslog via `logger`. Deny entries are warnings.
|
||||
|
||||
set -euo pipefail
|
||||
set -f # disable glob expansion when we exec the command
|
||||
|
||||
CMD="${SSH_ORIGINAL_COMMAND:-}"
|
||||
|
||||
deny() {
|
||||
logger -t mcp-reader -p auth.warning "DENY from=${SSH_CLIENT:-?}: ${CMD:-<empty>}"
|
||||
echo "mcp-reader: command not allowed" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [ -z "$CMD" ]; then
|
||||
deny
|
||||
fi
|
||||
|
||||
# Reject any shell metacharacter that would let an attacker chain or escape
|
||||
# from the patterns below.
|
||||
if [[ "$CMD" =~ [\;\&\|\>\<\`\$\\\(\)\{\}\*\?\~\!] ]]; then
|
||||
deny
|
||||
fi
|
||||
|
||||
case "$CMD" in
|
||||
"systemctl is-active "*|"systemctl is-enabled "*) ;;
|
||||
"journalctl -u "*) ;;
|
||||
"pct list") ;;
|
||||
"pct status "*|"pct config "*) ;;
|
||||
"pct exec "*" -- systemctl is-active "*) ;;
|
||||
"pct exec "*" -- systemctl is-enabled "*) ;;
|
||||
"pct exec "*" -- journalctl -u "*) ;;
|
||||
*) deny ;;
|
||||
esac
|
||||
|
||||
logger -t mcp-reader -p auth.info "ALLOW from=${SSH_CLIENT:-?}: $CMD"
|
||||
exec $CMD
|
||||
@@ -30,11 +30,23 @@ 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"
|
||||
# All management tools proxy through hubris (the Proxmox host) via a single
|
||||
# restricted-shell SSH connection. The wrapper at mcp/mcp-reader-shell on
|
||||
# hubris validates each command against a strict read-only allowlist.
|
||||
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_USER = os.environ.get("HOMELAB_MCP_SSH_USER", "root")
|
||||
SSH_KNOWN_HOSTS = os.environ.get("HOMELAB_MCP_SSH_KNOWN_HOSTS",
|
||||
"/etc/homelab-mcp/known_hosts")
|
||||
HUBRIS_HOST = os.environ.get("HOMELAB_MCP_HUBRIS_HOST", "192.168.8.77")
|
||||
SSH_TIMEOUT = int(os.environ.get("HOMELAB_MCP_SSH_TIMEOUT", "10"))
|
||||
|
||||
mcp = FastMCP("homelab")
|
||||
mcp.settings.host = os.environ.get("HOMELAB_MCP_HOST", "0.0.0.0")
|
||||
mcp.settings.port = int(os.environ.get("HOMELAB_MCP_PORT", "9810"))
|
||||
# FastMCP's DNS-rebinding protection only whitelists 127.0.0.1 / localhost / [::1]
|
||||
# by default, which breaks any LAN/mesh client. We're already mesh+LAN-gated at
|
||||
# nftables and the browser-attack threat doesn't apply to mesh-only services.
|
||||
mcp.settings.transport_security.enable_dns_rebinding_protection = False
|
||||
|
||||
|
||||
def _load_inventory() -> dict:
|
||||
@@ -55,36 +67,49 @@ def inventory() -> dict:
|
||||
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 = [
|
||||
def _run_via_hubris(remote_host: str, cmd: list[str],
|
||||
timeout: int | None = None) -> subprocess.CompletedProcess:
|
||||
"""SSH to hubris (single channel, restricted-shell key) and run `cmd`.
|
||||
If remote_host is an LXC, the command is wrapped in `pct exec <id> --`
|
||||
so hubris executes it inside the LXC. The wrapper at /usr/local/bin/
|
||||
mcp-reader-shell on hubris validates the final command against an
|
||||
allowlist before execution.
|
||||
"""
|
||||
inv_hosts = inventory().get("hosts", {})
|
||||
if remote_host == "hubris":
|
||||
full = cmd
|
||||
else:
|
||||
host_entry = inv_hosts.get(remote_host)
|
||||
if host_entry is None:
|
||||
raise ValueError(f"unknown host: {remote_host}")
|
||||
pve_id = host_entry.get("pve_id")
|
||||
if pve_id is None:
|
||||
raise ValueError(f"{remote_host} has no pve_id; can't pct-exec into it")
|
||||
full = ["pct", "exec", str(pve_id), "--", *cmd]
|
||||
# The restricted shell on hubris reads the joined command from
|
||||
# $SSH_ORIGINAL_COMMAND and validates it as a single string.
|
||||
joined = " ".join(full)
|
||||
ssh_args = [
|
||||
"ssh", "-i", SSH_IDENTITY, "-o", "BatchMode=yes",
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
# The systemd unit runs with ProtectHome=true so ~/.ssh is unreachable.
|
||||
# Use a pre-populated known_hosts in /etc/homelab-mcp/.
|
||||
"-o", f"UserKnownHostsFile={SSH_KNOWN_HOSTS}",
|
||||
"-o", "StrictHostKeyChecking=yes",
|
||||
"-o", f"ConnectTimeout={SSH_TIMEOUT}",
|
||||
target, *cmd,
|
||||
f"{SSH_USER}@{HUBRIS_HOST}",
|
||||
joined,
|
||||
]
|
||||
return subprocess.run(full, capture_output=True, text=True,
|
||||
timeout=timeout or SSH_TIMEOUT * 3)
|
||||
proc = subprocess.run(
|
||||
ssh_args, capture_output=True, text=True,
|
||||
timeout=timeout or SSH_TIMEOUT * 3,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
import logging as _l
|
||||
_l.getLogger("homelab-mcp").warning(
|
||||
"ssh failed rc=%s host=%s cmd=%r stderr=%r",
|
||||
proc.returncode, remote_host, joined, proc.stderr.strip(),
|
||||
)
|
||||
return proc
|
||||
|
||||
|
||||
def _service_to_host(service: str) -> str:
|
||||
@@ -244,8 +269,8 @@ def get_service_status(service: str) -> dict:
|
||||
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)
|
||||
active = _run_via_hubris(host, ["systemctl", "is-active", unit])
|
||||
enabled = _run_via_hubris(host, ["systemctl", "is-enabled", unit])
|
||||
return {
|
||||
"service": service,
|
||||
"host": host,
|
||||
@@ -261,14 +286,15 @@ def tail_log(service: str, lines: int = 200) -> str:
|
||||
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")
|
||||
proc = _run_via_hubris(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
|
||||
return _run_via_hubris("hubris", ["pct", "list"]).stdout
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@@ -280,8 +306,8 @@ def get_lxc_state(lxc: str) -> dict:
|
||||
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))
|
||||
status = _run_via_hubris("hubris", ["pct", "status", str(pve_id)])
|
||||
cfg = _run_via_hubris("hubris", ["pct", "config", str(pve_id)])
|
||||
return {"lxc": lxc, "pve_id": pve_id,
|
||||
"status": status.stdout.strip(),
|
||||
"config": cfg.stdout}
|
||||
|
||||
291
operations/agent-enrollment.md
Normal file
291
operations/agent-enrollment.md
Normal file
@@ -0,0 +1,291 @@
|
||||
# Agent enrollment — bootstrap a client into the homelab context system
|
||||
|
||||
This walks through enrolling a new machine (workstation, LXC, or VM) so it
|
||||
joins the cross-client context system: a `/opt/homelab-context/` clone of
|
||||
this repo that auto-syncs every 5 min, a per-client age key for SOPS
|
||||
decryption, the `homelab` CLI, and an MCP endpoint in Claude Code's config.
|
||||
|
||||
> Onboarding a Nous-Hermes-powered Goose agent on top of standard enrollment?
|
||||
> See [hermes-agent.md](./hermes-agent.md). It uses the same `bootstrap.sh`
|
||||
> with an additional `--with-hermes` flag.
|
||||
|
||||
Architecture in [project_homelab_context_plan](https://… memory link); the
|
||||
operational reference is here.
|
||||
|
||||
## Prerequisites the client must satisfy
|
||||
|
||||
| Requirement | Why | How to check |
|
||||
| --- | --- | --- |
|
||||
| Hostname matches an entry in `inventory.yaml` | The bootstrap looks up `hosts/$(hostname).yaml`. | `hostname` (Linux) / `scutil --get LocalHostName` (macOS) |
|
||||
| OS is Linux or macOS | bootstrap detects via `uname -s` | `uname -s` |
|
||||
| On the mesh (Netbird or Tailscale) **or** on the LAN | issuance is gated to mesh + LAN subnets. **For Netbird: use a setup-key, not interactive auth** — see "Getting onto Netbird" below. | `netbird status` / `tailscale status` |
|
||||
| `git`, `python3`, `python3-yaml`, `age`, `sops` | bootstrap preflight; `homelab` CLI imports yaml | See per-OS commands below |
|
||||
| Can resolve `*.hubris.network` | bootstrap calls `https://secrets.hubris.network/issue` and writes `https://mcp.hubris.network/sse` | `dig +short mcp.hubris.network` (should return `192.168.8.175`) |
|
||||
|
||||
### Hostname mismatch is the most common bootstrap failure
|
||||
|
||||
If the bootstrap exits with `no hosts/<name>.yaml in the repo`, the
|
||||
hostname doesn't match any inventory entry. Two fixes:
|
||||
|
||||
- **Rename the host**: `sudo hostnamectl set-hostname <inventory-name>`
|
||||
(Linux) or System Preferences → Sharing (macOS), then re-run.
|
||||
- **Rename the inventory entry**: edit `inventory.yaml` on hubris,
|
||||
regenerate `hosts/*.yaml`, push. The next sync (≤5 min) propagates.
|
||||
|
||||
### Getting onto Netbird
|
||||
|
||||
Bootstrap auto-installs netbird and drives `netbird up` if the mesh isn't already connected (since commit `<bootstrap-tier1>`). Both paths below produce the same end state: `netbird status` shows `Management: Connected`, peer IP `100.122.x.x/16`.
|
||||
|
||||
**Path B — interactive OIDC (default; recommended):**
|
||||
|
||||
The new client runs bootstrap straight from a fresh OS. Bootstrap installs netbird (apt/dnf/brew based on the OS), then runs `netbird up --management-url https://netbird.hubris.network --ssh-jwt-cache-ttl 86400`. A device-code URL prints inline. The operator opens it (in a browser logged into Authentik), goes through identification → password → consent, and the CLI returns `Connected`. Bootstrap then proceeds with the rest of preflight.
|
||||
|
||||
Pre-condition: the operator must be a registered user in Authentik (typically the lab owner). The first user-login against a netbird account with existing peers is added as `pending_approval=1` and needs an sqlite promotion to `owner` — see [124-authentik.md First-time owner promotion gotcha](../containers/124-authentik.md). Only needed once per account.
|
||||
|
||||
**Path A — setup-key (headless/scripted onboarding):**
|
||||
|
||||
Useful for headless servers (no browser at all) or unattended cloud-init bootstraps.
|
||||
|
||||
1. From an already-enrolled machine, log into the dashboard at `https://netbird.hubris.network/`.
|
||||
2. **Setup Keys** → Create → set reusable + expiry → copy.
|
||||
3. On the new client (after installing netbird, OR let bootstrap install it and skip its `netbird up` driver):
|
||||
```bash
|
||||
sudo netbird up --setup-key <KEY> \
|
||||
--management-url https://netbird.hubris.network \
|
||||
--ssh-jwt-cache-ttl 86400
|
||||
```
|
||||
|
||||
**Why we can't OIDC-login from the public internet (still open as a follow-up):**
|
||||
|
||||
`auth.hubris.network` resolves publicly to the VPS (`82.165.190.79`), but Traefik on the VPS doesn't currently route that hostname — only `netbird.hubris.network` is exposed. A brand-new client *off the mesh* hitting `auth.hubris.network` directly gets a Traefik default 404. In practice, Path B works fine because the operator's BROWSER (which clicks the device-code URL) is usually on a network that can reach Authentik through the public IONOS IP via some path. But "fresh laptop in a coffee shop with no prior session anywhere" still gets stuck. Future-session fix: add a Traefik route on the VPS forwarding `auth.hubris.network` via the netbird-routed `192.168.8.0/24` to LXC 124.
|
||||
|
||||
### DNS prerequisite
|
||||
|
||||
`*.hubris.network` resolves via the split-horizon dnsmasq on LXC 124
|
||||
([dns.md](../infrastructure/dns.md)) for LAN clients, **but only if the
|
||||
client uses 192.168.8.180 as its resolver**. Most LXCs and roaming
|
||||
workstations don't by default. Options:
|
||||
|
||||
- **LAN client**: set DNS to 192.168.8.180 (per-interface or
|
||||
`/etc/resolv.conf`).
|
||||
- **Off-LAN workstation on Netbird**: configure Netbird DNS forwarder to
|
||||
point `*.hubris.network` at LXC 124.
|
||||
- **Hack-fix anywhere**: append to `/etc/hosts`:
|
||||
```
|
||||
192.168.8.175 mcp.hubris.network secrets.hubris.network
|
||||
192.168.8.175 git.hubris.network
|
||||
```
|
||||
(192.168.8.175 = caddy on LXC 121, terminates all `*.hubris.network`.)
|
||||
|
||||
If DNS isn't an option at all, override the URLs at bootstrap time:
|
||||
|
||||
```bash
|
||||
sudo HOMELAB_GITEA_TOKEN=... \
|
||||
HOMELAB_REPO_URL=http://192.168.8.121:3000/dtoro/Homelab-Docs.git \
|
||||
HOMELAB_ISSUANCE_NETBIRD=http://192.168.8.205:9820/issue \
|
||||
HOMELAB_MCP_URL=http://192.168.8.205:9810/sse \
|
||||
bash /tmp/bootstrap.sh --with-mcp
|
||||
```
|
||||
|
||||
## Install dependencies
|
||||
|
||||
Bootstrap auto-installs missing prerequisites (`git`, `python3` + PyYAML, `age`, `sops`, `netbird`) on Fedora/RHEL/Debian/Ubuntu/macOS — no manual `apt`/`dnf`/`brew` needed before running it. The only thing you must have on hand BEFORE the `curl ... | sudo bash` line is `curl` itself (used to pipe the script).
|
||||
|
||||
Manual install is still possible (e.g. for air-gapped or unusual platforms); the per-OS recipes are below for reference but optional.
|
||||
|
||||
<details>
|
||||
<summary>Manual recipes (Fedora / Debian / macOS)</summary>
|
||||
|
||||
```bash
|
||||
# Fedora / RHEL / Nobara
|
||||
sudo dnf install -y git python3-pyyaml age curl
|
||||
SOPS_VERSION=v3.9.4
|
||||
sudo curl -fsSL https://github.com/getsops/sops/releases/download/$SOPS_VERSION/sops-$SOPS_VERSION.linux.amd64 \
|
||||
-o /usr/local/bin/sops && sudo chmod +x /usr/local/bin/sops
|
||||
|
||||
# Debian / Ubuntu
|
||||
sudo apt update && sudo apt install -y git python3-yaml age curl
|
||||
SOPS_VERSION=v3.9.4
|
||||
sudo curl -fsSL https://github.com/getsops/sops/releases/download/$SOPS_VERSION/sops-$SOPS_VERSION.linux.amd64 \
|
||||
-o /usr/local/bin/sops && sudo chmod +x /usr/local/bin/sops
|
||||
|
||||
# macOS
|
||||
brew install git age sops
|
||||
pip3 install pyyaml # if `python3 -c "import yaml"` fails
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Run the bootstrap
|
||||
|
||||
You need a Gitea read-only personal access token for the initial clone
|
||||
(the in-cluster shared PAT is encrypted at `secrets/gitea-readonly-pat.yaml`
|
||||
but a new client can't decrypt it before bootstrap — chicken-and-egg).
|
||||
Ask the operator (or generate in Gitea: Settings → Applications → Generate
|
||||
New Token → scope `read:repository`).
|
||||
|
||||
```bash
|
||||
TOKEN=... # your Gitea PAT, scope read:repository
|
||||
|
||||
# Fetch bootstrap.sh from gitea (HTTPS uses split-DNS → caddy).
|
||||
curl -fsSL -u "dtoro:$TOKEN" \
|
||||
https://git.hubris.network/dtoro/Homelab-Docs/raw/branch/main/bootstrap.sh \
|
||||
-o /tmp/bootstrap.sh
|
||||
|
||||
# Run it.
|
||||
sudo HOMELAB_GITEA_TOKEN=$TOKEN bash /tmp/bootstrap.sh --with-mcp
|
||||
```
|
||||
|
||||
Flags:
|
||||
|
||||
| Flag | Effect |
|
||||
| --- | --- |
|
||||
| `--with-mcp` | Merges the homelab MCP server into `~/.claude/.mcp.json` of the invoking user |
|
||||
| `--no-secrets` | Skips age-key issuance (use when bringing up the first hosts before secrets-issuance exists) |
|
||||
| `--dry-run` | Prints actions without executing |
|
||||
|
||||
The bootstrap is idempotent: re-running on an enrolled client just
|
||||
verifies state, re-issues the age key only if it doesn't match the
|
||||
inventory pubkey, and refreshes the sync timer + symlinks.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
homelab whoami # prints hosts/$(hostname).yaml
|
||||
homelab list # shows the full topology
|
||||
homelab status # ping + HTTP-check across hosts/services
|
||||
homelab secret hello # decrypt the bootstrap-test secret
|
||||
systemctl list-timers homelab-context-sync.timer
|
||||
# next run within ≤5 min
|
||||
```
|
||||
|
||||
For Claude Code: start a new session — the `homelab` MCP server appears
|
||||
in `~/.claude/.mcp.json` and registers 14 tools (8 context, 5 management,
|
||||
1 secrets-metadata).
|
||||
|
||||
## Claude Code permissions for fleet ops
|
||||
|
||||
By default Claude Code's auto-mode classifier asks for confirmation on every
|
||||
ssh into the mesh. The bootstrap already installs the ssh ControlMaster block
|
||||
so subsequent in-session sshes multiplex, but the *first* ssh of each session
|
||||
still gets classifier-evaluated. Pre-authorize the common fleet ssh patterns
|
||||
by adding to `~/.claude/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"permissions": {
|
||||
"defaultMode": "auto",
|
||||
"allow": [
|
||||
"Bash(ssh -p 22022 *)",
|
||||
"Bash(homelab *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The first rule covers any ssh to a mesh peer on the homelab netbird port; the
|
||||
second covers all `homelab` CLI invocations. Both are scoped tight enough that
|
||||
the classifier doesn't gate them but loose enough to handle the variety of
|
||||
arguments.
|
||||
|
||||
If you also want the netbird `--ssh-jwt-cache-ttl` flag rationale to be
|
||||
visible to the classifier (it's not actually durable in 0.71.2, but the
|
||||
ControlMaster block is — see [runbook-dpkg-interrupted](runbook-dpkg-interrupted.md)
|
||||
for context), drop a free-text rule into `autoMode.allow` describing the
|
||||
authorization. Optional.
|
||||
|
||||
## Adding a new client to inventory
|
||||
|
||||
If the hostname you want isn't yet in inventory, enrollment is a two-step
|
||||
ceremony driven from an existing enrolled client (e.g. hubris). The
|
||||
`homelab` CLI handles steps 1 + 4; you provide steps 2 + 3.
|
||||
|
||||
```bash
|
||||
# 1. On hubris (or any existing client): add the inventory entry.
|
||||
homelab client add my-new-machine
|
||||
# Prompts for kind, os, netbird FQDN, role. Commits + pushes.
|
||||
|
||||
# 2. Join the new machine to Netbird (out-of-band, Netbird console / setup key).
|
||||
|
||||
# 3. On the new machine: install deps + run bootstrap (above).
|
||||
# Bootstrap calls /issue, receives a fresh age keypair, and prints the
|
||||
# public key for the operator to commit back to inventory.
|
||||
|
||||
# 4. On hubris: finalize the age public key.
|
||||
homelab client add my-new-machine --finalize-pubkey age1...
|
||||
# Updates inventory.yaml hosts.my-new-machine.age_pubkey, regenerates
|
||||
# hosts/*.yaml, commits + pushes. The 5-min sync propagates.
|
||||
```
|
||||
|
||||
## Granting a secret to a new client
|
||||
|
||||
Adding a client doesn't grant them every secret. Recipients are explicit
|
||||
per file via `.sops.yaml` glob rules. To grant a client access to (say)
|
||||
`secrets/hello.yaml`:
|
||||
|
||||
1. Edit `.sops.yaml` at the repo root, add the client's `age_pubkey` to
|
||||
the matching `creation_rules` block.
|
||||
2. Re-key the existing ciphertext for the new recipient list:
|
||||
```bash
|
||||
sops updatekeys -y secrets/hello.yaml
|
||||
```
|
||||
3. Commit + push. On the next sync (≤5 min), the client can decrypt.
|
||||
|
||||
## Removing a client
|
||||
|
||||
```bash
|
||||
# From any existing client:
|
||||
homelab client remove my-old-machine
|
||||
```
|
||||
|
||||
This:
|
||||
1. Removes the inventory entry and `hosts/my-old-machine.yaml`.
|
||||
2. Runs `sops updatekeys -y` against every file in `secrets/` (operator
|
||||
must first remove the pubkey from `.sops.yaml` rules).
|
||||
3. Calls `secrets-issuance` `/revoke` (admin-token-gated, on LXC 105) to
|
||||
shred the key file and add the hostname to the denylist.
|
||||
4. Commits + pushes.
|
||||
|
||||
The CLI prints a follow-up checklist that the operator must do manually:
|
||||
|
||||
- Revoke the peer in the Netbird console (denies future mesh access).
|
||||
- **Rotate any credentials whose ciphertext the removed client already
|
||||
has on disk.** The age key revocation only protects *future*
|
||||
ciphertext; what's already been pulled is still decryptable until the
|
||||
underlying credential changes.
|
||||
- Optional: `homelab nuke my-old-machine` SSHes in, shreds
|
||||
`/etc/age/key.txt`, removes `/opt/homelab-context`, disables sync.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
| --- | --- | --- |
|
||||
| `no hosts/<hostname>.yaml in the repo` | Hostname doesn't match inventory entry | Rename either side (see above) |
|
||||
| `fatal: could not read Username for 'http://192.168.8.121:3000'` | bootstrap.sh's credentials file has wrong scheme | Fixed in commit `de6f8be`; pull latest `bootstrap.sh` |
|
||||
| `gnutls_handshake() failed: TLS connection was non-properly terminated` cloning `git.hubris.network` | Client DNS resolves `*.hubris.network` to the public VPS IP | Configure split-DNS (LXC 180 / Netbird forwarder) or `/etc/hosts` override; or use `HOMELAB_REPO_URL=http://192.168.8.121:3000/dtoro/Homelab-Docs.git` |
|
||||
| `TLS/SSL connection has been closed (EOF)` connecting MCP | Same — `mcp.hubris.network` resolves to public VPS without this vhost | Same DNS fix |
|
||||
| `Invalid Host header` from MCP server | FastMCP's DNS-rebinding protection (default whitelist is 127.0.0.1 only) | Fixed in commit `6848640`; pull latest `mcp/server.py` and redeploy |
|
||||
| `python3-yaml` install fails on Fedora | Wrong package name | Use `python3-pyyaml` (Fedora) instead of `python3-yaml` (Debian) |
|
||||
| `address already in use` for FastMCP | FastMCP defaults to 127.0.0.1:8000 | Fixed: server now sets `mcp.settings.host/port` from env (default `0.0.0.0:9810`) |
|
||||
| `homelab: no age key at /etc/age/key.txt` even after bootstrap | `/etc/age` is 0700 root, so non-root users couldn't even stat the key file; existence check returned False under regular users | Fixed in commit `df6aca8`: the CLI re-execs `sops -d` via sudo when invoked as a non-root user. On older deployments, re-link the CLI with `sudo ln -sfn /opt/homelab-context/bin/homelab /usr/local/bin/homelab` after the 5-min sync. |
|
||||
| `homelab` CLI doesn't pick up repo updates | Pre-`02db…` bootstrap copied the binary instead of symlinking | One-time migration: `sudo ln -sfn /opt/homelab-context/bin/homelab /usr/local/bin/homelab`. New bootstraps use the symlink, which auto-tracks the synced repo. |
|
||||
| `homelab-context-sync.service` journal shows `fatal: could not read Username for 'https://git.hubris.network'` | Pre-fix bootstrap set the gitea credential helper via `git config --global`, which writes to `/root/.gitconfig` — invisible to the systemd timer's git process (no HOME set). | One-time migration: `sudo git config --system credential.helper "store --file=/etc/homelab-context/git-credentials"`. New bootstraps store the helper in `/etc/gitconfig` instead. |
|
||||
| Chat-mode `!` shell can't `sudo` (`a terminal is required to read the password`) | Claude Code's `!` invocation doesn't allocate a tty, and standard `sudo` won't read its password from stdin or a non-tty pipe. | Run the sudo'd command in a real terminal outside chat. For commands the agent issues repeatedly, configure passwordless sudo for the narrow set (e.g. `/etc/sudoers.d/homelab-self` with `<user> ALL=(ALL) NOPASSWD: /usr/bin/dnf upgrade -y, /usr/bin/apt-get *`). |
|
||||
| `netbird status -d` reports `192.168.8.180:53 ... is Unavailable` but DNS actually works | netbird's UDP-53 probe times out over the relay latency (~90ms), but actual queries still flow through systemd-resolved. Cosmetic. | Ignore unless `dig @192.168.8.180 git.hubris.network` also fails — then check dnsmasq on [LXC 124](../containers/124-authentik.md). |
|
||||
| `netbird ssh` rejected with `JWT authentication failed: validate token (expected issuer=https://netbird.hubris.network/oauth2 ...)` | Peer's SSH JWT validator cached the OLD embedded-Dex issuer from before the 2026-05-21 Authentik migration. `systemctl restart netbird` and `netbird down/up` don't clear it — `client/internal/engine_ssh.go` bails out of `updateSSH()` if the SSH server is already running. | Full daemon bounce: `sudo systemctl stop netbird; sleep 3; sudo systemctl start netbird`. Verify with `grep -iE "issuer\|audience" /var/log/netbird/client.log \| tail`. Apply once per peer post-migration. |
|
||||
| `netbird ssh` JWT passes but session closes with `user privilege check failed: user dtoro not found: unknown user dtoro` | netbird-ssh defaults the remote username to the LOCAL one (operator's laptop user). Hubris and LXCs only have `root`. | Always use explicit `root@` prefix manually: `netbird ssh -p 22022 root@proxmox-server.netbird.selfhosted`. `homelab ssh <host>` does this automatically via `inventory.yaml`'s per-host `ssh.user` field (defaults to `root`). |
|
||||
| `homelab ssh hubris` (or any host on the LAN) fails with `Connection refused` or hangs, despite mesh routing being up | Off-LAN networks (operator on a VPN / coffee shop / symmetric NAT) sometimes can't reach the LAN IP even with the netbird subnet route. | Newer homelab CLIs probe the LAN with a 1.5s TCP connect and transparently fall back to the netbird FQDN. If your `/usr/local/bin/homelab` is a symlink to `/opt/homelab-context/bin/homelab` it'll pick up the fix on the next 5-min context sync. Otherwise pull the latest from gitea. |
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-05-31 — cross-link to hermes-agent.md
|
||||
Added a sibling page covering Nous-Hermes-on-Goose enrollment ([hermes-agent.md](./hermes-agent.md)) and noted it at the top of this page. The Hermes flow extends `bootstrap.sh` with `--with-hermes` and `homelab client add` with the same flag; it does not change the underlying enrollment steps documented here.
|
||||
|
||||
### 2026-05-21 — netbird-ssh JWT issuer + username + LAN-fallback troubleshooting rows
|
||||
Added three rows to the troubleshooting table covering issues surfaced during the netbird vanilla migration: (1) post-migration SSH JWT validator cache stuck on old Dex issuer (full `systemctl stop/start` required, not `restart`), (2) `user not found` from netbird-ssh's local-username default (use explicit `root@`), and (3) homelab CLI's LAN→netbird-FQDN fallback for off-LAN operators. Companion code change: per-host `ssh.user` field in `inventory.yaml` + `homelab` CLI's `ssh_target()` helper.
|
||||
|
||||
### 2026-05-20 — initial page
|
||||
Captures the enrollment flow validated during Phase 2 of the homelab
|
||||
context distribution rollout. hubris + LXC 105 (apps) enrolled; first
|
||||
workstation (republic-laptop) blocked on hostname mismatch, documented
|
||||
the resolution.
|
||||
@@ -48,9 +48,25 @@ Run from the [hubris host](../hosts/hubris.md) as root. When working from `/root
|
||||
- `cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor` — should be `powersave`
|
||||
- `ls /sys/fs/pstore/ /var/lib/systemd/pstore/` — panic traces from a previous crash (empty for pure hardware hangs — see [investigation](../investigations/2026-04-21-hubris-crash-loop.md))
|
||||
|
||||
## Fleet apt operations
|
||||
|
||||
Two `homelab` subcommands wrap the common patterns; both fan out to hubris + every LXC.
|
||||
|
||||
| Command | What it does |
|
||||
| --- | --- |
|
||||
| `homelab apt-audit [--target HOST]` | Per-host table: dpkg-interrupted state, holds, upgradable count, non-apt binaries in system paths, DNS health. Exits nonzero if any host has dpkg-interrupted state. |
|
||||
| `homelab apt-upgrade --target HOST` | Launch `apt update && apt upgrade` inside a transient `systemd-run --collect` unit on the target. Survives ssh teardown. Apt configured with `Acquire::Retries=3` + `ForceIPv4=true`. |
|
||||
| `homelab apt-upgrade --all` | Same, fanned out across the standard targets. |
|
||||
| `homelab apt-upgrade ... --status` | Show running unit + tail `/var/log/homelab-apt-upgrade.log` on each target. |
|
||||
| `homelab apt-upgrade ... --safe` | Take a pre-upgrade snapshot per LXC first (`pct snapshot` → `vzdump` fallback for bind-mounted LXCs). Refuses if any snapshot fails unless `--force`. |
|
||||
| `homelab apt-upgrade ... --force` | Skip both the dpkg-audit gate and snapshot-failure refusal. |
|
||||
|
||||
PVE/kernel deferral on hubris: `homelab apt-upgrade --target hubris` will try every upgrade, including kernel + `pve-*`. To skip those, `apt-mark hold` the relevant packages on hubris first; `homelab apt-audit` shows held packages so you can confirm.
|
||||
|
||||
## Related
|
||||
- [Hubris host](../hosts/hubris.md)
|
||||
- [Containers index](../containers/index.md)
|
||||
- [DNS](../infrastructure/dns.md)
|
||||
- [Monitoring](../infrastructure/monitoring.md)
|
||||
- [Auto-deploy](../infrastructure/auto-deploy.md)
|
||||
- [Runbook: dpkg-interrupted recovery](runbook-dpkg-interrupted.md) — what to do when apt got killed mid-transaction
|
||||
|
||||
199
operations/hermes-agent.md
Normal file
199
operations/hermes-agent.md
Normal file
@@ -0,0 +1,199 @@
|
||||
# Hermes agent — Nous-Hermes-powered Goose sessions on a homelab client
|
||||
|
||||
Onboards [Nous Research's Hermes](https://nousresearch.com/) (a fine-tuned
|
||||
Llama variant) as a working terminal agent on a homelab client. Builds on top
|
||||
of standard client enrollment (see [agent-enrollment.md](./agent-enrollment.md))
|
||||
— this page covers only the Hermes-specific additions.
|
||||
|
||||
The agent runs as a [Goose](https://goose-docs.ai/) session. Goose provides:
|
||||
|
||||
- The chat loop, multi-turn history, and streaming
|
||||
- The OpenRouter provider that routes to Nous Hermes
|
||||
- The built-in `developer` extension (shell + file editor — same surface Claude
|
||||
Code has)
|
||||
- A remote MCP extension pointed at `mcp.hubris.network` for read-only
|
||||
homelab context (`list_lxcs`, `tail_log`, `search_docs`, etc.)
|
||||
|
||||
The persona is `/opt/homelab-context/HERMES.md`, symlinked as Goose's global
|
||||
`.goosehints` so it's injected into the system prompt on every session.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Requirement | How |
|
||||
| --- | --- |
|
||||
| Standard enrollment complete (`homelab whoami` works) | [agent-enrollment.md](./agent-enrollment.md) |
|
||||
| `secrets/openrouter-api-key.yaml` exists with a real `sk-or-...` value | See "Seeding the OpenRouter key" below |
|
||||
| The host's `age_pubkey` is on the openrouter-api-key.yaml sops rule | `homelab client add <host> --finalize-pubkey <age1...> --with-hermes` |
|
||||
|
||||
## Onboarding flow
|
||||
|
||||
```bash
|
||||
# 1. On hubris (or any enrolled client): reserve the inventory entry.
|
||||
homelab client add new-machine
|
||||
|
||||
# 2. Join new-machine to Netbird (setup-key or OIDC).
|
||||
|
||||
# 3. On new-machine: bootstrap with --with-hermes.
|
||||
TOKEN=... # gitea PAT, read:repository
|
||||
curl -fsSL -u "dtoro:$TOKEN" \
|
||||
https://git.hubris.network/dtoro/Homelab-Docs/raw/branch/main/bootstrap.sh \
|
||||
-o /tmp/bootstrap.sh
|
||||
sudo HOMELAB_GITEA_TOKEN=$TOKEN bash /tmp/bootstrap.sh --with-mcp --with-hermes
|
||||
|
||||
# 4. Back on hubris: finalize the age pubkey AND grant the Hermes secret.
|
||||
homelab client add new-machine \
|
||||
--finalize-pubkey age1... \
|
||||
--with-hermes
|
||||
|
||||
# 5. Wait ≤5 min for sync, then on new-machine:
|
||||
hermes "what LXCs are running?"
|
||||
```
|
||||
|
||||
The bootstrap `--with-hermes` flag does five things, all idempotent:
|
||||
|
||||
1. Downloads the latest Goose binary into the operator's `~/.local/bin/goose`
|
||||
(upstream installer) and symlinks `/usr/local/bin/goose` to it.
|
||||
2. Symlinks `/opt/homelab-context/bin/hermes` → `/usr/local/bin/hermes`.
|
||||
3. Symlinks `/opt/homelab-context/HERMES.md` → `/root/HERMES.md` (Linux) or
|
||||
`/etc/HERMES.md` (macOS) for `cat`-as-operator convenience.
|
||||
4. Drops `~/.config/goose/config.yaml` pinning the provider, model, and
|
||||
extensions (preserves any keys the operator added by hand).
|
||||
5. Symlinks `~/.config/goose/.goosehints` → HERMES.md, so the persona is
|
||||
injected as the system prompt on every session.
|
||||
|
||||
## Seeding the OpenRouter key
|
||||
|
||||
The first time anyone enrolls with `--with-hermes`, the encrypted file
|
||||
`secrets/openrouter-api-key.yaml` contains a placeholder. On hubris (or any
|
||||
existing recipient):
|
||||
|
||||
```bash
|
||||
sops secrets/openrouter-api-key.yaml
|
||||
# editor opens; replace api_key value with the real sk-or-... key, save, close.
|
||||
git -C /opt/homelab-context add secrets/openrouter-api-key.yaml
|
||||
git -C /opt/homelab-context commit -m 'openrouter-api-key: seed real key'
|
||||
git -C /opt/homelab-context push
|
||||
```
|
||||
|
||||
Until this step happens, `hermes …` exits with `openrouter-api-key.yaml still
|
||||
contains the placeholder`. Subsequent enrollees get the real key automatically
|
||||
via `--with-hermes` (which adds them as a sops recipient on
|
||||
`secrets/openrouter-api-key.yaml`).
|
||||
|
||||
## Granting the OpenRouter key to an already-enrolled host
|
||||
|
||||
If a host was enrolled without `--with-hermes` and you want to add it later:
|
||||
|
||||
```bash
|
||||
# On hubris:
|
||||
PUBKEY=$(homelab whoami --hostname <host> | grep age_pubkey | awk '{print $2}')
|
||||
homelab client add <host> --finalize-pubkey "$PUBKEY" --with-hermes
|
||||
```
|
||||
|
||||
`--finalize-pubkey` is required by the existing flow even when the pubkey is
|
||||
unchanged — it's also the trigger that runs the sops grant.
|
||||
|
||||
After ≤5 min sync the host can decrypt the key. Bootstrap doesn't need to
|
||||
re-run; only the secret recipient list changed.
|
||||
|
||||
## Verifying
|
||||
|
||||
```bash
|
||||
homelab whoami # standard enrollment OK
|
||||
homelab secret openrouter-api-key | head -c 8 # decrypts (prints `api_key:`)
|
||||
which goose && which hermes # binaries present
|
||||
goose info -v # provider/model wiring sane
|
||||
hermes "what LXCs are running?" # interactive Goose session
|
||||
|
||||
# Non-interactive smoke test:
|
||||
echo "List the homelab MCP tools you have available" | hermes
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The bootstrap-managed keys in `~/.config/goose/config.yaml`:
|
||||
|
||||
```yaml
|
||||
GOOSE_PROVIDER: openrouter
|
||||
GOOSE_MODEL: nousresearch/hermes-4-405b
|
||||
GOOSE_MODE: smart_approve # asks before destructive tool calls
|
||||
extensions:
|
||||
developer:
|
||||
type: builtin
|
||||
bundled: true
|
||||
enabled: true
|
||||
name: developer
|
||||
timeout: 300
|
||||
homelab:
|
||||
type: streamable_http
|
||||
enabled: true
|
||||
name: homelab
|
||||
uri: https://mcp.hubris.network/mcp
|
||||
timeout: 60
|
||||
```
|
||||
|
||||
Override via env on a single bootstrap run:
|
||||
|
||||
```bash
|
||||
HOMELAB_HERMES_MODEL=nousresearch/hermes-3-llama-3.1-405b \
|
||||
HOMELAB_HERMES_MCP_URI=https://mcp.hubris.network/mcp \
|
||||
sudo bash /tmp/bootstrap.sh --with-hermes
|
||||
```
|
||||
|
||||
Any keys you add by hand (e.g. `GOOSE_TEMPERATURE`, extra `extensions.*`) are
|
||||
preserved across re-bootstraps — the merge only overwrites the keys it manages.
|
||||
|
||||
## Tool permissions
|
||||
|
||||
`GOOSE_MODE: smart_approve` is the bootstrap default: Goose runs read-only
|
||||
shell commands without prompting and asks for confirmation before destructive
|
||||
ones. To make the agent fully unattended (e.g. for scheduled jobs), set
|
||||
`GOOSE_MODE: auto` in `~/.config/goose/config.yaml`. To require confirmation on
|
||||
every tool call, use `approve`. See
|
||||
[goose-permissions](https://goose-docs.ai/docs/guides/managing-tools/goose-permissions/).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
| --- | --- | --- |
|
||||
| `hermes: could not decrypt secrets/openrouter-api-key.yaml` | Host isn't a recipient on the sops rule | `homelab client add <host> --finalize-pubkey <age1...> --with-hermes` from hubris |
|
||||
| `hermes: openrouter-api-key.yaml still contains the placeholder` | No real key has been seeded yet | See "Seeding the OpenRouter key" above |
|
||||
| Goose hangs on first `hermes` invocation with no output | Goose's interactive `configure` ran on first launch and is awaiting input | Re-run; the installer is supposed to skip it (CONFIGURE=false). If it persists, run `goose configure` once manually in a real terminal to commit the config. |
|
||||
| `homelab` extension fails to connect / no MCP tools listed | MCP server still runs SSE-only; Goose requires `streamable_http`. See follow-up #1 below. | Either: (a) migrate the FastMCP server to streamable_http (one-line change in `mcp/server.py` — `mcp.run(transport="streamable_http")` — then redeploy), or (b) accept that the agent works via the developer extension alone (shell + `homelab` CLI cover everything MCP would). |
|
||||
| `goose: command not found` after bootstrap | Upstream installer dropped binary in `~/.local/bin/` but `/usr/local/bin/goose` symlink didn't land | Re-run bootstrap with `--with-hermes`; the symlink step is at the end of the install block. If still missing, `ln -sfn ~/.local/bin/goose /usr/local/bin/goose` manually. |
|
||||
| Tool calls hit OpenRouter rate limits | One shared key across many hosts | Future: per-host keys; for now, see the rate-limits guide referenced in `goose info -v`. |
|
||||
|
||||
## Cross-references
|
||||
|
||||
- [agent-enrollment.md](./agent-enrollment.md) — base client onboarding the
|
||||
Hermes flow assumes is done.
|
||||
- [`HERMES.md`](../HERMES.md) — the persona the Hermes agent reads on every
|
||||
session start (via `~/.config/goose/.goosehints`).
|
||||
- [`bin/hermes`](../bin/hermes) — the wrapper that decrypts the OpenRouter key
|
||||
and execs `goose session`.
|
||||
- [`bootstrap.sh`](../bootstrap.sh) — the `--with-hermes` flag's install block.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
1. **Migrate the MCP server to streamable_http.** Goose 1.x deprecated SSE
|
||||
(`"SSE transport is no longer supported - kept only for config file
|
||||
compatibility"` in `crates/goose/src/agents/extension.rs`). Our FastMCP
|
||||
server at `mcp/server.py:336` still calls `mcp.run(transport="sse")`. Until
|
||||
that's changed, the `homelab` MCP extension in Goose will fail to connect.
|
||||
The developer extension (shell + edit) covers most ops without it; this is
|
||||
a polish item, not a blocker.
|
||||
2. **Per-host OpenRouter keys** for billing attribution. Today all Hermes
|
||||
hosts share one key.
|
||||
3. **Pin the model version** rather than tracking `nousresearch/hermes-4-405b`
|
||||
directly — OpenRouter periodically rotates the underlying weights.
|
||||
4. **Local-inference fallback** (ollama / vllm) once the homelab has a GPU
|
||||
node. The wrapper, persona, and MCP wiring stay unchanged; only
|
||||
`GOOSE_PROVIDER`/`GOOSE_MODEL` change.
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-05-31 — initial page
|
||||
Captures the Hermes-on-Goose onboarding flow added in the same commit as
|
||||
`bootstrap.sh --with-hermes`, `bin/hermes`, the sops rule for
|
||||
`secrets/openrouter-api-key.yaml`, and the `homelab client add --with-hermes`
|
||||
extension. MCP streamable_http migration is queued as follow-up #1.
|
||||
108
operations/runbook-dpkg-interrupted.md
Normal file
108
operations/runbook-dpkg-interrupted.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Runbook — recover from dpkg-interrupted state
|
||||
|
||||
You're here because an apt run got killed mid-transaction and the target now
|
||||
has packages that are **unpacked but not configured**. Symptoms:
|
||||
|
||||
- `apt` refuses to do anything new: `Error: dpkg was interrupted, you must
|
||||
manually run 'dpkg --configure -a' to correct the problem.`
|
||||
- `dpkg --audit` lists packages with header
|
||||
`The following packages have been unpacked but not yet configured.`
|
||||
- `homelab apt-audit` shows `DPKG: DIRTY(N)` for the host.
|
||||
|
||||
The system is still running the **old** binaries (still in memory), but the
|
||||
**new** binaries are unpacked and waiting for their postinst to run. Two
|
||||
worst-case manifestations from the 2026-05-21 sweep:
|
||||
|
||||
- LXC 121 caddy: leftover state from a prior aborted apt run; caddy itself was
|
||||
still serving but the new caddy binary on disk hadn't been wired up.
|
||||
- hubris: ssh master died mid-Wave-6 → 135 packages unpacked-not-configured,
|
||||
including `systemd`, `openssh-server`, `sudo`, `netbird`. The half-
|
||||
configured netbird daemon dropped the mesh peer, and we got locked out
|
||||
until we recovered from the PVE web UI Shell.
|
||||
|
||||
**Do not reboot until dpkg is clean.** A reboot tries to start the new
|
||||
binaries' services, which may fail because postinst never ran (missing users,
|
||||
config dirs, capabilities, etc.). The system might not come back up cleanly.
|
||||
|
||||
## Path A — target is still reachable over ssh (preferred)
|
||||
|
||||
```
|
||||
homelab ssh <host> -- bash -c 'DEBIAN_FRONTEND=noninteractive dpkg --configure -a && apt -y -o Dpkg::Options::=--force-confold upgrade'
|
||||
```
|
||||
|
||||
Or for an LXC by name:
|
||||
|
||||
```
|
||||
homelab pct <lxc> exec -- bash -c 'DEBIAN_FRONTEND=noninteractive dpkg --configure -a && apt -y -o Dpkg::Options::=--force-confold upgrade'
|
||||
```
|
||||
|
||||
When that returns, confirm:
|
||||
|
||||
```
|
||||
homelab apt-audit --target <host>
|
||||
```
|
||||
|
||||
Expect `DPKG: ok` and the remaining `UPGR` count to match what's intentionally
|
||||
deferred (kernel/PVE on hubris, 0 elsewhere).
|
||||
|
||||
## Path B — target locked out (mesh broken / ssh dead)
|
||||
|
||||
Most common for hubris when netbird itself went half-configured: the daemon
|
||||
crashed on the new binary, the mesh peer dropped, port 22022 stopped listening,
|
||||
and you can't ssh in.
|
||||
|
||||
1. Open `https://proxmox.hubris.network` in a browser.
|
||||
2. Datacenter → node `hubris` → `>_ Shell` (or `_ Console`). That's a root
|
||||
shell on hubris served by the PVE web UI, independent of the netbird mesh.
|
||||
3. Run the recovery one-liner:
|
||||
|
||||
```
|
||||
DEBIAN_FRONTEND=noninteractive dpkg --configure -a \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt -y -o Dpkg::Options::=--force-confold upgrade \
|
||||
&& systemctl restart netbird \
|
||||
&& dpkg --audit \
|
||||
&& echo RECOVERY_OK
|
||||
```
|
||||
|
||||
Wait for `RECOVERY_OK`. The `systemctl restart netbird` is the bit that
|
||||
heals the mesh — once netbird's daemon comes back up clean, your client's
|
||||
peer state moves from `Connecting` to `Connected` within ~30 seconds and
|
||||
the rest of your tooling works again.
|
||||
|
||||
4. For an **LXC** that's locked out (less common — LXCs reach the world via
|
||||
netbird routed through hubris, so unless hubris itself is broken, you can
|
||||
still `pct enter` from the hubris shell):
|
||||
|
||||
From the PVE web UI shell on hubris:
|
||||
|
||||
```
|
||||
pct enter <id>
|
||||
DEBIAN_FRONTEND=noninteractive dpkg --configure -a && apt -y upgrade
|
||||
exit
|
||||
```
|
||||
|
||||
## Prevention
|
||||
|
||||
The `homelab apt-upgrade` wrapper launches apt inside a `systemd-run --collect`
|
||||
unit on the target, so it survives ssh teardown — the failure mode that put
|
||||
hubris into this state in the first place is no longer reachable through the
|
||||
standard tool. If you absolutely need to run apt manually over ssh, wrap it:
|
||||
|
||||
```
|
||||
ssh <host> systemd-run --unit=apt-recovery --collect bash -c 'apt -y upgrade'
|
||||
```
|
||||
|
||||
Then `systemctl status apt-recovery` from a fresh ssh to check progress.
|
||||
|
||||
## Related
|
||||
|
||||
- [Operations cheatsheet](commands.md)
|
||||
- [Auto-deploy pipelines](../infrastructure/auto-deploy.md)
|
||||
- [Hubris host page](../hosts/hubris.md)
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-05-21 — initial page
|
||||
Documents the dpkg-interrupted recovery path that came out of the
|
||||
fleet apt sweep (Wave 6 killed mid-transaction; hubris recovered via PVE
|
||||
web Shell).
|
||||
9
secrets-issuance/backup.service
Normal file
9
secrets-issuance/backup.service
Normal file
@@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=Snapshot secrets-issuance state to /mnt/library
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/opt/secrets-issuance/secrets-issuance/backup.sh
|
||||
Nice=10
|
||||
TimeoutStartSec=300
|
||||
51
secrets-issuance/backup.sh
Executable file
51
secrets-issuance/backup.sh
Executable file
@@ -0,0 +1,51 @@
|
||||
#!/bin/bash
|
||||
# secrets-issuance backup — snapshot the per-client age keys + denylist +
|
||||
# admin token to /mnt/library so they survive an LXC-105-only failure.
|
||||
#
|
||||
# Destination is under /mnt/library/.secrets-issuance-backup/ (dot-prefix to
|
||||
# stay out of any indexer's path). Mode 0700 root-owned. Privileged LXCs
|
||||
# that mount /mnt/library would still be able to read it as their root maps
|
||||
# to host root — accept that trade today; an encrypted-tarball variant
|
||||
# (separate offline age key) is a follow-up if the LAN trust model changes.
|
||||
#
|
||||
# Retention: 14 daily snapshots. Each snapshot is the WHOLE /var/lib/
|
||||
# secrets-issuance directory (small — ~few KiB per client), so restore is
|
||||
# just `tar xf <snap> -C /`.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SRC=/var/lib/secrets-issuance
|
||||
DEST=/mnt/library/.secrets-issuance-backup
|
||||
RETAIN=14
|
||||
|
||||
if [ ! -d "$SRC" ]; then
|
||||
echo "[backup] source $SRC missing — nothing to do" >&2
|
||||
exit 0
|
||||
fi
|
||||
if [ ! -d /mnt/library ]; then
|
||||
echo "[backup] /mnt/library not mounted — can't write backup" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
install -d -m 0700 "$DEST"
|
||||
chown root:root "$DEST"
|
||||
|
||||
stamp=$(date +%Y%m%d-%H%M%S)
|
||||
out="$DEST/secrets-issuance-$stamp.tar.gz"
|
||||
|
||||
# Include the admin token too (recoverable separately, but trivial size and
|
||||
# saves a step when restoring after total LXC 105 loss).
|
||||
tar czf "$out" \
|
||||
-C / \
|
||||
var/lib/secrets-issuance \
|
||||
$([ -f /etc/secrets-issuance/admin-token ] && echo etc/secrets-issuance/admin-token || true)
|
||||
chmod 0600 "$out"
|
||||
|
||||
# Drop oldest beyond retention.
|
||||
ls -1t "$DEST"/secrets-issuance-*.tar.gz 2>/dev/null \
|
||||
| tail -n +$((RETAIN + 1)) \
|
||||
| xargs -r rm -f
|
||||
|
||||
count=$(ls -1 "$DEST"/secrets-issuance-*.tar.gz 2>/dev/null | wc -l)
|
||||
size=$(du -sh "$out" | cut -f1)
|
||||
echo "[backup] wrote $out ($size); kept $count snapshots"
|
||||
11
secrets-issuance/backup.timer
Normal file
11
secrets-issuance/backup.timer
Normal file
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=Daily secrets-issuance backup
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 03:30:00
|
||||
RandomizedDelaySec=10min
|
||||
Persistent=true
|
||||
Unit=secrets-issuance-backup.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -36,6 +36,11 @@ 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
|
||||
install -m 755 secrets-issuance/backup.sh /usr/local/bin/secrets-issuance-backup
|
||||
install -m 644 secrets-issuance/backup.service \
|
||||
/etc/systemd/system/secrets-issuance-backup.service
|
||||
install -m 644 secrets-issuance/backup.timer \
|
||||
/etc/systemd/system/secrets-issuance-backup.timer
|
||||
|
||||
if [ ! -d /opt/homelab-context/.git ]; then
|
||||
echo " /opt/homelab-context is not a git clone — run bootstrap.sh first." >&2
|
||||
@@ -50,6 +55,10 @@ if systemctl is-active --quiet secrets-issuance-deploy.service; then
|
||||
systemctl restart secrets-issuance-deploy.service
|
||||
fi
|
||||
|
||||
echo "[deploy] daemon-reload + enable backup timer"
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now secrets-issuance-backup.timer
|
||||
|
||||
echo "[deploy] done"
|
||||
echo "First-time enable:"
|
||||
echo " systemctl enable --now secrets-issuance.service secrets-issuance-deploy.service"
|
||||
|
||||
@@ -8,7 +8,7 @@ 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
|
||||
Environment=MESH_SUBNETS=100.122.0.0/16,100.64.0.0/10,192.168.8.0/24
|
||||
ExecStart=/opt/secrets-issuance/.venv/bin/python /opt/secrets-issuance/secrets-issuance/server.py
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
44
secrets/gitea-pat.yaml
Normal file
44
secrets/gitea-pat.yaml
Normal file
@@ -0,0 +1,44 @@
|
||||
user: ENC[AES256_GCM,data:e6Dj7CE=,iv:Bf4tvdWQr0vdF3PShG2cRTryxqRC5rN6UdGd1qwFg7g=,tag:22rrPMAF97C2GUhRGgutOw==,type:str]
|
||||
token: ENC[AES256_GCM,data:98bMZO2t0qgOO/rTYmyDBZdRTHu8iPgUsctS4dc0B2BCML6tBxBNhA==,iv:oXehq2FJy1pQxzK8M3LqDtjpoFbdfAPvYcKhygwf51A=,tag:RMYdimBwxw247OARznBu3g==,type:str]
|
||||
scopes:
|
||||
- ENC[AES256_GCM,data:S3nz8buLTp1PCHpf8F3Y,iv:IkHU+QwIlsxrNrGEu3tRS0zlnuCyp9Fs8ILxUaZrabw=,tag:Ufd+MVYXAAtbSbfvRi3jCg==,type:str]
|
||||
- ENC[AES256_GCM,data:/UG/8LQD21l/Ca0wfEodKw==,iv:f3tW1ZNLLkzE5Oy8acQ6GfyDvzEH3++kp8duBOI8+Is=,tag:IgfhvEaX9EGyPHhr6FSAjQ==,type:str]
|
||||
note: ENC[AES256_GCM,data:vRDjHXh5fYlhwJ4LyS/hcqMGdS+Z/D1W2oH6NaJak/XRUr+nFVQqcXSH/zUgqEBWNrMv6j8n2Ouxex/q2mr+p3Ds1jfThJTobX/MFqMkxvxk0TmB7ucui7CDa4mcVgSo3wyhcaba2o3erObByZ3o2uay3mmqzAUdo/wy2bCgQTkTG9/xqVSf3EYuR1j/A+SAzSvpsOORMNUjC5NRP1DUL+3hZkPXsfkXuy7sV6T2vF+oOrbEI0WxR8o4/JcF1B/4ezFAJ+f+nukCMz6eSCwur/wHffbcYa8mtD6BIRuV1pTd/Zob8arhe2ir19MB0fR27E3UkcCwyCkQ+QWuGzisvciCPtwR4JidH7xOpPW+4AK3vqEjmpEeMSWlyxbZadQFuSi8f3bPH+8eB2h/UFvT0BU6xw==,iv:r2dUnKyUWhyafIeDIjHOdqh+PNFzGtVZfcHc2TAhvvI=,tag:Cmg6esU4mQ8/9UbIQQL/gw==,type:str]
|
||||
sops:
|
||||
kms: []
|
||||
gcp_kms: []
|
||||
azure_kv: []
|
||||
hc_vault: []
|
||||
age:
|
||||
- recipient: age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6
|
||||
enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBQL1FueldvT09lV011Q0hz
|
||||
MlJ1YkxIYXZDNDVQQWo1MFQrRlNBcXNIYVNRCllXYVZrczdBNHdPemJybUVlQkVl
|
||||
d0hKUTVsTyszOUFrY1BQQllSM2JWa2sKLS0tIDFISUw3T1lLYm10YXZaeFlTMWVj
|
||||
ZHFWN0ZQTlhIc0pGMThoWWViN2lmVUUKsWRBHqoRVmY2Clcro5aKhQj9yX5W4Kh3
|
||||
arC5FKBoZ/8KlcS7zxC9vIISOqlQu7MCKA8L//HX/gkfcz77aS6t1w==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
- recipient: age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0
|
||||
enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBFTVB1eWh5M3RUVFRubUFE
|
||||
OTRrbWRnUDhsVGlLZkFWckRXZ08xTkxCMlVNCnFHcU9aMklma3RJZnZ5OHUxQVYx
|
||||
SWFZMEJSNHExT0lsdW9FNkx0T3ZNQ0UKLS0tIEpseGdCbTJudUNWUUJGYUg1STJV
|
||||
Qm9QU3Z2NHhiRWh6VnY5cDRRQVk2ZUEKktovOMBi2eT4Hxwm8i8gy4GYPpOZ3nIy
|
||||
cDM6yBpUQMybiY3jMji41ELg12vF2kQ286W6lQKURdl9yYoGz0SfqQ==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
- recipient: age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
|
||||
enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBHd0NhOC9FWmVSL0ExKzIz
|
||||
L1hiU3RhKzh4SzFCNURhWFAxZTF4QWJIdmhBClQ4SzBEY2VweUR5UnJJSkk1OUVj
|
||||
MWp5WUtQcE0yNFh5R0JoTGpUaWJiU0UKLS0tIDFQbDZoUXlPSURndTNEN2d0TjJS
|
||||
djVvR2hGSllUNlZJNURrMG43bmZlU0EKl7SfN6/JGbzDA3YVrRnua5QK8xEdkWoB
|
||||
ey5Yf4Zg0obqYIS5SX8QC/YtoeMANVkKtqVTOVE2PYLJtrC6l7vKxQ==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
lastmodified: "2026-05-20T16:24:18Z"
|
||||
mac: ENC[AES256_GCM,data:d3qcjEqpPIFeut634ImvISJLdit0MQWcdFYomrsqriqJ0NUKdyq3XCk85+vvPvDWikB4WEApk6HMXssSY8mhDci95q5Ssmr+JRAhLebMZjs9yXUf9A7vXpNFswmdldu3CKBiDrhm2GE08qUfsCkcf3jjihEqsfHhxMuWSd5fZpU=,iv:VZgvZ3bBArRChgRX38U6/43XoX5aTnts7Kd4S9spBPk=,tag:/5oOw5yvulvFT6KXv2zj0g==,type:str]
|
||||
pgp: []
|
||||
unencrypted_suffix: _unencrypted
|
||||
version: 3.9.4
|
||||
40
secrets/hello.yaml
Normal file
40
secrets/hello.yaml
Normal file
@@ -0,0 +1,40 @@
|
||||
greeting: ENC[AES256_GCM,data:eNMtZcpv04PHl/lGBZGBp7xAcO/5Sw==,iv:A4jtwePX0p/eDTP5+ztfV6gege9RwdPfLBY2N+DPfx0=,tag:MU8HpvLt/PUrNMw7BNUVIg==,type:str]
|
||||
phase: ENC[AES256_GCM,data:oIeo+kmkwaMDQrf10iY2PqOXJnq+hCIwOROQ86xgpkU7EJEeJKFssrNgbvQPHhIRZUZblQg9ZPamiZixQbYSTOXQSTWWu2FOnu6XFb24wCay,iv:TYKqoHcN+V324/roNopYhkwRpmv3cPrTFcmni5TajYQ=,tag:g8DXqEyORbQZ9B7PaFWgOQ==,type:str]
|
||||
sops:
|
||||
kms: []
|
||||
gcp_kms: []
|
||||
azure_kv: []
|
||||
hc_vault: []
|
||||
age:
|
||||
- recipient: age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6
|
||||
enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBCbWhQa2RreXl3Ni9zZExQ
|
||||
dzB5Q1hSZzJSb1NlaEx3bEFCK2x6Ty82ZEJ3Cng3Q1NmKzhpdmpvTWFOL0lUb3F6
|
||||
emtvNCt3elQwTndoNzRIM2FoZTlpTVUKLS0tIER6STdwVUtnL0lreFh3Z09FYlBo
|
||||
azJuWjVuNGJURTNPYU1aWWZGM0dpc2cKFM8AjHFg68fedY0Xrg2U69+Qxc5Pgn6f
|
||||
QipSsaXrCohzNKieqCG9maKuLcGrgMQ1OJqZQm+hfit6CRKUmVHRkw==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
- recipient: age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0
|
||||
enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB2TVpWeC9FcmUxckNadmtK
|
||||
d3BwZnExR3VqeXBQSGhNdStPVXM2MkdjQXhBCkpHMmxPZ3JWaVVvUGwrVjZadXlR
|
||||
V3VaR2l5Zm9zb1VrREphM09RMU5mR0UKLS0tIGlYZm5XbTA2WUcvWU9FWlZYSWNZ
|
||||
L1JYYmJzLyt6bzJqdlk2TjYvNUdVVTgKlj1b9zQ9cBG3YQI2t4fjz3t9ySBxQ15w
|
||||
qxcyGspEQ9F+4OtoRQP0FCfmJIBNxvAoT2I5TjspH6dl2o1ID+332Q==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
- recipient: age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
|
||||
enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBFa1NwSGlhUzgxdjI2V0Ew
|
||||
VVNRS1o2L00wM0QvdTFXelFLVnBrQ2ZHRHprCks0Z2ZQbzdQNG5hVFlCZXFrSG5B
|
||||
cG5Qb2JUbnk4ekdJSER2MU5NRGZ6NEUKLS0tIDF4cjNzWnBLaXJNS1RHbVozVUtp
|
||||
bTkzM21jNndEdDV0RmtxSVZ5SkdQcGMK+MZNNAfnnXq7VjIvaDtJEaImk0mecErp
|
||||
gzV3b0MOKc2eTiuaWRNH1yAhJOFTK9iQfC7dDUI8npE+zLOgpLEZ9w==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
lastmodified: "2026-05-20T14:39:14Z"
|
||||
mac: ENC[AES256_GCM,data:CAoIW1sJkIbNoXMaVlXwfHdREzATNqDKpSN5ecaSiRARThB0/tcLNbkazXKyV1Rx5b+b3s+yCpBzwmNnH6ytaCzG5pKPcnfd6YJq7C5rm/EQHyaVGrPdBsSfqiz4yBXKClej//v3+qkD5Ls2PMppv5KmqigOKNQC/ot3ht2c7lM=,iv:eOmDDclksr9f9CDu6dJprSFwlWuoggzVFjyfqWf5Uhg=,tag:IU3sMlIi92ohM86zOIJdAg==,type:str]
|
||||
pgp: []
|
||||
unencrypted_suffix: _unencrypted
|
||||
version: 3.9.4
|
||||
39
secrets/netbird-authentik-oidc.yaml
Normal file
39
secrets/netbird-authentik-oidc.yaml
Normal file
@@ -0,0 +1,39 @@
|
||||
client_secret: ENC[AES256_GCM,data:p8csBIMAuDb1BYLLL9aYIifShpw9uIWm1Z0c59SlzBDP3SJD4I8Z6ipYBETisgxcECr+78zs2wtuebtcHZ3DBqF5PZaG87TJEGotTkjOVSjDLKgelMq0TDfnpWyR1yedsi6/SiRhm4ggTz0Vwwo+TLDNkw3Gv7EP7fZQphYdMyA=,iv:MZcvhiBwDc8FWcNqQvCiTL+9OVEfHpZrzt1kmcKcxdA=,tag:FmEIrw7u2wYiF3ksymC5lA==,type:str]
|
||||
sops:
|
||||
kms: []
|
||||
gcp_kms: []
|
||||
azure_kv: []
|
||||
hc_vault: []
|
||||
age:
|
||||
- recipient: age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6
|
||||
enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBzVVZIQzlPak4xTVVPMm9w
|
||||
M2tFbTFoQmpRUGFETjF1ZTQ3ajlEdGxWQmtZCkdkZzdhblFybGV3OEUrWERBWFUv
|
||||
bTdXSnZFSU8vY1JJVTJjL3dLdWpiNGsKLS0tIHVlVmcrcTNZUGs4MmJtWkR0aTh3
|
||||
Z0pTOWhsZWYyVW5TeXk5ZHhaQnZvZ3MKrnBt5T7WjSxGYvRc1olfhuMN6nOEJbbX
|
||||
xoliPcKkGsBExXVgkpood+OdlH8dNAaT0z1+INzNiBAZ8SazZA4p9g==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
- recipient: age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0
|
||||
enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBjYk1SOVZxUTBWYUw4aUhy
|
||||
WG93N2xVWDlEWnBqQ0JTRWcxc2tXUnppSFF3Cmp6L1g4OUliVlNFYUo2bEtNOHhm
|
||||
aytHK0RFWmNpSWFMaGc1cnNXNlEwbTQKLS0tIHMxU3VXYTZHNWk3cWZFNXBNRElD
|
||||
bE1CUHc1TFlsbGU2aTJjSmhwZENuUDgKvGHg2Df/eBw5akRPYFLvXhzh7P6jTOgj
|
||||
E56n29EJ+p4kTkC8yVBci01qpMioL+Wx2Rt+X+0LWrGBzu5fic2U5g==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
- recipient: age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
|
||||
enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAwSGdBYzlsYmc4dVJ5ZE8r
|
||||
Q1FMR1NQL2lzZ1lmaTg1bGROa0hOcVk2ZTJvCllFMUtJdG5JVFhseWc5eXFsYk9p
|
||||
Q2tIVm5yelAreVRrVkQrTnJ1N3FLclEKLS0tIGQ5dTdqZ3lKWU1KcHdHK1UwajIw
|
||||
Znh5aXkwNmdyVi9EZlBzMUpTTW1VdjgKVVNGjiiKw3nLxq4YsCWoTS4R8wUD8wqD
|
||||
awXSIOvZj5xiz6NvFk1X5T3H4XeEm6tKKzOpBvoVn5yl3zgn/bh5pA==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
lastmodified: "2026-05-21T20:13:20Z"
|
||||
mac: ENC[AES256_GCM,data:bi4o17skIOtZoxJGzLFJ7IAv+X265qIhgnQz6wr4bch2xofwPZZfzECck0I0xDhcwxcDtx3VZ5E2VBdvHWk+iSan+clRq7735k9+DIpjdWaGxIHuylmelZl6aE5kE36UO7fQdnwmiqCPHxiVq/sKuqknFXkl1+FvO1ryrtSHQsU=,iv:xxm+5rK7o2c43iGS1j2Q274QCRsbDn+k+wskpp5tzeU=,tag:sZHk9eth/TwqM8tIWSrv3w==,type:str]
|
||||
pgp: []
|
||||
unencrypted_suffix: _unencrypted
|
||||
version: 3.9.4
|
||||
30
secrets/openrouter-api-key.yaml
Normal file
30
secrets/openrouter-api-key.yaml
Normal file
@@ -0,0 +1,30 @@
|
||||
api_key: ENC[AES256_GCM,data:ps6aTT939SWpgyUoMMIj6YsJG3UnJFBdRKuESpoMeyphB9QnrkqSb5Y/kXI=,iv:6KxZTpPUR2GmPzr5opMP4Wp/yPIQqE9AWtAO5xBDGTI=,tag:qfRt1AlQ97PeHMsGmXnqsQ==,type:str]
|
||||
sops:
|
||||
kms: []
|
||||
gcp_kms: []
|
||||
azure_kv: []
|
||||
hc_vault: []
|
||||
age:
|
||||
- recipient: age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6
|
||||
enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBUZEU0QWtxb0RMMkNqTHI3
|
||||
d25ONHF2Z2F6L0c4VWd6amMvYkQ0NU5YeEh3ClpBSEt5bXNJVmF5Q2pzNGdzVWVC
|
||||
MzFDSTk2YlpyWkxIdDg0RmFxR21EMzgKLS0tIE1GRW52Wk10eXppM05XSDMrc3RH
|
||||
eWQ5TFR0R29RWUxsbVhlWm1TVkFvb0UKwv6OafGdw06J21tQoUlvNI0aLVvl2CVm
|
||||
nwbltuhQvWdFPVBRP/Cx0eK7Vh80tJrnOfXeGpNVgiCs2lpxBlUR2g==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
- recipient: age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
|
||||
enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBMZVdubENvWkxGWmNnVks0
|
||||
NFJYQVBKMnJJZ3FEUDVJWXl2WitMTDFYdEd3CmJQaSt4bTZyUVlBZHgybXRhbWky
|
||||
UGIzNkwzUzNZaWN3WmNKSUkxQkl0OTAKLS0tIENyNlphYmVRV3BUNU1FQzd0QStN
|
||||
V0JLNU5RT09RVjd2OTJUTWMvek9NaTQK0R0OfQnm7AhIeroefldUNDsgDHX1cDmk
|
||||
GybdGEoM8j8Is8GLc6Gk+yQmz7yNvVfYBkaaFsH3Crz3HupuyXI59g==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
lastmodified: "2026-05-30T23:08:58Z"
|
||||
mac: ENC[AES256_GCM,data:68i7qPK7zJXDspmVYfTVJGd141ldXeiPEHK/tsX3p9QHBSP8otXwAzaEqcjNG83dXhrse9CRzzyJmrLm7EO714hZyHKh6MKZb/BOuiwnMCZse0qbK55ghkzTQLMkeSuIM2KsN396mlH/Ehd/hQV6oC6OPq2RQUs/aJt55ESutLc=,iv:kGPKf9e6w1vz7soEyDL7mGQCBkrxiG0Wvm7FHEYp3jc=,tag:zHd7qoIx9Fei2Aabgsztuw==,type:str]
|
||||
pgp: []
|
||||
unencrypted_suffix: _unencrypted
|
||||
version: 3.9.4
|
||||
39
secrets/turn-shared-secret.yaml
Normal file
39
secrets/turn-shared-secret.yaml
Normal file
@@ -0,0 +1,39 @@
|
||||
password: ENC[AES256_GCM,data:C9uA7LHAc/SmWJsICGMNLu2OOYyowIhLq/sTfuNo/w==,iv:211oFm7MdphnXwYszRxWrHLeKw7qeh0BNzP0yomTUTk=,tag:zfhfkzLm0AivKwD7TZTEqw==,type:str]
|
||||
sops:
|
||||
kms: []
|
||||
gcp_kms: []
|
||||
azure_kv: []
|
||||
hc_vault: []
|
||||
age:
|
||||
- recipient: age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6
|
||||
enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBiaFhuUlZJMkxERlR1T2xL
|
||||
dnVKMXNMNVBIdDFuYzJtTjJWZE9JZnlaQ1dJCnNNbzJKc3VXOUZ1QUFzRWhSUkdF
|
||||
WVJlNUtJSytSS2h5NGQyMHA4d2U0c28KLS0tIFVhSGJQOWlEQlp6SlRmS0ZMZzJ4
|
||||
Y2ZoVVVkeVNiYUNwWFFLd2VGaTR1VUkK9Dpk0kjuKoUh3zfVQV7qs/YTTg2BaOkg
|
||||
kNYY01k9MftEwdtvpKk1ogzdHyhGFJ1yEepwK7se6W5KHDJXyVfA8w==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
- recipient: age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0
|
||||
enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA4N2xmZEM3R2tMQjA4a1Fr
|
||||
cWZ5a2lwUWlJZDdVSExnaHA3cEpubTVMWG1BCjNjTEJzL01PWm9xRWlEWUlWend5
|
||||
TUdMMjUzNE1RN0h5bzJuQnl5QkJXVzAKLS0tIDZ6aVh6dXlxTTVPZDRKQUkyZmlk
|
||||
a2FLZ2s1NTA1ZUljeFBhSDZEcGZLNlkKcX5b3dXcJZejeSP4TLr1cOXQj7YEjj0G
|
||||
znXBdDt6c42qE5XS+LGciunf3MmYxt0xXDb11cnSSEVgn0VyqE2x6w==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
- recipient: age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
|
||||
enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBybWtTZEw3NTdwMWZhOUMv
|
||||
L1RNWGduU1BSM0FodWMwUEtHNkxSY3MvdGhzCm1qTGlnM2ZRM2RoekhZbjFzZ2tE
|
||||
Qk1DNjlnMk01R1ZzUDBubTMvR2hPNmcKLS0tIGsxZ2lQMi9vY0Zpb3pwRHBSSm5t
|
||||
OWtkeCtNNnpTVXV6S1p2Y2E0V2toL1EK3RI0RgM0SudRguOpOimke7niuX3cIIVi
|
||||
X+zK75QbvLN7pe1pIIC2UOEmRB9BhgGMVssHxnLzTJv0LUKjDMGdbw==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
lastmodified: "2026-05-21T20:13:20Z"
|
||||
mac: ENC[AES256_GCM,data:PXXEpDbJIoIwWuNwMetpALtxrcB7yJhDYqp6LSKV8WFQI2TJSDgDHiNQ2cngfB9PXKmUNNhlvhmB52Hi6WQSWQdXjiy79T1fOjMhbUAKmykTaZDrwvlrDGkPgiZ1cFSb8+hx/5aZw7YhdQO9+7LAmymeIldZIFEpEO2pvuPZ6MM=,iv:mJb3K8TnsnY183OX7o9pF7oVMDcwVU/rOgX74KTRpr4=,tag:f+M+FT0Vjozm7a8LFDI8lA==,type:str]
|
||||
pgp: []
|
||||
unencrypted_suffix: _unencrypted
|
||||
version: 3.9.4
|
||||
74
vps/management.json.tmpl
Normal file
74
vps/management.json.tmpl
Normal file
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"Stuns": [
|
||||
{"Proto": "udp", "URI": "stun:stun.l.google.com:19302", "Username": "", "Password": null},
|
||||
{"Proto": "udp", "URI": "stun:stun1.l.google.com:19302", "Username": "", "Password": null},
|
||||
{"Proto": "udp", "URI": "stun:stun.cloudflare.com:3478", "Username": "", "Password": null}
|
||||
],
|
||||
"TURNConfig": {
|
||||
"Turns": [
|
||||
{"Proto": "tcp", "URI": "turn:netbird.hubris.network:3478?transport=tcp", "Username": "netbird", "Password": "{{TURN_PASSWORD}}"}
|
||||
],
|
||||
"CredentialsTTL": "12h",
|
||||
"Secret": "not-used-when-time-based-false",
|
||||
"TimeBasedCredentials": false
|
||||
},
|
||||
"Relay": {
|
||||
"Addresses": ["rels://netbird.hubris.network:443"],
|
||||
"CredentialsTTL": "24h",
|
||||
"Secret": "f6vaBSTqv53Jl9Fr+zUkzJ6iIsKv0RoYt++hKRqq58Q"
|
||||
},
|
||||
"Signal": {
|
||||
"Proto": "https",
|
||||
"URI": "netbird.hubris.network:443",
|
||||
"Username": "",
|
||||
"Password": null
|
||||
},
|
||||
"ReverseProxy": {
|
||||
"TrustedHTTPProxies": ["172.30.0.0/24"],
|
||||
"TrustedHTTPProxiesCount": 0,
|
||||
"TrustedPeers": ["0.0.0.0/0"]
|
||||
},
|
||||
"Datadir": "",
|
||||
"DataStoreEncryptionKey": "U60qK19PEpe6LSocYs1OR+qeoE2rUq6tN+W20NAC+gs=",
|
||||
"StoreConfig": {"Engine": "sqlite"},
|
||||
"DisableDefaultPolicy": false,
|
||||
"HttpConfig": {
|
||||
"Address": "0.0.0.0:80",
|
||||
"AuthIssuer": "https://auth.hubris.network/application/o/netbird/",
|
||||
"AuthAudience": "netbird-dashboard",
|
||||
"AuthUserIDClaim": "sub",
|
||||
"AuthKeysLocation": "https://auth.hubris.network/application/o/netbird/jwks/",
|
||||
"OIDCConfigEndpoint": "https://auth.hubris.network/application/o/netbird/.well-known/openid-configuration",
|
||||
"IdpSignKeyRefreshEnabled": true,
|
||||
"CertFile": "",
|
||||
"CertKey": ""
|
||||
},
|
||||
"IdpManagerConfig": {
|
||||
"ManagerType": "none"
|
||||
},
|
||||
"DeviceAuthorizationFlow": {
|
||||
"Provider": "hosted",
|
||||
"ProviderConfig": {
|
||||
"ClientID": "netbird-dashboard",
|
||||
"Audience": "netbird-dashboard",
|
||||
"Domain": "auth.hubris.network",
|
||||
"TokenEndpoint": "https://auth.hubris.network/application/o/token/",
|
||||
"DeviceAuthEndpoint": "https://auth.hubris.network/application/o/device/",
|
||||
"Scope": "openid profile email offline_access",
|
||||
"UseIDToken": false
|
||||
}
|
||||
},
|
||||
"PKCEAuthorizationFlow": {
|
||||
"ProviderConfig": {
|
||||
"ClientID": "netbird-dashboard",
|
||||
"ClientSecret": "{{AUTHENTIK_CLIENT_SECRET}}",
|
||||
"Audience": "netbird-dashboard",
|
||||
"Domain": "auth.hubris.network",
|
||||
"TokenEndpoint": "https://auth.hubris.network/application/o/token/",
|
||||
"AuthorizationEndpoint": "https://auth.hubris.network/application/o/authorize/",
|
||||
"Scope": "openid profile email offline_access",
|
||||
"UseIDToken": false,
|
||||
"RedirectURLs": ["http://localhost:53000/"]
|
||||
}
|
||||
}
|
||||
}
|
||||
23
vps/turnserver.conf.tmpl
Normal file
23
vps/turnserver.conf.tmpl
Normal file
@@ -0,0 +1,23 @@
|
||||
# coturn for netbird symmetric-NAT peers — rendered from this template by
|
||||
# `homelab render-vps-configs`. DO NOT edit /etc/turnserver.conf on the VPS
|
||||
# directly; that file is recreated from this template on each render.
|
||||
listening-port=3478
|
||||
listening-ip=0.0.0.0
|
||||
relay-ip=82.165.190.79
|
||||
external-ip=82.165.190.79
|
||||
|
||||
min-port=49152
|
||||
max-port=49999
|
||||
|
||||
fingerprint
|
||||
lt-cred-mech
|
||||
realm=netbird.hubris.network
|
||||
user=netbird:{{TURN_PASSWORD}}
|
||||
|
||||
no-stun
|
||||
no-multicast-peers
|
||||
no-cli
|
||||
no-loopback-peers
|
||||
no-tlsv1
|
||||
no-tlsv1_1
|
||||
no-udp
|
||||
Reference in New Issue
Block a user