feat(deploy): staging bootstrap script + manage-token minter for Phase-1 VPS deploy

This commit is contained in:
Yaojia Wang
2026-07-06 19:37:58 +02:00
parent d77f1ff62c
commit 1a8984e851
2 changed files with 195 additions and 0 deletions

View File

@@ -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" <<EOF
PG_URL=postgres://relay:${POSTGRES_PASSWORD}@127.0.0.1:5432/relay
REDIS_URL=redis://127.0.0.1:6379
CAPABILITY_SIGN_PUBKEY_B64=${CAP_B64}
CA_INTERMEDIATE_KMS_KEY_REF=dev-local
CA_INTERMEDIATE_CERT_PATH=${CA_DIR}/intermediate.cert.pem
NODE_MTLS_TRUST_BUNDLE_PATH=${CA_DIR}/agent-ca.bundle.pem
BASE_DOMAIN=${BASE_DOMAIN}
CP_BIND_HOST=127.0.0.1
CP_BIND_PORT=8080
EOF
chmod 600 "${ETC}/control-plane.env"
cat > "${ETC}/relay.env" <<EOF
BIND_HOST=0.0.0.0
BIND_PORT=443
AGENT_BIND_PORT=8444
TLS_CERT_PATH=${TLS_DIR}/fullchain.pem
TLS_KEY_PATH=${TLS_DIR}/privkey.pem
AGENT_SERVER_CERT_PATH=${CA_DIR}/relay-agent-server.cert.pem
AGENT_SERVER_KEY_PATH=${CA_DIR}/relay-agent-server.key.pem
AGENT_CA_CERT_PATH=${CA_DIR}/agent-ca.cert.pem
AGENT_CA_CHAIN_PATH=${CA_DIR}/agent-ca.bundle.pem
BASE_DOMAIN=${BASE_DOMAIN}
RELAY_NODE_ID=${RELAY_NODE_ID}
RELAY_TRUST_DOMAIN=${RELAY_TRUST_DOMAIN}
ALLOWED_ORIGINS=https://${FQDN}
PG_URL=postgres://relay:${POSTGRES_PASSWORD}@127.0.0.1:5432/relay
REDIS_URL=redis://127.0.0.1:6379
RELAY_AUTH_VERIFY_PUBKEY=${CAP_B64URL}
OPERATOR_PASSWORD=${OPERATOR_PASSWORD}
CAPABILITY_SIGN_PRIVKEY=${CAP_PRIV_B64}
MINT_RATE_SALT=${MINT_RATE_SALT}
EOF
chmod 600 "${ETC}/relay.env"
echo "== [6/6] docker compose up -d (Postgres + Redis, loopback-only) =="
cat > "${REPO}/deploy/.env" <<EOF
POSTGRES_DB=relay
POSTGRES_USER=relay
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
EOF
chmod 600 "${REPO}/deploy/.env"
docker compose --env-file "${REPO}/deploy/.env" -f "${REPO}/deploy/docker-compose.yml" up -d
cat <<SUMMARY
=== bootstrap OK ===
FQDN : ${FQDN}
capability pubkey : b64 ${#CAP_B64} chars / b64url ${#CAP_B64URL} chars (same 32 bytes)
env files (0600) : ${ETC}/control-plane.env ${ETC}/relay.env
OPERATOR_PASSWORD : ${OPERATOR_PASSWORD} <-- browser login; shown once, also in ${SECRETS} (0600)
Next: start control-plane + relay, then mint a manage token to create an account + pairing code.
SUMMARY

View File

@@ -0,0 +1,56 @@
/**
* RELAY-PHASE1 · staging admin-token minter (run from relay-run/ so relay-auth resolves).
*
* cd relay-run && npx tsx scripts/mint-manage-token.ts --aud <BASE_DOMAIN> [--sub <accountId>]
*
* 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<void> {
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 <BASE_DOMAIN> 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)
})