homelab CLI: per-host ssh.user + LAN→mesh fallback; wiki for netbird-ssh JWT issuer fix

Three coordinated changes addressing follow-ups from the 2026-05-21 netbird vanilla
migration, plus a related off-LAN ergonomics fix:

bin/homelab:
- New ssh_target(name, force_mesh=False) helper resolves (addr, port, user)
  from inventory, honoring ssh.netbird_port (forces mesh path) and ssh.user
  (default "root"). Falls back to the netbird FQDN when LAN IP fails a
  cached 1.5s TCP probe — helps off-LAN operators on VPN/symmetric-NAT
  paths where the netbird subnet route doesn't reach 192.168.8.0/24.
- New ssh_base() builds the full `ssh ... user@addr` invocation; hubris_ssh()
  is now a back-compat shim. cmd_ssh, cmd_logs, cmd_restart, cmd_nuke
  refactored to use it — no more hardcoded "root@" anywhere.

inventory.yaml:
- New ssh.user convention (root by default, explicit per workstation).
- hubris.ssh.user=root (explicit, documents convention).
- republic-laptop, mac-mini: ssh.user=dtoro. ludo-mini left default (TODO).
- Comment block in the header explains the field + why it exists (netbird-ssh
  defaults to LOCAL username; "user not found" on LXCs is the gotcha).
