From 1a8984e8517b17505e59a286d2c176ad00807564 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Mon, 6 Jul 2026 19:37:58 +0200 Subject: [PATCH] feat(deploy): staging bootstrap script + manage-token minter for Phase-1 VPS deploy --- deploy/scripts/bootstrap-staging.sh | 139 +++++++++++++++++++++++++ relay-run/scripts/mint-manage-token.ts | 56 ++++++++++ 2 files changed, 195 insertions(+) create mode 100755 deploy/scripts/bootstrap-staging.sh create mode 100644 relay-run/scripts/mint-manage-token.ts diff --git a/deploy/scripts/bootstrap-staging.sh b/deploy/scripts/bootstrap-staging.sh new file mode 100755 index 0000000..715ce52 --- /dev/null +++ b/deploy/scripts/bootstrap-staging.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# +# RELAY-PHASE1 · staging bootstrap (mode B — self-signed TLS, no Let's Encrypt yet). +# +# Idempotent one-shot that prepares EVERYTHING the two services need to boot, EXCEPT starting them: +# 1. strong random POSTGRES_PASSWORD + OPERATOR_PASSWORD (persisted 0600, reused on re-run) +# 2. P5 capability keypair (deploy/scripts/gen-capability-key.sh) +# 3. private agent-enrollment CA (deploy/scripts/gen-agent-ca.sh) +# 4. self-signed browser :443 cert (mode B stand-in for issue-tls-cert.sh) +# 5. /etc/relay/control-plane.env + /etc/relay/relay.env — the REAL env contract read by the code +# (control-plane/src/env.ts + relay-run/src/main-phase1.ts), which is a SUPERSET of .env.example: +# · relay needs AGENT_SERVER_CERT_PATH / AGENT_SERVER_KEY_PATH / ALLOWED_ORIGINS (template omits) +# · relay hosts the STAGING /auth/mint, so it needs CAPABILITY_SIGN_PRIVKEY (base64 PKCS#8 DER) +# + MINT_RATE_SALT. In Phase 1 the relay IS a token minter (staging shortcut); Phase 2 = WebAuthn. +# 6. deploy/.env for docker-compose, then `docker compose up -d` Postgres + Redis (loopback-only). +# +# Secrets are generated in place (0600); NOTHING secret is printed except OPERATOR_PASSWORD (once). +# Private keys are NEVER printed. Config via ENV (staging defaults for THIS deploy): +# BASE_DOMAIN (yaojia.wang) SUBDOMAIN (kai) SERVER_IP (8.138.1.192) +# RELAY_TRUST_DOMAIN (relay.yaojia.wang) RELAY_NODE_ID (relay-1) REPO (repo root) +# +# Verify (syntax): bash -n deploy/scripts/bootstrap-staging.sh +set -euo pipefail + +BASE_DOMAIN="${BASE_DOMAIN:-yaojia.wang}" +SUBDOMAIN="${SUBDOMAIN:-kai}" +SERVER_IP="${SERVER_IP:-8.138.1.192}" +RELAY_TRUST_DOMAIN="${RELAY_TRUST_DOMAIN:-relay.${BASE_DOMAIN}}" +RELAY_NODE_ID="${RELAY_NODE_ID:-relay-1}" +FQDN="${SUBDOMAIN}.${BASE_DOMAIN}" + +# Repo root = two levels up from this script, unless overridden. +SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +REPO="${REPO:-$SELF}" + +ETC=/etc/relay +CAP_DIR="${ETC}/capability" +CA_DIR="${ETC}/ca" +TLS_DIR="${ETC}/tls" +SECRETS="${ETC}/secrets.env" + +command -v openssl >/dev/null 2>&1 || { echo "FATAL: openssl not on PATH" >&2; exit 3; } +command -v docker >/dev/null 2>&1 || { echo "FATAL: docker not on PATH" >&2; exit 3; } + +umask 077 +mkdir -p "${ETC}" "${TLS_DIR}" + +echo "== [1/6] passwords (persisted, reused on re-run) ==" +# shellcheck disable=SC1090 +[[ -f "${SECRETS}" ]] && source "${SECRETS}" +POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9' | head -c 32)}" +OPERATOR_PASSWORD="${OPERATOR_PASSWORD:-$(openssl rand -base64 18 | tr -dc 'A-Za-z0-9' | head -c 24)}" +MINT_RATE_SALT="${MINT_RATE_SALT:-$(openssl rand -hex 16)}" +{ + echo "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}" + echo "OPERATOR_PASSWORD=${OPERATOR_PASSWORD}" + echo "MINT_RATE_SALT=${MINT_RATE_SALT}" +} > "${SECRETS}" +chmod 600 "${SECRETS}" + +echo "== [2/6] P5 capability keypair ==" +KEY_DIR="${CAP_DIR}" bash "${REPO}/deploy/scripts/gen-capability-key.sh" > "${ETC}/capability-summary.txt" 2>&1 \ + || { echo "FATAL: gen-capability-key.sh failed:" >&2; cat "${ETC}/capability-summary.txt" >&2; exit 1; } +CAP_B64="$(grep -oE 'CAPABILITY_SIGN_PUBKEY_B64=[^ ]+' "${ETC}/capability-summary.txt" | head -1 | cut -d= -f2-)" +CAP_B64URL="$(grep -oE 'RELAY_AUTH_VERIFY_PUBKEY=[^ ]+' "${ETC}/capability-summary.txt" | head -1 | cut -d= -f2-)" +[[ -n "${CAP_B64}" && -n "${CAP_B64URL}" ]] || { echo "FATAL: could not parse capability pubkeys" >&2; exit 1; } +# The relay ALSO mints operator tokens (staging /auth/mint) → it needs the private key as base64 PKCS#8 DER. +CAP_PRIV_B64="$(openssl pkey -in "${CAP_DIR}/capability-sign.key.pem" -outform DER | base64 | tr -d '\n')" + +echo "== [3/6] private agent-enrollment CA (NOT Let's Encrypt) ==" +CA_DIR="${CA_DIR}" AGENT_FACING_HOST="${FQDN}" AGENT_FACING_IP="${SERVER_IP}" \ + RELAY_TRUST_DOMAIN="${RELAY_TRUST_DOMAIN}" \ + bash "${REPO}/deploy/scripts/gen-agent-ca.sh" > "${ETC}/ca-summary.txt" 2>&1 \ + || { echo "FATAL: gen-agent-ca.sh failed:" >&2; cat "${ETC}/ca-summary.txt" >&2; exit 1; } + +echo "== [4/6] self-signed browser :443 cert for ${FQDN} (mode B) ==" +if [[ ! -f "${TLS_DIR}/privkey.pem" ]]; then + openssl req -x509 -newkey rsa:2048 -nodes -days 825 \ + -keyout "${TLS_DIR}/privkey.pem" -out "${TLS_DIR}/fullchain.pem" \ + -subj "/CN=${FQDN}" -addext "subjectAltName=DNS:${FQDN},IP:${SERVER_IP}" +fi +chmod 600 "${TLS_DIR}/privkey.pem"; chmod 644 "${TLS_DIR}/fullchain.pem" + +echo "== [5/6] env files (REAL contract from env.ts + main-phase1.ts) ==" +cat > "${ETC}/control-plane.env" < "${ETC}/relay.env" < "${REPO}/deploy/.env" < [--sub ] + * + * Mints a §4.3 `manage` capability token signed by the P5 PRIVATE key, for the deny-by-default + * control-plane admin API (POST /accounts, /accounts/:id/pairing-codes). `aud` MUST equal the CP's + * BASE_DOMAIN (its `expectedAud`). The admin API verifies signature+aud+rights and derives accountId + * ONLY from the token (INV3) — it does NOT require a live DPoP proof — but the issuer still stamps a + * well-formed `cnf.jkt` (from a throwaway ephemeral key) because issueCapabilityToken mandates it. + * TTL is clamped to 60 s, so mint immediately before each admin call. + * + * Prints ONLY the raw token to stdout; the signing key material is never printed. + */ +import { readFileSync } from 'node:fs' +import { issueCapabilityToken } from 'relay-auth' +import { generateEd25519KeyPair, exportEd25519PublicRaw } from 'relay-auth/src/crypto/ed25519.js' +import { jwkThumbprint } from 'relay-auth/src/crypto/thumbprint.js' + +function arg(name: string, fallback?: string): string | undefined { + const i = process.argv.indexOf(`--${name}`) + return i >= 0 && i + 1 < process.argv.length ? process.argv[i + 1] : fallback +} + +async function main(): Promise { + const keyPath = arg('key', '/etc/relay/capability/capability-sign.key.pem') as string + const aud = arg('aud') + const sub = arg('sub', 'bootstrap') as string + const host = arg('host', '_manage_') as string + const rights = (arg('rights', 'manage') as string).split(',').map((r) => r.trim()) + if (aud === undefined || aud.length === 0) { + process.stderr.write('FATAL: --aud is required\n') + process.exit(2) + } + + const pem = readFileSync(keyPath, 'utf8') + const der = Buffer.from(pem.replace(/-----[^-]+-----/g, '').replace(/\s+/g, ''), 'base64') + const signingKey = await globalThis.crypto.subtle.importKey('pkcs8', der, { name: 'Ed25519' }, false, ['sign']) + + const eph = await generateEd25519KeyPair() + const cnfJkt = await jwkThumbprint(await exportEd25519PublicRaw(eph.publicKey)) + const now = Math.floor(Date.now() / 1000) + + const token = await issueCapabilityToken( + // Runtime only reads principal.accountId; a minimal shape is intentional for this staging tool. + { principal: { accountId: sub } as never, aud, host, rights: rights as never, ttlSeconds: 60, cnfJkt }, + signingKey, + now, + ) + process.stdout.write(token) +} + +main().catch((e: unknown) => { + process.stderr.write(`mint failed: ${e instanceof Error ? e.message : String(e)}\n`) + process.exit(1) +})