device-code onboarding: bootstrap auto-installs deps + netbird; Authentik device flow wired

Two changes that together collapse new-workstation onboarding from ~7 steps
to ~2 commands:

* bootstrap.sh:
  - Dep-check now AUTO-INSTALLS missing tools (apt/dnf/brew) instead of
    printing instructions and exiting. Re-verifies after install.
  - New pre-mesh-check block: if netbird isn't installed, installs it
    from the netbird apt/dnf repo (or `brew install --cask netbird` on
    Darwin), then if mgmt isn't connected, runs `netbird up
    --management-url=https://netbird.hubris.network --ssh-jwt-cache-ttl 86400`.
    Operator clicks the device-code URL inline. Waits up to ~30s for
    Management: Connected before continuing. Skipped on --no-secrets +
    --dry-run.

* containers/124-authentik.md: replaces the "KNOWN MISSING — Device Code
  Stage" subsection with a working recipe — Authentik 2026.2 routes
  /device via a BRAND-level "Device code flow" field, not a provider
  field. Documented stage bindings for a `default-device-code-flow`
  flow (identification → password → user-login → consent) and the
  brand-level binding step.

* operations/agent-enrollment.md: Path B (interactive `netbird up`) is
  now the default; Path A (setup-key) demoted to "headless/scripted"
  alternative. "Install dependencies" section collapsed into a note
  that bootstrap handles it, with the manual recipes kept in a
  collapsible <details> block for air-gapped use.

The flow uniquely available to lab owners (single Authentik user today)
still relies on the first-time-owner sqlite promotion documented in
124-authentik.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 15:05:27 +02:00
parent b42a986cc0
commit d41d73f323
3 changed files with 145 additions and 51 deletions

View File

@@ -114,18 +114,21 @@ 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 [ "$DRY_RUN" -eq 1 ]; then
echo "+ would install missing tools: ${missing[*]}"
else
echo "[bootstrap] installing missing tools: ${missing[*]}"
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 ;;
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 ] && echo " brew install ${brew_list[*]}" >&2
[ "${#brew_list[@]}" -gt 0 ] && brew install "${brew_list[@]}"
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
@@ -133,14 +136,89 @@ if [ "${#missing[@]}" -gt 0 ]; then
*) 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
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 " install with your package manager: ${missing[*]}" >&2
fi
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 <<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

View File

@@ -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 <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)

View File

@@ -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 `<bootstrap-tier1>`). Both paths below produce the same end state: `netbird status` shows `Management: Connected`, peer IP `100.122.x.x/16`.
**Path Asetup-key (currently the only working path, as of 2026-05-21):**
**Path Binteractive 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 <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.
<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
```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
```
</details>
## Run the bootstrap
You need a Gitea read-only personal access token for the initial clone