feat(deploy): staging bootstrap script + manage-token minter for Phase-1 VPS deploy
This commit is contained in:
139
deploy/scripts/bootstrap-staging.sh
Executable file
139
deploy/scripts/bootstrap-staging.sh
Executable 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
|
||||
56
relay-run/scripts/mint-manage-token.ts
Normal file
56
relay-run/scripts/mint-manage-token.ts
Normal 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)
|
||||
})
|
||||
Reference in New Issue
Block a user