Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6292ad6708 | |||
| 90d94b741d | |||
| fcc4997dee | |||
| b7d296f6e9 | |||
| c9f8494b00 | |||
| 3173abfba2 |
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
# in per platform. The CI runner has no binfmt registration and no way to get one (the
|
# in per platform. The CI runner has no binfmt registration and no way to get one (the
|
||||||
# JS action that used to install it cannot run there), so this is not just an
|
# JS action that used to install it cannot run there), so this is not just an
|
||||||
# optimisation -- it is what makes the arm64 image buildable at all.
|
# optimisation -- it is what makes the arm64 image buildable at all.
|
||||||
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder
|
FROM --platform=$BUILDPLATFORM golang:1.26.9-alpine AS builder
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|||||||
@@ -15,5 +15,5 @@ type: application
|
|||||||
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
|
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
|
||||||
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
|
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
|
||||||
# metadata and drives nothing.
|
# metadata and drives nothing.
|
||||||
version: 0.44.0
|
version: 0.45.1
|
||||||
appVersion: "v0.44.0"
|
appVersion: "v0.45.1"
|
||||||
|
|||||||
@@ -42,10 +42,29 @@ spec:
|
|||||||
- |
|
- |
|
||||||
SERVICE_URL="http://{{ include "terdut-server.fullname" . }}:{{ .Values.service.port }}"
|
SERVICE_URL="http://{{ include "terdut-server.fullname" . }}:{{ .Values.service.port }}"
|
||||||
SECRET_NAME="{{ include "terdut-server.bootstrapSecretName" . }}"
|
SECRET_NAME="{{ include "terdut-server.bootstrapSecretName" . }}"
|
||||||
|
USERNAME="{{ .Values.bootstrap.username }}"
|
||||||
|
EMAIL="{{ .Values.bootstrap.email }}"
|
||||||
K8S_API="https://kubernetes.default.svc"
|
K8S_API="https://kubernetes.default.svc"
|
||||||
SA_TOKEN="$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)"
|
SA_TOKEN="$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)"
|
||||||
CA_CERT="/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
|
CA_CERT="/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
|
||||||
NAMESPACE="$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace)"
|
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..."
|
echo "Waiting for terdut-server to be ready..."
|
||||||
RETRIES=60
|
RETRIES=60
|
||||||
@@ -60,42 +79,86 @@ spec:
|
|||||||
fi
|
fi
|
||||||
echo "Server is ready."
|
echo "Server is ready."
|
||||||
|
|
||||||
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$SERVICE_URL/api/bootstrap" \
|
# The Secret is the source of truth for the administrator's password: reuse
|
||||||
-H "Content-Type: application/json" \
|
# the one it holds, so recreating the database brings the same account back.
|
||||||
-d '{"username":"{{ .Values.bootstrap.username }}","email":"{{ .Values.bootstrap.email }}"}')
|
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)
|
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
|
||||||
BODY=$(echo "$RESPONSE" | head -1)
|
RESP_BODY=$(echo "$RESPONSE" | head -1)
|
||||||
|
|
||||||
if [ "$HTTP_CODE" = "403" ]; then
|
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
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$HTTP_CODE" != "201" ]; then
|
if [ "$HTTP_CODE" != "201" ]; then
|
||||||
echo "Bootstrap failed (HTTP $HTTP_CODE): $BODY"
|
echo "Bootstrap failed (HTTP $HTTP_CODE): $RESP_BODY"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
if [ -z "$API_KEY" ]; then
|
||||||
echo "Failed to extract API key from response."
|
echo "Failed to extract API key from response."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Bootstrap succeeded. Storing API key in secret '$SECRET_NAME'."
|
echo "Bootstrap succeeded. Storing the API key in secret '$SECRET_NAME'."
|
||||||
|
kapi PATCH "$SECRETS/$SECRET_NAME" "{\"stringData\":{\"api-key\":\"$API_KEY\"}}"
|
||||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
|
if [ "$CODE" != "200" ]; then
|
||||||
-X POST "$K8S_API/api/v1/namespaces/$NAMESPACE/secrets" \
|
echo "Failed to store the API key (HTTP $CODE)."
|
||||||
--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)."
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Secret '$SECRET_NAME' created successfully."
|
echo "Secret '$SECRET_NAME' holds username, password and api-key."
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ rules:
|
|||||||
- apiGroups: [""]
|
- apiGroups: [""]
|
||||||
resources: ["secrets"]
|
resources: ["secrets"]
|
||||||
verbs: ["create"]
|
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
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
kind: RoleBinding
|
kind: RoleBinding
|
||||||
|
|||||||
@@ -160,9 +160,14 @@ oidc:
|
|||||||
# Backups are not this chart's business: Postgres is backed up where it runs,
|
# 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.
|
# 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:
|
bootstrap:
|
||||||
enabled: true
|
enabled: true
|
||||||
username: admin
|
username: admin
|
||||||
email: admin@example.com
|
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: ""
|
secretName: ""
|
||||||
|
|||||||
+3
-1
@@ -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.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.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` |
|
| `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.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.name` | `""` | Secret supplying `PGPASSWORD`. With the Zalando postgres operator, the Secret it generates for the role |
|
||||||
| `database.passwordSecret.key` | `password` | Key within that Secret |
|
| `database.passwordSecret.key` | `password` | Key within that Secret |
|
||||||
|
|||||||
@@ -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
|
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
|
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
|
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,
|
install set `bootstrap.enabled: false` in the chart if you don't want that account;
|
||||||
or keep it and never give it a password.
|
left on, the chart creates it with a generated password kept in the `<release>-admin-key` Secret.
|
||||||
|
|||||||
@@ -998,6 +998,8 @@ kbd {
|
|||||||
.admin-settings .setting-value { width: 5.5em; margin-right: 6px; }
|
.admin-settings .setting-value { width: 5.5em; margin-right: 6px; }
|
||||||
.admin-settings .setting-unit { max-width: 8em; }
|
.admin-settings .setting-unit { max-width: 8em; }
|
||||||
.admin-settings button[type="submit"] { margin-top: 12px; }
|
.admin-settings button[type="submit"] { margin-top: 12px; }
|
||||||
|
/* The environment section follows the form in the same card; h3 has no margin of its own. */
|
||||||
|
.admin-settings + h3 { margin-top: 24px; }
|
||||||
.small { font-size: 13px; }
|
.small { font-size: 13px; }
|
||||||
|
|
||||||
/* A name in an admin table is the way to that row's own page -- a person's or
|
/* A name in an admin table is the way to that row's own page -- a person's or
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ function render() {
|
|||||||
if (!data) {
|
if (!data) {
|
||||||
clear(view(), error
|
clear(view(), error
|
||||||
? h('div', { class: 'load-error', text: error })
|
? h('div', { class: 'load-error', text: error })
|
||||||
: h('div', { class: 'card' }, h('p', { class: 'muted', text: 'You are not in a team yet.' })));
|
: h('div', { class: 'card card-pad' }, h('p', { class: 'muted', text: 'You are not in a team yet.' })));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
clear(view(),
|
clear(view(),
|
||||||
|
|||||||
Reference in New Issue
Block a user