diff --git a/bootstrap.sh b/bootstrap.sh index e0cfe45..ec6b275 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -114,32 +114,110 @@ 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 - brew_list=() - for m in "${missing[@]}"; do - case "$m" in - python3-yaml) echo " pip3 install pyyaml (or brew install pyyaml)" >&2 ;; - *) brew_list+=("$m") ;; - esac - done - [ "${#brew_list[@]}" -gt 0 ] && echo " brew install ${brew_list[*]}" >&2 - elif command -v dnf >/dev/null 2>&1; then - # Fedora/RHEL/Nobara: python yaml package is python3-pyyaml. - dnf_list=() - for m in "${missing[@]}"; do - case "$m" in - python3-yaml) dnf_list+=("python3-pyyaml") ;; - *) dnf_list+=("$m") ;; - esac - done - echo " sudo dnf install -y ${dnf_list[*]}" >&2 - elif command -v apt >/dev/null 2>&1; then - echo " sudo apt install -y ${missing[*]}" >&2 + if [ "$DRY_RUN" -eq 1 ]; then + echo "+ would install missing tools: ${missing[*]}" else - echo " install with your package manager: ${missing[*]}" >&2 + 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 +fi + +# -------- 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 <&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 - exit 1 fi # Mesh check — accept Netbird, Tailscale, or LAN reachability of the issuance diff --git a/containers/124-authentik.md b/containers/124-authentik.md index 487eac4..af46927 100644 --- a/containers/124-authentik.md +++ b/containers/124-authentik.md @@ -127,13 +127,24 @@ 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`). -### KNOWN MISSING — Device Code Stage +### Device Code grant — configured (2026-05-21) -Authentik does NOT yet have an `OAuth2DeviceCodeStage` configured for this provider. Symptom: `netbird up` (without `--setup-key`) prints a device-code URL → user opens it → blank consent screen → device-code expires after 60s → CLI errors out. The token endpoint itself works (we verified with a direct device-code POST); only the consent UI is missing. +`netbird up` (interactive, without `--setup-key`) works against Authentik. The recipe: -**Workaround**: every new peer must use `netbird up --setup-key `. Setup keys are generated from the dashboard at `https://netbird.hubris.network/setup-keys` by an already-enrolled operator. +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`. -**Proper fix** (TODO): Authentik admin → Flows & Stages → Stages → Create → `OAuth2 Device Code Stage` + matching `OAuth2 Device Code Finish Stage`. Then either edit the provider's Authorization flow to include those stages, or create a dedicated device-code flow and bind the provider to it. +**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) diff --git a/operations/agent-enrollment.md b/operations/agent-enrollment.md index 1e30593..9371a02 100644 --- a/operations/agent-enrollment.md +++ b/operations/agent-enrollment.md @@ -30,27 +30,30 @@ hostname doesn't match any inventory entry. Two fixes: ### Getting onto Netbird -The bootstrap *requires* the new client to already be a connected mesh peer (its `netbird status` shows `Management: Connected`). Two paths to get there: +Bootstrap auto-installs netbird and drives `netbird up` if the mesh isn't already connected (since commit ``). Both paths below produce the same end state: `netbird status` shows `Management: Connected`, peer IP `100.122.x.x/16`. -**Path A — setup-key (currently the only working path, as of 2026-05-21):** +**Path B — interactive OIDC (default; recommended):** -1. From an **already-enrolled** machine (e.g. an existing workstation or hubris), log into the dashboard at `https://netbird.hubris.network/`. The dashboard auth flow goes via Authentik on [LXC 124](../containers/124-authentik.md), which is only reachable from inside the mesh — that's why this step has to be done from a mesh peer. -2. **Setup Keys** → Create → set reusable + expiry as appropriate → copy the key. -3. On the new client (after installing netbird per [netbird docs](https://docs.netbird.io/how-to/installation)): +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 \ - --management-url https://netbird.hubris.network + --management-url https://netbird.hubris.network \ + --ssh-jwt-cache-ttl 86400 ``` - Optionally pass `--ssh-jwt-cache-ttl=86400` here too (one less SSO per day for ssh into mesh peers). -4. Confirm: `netbird status` shows `Management: Connected`, `Signal: Connected`, peer IP `100.122.x.x/16`. Then proceed to "Install dependencies" and "Run the bootstrap" below. -**Path B — interactive OIDC ("`netbird up` without setup-key"):** +**Why we can't OIDC-login from the public internet (still open as a follow-up):** -Currently **broken** because Authentik's OAuth2 Device Code Stage isn't configured yet — the device-code URL renders a blank consent screen, and the code expires after 60s. See [124-authentik.md "KNOWN MISSING — Device Code Stage"](../containers/124-authentik.md) for the fix. Until then, stick with Path A. - -**Why we can't OIDC-login from the public internet:** - -`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 new client off-mesh hitting `auth.hubris.network` gets a Traefik default 404. Solving this needs a VPS-side Traefik route forwarding `auth.hubris.network` via the netbird-routed `192.168.8.0/24` to LXC 124. Tracked as a future-session improvement. +`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 @@ -82,31 +85,33 @@ sudo HOMELAB_GITEA_TOKEN=... \ ## Install dependencies -### Fedora / RHEL / Nobara +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. + +
+Manual recipes (Fedora / Debian / macOS) ```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 - -```bash +# 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 - -```bash +# macOS brew install git age sops pip3 install pyyaml # if `python3 -c "import yaml"` fails ``` +
+ ## Run the bootstrap You need a Gitea read-only personal access token for the initial clone