#!/usr/bin/env bash
# Seed the `secret-store` namespace that this cluster's ESO ClusterSecretStore
# reads from — the on-prem stand-in for Google Secret Manager.
#
# Runs ON the node, after install.sh. Two kinds of material:
#   GENERATED  everything that is merely a shared random value (JWT key pairs,
#              DB-adjacent client secrets, S3 keys). Nobody outside the cluster
#              needs to know them, so they are made here and never leave.
#   SUPPLIED   the customer's TLS certificate + key, and optionally the CA
#              chain that signed it; the emteria server license (certificate +
#              X25519 key, website `/app/certificate`). This is the only
#              material that has to travel.
#
#   sudo ./seed-secrets.sh --cert <path> --key <path> [--ca-chain <path>] [--force]
#   sudo ./seed-secrets.sh --self-signed <base-domain> [--force]
#   sudo ./seed-secrets.sh --license-cert <file> --license-key <file> [--force]
#   sudo ./seed-secrets.sh ... --rotate-generated      # DESTRUCTIVE, see below
#
# --self-signed generates a 2-year certificate covering api./hub./mdm.<domain>
# on the spot. It is for bring-up before the customer's real certificate exists:
# every device and browser will reject it until the cert (or its CA) is trusted,
# so it is a starting point, not a destination. Swapping in the real one later
# is the same command with --cert/--key --force.
#
# --license-cert is the emteria-signed two-part hex file (a literal `\n` between
# the parts is accepted); --license-key its X25519 private key as 32 raw bytes,
# base64 or hex. Both are checked (signature, key match) before anything is
# written (a UTF-8 BOM is stripped). SODIUM_DEVICE_PUK only has to be a valid
# X25519 public key: it is generated, kept across re-imports, and can be set
# with --device-pubkey <hex>. With no license flags a first run seeds a
# placeholder so the website starts; OS license grants stay disabled until the
# real one is imported.
#
# Idempotent: existing secrets are left alone. Two flags override that, and
# they are deliberately NOT the same flag:
#   --force             replaces the SUPPLIED material only: tls + ca-bundle
#                       when --cert/--self-signed is given, website-license +
#                       website-sodium when --license-* is given. This is the
#                       certificate renewal/swap path and is safe to run on a
#                       live cluster — no generated secret changes.
#   --rotate-generated  deletes and regenerates every GENERATED secret. This
#                       breaks a running installation on purpose: encrypted
#                       CredentialVariable rows become unreadable (productmanager
#                       encryption key), every session and device token is
#                       invalidated (website JWT keys), and backend-to-backend
#                       client_credentials fail until the OpenIddict client row
#                       is updated with the new main-backend secret. Only for
#                       a cluster that is being reset anyway.
# Before 2026-09 `--force` did both, so a certificate swap silently rotated
# everything. Re-running with a new certificate is the renewal procedure — see
# "RENEWAL" at the end.
set -euo pipefail

NS=secret-store
KUBECTL="${KUBECTL:-/usr/local/bin/k3s kubectl}"

# The secret-store keys are prefixed per cluster (`idnow-tls`, …) because the
# ExternalSecrets name them explicitly. Derive it from the bundle rather than
# making the operator remember `PREFIX=…`: getting it wrong seeds a set of
# secrets nothing consumes, and the only symptom is ExternalSecrets that never
# resolve and pods stuck on missing volumes.
if [ -z "${PREFIX:-}" ]; then
  _bundle_txt="$(cd "$(dirname "$0")" && pwd)/BUNDLE.txt"
  if [ -r "$_bundle_txt" ]; then
    PREFIX="$(awk '/^cluster:/{n=split($2,a,"/"); print a[n]}' "$_bundle_txt")"
  fi
  PREFIX="${PREFIX:-onprem}"
fi
echo "── secret-store prefix: ${PREFIX}- ──"
CERT=""; KEY=""; CA_CHAIN=""; FORCE=0; ROTATE_GENERATED=0; SELF_SIGNED=""
LICENSE_CERT=""; LICENSE_KEY=""; DEVICE_PUK=""

