Create the first administrator with a generated password kept in a Secret

The bootstrap hook created `admin` with no password, so the account could
only use its API key and could not sign in on the web UI or the TUI. It
also won the one-shot /api/bootstrap against whoever ran it by hand, and
failed with exit 1 when the Secret already existed, which fails the Helm
release.

The hook now generates a 32-character password, stores username, password
and api-key in the <release>-admin-key Secret, and reuses the stored
password on later runs, so recreating the database brings the same
account back. The password is written before the account is created, so a
crash in between cannot leave an administrator nobody has the password
for. When the server was bootstrapped by something else, it checks the
stored password against /api/login and removes only the password it
generated if that does not sign in, then exits cleanly. bootstrap.enabled:
false still removes the hook and its role.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
This commit is contained in:
Niklas Ye
2026-10-10 14:36:15 +02:00
parent c9f8494b00
commit b7d296f6e9
5 changed files with 99 additions and 23 deletions
@@ -42,10 +42,29 @@ spec:
- |
SERVICE_URL="http://{{ include "terdut-server.fullname" . }}:{{ .Values.service.port }}"
SECRET_NAME="{{ include "terdut-server.bootstrapSecretName" . }}"
USERNAME="{{ .Values.bootstrap.username }}"
EMAIL="{{ .Values.bootstrap.email }}"
K8S_API="https://kubernetes.default.svc"
SA_TOKEN="$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)"
CA_CERT="/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
NAMESPACE="$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace)"
SECRETS="$K8S_API/api/v1/namespaces/$NAMESPACE/secrets"
# kapi METHOD URL [BODY]: sets CODE and BODY from the Kubernetes API.
kapi() {
_ctype="application/json"
[ "$1" = "PATCH" ] && _ctype="application/merge-patch+json"
if [ -n "$3" ]; then
_resp=$(printf '%s' "$3" | curl -s -w "\n%{http_code}" -X "$1" "$2" \
--cacert "$CA_CERT" -H "Authorization: Bearer $SA_TOKEN" \
-H "Content-Type: $_ctype" -d @-)
else
_resp=$(curl -s -w "\n%{http_code}" -X "$1" "$2" \
--cacert "$CA_CERT" -H "Authorization: Bearer $SA_TOKEN")
fi
CODE=$(echo "$_resp" | tail -1)
BODY=$(echo "$_resp" | sed '$d')
}
echo "Waiting for terdut-server to be ready..."
RETRIES=60
@@ -60,42 +79,86 @@ spec:
fi
echo "Server is ready."
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$SERVICE_URL/api/bootstrap" \
-H "Content-Type: application/json" \
-d '{"username":"{{ .Values.bootstrap.username }}","email":"{{ .Values.bootstrap.email }}"}')
# The Secret is the source of truth for the administrator's password: reuse
# the one it holds, so recreating the database brings the same account back.
PASSWORD=""
EXISTS=0
kapi GET "$SECRETS/$SECRET_NAME"
if [ "$CODE" = "200" ]; then
EXISTS=1
PASSWORD=$(echo "$BODY" | grep -o '"password": *"[^"]*"' | head -1 | cut -d'"' -f4 | base64 -d)
elif [ "$CODE" != "404" ]; then
echo "Failed to read secret '$SECRET_NAME' (HTTP $CODE)."
exit 1
fi
# Written BEFORE the server is asked to create the account, so a crash in
# between cannot leave an administrator whose password nobody has.
GENERATED=0
if [ -z "$PASSWORD" ]; then
PASSWORD=$(head -c 256 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 32)
if [ "${#PASSWORD}" -lt 32 ]; then
echo "Failed to generate a password."
exit 1
fi
GENERATED=1
DATA=$(printf '"username":"%s","password":"%s"' "$USERNAME" "$PASSWORD")
if [ "$EXISTS" = "1" ]; then
kapi PATCH "$SECRETS/$SECRET_NAME" "{\"stringData\":{$DATA}}"
else
kapi POST "$SECRETS" "{\"apiVersion\":\"v1\",\"kind\":\"Secret\",\"metadata\":{\"name\":\"$SECRET_NAME\"},\"stringData\":{$DATA}}"
fi
case "$CODE" in 200|201) ;; *)
echo "Failed to store the password in secret '$SECRET_NAME' (HTTP $CODE)."
exit 1 ;;
esac
fi
RESPONSE=$(printf '{"username":"%s","email":"%s","password":"%s"}' "$USERNAME" "$EMAIL" "$PASSWORD" \
| curl -s -w "\n%{http_code}" -X POST "$SERVICE_URL/api/bootstrap" \
-H "Content-Type: application/json" -d @-)
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | head -1)
RESP_BODY=$(echo "$RESPONSE" | head -1)
if [ "$HTTP_CODE" = "403" ]; then
echo "Server already bootstrapped, nothing to do."
# Somebody else made the first account. The Secret is only worth keeping
# if its password signs in.
LOGIN=$(printf '{"username":"%s","password":"%s"}' "$USERNAME" "$PASSWORD" \
| curl -s -o /dev/null -w "%{http_code}" -X POST "$SERVICE_URL/api/login" \
-H "Content-Type: application/json" -d @-)
if [ "$LOGIN" = "200" ]; then
echo "Server already bootstrapped; the password in '$SECRET_NAME' signs in."
exit 0
fi
echo "Server already bootstrapped by something else; '$USERNAME' does not sign in with the password in '$SECRET_NAME'."
if [ "$GENERATED" = "1" ]; then
if [ "$EXISTS" = "1" ]; then
kapi PATCH "$SECRETS/$SECRET_NAME" '{"data":{"username":null,"password":null}}'
else
kapi DELETE "$SECRETS/$SECRET_NAME"
fi
echo "Removed the password this run generated."
fi
exit 0
fi
if [ "$HTTP_CODE" != "201" ]; then
echo "Bootstrap failed (HTTP $HTTP_CODE): $BODY"
echo "Bootstrap failed (HTTP $HTTP_CODE): $RESP_BODY"
exit 1
fi
API_KEY=$(echo "$BODY" | grep -o '"key":"[^"]*"' | cut -d'"' -f4)
API_KEY=$(echo "$RESP_BODY" | grep -o '"key":"[^"]*"' | cut -d'"' -f4)
if [ -z "$API_KEY" ]; then
echo "Failed to extract API key from response."
exit 1
fi
echo "Bootstrap succeeded. Storing API key in secret '$SECRET_NAME'."
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
-X POST "$K8S_API/api/v1/namespaces/$NAMESPACE/secrets" \
--cacert "$CA_CERT" \
-H "Authorization: Bearer $SA_TOKEN" \
-H "Content-Type: application/json" \
-d "$(printf '{"apiVersion":"v1","kind":"Secret","metadata":{"name":"%s"},"stringData":{"api-key":"%s"}}' "$SECRET_NAME" "$API_KEY")")
if [ "$HTTP_CODE" != "201" ]; then
echo "Failed to create secret (HTTP $HTTP_CODE)."
echo "Bootstrap succeeded. Storing the API key in secret '$SECRET_NAME'."
kapi PATCH "$SECRETS/$SECRET_NAME" "{\"stringData\":{\"api-key\":\"$API_KEY\"}}"
if [ "$CODE" != "200" ]; then
echo "Failed to store the API key (HTTP $CODE)."
exit 1
fi
echo "Secret '$SECRET_NAME' created successfully."
echo "Secret '$SECRET_NAME' holds username, password and api-key."
{{- end }}
@@ -27,6 +27,12 @@ rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["create"]
# Reading, filling in and (when the account turns out not to be ours) cleaning up the
# one Secret by name; `create` cannot be restricted to a name.
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["{{ include "terdut-server.bootstrapSecretName" . }}"]
verbs: ["get", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
+6 -1
View File
@@ -160,9 +160,14 @@ oidc:
# Backups are not this chart's business: Postgres is backed up where it runs,
# through a k8up.io/backupcommand pg_dump annotation on the database pod itself.
# Creates the first administrator after install/upgrade, with a generated password, and keeps
# the credentials in a Secret. Set enabled: false to create the first user yourself with
# POST /api/bootstrap (or on an SSO-only install that wants no local account).
bootstrap:
enabled: true
username: admin
email: admin@example.com
# secretName overrides the default of <fullname>-admin-key
# secretName overrides the default of <fullname>-admin-key. It holds username, password and
# api-key. The password is generated once and then reused: delete the Secret and the
# database to start over.
secretName: ""
+3 -1
View File
@@ -35,7 +35,9 @@ than an `Ingress`. TLS is terminated at the gateway, so the server itself never
| `networking.hostname` | `terdut.example.com` | Hostname the `HTTPRoute` serves |
| `networking.listener` | `""` | Gateway listener (`sectionName`) to bind to. Empty attaches to every matching listener, **including plaintext HTTP** — set it to the HTTPS listener's name to serve TLS only |
| `networking.servicePort` | `8080` | Port the route forwards to; keep in sync with `service.port` |
| `bootstrap.enabled` | `true` | Runs a post-install hook that creates the first user and stores its API key in the `<release>-admin-key` Secret. Already-bootstrapped servers are left alone |
| `bootstrap.enabled` | `true` | Runs a post-install/upgrade hook that creates the first administrator with a generated password and stores `username`, `password` and `api-key` in the `<release>-admin-key` Secret. The password is generated once and reused, so recreating the database brings the same account back. Already-bootstrapped servers are left alone. Set `false` to create the first user yourself with `POST /api/bootstrap` |
| `bootstrap.username` / `bootstrap.email` | `admin` / `admin@example.com` | Account the hook creates |
| `bootstrap.secretName` | `""` | Secret to keep the credentials in; empty means `<release>-admin-key` |
| `database.dsn` | `""` | **Required.** Postgres DSN, with no password in it. The chart provisions no database |
| `database.passwordSecret.name` | `""` | Secret supplying `PGPASSWORD`. With the Zalando postgres operator, the Secret it generates for the role |
| `database.passwordSecret.key` | `password` | Key within that Secret |
+2 -2
View File
@@ -101,5 +101,5 @@ A login expires after 10 minutes. `GET /api/auth/config` reports `device_login`.
use) and password login is the way in. With `TERDUT_PASSWORD_LOGIN=false` that way
is closed: set it back to `true`. The first administrator comes from the bootstrap
endpoint, and stays a manual administrator that no group can revoke; on an SSO-only
install set `bootstrap.enabled: false` in the chart if you don't want that account,
or keep it and never give it a password.
install set `bootstrap.enabled: false` in the chart if you don't want that account;
left on, the chart creates it with a generated password kept in the `<release>-admin-key` Secret.