From b7d296f6e9ce6389f976cc5ae0adf148c7554f25 Mon Sep 17 00:00:00 2001 From: Niklas Ye Date: Sat, 10 Oct 2026 14:36:15 +0200 Subject: [PATCH] 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 -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 --- .../templates/bootstrap-job.yaml | 101 ++++++++++++++---- .../templates/bootstrap-rbac.yaml | 6 ++ charts/terdut-server/values.yaml | 7 +- docs/deployment.md | 4 +- docs/single-sign-on.md | 4 +- 5 files changed, 99 insertions(+), 23 deletions(-) diff --git a/charts/terdut-server/templates/bootstrap-job.yaml b/charts/terdut-server/templates/bootstrap-job.yaml index b3adc2b..a64c6b0 100644 --- a/charts/terdut-server/templates/bootstrap-job.yaml +++ b/charts/terdut-server/templates/bootstrap-job.yaml @@ -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 }} diff --git a/charts/terdut-server/templates/bootstrap-rbac.yaml b/charts/terdut-server/templates/bootstrap-rbac.yaml index eb5b408..2cfd55a 100644 --- a/charts/terdut-server/templates/bootstrap-rbac.yaml +++ b/charts/terdut-server/templates/bootstrap-rbac.yaml @@ -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 diff --git a/charts/terdut-server/values.yaml b/charts/terdut-server/values.yaml index e084c23..4268e69 100644 --- a/charts/terdut-server/values.yaml +++ b/charts/terdut-server/values.yaml @@ -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 -admin-key + # secretName overrides the default of -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: "" diff --git a/docs/deployment.md b/docs/deployment.md index 20e1ae9..3a30f0e 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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 `-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 `-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 `-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 | diff --git a/docs/single-sign-on.md b/docs/single-sign-on.md index 6432c0c..068cb09 100644 --- a/docs/single-sign-on.md +++ b/docs/single-sign-on.md @@ -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 `-admin-key` Secret.