while [ $# -gt 0 ]; do
  case "$1" in
    --cert)        CERT="$2"; shift 2 ;;
    --key)         KEY="$2"; shift 2 ;;
    --ca-chain)    CA_CHAIN="$2"; shift 2 ;;
    --self-signed) SELF_SIGNED="$2"; shift 2 ;;
    --license-cert) LICENSE_CERT="$2"; shift 2 ;;
    --license-key)  LICENSE_KEY="$2"; shift 2 ;;
    --device-pubkey) DEVICE_PUK="$2"; shift 2 ;;
    --force)       FORCE=1; shift ;;
    --rotate-generated) ROTATE_GENERATED=1; shift ;;
    *) echo "unknown argument: $1" >&2; exit 1 ;;
  esac
done

die() { echo "ERROR: $*" >&2; exit 1; }

# License-only runs leave TLS alone, so `--force` there cannot touch it.
LICENSE=0; TLS=1
if [ -n "$LICENSE_CERT$LICENSE_KEY" ]; then
  [ -n "$LICENSE_CERT" ] && [ -n "$LICENSE_KEY" ] || die "--license-cert and --license-key go together"
  LICENSE=1
  [ -n "$CERT$SELF_SIGNED" ] || TLS=0
fi

if [ "$ROTATE_GENERATED" = 1 ]; then
  cat >&2 <<EOF

!! --rotate-generated: this DESTROYS state on a running installation !!

  ${PREFIX}-productmanager-encryption-key  every encrypted CredentialVariable
                                           row becomes UNREADABLE — permanently
  ${PREFIX}-website-jwt                    every login session and every device
                                           token is invalidated
  ${PREFIX}-main-backend-client            backend-to-backend client_credentials
                                           fail until the OpenIddict client row
                                           carries the new secret (the OAuth
                                           reconciler Job must be re-run)
  ${PREFIX}-seaweedfs-s3                   storagemanager loses access to every
                                           object until SeaweedFS is re-keyed
  ${PREFIX}-gitops-provisioner-client      the OAuth reconciler cannot
                                           authenticate until re-seeded

There is no undo. To swap a certificate use --force instead; it never touches
generated secrets.
EOF
  if [ "${ROTATE_CONFIRM:-}" != "yes" ]; then
    printf 'Type ROTATE to continue: ' >&2
    read -r _answer </dev/tty || _answer=""
    [ "$_answer" = "ROTATE" ] || die "aborted — nothing was changed"
  fi
fi

# ── license: validate everything before the cluster is touched ──────────────
LIC_DIR="$(mktemp -d)"; chmod 700 "$LIC_DIR"
trap 'rm -rf "$LIC_DIR"' EXIT
hex2bin() { printf '%b' "$(printf '%s' "$1" | sed 's/../\\x&/g')"; }
bin2hex() { od -An -v -tx1 | tr -d ' \n'; }
# X25519 public key of a raw 32-byte private key (fixed PKCS#8 DER header).
X25519_ERR="openssl could not derive the X25519 public key (FIPS mode?)"
x25519_pub() {
  { hex2bin 302e020100300506032b656e04220420; cat "$1"; } \
    | openssl pkey -inform DER -pubout -outform DER 2>/dev/null | tail -c 32 | bin2hex
}
strip_bom() { LC_ALL=C sed '1s/^\xEF\xBB\xBF//' "$1"; }
MASTER_PUK=fc13c0637062519d91f5634d900cdd0d6738c5d23af02392ddfabcee70413481

if [ -n "$DEVICE_PUK" ]; then
  printf '%s' "$DEVICE_PUK" | grep -Eqx '[0-9a-fA-F]{64}' || die "--device-pubkey must be 64 hex characters"
  DEVICE_PUK="$(printf '%s' "$DEVICE_PUK" | tr 'A-F' 'a-f')"
fi

