portainer: gate the admin bootstrap on being able to log in

The previous guard was 'does the password file exist'. The first lab1 install
wrote the password, then failed init on the missing setup token — leaving a file
that made every later run skip the bootstrap and then fail to authenticate, so
OIDC never got configured. Ask Portainer instead: try /api/auth, and bootstrap
only when that yields no token. The JWT is then reused for the settings PUT
rather than logging in twice.
This commit is contained in:
Lars Rossen 2026-08-24 20:58:22 +02:00
parent 77f361120a
commit 1c48eb4634

View file

@ -123,16 +123,28 @@ for _ in $(seq 1 24); do
done done
[[ "${UP}" -eq 1 ]] || die "Portainer did not answer on :9443" [[ "${UP}" -eq 1 ]] || die "Portainer did not answer on :9443"
# ── 4. Bootstrap the local admin (once) ────────────────────────────── # ── 4. Bootstrap the local admin ─────────────────────────────────────
# Portainer refuses admin creation after a timeout window, so this must happen # The password is kept on the VM for break-glass access — OIDC is the everyday
# promptly after first start. The password is kept on the VM for break-glass # path, this is the account that survives an Authentik outage.
# access — OIDC is the everyday path, this is the account that survives an #
# Authentik outage. # The guard is "can we log in?", NOT "does the password file exist": a failed
# init leaves a password file behind, and keying off the file makes every later
# run skip the bootstrap forever (seen on the first lab1 install).
if ! vm "sudo test -s ${ADMIN_SECRET}" 2>/dev/null; then if ! vm "sudo test -s ${ADMIN_SECRET}" 2>/dev/null; then
info " Bootstrapping the break-glass admin account..."
vm "sudo install -d -m 0700 \$(dirname ${ADMIN_SECRET})" vm "sudo install -d -m 0700 \$(dirname ${ADMIN_SECRET})"
vm "openssl rand -base64 24 | sudo tee ${ADMIN_SECRET} >/dev/null && sudo chmod 0600 ${ADMIN_SECRET}" vm "openssl rand -base64 24 | sudo tee ${ADMIN_SECRET} >/dev/null && sudo chmod 0600 ${ADMIN_SECRET}"
fi
ADMIN_PW="$(vm "sudo cat ${ADMIN_SECRET}" | tr -d '\r')" ADMIN_PW="$(vm "sudo cat ${ADMIN_SECRET}" | tr -d '\r')"
portainer_jwt() {
vm "curl -sk -X POST https://localhost:9443/api/auth \
-H 'Content-Type: application/json' \
-d '{\"Username\":\"admin\",\"Password\":\"${ADMIN_PW}\"}'" 2>/dev/null | jq -r '.jwt // empty'
}
JWT="$(portainer_jwt)"
if [[ -z "${JWT}" ]]; then
info " Bootstrapping the break-glass admin account..."
# Portainer >= 2.39 prints a one-time setup token at startup and refuses # Portainer >= 2.39 prints a one-time setup token at startup and refuses
# admin creation without it (HTTP 403). The log line wraps the value in ANSI # admin creation without it (HTTP 403). The log line wraps the value in ANSI
# colour codes, so match the 64-hex token rather than "setup_token=<value>". # colour codes, so match the 64-hex token rather than "setup_token=<value>".
@ -144,18 +156,18 @@ if ! vm "sudo test -s ${ADMIN_SECRET}" 2>/dev/null; then
-d '{\"Username\":\"admin\",\"Password\":\"${ADMIN_PW}\"}'" || echo 000)" -d '{\"Username\":\"admin\",\"Password\":\"${ADMIN_PW}\"}'" || echo 000)"
case "${init_code}" in case "${init_code}" in
200|204) info " admin created (password in ${ADMIN_SECRET} on the VM)" ;; 200|204) info " admin created (password in ${ADMIN_SECRET} on the VM)" ;;
409) info " admin already existed — keeping it" ;; 409) warn " an admin exists but ${ADMIN_SECRET} does not match it — reset it by hand" ;;
*) warn " admin init returned HTTP ${init_code}; configure the admin by hand at ${UI_URL}" ;; *) warn " admin init returned HTTP ${init_code}; configure the admin by hand at ${UI_URL}" ;;
esac esac
JWT="$(portainer_jwt)"
else else
ADMIN_PW="$(vm "sudo cat ${ADMIN_SECRET}" | tr -d '\r')"
info " break-glass admin already provisioned" info " break-glass admin already provisioned"
fi fi
# ── 5. Point Portainer at TAPPaaS identity (OIDC) ──────────────────── # ── 5. Point Portainer at TAPPaaS identity (OIDC) ────────────────────
# identity:identity wrote these three values; if they are missing the module is # identity:identity wrote these three values; if they are missing the module is
# still usable with the local admin, so warn rather than fail. # still usable with the local admin, so warn rather than fail.
if [[ -n "${ADMIN_PW:-}" ]] && vm "sudo test -s ${SECRETS_ENV}" 2>/dev/null; then if [[ -n "${JWT:-}" ]] && vm "sudo test -s ${SECRETS_ENV}" 2>/dev/null; then
info " Configuring OIDC login against Authentik..." info " Configuring OIDC login against Authentik..."
CLIENT_ID="$(vm "sudo sh -c '. ${SECRETS_ENV}; printf %s \"\$OIDC_CLIENT_ID\"'" | tr -d '\r')" CLIENT_ID="$(vm "sudo sh -c '. ${SECRETS_ENV}; printf %s \"\$OIDC_CLIENT_ID\"'" | tr -d '\r')"
CLIENT_SECRET="$(vm "sudo sh -c '. ${SECRETS_ENV}; printf %s \"\$OIDC_CLIENT_SECRET\"'" | tr -d '\r')" CLIENT_SECRET="$(vm "sudo sh -c '. ${SECRETS_ENV}; printf %s \"\$OIDC_CLIENT_SECRET\"'" | tr -d '\r')"
@ -169,9 +181,6 @@ if [[ -n "${ADMIN_PW:-}" ]] && vm "sudo test -s ${SECRETS_ENV}" 2>/dev/null; the
USER_URI="$(jq -r '.userinfo_endpoint // empty' <<<"${WELL_KNOWN}")" USER_URI="$(jq -r '.userinfo_endpoint // empty' <<<"${WELL_KNOWN}")"
if [[ -n "${AUTH_URI}" && -n "${TOKEN_URI}" && -n "${USER_URI}" ]]; then if [[ -n "${AUTH_URI}" && -n "${TOKEN_URI}" && -n "${USER_URI}" ]]; then
JWT="$(vm "curl -sk -X POST https://localhost:9443/api/auth \
-H 'Content-Type: application/json' \
-d '{\"Username\":\"admin\",\"Password\":\"${ADMIN_PW}\"}'" | jq -r '.jwt // empty')"
if [[ -n "${JWT}" ]]; then if [[ -n "${JWT}" ]]; then
# AuthenticationMethod 3 = OAuth. OAuthAutoCreateUsers lets a # AuthenticationMethod 3 = OAuth. OAuthAutoCreateUsers lets a
# TAPPaaS identity log in without an admin pre-creating it; # TAPPaaS identity log in without an admin pre-creating it;