- hosts/*.yaml regenerated from build_host_files.py.

infrastructure/mesh.md:
- Migration changelog entry updated: Device Code Stage is now configured
  (was "NOT yet" — landed in d41d73f); --setup-key no longer required.
- New subsection documenting the post-migration JWT-issuer cache bug:
  client/internal/engine_ssh.go's updateSSH() bails out when sshServer is
  already running, so systemctl restart and netbird down/up don't refresh
  the SSH JWT validator. Full daemon stop/start is the fix.
- Companion username gotcha (`netbird ssh` defaulting to local username).

operations/agent-enrollment.md:
- Three new troubleshooting rows: JWT-issuer cache, user-not-found, and
  LAN-unreachable-from-mesh-peer (the new homelab CLI behavior).

Verification: ssh_target resolution against the live inventory yields
- hubris → ssh -p 22022 root@proxmox-server.netbird.selfhosted (mesh-forced)
- jellyfin/gitea → ssh root@192.168.8.x (LAN reachable, probe passed)
- republic-laptop/mac-mini → ssh dtoro@<fqdn> (per ssh.user)
- ludo-mini → ssh root@<fqdn> (default)
This commit is contained in:
2026-05-21 21:57:19 +02:00
parent d41d73f323
commit 21063015c7
7 changed files with 128 additions and 37 deletions

View File

@@ -17,10 +17,12 @@ import argparse
import json import json
import os import os
import shutil import shutil
import socket
import subprocess import subprocess
import sys import sys
import urllib.request import urllib.request
from datetime import datetime from datetime import datetime
from functools import lru_cache
from pathlib import Path from pathlib import Path
try: try:
@@ -81,25 +83,69 @@ def host_address(name: str, prefer_lan: bool = False) -> str:
die(f"no reachable address for host {name}") die(f"no reachable address for host {name}")
def hubris_ssh() -> list[str]: @lru_cache(maxsize=None)
"""SSH base command for hubris, honoring its non-default Netbird SSH port. def _can_tcp_connect(addr: str, port: int) -> bool:
"""Cached 1.5s TCP probe. Used to detect LAN unreachability before
falling back to the mesh FQDN — cheaper and more predictable than
waiting for ssh to timeout."""
try:
with socket.create_connection((addr, port), timeout=1.5):
return True
except OSError:
return False
The non-default port (22022 = netbird-ssh-server) only listens on hubris's
netbird interface — it is NOT reachable via the LAN IP 192.168.8.77 even def ssh_target(name: str, *, force_mesh: bool = False) -> tuple[str, int, str]:
from peers that route 192.168.8.0/24 through hubris. So when that port is """Resolve (addr, port, user) for ssh-ing into `name`.
in use, the target address MUST be the netbird FQDN/IP, not the lan_ip
that `host_address()` prefers. Standard port 22 (LAN ssh) keeps the Address rules:
`host_address()` default. - If `ssh.netbird_port` is set (e.g. hubris with 22022), use it +
force the netbird FQDN/IP since netbird-ssh-server binds only the
netbird interface, not the LAN IP.
- Otherwise: try the LAN IP first. If a 1.5s TCP probe fails (off-LAN,
VPN/symmetric-NAT path, etc.), fall back to the mesh FQDN.
- `force_mesh=True` skips the LAN probe entirely.
User: `ssh.user` from inventory if set, else `root`. Mismatched user is
the netbird-ssh "user not found" gotcha — defaults baked into the
inventory prevent it.
""" """
h = host("hubris") h = host(name)
port = h.get("ssh", {}).get("netbird_port", 22) ssh = h.get("ssh", {}) or {}
netbird_port = h.get("ssh", {}).get("netbird_port") user = ssh.get("user", "root")
if port == netbird_port: nb = h.get("mesh", {}).get("netbird") or {}
nb = h.get("mesh", {}).get("netbird") or {} mesh_addr = nb.get("fqdn") or nb.get("ip")
addr = nb.get("fqdn") or nb.get("ip") or host_address("hubris")
else: nb_port = ssh.get("netbird_port")
addr = host_address("hubris") if nb_port is not None:
return ["ssh", "-p", str(port), f"root@{addr}"] addr = mesh_addr or host_address(name)
return addr, nb_port, user
port = ssh.get("port", 22)
lan = h.get("lan_ip")
if force_mesh or not lan:
return mesh_addr or host_address(name), port, user
if _can_tcp_connect(lan, port):
return lan, port, user
if mesh_addr:
return mesh_addr, port, user
return lan, port, user # last resort; ssh will surface the real error
def ssh_base(name: str, *, force_mesh: bool = False) -> list[str]:
"""Construct an `ssh ... user@addr` invocation for `name`, honoring port
and per-host user from inventory."""
addr, port, user = ssh_target(name, force_mesh=force_mesh)
cmd = ["ssh"]
if port != 22:
cmd.extend(["-p", str(port)])
cmd.append(f"{user}@{addr}")
return cmd
def hubris_ssh() -> list[str]:
"""Back-compat shim — prefer `ssh_base("hubris")` in new code."""
return ssh_base("hubris")
def confirm(question: str, default_no: bool = True) -> bool: def confirm(question: str, default_no: bool = True) -> bool:
@@ -443,15 +489,10 @@ def cmd_list(args: argparse.Namespace) -> int:
def cmd_ssh(args: argparse.Namespace) -> int: def cmd_ssh(args: argparse.Namespace) -> int:
name = args.host name = args.host
h = host(name) cmd = ssh_base(name)
addr = host_address(name) # Optional --user override replaces the resolved user in the last arg
cmd = ["ssh"] if args.user:
# hubris uses a non-default Netbird SSH port for the mesh path cmd[-1] = f"{args.user}@{cmd[-1].split('@', 1)[1]}"
if name == "hubris":
port = h.get("ssh", {}).get("netbird_port", 22)
cmd.extend(["-p", str(port)])
user = args.user or "root"
cmd.append(f"{user}@{addr}")
if args.command: if args.command:
cmd.append(" ".join(args.command)) cmd.append(" ".join(args.command))
os.execvp(cmd[0], cmd) os.execvp(cmd[0], cmd)
@@ -486,10 +527,7 @@ def cmd_logs(args: argparse.Namespace) -> int:
svc = args.service svc = args.service
host_name = service_backend_host(svc) host_name = service_backend_host(svc)
unit = service(svc).get("systemd_unit", svc) unit = service(svc).get("systemd_unit", svc)
if host_name == "hubris": base = ssh_base(host_name)
base = hubris_ssh()
else:
base = ["ssh", f"root@{host_address(host_name)}"]
remote = ["journalctl", "-u", unit, "-n", str(args.lines), "--no-pager"] remote = ["journalctl", "-u", unit, "-n", str(args.lines), "--no-pager"]
if args.follow: if args.follow:
remote.append("-f") remote.append("-f")
@@ -503,10 +541,7 @@ def cmd_restart(args: argparse.Namespace) -> int:
if not args.yes: if not args.yes:
if not confirm(f"restart systemd unit '{unit}' on {host_name}?"): if not confirm(f"restart systemd unit '{unit}' on {host_name}?"):
return 1 return 1
if host_name == "hubris": base = ssh_base(host_name)
base = hubris_ssh()
else:
base = ["ssh", f"root@{host_address(host_name)}"]
return subprocess.call(base + ["--", "systemctl", "restart", unit]) return subprocess.call(base + ["--", "systemctl", "restart", unit])
@@ -929,14 +964,14 @@ def cmd_nuke(args: argparse.Namespace) -> int:
if not args.yes: if not args.yes:
if not confirm(f"destroy /etc/age/key.txt + /opt/homelab-context on {name}?"): if not confirm(f"destroy /etc/age/key.txt + /opt/homelab-context on {name}?"):
return 1 return 1
addr = host_address(name) base = ssh_base(name)
remote = ("set -euo pipefail; " remote = ("set -euo pipefail; "
"shred -u /etc/age/key.txt 2>/dev/null || true; " "shred -u /etc/age/key.txt 2>/dev/null || true; "
"rm -rf /opt/homelab-context; " "rm -rf /opt/homelab-context; "
"systemctl disable --now homelab-context-sync.timer 2>/dev/null || true; " "systemctl disable --now homelab-context-sync.timer 2>/dev/null || true; "
"launchctl bootout system/network.hubris.homelab-context-sync 2>/dev/null || true; " "launchctl bootout system/network.hubris.homelab-context-sync 2>/dev/null || true; "
"echo nuked") "echo nuked")
return subprocess.call(["ssh", f"root@{addr}", remote]) return subprocess.call(base + [remote])
# ---------- argparse ---------- # ---------- argparse ----------

View File

@@ -20,6 +20,7 @@ mounts:
ssh: ssh:
port: 22 port: 22
netbird_port: 22022 netbird_port: 22022
user: root
runs: runs:
- proxmox_ui - proxmox_ui
services_hosted: services_hosted:

View File

@@ -13,6 +13,8 @@ mesh_globals:
accepted: accepted:
- netbird - netbird
- tailscale - tailscale
ssh:
user: dtoro
notes: notes:
- Only macOS in the fleet. Bootstrap uses launchd. - Only macOS in the fleet. Bootstrap uses launchd.
mcp_endpoint: https://mcp.hubris.network/sse mcp_endpoint: https://mcp.hubris.network/sse

View File

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

View File

@@ -123,7 +123,38 @@ Same migration also swapped OIDC from the combined image's embedded Dex IdP to A
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). 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. Device Code Stage is NOT yet configured in Authentik → `netbird up` interactive auth flow returns an empty consent screen; new peers must use `--setup-key` until the stage is added. 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. 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.

View File

@@ -15,6 +15,12 @@
# committed back via `homelab client add --finalize-pubkey <key>`. # committed back via `homelab client add --finalize-pubkey <key>`.
# - When a service moves hosts, update only the `services:` section here; # - When a service moves hosts, update only the `services:` section here;
# never duplicate addresses elsewhere. # 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. # `homelab client add/remove` does surgical line-edits — comments survive.
# Avoid round-tripping the file through yaml.safe_dump (it strips comments). # Avoid round-tripping the file through yaml.safe_dump (it strips comments).
@@ -107,6 +113,7 @@ hosts:
ssh: ssh:
port: 22 port: 22
netbird_port: 22022 netbird_port: 22022
user: root
mounts: mounts:
- /mnt/library - /mnt/library
age_pubkey: age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6 age_pubkey: age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6
@@ -329,6 +336,8 @@ hosts:
mesh: mesh:
netbird: netbird:
fqdn: republic-laptop.netbird.selfhosted fqdn: republic-laptop.netbird.selfhosted
ssh:
user: dtoro
mac-mini: mac-mini:
kind: workstation kind: workstation
os: macos os: macos
@@ -336,6 +345,8 @@ hosts:
mesh: mesh:
netbird: netbird:
fqdn: mac-mini-234-17.netbird.selfhosted fqdn: mac-mini-234-17.netbird.selfhosted
ssh:
user: dtoro
notes: notes:
- Only macOS in the fleet. Bootstrap uses launchd. - Only macOS in the fleet. Bootstrap uses launchd.
age_pubkey: '' age_pubkey: ''
@@ -346,3 +357,6 @@ hosts:
mesh: mesh:
netbird: netbird:
fqdn: ludo-mini.netbird.selfhosted 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 }

View File

@@ -268,9 +268,15 @@ The CLI prints a follow-up checklist that the operator must do manually:
| `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. | | `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 *`). | | 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 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 ## Changelog
### 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 ### 2026-05-20 — initial page
Captures the enrollment flow validated during Phase 2 of the homelab Captures the enrollment flow validated during Phase 2 of the homelab
context distribution rollout. hubris + LXC 105 (apps) enrolled; first context distribution rollout. hubris + LXC 105 (apps) enrolled; first