#!/usr/bin/env bash # Copy the image builder's CLIENT certificate from the cluster into the three repository Actions # secrets the image pipeline reads: BUILDKIT_CA, BUILDKIT_CLIENT_CERT, BUILDKIT_CLIENT_KEY. # # ./infra/gitea/buildkit-client-secret.sh # copy and verify # ./infra/gitea/buildkit-client-secret.sh --check # compare only; changes nothing # # RUNNING IT IS AN OPERATOR ACTION. It writes repository secrets. # # WHERE THE CERTIFICATE COMES FROM. platform/buildkit/certificates.yaml has cert-manager issue # `buildkit-client-ci` from a CA that exists only for the builder; buildkitd admits a connection # only with a certificate signed by that CA. CI jobs hold NO cluster credential (R-SEC-02), so they # cannot read the Secret themselves: this script is the one bridge, run by a human who has both # a kube context and the Gitea credential. # # WHEN TO RUN IT # 1. once, after Flux has applied platform/buildkit (Kustomization `buildkit` Ready, and # `kubectl -n buildkit get certificate buildkit-client-ci` READY=True); # 2. after every renewal of that certificate (duration 1y, renewed 30 days early). Until then # the image pipeline fails LOUDLY at its builder preflight — it never skips. # `--check` tells you whether the copy is stale. # # NOTHING SECRET IS PRINTED. The key goes from `kubectl get secret` into the Gitea secrets API # through shell variables and nowhere else. The script prints only names, sizes and the # certificate's subject and expiry (public data). # # NEEDS: kubectl with a context for the cluster (default k3s-prod), jq, curl, openssl, base64, # and the Gitea credential `git` already has stored for the host — the same requirements as # infra/gitea/conformance-trigger-kubeconfig.sh, run from the same place. set -euo pipefail HOST=gitea.home.keithjackson.dev; OWNER=Home-Network; REPO=home-network-k3s-impl NS=buildkit; SECRET=buildkit-client-ci; KCTX="${KCTX:-k3s-prod}" CHECK=0 for a in "$@"; do case "$a" in --check) CHECK=1 ;; -h|--help) sed -n '2,/^set -euo/p' "${BASH_SOURCE[0]}" | sed '$d' | sed 's/^# \{0,1\}//'; exit 0 ;; *) echo "unknown argument: $a" >&2; exit 2 ;; esac done die() { echo "ERROR: $*" >&2; exit 1; } for t in kubectl jq curl openssl base64 git; do command -v "$t" >/dev/null || die "missing required tool: $t"; done echo "==> reading Secret ${NS}/${SECRET} (context ${KCTX}; values not shown)" js="$(kubectl --context "$KCTX" -n "$NS" get secret "$SECRET" -o json)" \ || die "cannot read ${NS}/${SECRET} — is platform/buildkit deployed and the Certificate Ready?" CA="$(jq -r '.data["ca.crt"] // empty' <<<"$js" | base64 -d)" CRT="$(jq -r '.data["tls.crt"] // empty' <<<"$js" | base64 -d)" KEY="$(jq -r '.data["tls.key"] // empty' <<<"$js" | base64 -d)" unset js [ -n "$CA" ] && [ -n "$CRT" ] && [ -n "$KEY" ] || die "${NS}/${SECRET} lacks ca.crt/tls.crt/tls.key" # Sanity before anything is written: the leaf is a CLIENT cert issued by this CA, and the key # belongs to it. A wrong pairing would otherwise surface only as a TLS failure in CI. openssl verify -CAfile <(printf '%s\n' "$CA") <(printf '%s\n' "$CRT") >/dev/null 2>&1 \ || die "tls.crt does not verify against ca.crt" openssl x509 -noout -ext extendedKeyUsage <<<"$CRT" 2>/dev/null | grep -q "TLS Web Client Authentication" \ || die "tls.crt is not a client-auth certificate" [ "$(openssl x509 -noout -pubkey <<<"$CRT" | openssl sha256)" = "$(openssl pkey -pubout <<<"$KEY" 2>/dev/null | openssl sha256)" ] \ || die "tls.key does not match tls.crt" echo " $(openssl x509 -noout -subject <<<"$CRT") / $(openssl x509 -noout -enddate <<<"$CRT")" creds="$(printf 'protocol=https\nhost=%s\n\n' "$HOST" | git credential fill)" U="$(sed -n 's/^username=//p' <<<"$creds")"; P="$(sed -n 's/^password=//p' <<<"$creds")"; unset creds [ -n "$U" ] && [ -n "$P" ] || die "no stored credential for $HOST" echo "==> authenticating to Gitea as '${U}' (secret not shown)" api() { curl -sS --config <(printf 'user = "%s:%s"\n' "$U" "$P") "$@"; } API="https://${HOST}/api/v1/repos/${OWNER}/${REPO}/actions/secrets" if [ "$CHECK" -eq 1 ]; then # Gitea never returns a secret's value, only its name and creation time. "Stale" therefore # means: the secret was created before the certificate's current notBefore. names="$(api "$API" | jq -c '[.[]? | {name, created_at}]')" nb="$(date -u -d "$(openssl x509 -noout -startdate <<<"$CRT" | cut -d= -f2)" +%s)" rc=0 for n in BUILDKIT_CA BUILDKIT_CLIENT_CERT BUILDKIT_CLIENT_KEY; do c="$(jq -r --arg n "$n" '.[] | select(.name==$n) | .created_at' <<<"$names")" if [ -z "$c" ]; then echo " MISSING $n"; rc=1 elif [ "$(date -u -d "$c" +%s)" -lt "$nb" ]; then echo " STALE $n (stored $c, certificate re-issued since)"; rc=1 else echo " current $n (stored $c)"; fi done exit "$rc" fi put() { # name value local code code="$(api -o /dev/null -w '%{http_code}' -X PUT -H 'Content-Type: application/json' \ -d "$(jq -n --arg d "$2" '{data:$d}')" "${API}/$1")" [ "$code" = 201 ] || [ "$code" = 204 ] || die "PUT $1 failed: HTTP ${code}" echo " stored $1 (${#2} bytes, value not shown)" } echo "==> storing repository Actions secrets" put BUILDKIT_CA "$CA" put BUILDKIT_CLIENT_CERT "$CRT" put BUILDKIT_CLIENT_KEY "$KEY" unset CA CRT KEY # VERIFY BY READING BACK (names only — Gitea never returns values). curl -sS exits 0 on an HTTP # error, which is how set-ci-token.sh once claimed a store that never happened. names="$(api "$API" | jq -r '[.[]?.name] | join(" ")')" for n in BUILDKIT_CA BUILDKIT_CLIENT_CERT BUILDKIT_CLIENT_KEY; do grep -qw "$n" <<<"$names" || die "stored $n but it is NOT listed (${names}) — refusing to claim success" done echo "==> stored and VERIFIED present. Re-run the images workflow (workflow_dispatch) to use them."