if [ "$LICENSE" = 1 ]; then
  [ -r "$LICENSE_CERT" ] || die "cannot read license certificate: $LICENSE_CERT"
  [ -r "$LICENSE_KEY" ]  || die "cannot read license key: $LICENSE_KEY"
  # Key: 32 raw bytes, 44-char base64 or 64-char hex → raw 32 bytes.
  if [ "$(wc -c < "$LICENSE_KEY")" -eq 32 ]; then
    cat "$LICENSE_KEY" > "$LIC_DIR/server_key"
  else
    strip_bom "$LICENSE_KEY" | tr -d ' \r\n' > "$LIC_DIR/key.txt"
    n="$(wc -c < "$LIC_DIR/key.txt")"
    if [ "$n" -eq 64 ] && ! grep -q '[^0-9a-fA-F]' "$LIC_DIR/key.txt"; then
      hex2bin "$(cat "$LIC_DIR/key.txt")" > "$LIC_DIR/server_key"
    elif [ "$n" -eq 44 ] && ! grep -q '[^A-Za-z0-9+/=]' "$LIC_DIR/key.txt"; then
      base64 -d "$LIC_DIR/key.txt" > "$LIC_DIR/server_key" 2>/dev/null || die "--license-key: invalid base64"
    else
      die "--license-key: expected 32 raw bytes, 44-char base64 or 64-char hex"
    fi
  fi
  [ "$(wc -c < "$LIC_DIR/server_key")" -eq 32 ] || die "--license-key does not decode to 32 bytes"
  SERVER_PUK="$(x25519_pub "$LIC_DIR/server_key")" || die "$X25519_ERR"
  [ ${#SERVER_PUK} -eq 64 ] || die "$X25519_ERR"

  # Certificate: short + extended part, one hex line each (the website splits
  # on a real newline; delivered files often carry a literal `\n`).
  strip_bom "$LICENSE_CERT" | tr -d '\r' | sed 's/\\n/\n/g' | tr -d ' \t' | grep -v '^$' > "$LIC_DIR/server_crt" || true
  n="$(wc -l < "$LIC_DIR/server_crt")"
  [ "$n" -eq 2 ] || die "license certificate: expected 2 hex parts (short, extended), found $n"
  hex2bin "302a300506032b6570032100$MASTER_PUK" \
    | openssl pkey -pubin -inform DER -out "$LIC_DIR/master.pem" 2>/dev/null \
    || die "openssl could not import the emteria Ed25519 master key (FIPS mode?)"
  for i in 1 2; do
    part="$(sed -n "${i}p" "$LIC_DIR/server_crt")"
    printf '%s' "$part" | grep -Eqx '([0-9a-fA-F]{2}){65,}' || die "license part $i is not valid hex"
    hex2bin "$part" > "$LIC_DIR/p$i"
    head -c 64 "$LIC_DIR/p$i" > "$LIC_DIR/sig$i"; tail -c +65 "$LIC_DIR/p$i" > "$LIC_DIR/json$i"
    [ "$(head -c 1 "$LIC_DIR/json$i")" = "{" ] && [ "$(tail -c 1 "$LIC_DIR/json$i")" = "}" ] \
      || die "license part $i: payload is not JSON"
    # Verbatim: the website compares case-sensitively against lowercase hex.
    cert_puk="$(grep -o '"PublicKey" *: *"[0-9a-fA-F]\{64\}"' "$LIC_DIR/json$i" | grep -o '[0-9a-fA-F]\{64\}' || true)"
    [ -n "$cert_puk" ] || die "license part $i: no PublicKey in payload"
    [ "$cert_puk" = "$SERVER_PUK" ] \
      || die "license part $i: PublicKey $cert_puk does not match --license-key (derives $SERVER_PUK) — wrong key file?"
    openssl pkeyutl -verify -pubin -inkey "$LIC_DIR/master.pem" -rawin \
      -in "$LIC_DIR/json$i" -sigfile "$LIC_DIR/sig$i" >/dev/null 2>&1 \
      || die "license part $i: signature does not verify against the emteria master key"
  done
  field() { grep -o "\"$1\" *: *\"\\{0,1\\}[^,}\"]*" "$LIC_DIR/json2" | head -n 1 | sed 's/^"[^"]*" *: *"\{0,1\}//'; }
  expiry="$(field ExpiryDate)"
  expiry_day="$(date -d "$expiry" +%Y%m%d 2>/dev/null)" || die "license: cannot parse ExpiryDate '$expiry'"
  [ "$expiry_day" -ge "$(date +%Y%m%d)" ] || die "license expired on $expiry"
  ! grep -Eq '"Development" *: *true' "$LIC_DIR/json2" || die "license is a Development certificate"
  echo "── license: expires $expiry, LicenseLimit $(field LicenseLimit), TargetTenant $(field TargetTenant) ──"
fi

if [ "$TLS" = 1 ]; then
  if [ -n "$SELF_SIGNED" ]; then
    [ -z "$CERT" ] || die "--self-signed and --cert are mutually exclusive"
    GEN_DIR="/var/lib/emteria/selfsigned"
    mkdir -p "$GEN_DIR"; chmod 700 "$GEN_DIR"
    CERT="$GEN_DIR/tls.crt"; KEY="$GEN_DIR/tls.key"
    if [ ! -s "$CERT" ] || [ "$FORCE" = 1 ]; then
      echo "── generating self-signed certificate for *.${SELF_SIGNED} ──"
      openssl req -x509 -newkey rsa:2048 -nodes -days 730 \
        -keyout "$KEY" -out "$CERT" \
        -subj "/CN=${SELF_SIGNED}" \
        -addext "subjectAltName=DNS:api.${SELF_SIGNED},DNS:hub.${SELF_SIGNED},DNS:mdm.${SELF_SIGNED}" \
        2>/dev/null || die "openssl failed to generate the certificate"
      chmod 600 "$KEY"
    else
      echo "── reusing existing self-signed certificate ($CERT) ──"
    fi
    # Self-signed IS its own CA, so it belongs in the in-cluster trust bundle —
    # otherwise the backend-to-backend https calls (workarounds L1) fail
    # verification against a certificate nothing trusts.
    [ -n "$CA_CHAIN" ] || CA_CHAIN="$CERT"
  fi

  [ -n "$CERT" ] && [ -n "$KEY" ] || die "need --cert/--key, --self-signed <base-domain>, or --license-cert/--license-key"
  [ -r "$CERT" ] || die "cannot read certificate: $CERT"
  [ -r "$KEY" ]  || die "cannot read key: $KEY"

  # Fail on a mismatched pair NOW rather than after ingress-nginx silently serves
  # a default certificate and someone spends an afternoon on it.
  # Compare public keys, not RSA moduli: works for RSA and EC keys alike.
  CERT_MOD="$(openssl x509 -pubkey -noout -in "$CERT" | openssl md5)"
  KEY_MOD="$(openssl pkey -in "$KEY" -pubout 2>/dev/null | openssl md5 || true)"
  if [ -n "$KEY_MOD" ] && [ "$CERT_MOD" != "$KEY_MOD" ]; then
    die "certificate and key do not match"
  fi

  echo "── certificate ──"
  openssl x509 -noout -subject -enddate -in "$CERT" | sed 's/^/   /'
  echo -n "   SANs: "; openssl x509 -noout -ext subjectAltName -in "$CERT" 2>/dev/null \
    | tail -n +2 | tr -d ' ' || echo "(none — clients will reject this certificate)"
  # The MDM broker and both web hosts share this one certificate, so a missing
  # SAN is a silent, post-install failure on whichever host was forgotten.
  for want in api hub mdm; do
    openssl x509 -noout -ext subjectAltName -in "$CERT" 2>/dev/null | grep -q "DNS:${want}\." \
      || echo "   WARN: no SAN starting 'DNS:${want}.' — ${want} will fail TLS"
  done
fi  # TLS

$KUBECTL get ns "$NS" >/dev/null 2>&1 || $KUBECTL create ns "$NS"

exists()  { $KUBECTL -n "$NS" get secret "$1" >/dev/null 2>&1; }
# NB: both must return 0 when they do nothing — a bare `[ ... ] && ...` would
# return 1 and, under `set -e`, abort the whole script on the first secret.
# Supplied material is replaced by --force; generated material ONLY by
# --rotate-generated. Keeping the two apart is the whole point (see header).
prepare_supplied() {
  if [ "$FORCE" = 1 ]; then
    $KUBECTL -n "$NS" delete secret "$1" --ignore-not-found >/dev/null
  fi
  return 0
}
prepare_generated() {
  if [ "$ROTATE_GENERATED" = 1 ]; then
    echo "   ! rotating $1"
    $KUBECTL -n "$NS" delete secret "$1" --ignore-not-found >/dev/null
  fi
  return 0
}
mk()      { echo "   + $1"; $KUBECTL -n "$NS" create secret generic "$@"; }

# ── SUPPLIED: TLS ────────────────────────────────────────────────────────────
# Key names are tls_crt/tls_key (not tls.crt) because ESO's `dataFrom.extract`
# maps them into a Go template, and dots are not valid template identifiers.
if [ "$TLS" = 1 ]; then
  prepare_supplied "${PREFIX}-tls"
  if ! exists "${PREFIX}-tls"; then
    mk "${PREFIX}-tls" --from-file=tls_crt="$CERT" --from-file=tls_key="$KEY" >/dev/null
  fi

  # System trust + the customer's chain, for backend-to-backend https (L1).
  prepare_supplied "${PREFIX}-ca-bundle"
  if ! exists "${PREFIX}-ca-bundle"; then
    TMP="$(mktemp)"
    cat /etc/pki/tls/certs/ca-bundle.crt > "$TMP" 2>/dev/null \
      || cat /etc/ssl/certs/ca-certificates.crt > "$TMP" 2>/dev/null \
      || : > "$TMP"
    [ -n "$CA_CHAIN" ] && cat "$CA_CHAIN" >> "$TMP"
    mk "${PREFIX}-ca-bundle" --from-file=ca_bundle="$TMP" >/dev/null
    rm -f "$TMP"
  fi
fi  # TLS

# ── SUPPLIED: website license + sodium keys ─────────────────────────────────
# server_crt/server_key → /app/certificate/server.{crt,key}; the website reads
# both at startup and would try to WRITE a key into the read-only mount if
# one were missing, so the Secret always carries both (placeholder included).
# --force replaces these only when --license-* is given: a later TLS renewal
# must never swap a real license for a placeholder.
LIC="${PREFIX}-website-license"; SOD="${PREFIX}-website-sodium"
# SODIUM_DEVICE_PUK only has to be a valid X25519 public key: keep the current
# one, else derive one from a throwaway key.
mk_license() {
  if [ -z "$DEVICE_PUK" ]; then
    DEVICE_PUK="$($KUBECTL -n "$NS" get secret "$SOD" -o jsonpath='{.data.SODIUM_DEVICE_PUK}' 2>/dev/null \
      | base64 -d 2>/dev/null || true)"
    printf '%s' "$DEVICE_PUK" | grep -Eqx '[0-9a-f]{64}' || DEVICE_PUK=""
  fi
  if [ -z "$DEVICE_PUK" ]; then
    openssl rand 32 > "$LIC_DIR/device_key"
    DEVICE_PUK="$(x25519_pub "$LIC_DIR/device_key")" || die "$X25519_ERR"
    [ ${#DEVICE_PUK} -eq 64 ] || die "$X25519_ERR"
    rm -f "$LIC_DIR/device_key"
  fi
  $KUBECTL -n "$NS" delete secret "$LIC" "$SOD" --ignore-not-found >/dev/null
  mk "$LIC" --from-file=server_crt="$LIC_DIR/server_crt" --from-file=server_key="$LIC_DIR/server_key" >/dev/null
  mk "$SOD" --from-literal=SODIUM_SERVER_PUK="$SERVER_PUK" --from-literal=SODIUM_DEVICE_PUK="$DEVICE_PUK" >/dev/null
}
if [ "$LICENSE" = 1 ]; then
  if exists "$LIC" && [ "$FORCE" != 1 ]; then
    echo "   = $LIC exists — add --force to replace it"
  else
    mk_license
  fi
elif ! exists "$LIC"; then
  openssl rand 32 > "$LIC_DIR/server_key"; : > "$LIC_DIR/server_crt"
  SERVER_PUK="$(x25519_pub "$LIC_DIR/server_key")" || die "$X25519_ERR"
  [ ${#SERVER_PUK} -eq 64 ] || die "$X25519_ERR"
  mk_license
  echo "   WARN: placeholder license — OS license grants are disabled until the real" >&2
  echo "         certificate is imported with --license-cert/--license-key --force" >&2
elif [ -n "$DEVICE_PUK" ]; then
  echo "   WARN: --device-pubkey ignored without --license-cert/--license-key" >&2
fi

# ── GENERATED ────────────────────────────────────────────────────────────────
# OpenIddict signing + encryption pairs for the website.
prepare_generated "${PREFIX}-website-jwt"
if ! exists "${PREFIX}-website-jwt"; then
  TMP="$(mktemp -d)"
  for kind in signing encryption; do
    openssl req -x509 -newkey rsa:2048 -keyout "$TMP/$kind.key" -out "$TMP/$kind.crt" \
      -days 3650 -nodes -subj "/CN=emteria-${PREFIX}-$kind" 2>/dev/null
  done
  mk "${PREFIX}-website-jwt" \
    --from-file=signing_crt="$TMP/signing.crt" --from-file=signing_key="$TMP/signing.key" \
    --from-file=encryption_crt="$TMP/encryption.crt" --from-file=encryption_key="$TMP/encryption.key" >/dev/null
  rm -rf "$TMP"
fi

# Shared OAuth credential for the .NET mesh (client_credentials between
# services). Consumed by the website via MAIN_BACKEND_CLIENT_SECRET, which is
# what lets us provision it instead of letting the website invent one.
prepare_generated "${PREFIX}-main-backend-client"
exists "${PREFIX}-main-backend-client" || mk "${PREFIX}-main-backend-client" \
  --from-literal=client_id=main_backend_client \
  --from-literal=client_secret="$(openssl rand -hex 24)" >/dev/null

prepare_generated "${PREFIX}-productmanager-encryption-key"
exists "${PREFIX}-productmanager-encryption-key" || mk "${PREFIX}-productmanager-encryption-key" \
  --from-literal=value="$(openssl rand -hex 16)" >/dev/null

prepare_generated "${PREFIX}-seaweedfs-s3"
exists "${PREFIX}-seaweedfs-s3" || mk "${PREFIX}-seaweedfs-s3" \
  --from-literal=accessKey="$(openssl rand -hex 12)" \
  --from-literal=secretKey="$(openssl rand -hex 24)" >/dev/null

prepare_generated "${PREFIX}-gitops-provisioner-client"
exists "${PREFIX}-gitops-provisioner-client" || mk "${PREFIX}-gitops-provisioner-client" \
  --from-literal=value="$(openssl rand -hex 24)" >/dev/null

# Image-pull credential. Images are served by the on-node bundle registry,
# which is unauthenticated — but the pod specs still carry
# `imagePullSecrets: regcred`, so the Secret has to exist or kubelet logs a
# lookup failure on every pod. Empty auths is correct here: there is no
# upstream registry to authenticate to.
prepare_generated "${PREFIX}-regcred"
exists "${PREFIX}-regcred" || mk "${PREFIX}-regcred" \
  --from-literal='.dockerconfigjson={"auths":{}}' >/dev/null

echo
$KUBECTL -n "$NS" get secrets
cat <<EOF

── seeded ─────────────────────────────────────────────────────────────────
ESO republishes within each ExternalSecret's refreshInterval (1h for TLS).
To force it now:
  ${KUBECTL} -n default annotate externalsecret emteria-tls \\
    force-sync=\$(date +%s) --overwrite

RENEWAL (the certificate is customer-supplied and nothing here can renew it):
  1. sudo ./seed-secrets.sh --cert new.crt --key new.key [--ca-chain chain.pem] --force
     ^ --force replaces ONLY the certificate + CA bundle. Generated secrets
       (JWT keys, encryption key, client secrets) are never touched by it.
  2. force-sync as above (or wait an hour)
  3. only if the mdm pin predates e6b01835 (mdm#253, fixed 2026-08-17):
     ${KUBECTL} -n default rollout restart daemonset/mdm
     ^ DaemonSet, not StatefulSet — the on-prem overlays run one broker per
       node. Current pins reload the renewed PEM by themselves.

LICENSE (after an import or replacement — the website reads it at startup):
  for es in website-license website-sodium; do ${KUBECTL} -n default \\
    annotate externalsecret \$es force-sync=\$(date +%s) --overwrite; done
  until [ -n "\$(${KUBECTL} -n default get secret website-license \\
    -o jsonpath='{.data.server_crt}')" ]; do sleep 5; done   # ESO has synced
  ${KUBECTL} -n default rollout restart deployment/website
EOF
