diff --git a/deploy/frp/README.md b/deploy/frp/README.md new file mode 100644 index 0000000..4fd1dd8 --- /dev/null +++ b/deploy/frp/README.md @@ -0,0 +1,73 @@ +# Native mTLS Tunnel — per-host runbook (V7) + +Bring one local host onto the tunnel so native clients (iOS / Android / desktop) can reach its base +app at `https://.terminal.yaojia.wang` over the VPS, gated **only** by device-certificate mTLS. +See `docs/PLAN_NATIVE_TUNNEL.md` for the full design; this is the short operational checklist. + +**Trust model: A — single-owner fleet** (all devices/hosts are the operator's). A shared device-CA +means "the closed set of my devices"; there is **no cross-tenant isolation** — do not enroll +distrusting third parties on this CA (that is Model B, out of scope for v1). + +## Files here + +| File | Install target on the VPS | Purpose | +|---|---|---| +| `frps.toml.example` | `/etc/relay/frp/frps.toml` (0600) | frps control `:7000` + vhost `:7080` | +| `frpc.example.toml` | on each LOCAL host (0600) | dials the VPS, exposes `127.0.0.1:3000` | +| `../nginx/frp-mtls.conf` | `/etc/nginx/conf.d/frp-mtls.conf` | `:8470` TLS-term + device-CA mTLS + WS proxy | +| `../nginx/stream-sni-additions.md` | (merge into the live stream map) | the two additive SNI routes | + +## One-time VPS setup (summarised — see PLAN V1–V5) + +1. **frps** (`frps.toml.example` → `/etc/relay/frp/frps.toml`): set `auth.token` = `openssl rand -hex 32`. +2. **frp-client CA** (control-channel mTLS): `bash deploy/scripts/gen-device-ca.sh --kind frp-client`. +3. **device CA** (data-path mTLS): `bash deploy/scripts/gen-device-ca.sh` (KIND=device default). +4. **LE wildcard** `*.terminal.yaojia.wang` (V3) → `/etc/relay/frp-tls/{fullchain,privkey}.pem`. +5. **nginx** `:8470` (`frp-mtls.conf`) + the two stream SNI lines (`stream-sni-additions.md`), then + `nginx -t && systemctl reload nginx`. + +## Onboard a host (the repeatable part) + +1. **frp-client cert** for this host (control mTLS), issued from the frp-client CA: + ```bash + CA_DIR=/etc/relay/frp-client-ca bash deploy/scripts/issue-device-cert.sh ./out- + # use out-/.cert.pem + .key.pem for frpc.certFile/keyFile (ignore the .p12) + ``` +2. **frpc.toml** on the host from `frpc.example.toml`: set `subdomain=`, the shared + `auth.token`, and the frp-client cert/key paths; `chmod 600 frpc.toml`. +3. **Base app** on the host — the tunnel is safe ONLY with these: + | Var | Value | Why | + |---|---|---| + | `BIND_HOST` | `127.0.0.1` | **Mandatory.** Default `0.0.0.0` exposes an unauth'd shell on the LAN, bypassing mTLS. | + | `PORT` | `3000` | frpc dials `127.0.0.1:3000`. | + | `ALLOWED_ORIGINS` | `https://.terminal.yaojia.wang` | bare host; `:443` also matches (normalized). | + | `IDLE_TTL` | `≥ 86400` | walk-away survival. | + | `USE_TMUX` | `1` (*nix) / `0` (Windows) | cross-restart PTY survival. | +4. **Device cert** for whoever will connect (data-path mTLS), issued from the device CA: + ```bash + P12_PASSWORD='...' bash deploy/scripts/issue-device-cert.sh ./out-dev + # deliver out-dev/<...>.p12 SECURELY: scp / AirDrop / Files — NEVER email. + ``` +5. **Verify** (M1): no-cert → TLS handshake failure; `curl --cert …/.cert.pem --key …/.key.pem + -sI https://.terminal.yaojia.wang/live-sessions` → `200`. + +## Revoke a device + +```bash +CA_DIR=/etc/relay/device-ca bash deploy/scripts/revoke-device.sh /path/to/.cert.pem +# copy the regenerated /etc/relay/device-ca/crl.pem to the VPS, then ON THE VPS: +nginx -t && nginx -s reload +``` + +The revoked cert is rejected fleet-wide the moment nginx reloads the CRL. + +## Trust-boundary note (Model A) + +- The **frp-client cert + token is a trusted secret**: leaking a host's frp-client cert **and** the + shared token lets an attacker register a subdomain (subdomain-takeover / MITM). Keep both `chmod 600`; + deliver over scp, never email. Revoke a compromised host by rotating the `frp-client-ca` trust + (re-issue the remaining hosts) — device certs are unaffected. +- The **device cert** is the data-path gate. Under Model A any valid device cert can reach any + subdomain and can read `/live-sessions/:id/preview` scrollback — acceptable because every device is + yours. Do not extend this CA to third parties without moving to Model B (per-tenant CA). +- **P-256, never Ed25519** for both CAs (nginx `ssl_verify_client` + iOS client-cert compatibility). diff --git a/deploy/frp/frpc.example.toml b/deploy/frp/frpc.example.toml new file mode 100644 index 0000000..ddcabb8 --- /dev/null +++ b/deploy/frp/frpc.example.toml @@ -0,0 +1,31 @@ +# frpc.toml — per-host client (Native mTLS tunnel · V7 / M1 / S1). +# +# Copy to the LOCAL host that runs the base app, fill the , chmod 600. +# TEMPLATE ONLY — NO REAL SECRETS committed. frpc dials OUT to the VPS :443; nothing is opened +# inbound on the local host. +# +# frps ≥ v0.52 required (`disableCustomTLSFirstByte` exists ≥ v0.50) — it makes frpc send a real TLS +# ClientHello first byte (0x16) so the VPS nginx `ssl_preread` stream can SNI-route it (PLAN M1 #6). + +serverAddr = "8.138.1.192" +serverPort = 443 + +auth.method = "token" +auth.token = "" # same token as frps.toml (/etc/relay/secrets.env) + +# --- control-channel TLS: present THIS host's frp-client cert to frps; verify the frps server cert --- +transport.tls.enable = true +transport.tls.serverName = "frp.terminal.yaojia.wang" # SNI the stream routes to frps :7000 +transport.tls.disableCustomTLSFirstByte = true # CRITICAL for ssl_preread (real ClientHello first) +transport.tls.certFile = "" # issued by gen-device-ca.sh --kind frp-client + issue-device-cert.sh +transport.tls.keyFile = "" # (0600) +transport.tls.trustedCaFile = "" # CA that signed frps-ctrl.cert.pem (V1) + +loginFailExit = false # keep retrying if the VPS/frps is briefly down + +[[proxies]] +name = "" # unique per host; frps rejects duplicate subdomains +type = "http" +localIP = "127.0.0.1" # base app must bind loopback (BIND_HOST=127.0.0.1) +localPort = 3000 +subdomain = "" # reachable at https://.terminal.yaojia.wang diff --git a/deploy/frp/frps.toml.example b/deploy/frp/frps.toml.example new file mode 100644 index 0000000..3f50d14 --- /dev/null +++ b/deploy/frp/frps.toml.example @@ -0,0 +1,29 @@ +# frps.toml — VPS control server (Native mTLS tunnel · V1). +# +# Install target on the VPS: /etc/relay/frp/frps.toml (chmod 600, owned frp:frp). +# This is a TEMPLATE with PLACEHOLDERS ONLY — NO REAL SECRETS are committed. +# +# auth.token: replace with `openssl rand -hex 32`, stored in /etc/relay/secrets.env +# (0600). The token is NOT the sole gate — control-channel mTLS below is required too, +# so a leaked token alone cannot register a subdomain (PLAN R3 / V1b, Model A). +# +# Both ports are LOOPBACK-only (nginx stream terminates/routes :443 → these; they must stay absent +# from the Aliyun security group — PLAN P-B). + +bindAddr = "127.0.0.1" +bindPort = 7000 # control channel (SNI frp.terminal.yaojia.wang → here, TLS passthrough) +vhostHTTPPort = 7080 # vhost HTTP (nginx :8470 proxies verified requests here by Host) + +auth.method = "token" +auth.token = "" + +subDomainHost = "terminal.yaojia.wang" + +# --- control-channel mTLS: token is NOT the sole gate (per-host frp-client CA) --- +transport.tls.force = true +transport.tls.certFile = "/etc/relay/frp-tls/frps-ctrl.cert.pem" # frps control SERVER cert (V1) +transport.tls.keyFile = "/etc/relay/frp-tls/frps-ctrl.key.pem" # (0600) +transport.tls.trustedCaFile = "/etc/relay/frp-client-ca/frp-client-ca.cert.pem" # verifies each host's frpc cert + +log.to = "/var/log/frp/frps.log" +log.level = "info" diff --git a/deploy/nginx/frp-mtls.conf b/deploy/nginx/frp-mtls.conf new file mode 100644 index 0000000..c6ef6ec --- /dev/null +++ b/deploy/nginx/frp-mtls.conf @@ -0,0 +1,44 @@ +# frp-mtls.conf — nginx :8470 TLS-termination + device-CA mTLS + WS-upgrade proxy to frps vhost. +# +# Native mTLS tunnel · V4. Install target: /etc/nginx/conf.d/frp-mtls.conf +# This is a conf.d snippet (safe to ADD — it only introduces a NEW loopback :8470 server + one map; +# it does NOT touch the existing stream :443 map, which is edited separately per stream-sni-additions.md). +# +# Data-path security gate: `ssl_verify_client on` against the DEVICE CA (with CRL) is the ONE gate for +# the tunnelled base app. `on`, NEVER `optional` (PLAN P-D / R2). The public :8470 SERVER cert is the +# LE wildcard (V3); the CLIENT trust anchor is the private device CA (V2) — two different chains. +# +# nginx 1.24: there is NO standalone `http2` directive here and NO `http2 off;` — HTTP/2 is off by +# default on this listen and `http2 off;` is INVALID on 1.24 (would fail `nginx -t`, PLAN R7). + +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 127.0.0.1:8470 ssl; + server_name ~^.+\.terminal\.yaojia\.wang$; + + ssl_certificate /etc/relay/frp-tls/fullchain.pem; # LE wildcard *.terminal.yaojia.wang (V3) + ssl_certificate_key /etc/relay/frp-tls/privkey.pem; + + # --- the data-path security gate --- + ssl_verify_client on; # ON, never `optional` + ssl_client_certificate /etc/relay/device-ca/device-ca.cert.pem; + ssl_verify_depth 1; + ssl_crl /etc/relay/device-ca/crl.pem; # revocation from day one (V2) + + location / { + proxy_pass http://127.0.0.1:7080; # frps vhost — routes by Host → subdomain + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Origin https://$host; # needed for Android/curl; harmless for iOS + proxy_set_header X-Client-Cert-CN $ssl_client_s_dn; + proxy_buffering off; + proxy_read_timeout 3600s; # prevents idle-WS proxy kill (default 60s, R6) + proxy_send_timeout 3600s; + } +} diff --git a/deploy/nginx/stream-sni-additions.md b/deploy/nginx/stream-sni-additions.md new file mode 100644 index 0000000..107e171 --- /dev/null +++ b/deploy/nginx/stream-sni-additions.md @@ -0,0 +1,45 @@ +# Stream SNI additions (Native mTLS tunnel · V5) + +Two SNI routes must be **merged into the existing** `stream { map $ssl_preread_server_name … }` +block on the VPS (`/etc/nginx/stream-relay.conf`). They add the frp control channel and the +`*.terminal` data path to the single shared `:443` `ssl_preread` listener. + +> **This is a MERGE, not a file to ship.** Do **not** drop a full stream conf here — that would +> clobber the live one. Only the two `map` body lines below are new. + +## The two additive lines + +Add these **inside the existing `map` body** (exact match wins over wildcard, so order is not +load-bearing, but keep the exact `frp.terminal` line above the `*.terminal` wildcard for clarity): + +```nginx +frp.terminal.yaojia.wang 127.0.0.1:7000; # frps control channel (TLS passthrough) +*.terminal.yaojia.wang 127.0.0.1:8470; # nginx :8470 TLS-term + device-CA mTLS (frp-mtls.conf) +``` + +## Coexistence warning (do NOT retype the existing lines) + +The existing `map` body already contains — and MUST be preserved **verbatim**: + +```nginx +# ... existing hostnames directive ... +*.term.yaojia.wang 127.0.0.1:8443; # E2E browser relay — DO NOT RETYPE / REORDER +default 127.0.0.1:10443; # xray Reality — DO NOT RETYPE / REORDER +``` + +- `*.term.yaojia.wang` (browser relay) and `*.terminal.yaojia.wang` (this tunnel) are **different + parent labels** — `term` vs `terminal`. `ssl_preread` matches the full SNI, so they never collide. +- Re-type nothing you did not author. Capture the current body first + (`nginx -T | sed -n '/map \$ssl_preread_server_name/,/}/p'`), then append only the two lines above. + +## Deploy gate + +```bash +cp /etc/nginx/stream-relay.conf{,.bak.$(date +%s)} # snapshot first +# ...edit: append the two lines into the map body... +nginx -t && systemctl reload nginx # NEVER reload on a failed -t +``` + +After reload, run the 4-zone SNI oracle (`openssl s_client -servername …` for +`frp.terminal` / `.terminal` / `.term` / default) to confirm all four routes still +resolve to the right backend (PLAN V5 / M1 #7). diff --git a/deploy/scripts/gen-device-ca.sh b/deploy/scripts/gen-device-ca.sh new file mode 100755 index 0000000..bdf2806 --- /dev/null +++ b/deploy/scripts/gen-device-ca.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# +# RELAY-PHASE1 · Native mTLS tunnel · V2 — Device client-CA (and frp-client control-CA). +# +# STAGING TEMPLATE. Mints a PRIVATE client-authentication CA whose leaves the native clients (iOS / +# Android / desktop) present at nginx :8470 (`ssl_verify_client on`). This CA is the ONE AND ONLY +# security gate for the tunnelled base app (the base app has no login of its own — PLAN §1), so it +# MUST be a SEPARATE trust root from Let's Encrypt (the public :8470 SERVER cert) and from the +# agent-enrollment CA (deploy/scripts/gen-agent-ca.sh). Mixing them would let a foreign leaf enroll. +# +# *** P-256 (prime256v1), NEVER Ed25519. *** nginx `ssl_verify_client` + iOS client-certificate +# selection require ECDSA-P256/RSA; Ed25519 client certs are not universally accepted (PLAN V2). +# +# Two KINDs (same structure, different name/subject/default dir): +# device → /etc/relay/device-ca (device-ca.cert.pem) leaves = client devices (V2/V4) +# frp-client → /etc/relay/frp-client-ca (frp-client-ca.cert.pem) leaves = per-host frpc control mTLS (V1/V1b) +# +# Produces an OpenSSL `ca` database (index.txt / serial / crlnumber / newcerts / openssl.cnf) so that +# deploy/scripts/issue-device-cert.sh can sign tracked leaves and deploy/scripts/revoke-device.sh can +# revoke by serial and regenerate the CRL. An EMPTY crl.pem is initialised NOW so V4's `ssl_crl` has a +# file to reference from day one. +# +# Outputs (0600 key, 0644 cert) under CA_DIR: +# .key.pem .cert.pem crl.pem +# index.txt serial crlnumber index.txt.attr newcerts/ openssl.cnf (the `openssl ca` db) +# +# Config via ENV or flags (no hardcoded secrets — this CA key is generated in place, never committed): +# KIND device | frp-client (default device) — or --kind +# CA_DIR output dir (default per-KIND above) — or --dir +# CA_DAYS CA lifetime in days (default 3650) +# +# Idempotent: an existing CA (key+cert) is REUSED, never overwritten (delete the dir to rotate). +# +# Verify (syntax): bash -n deploy/scripts/gen-device-ca.sh +set -euo pipefail + +KIND="${KIND:-device}" +CA_DIR="${CA_DIR:-}" +CA_DAYS="${CA_DAYS:-3650}" + +# ---- args (flags override env) -------------------------------------------------------------------- +while [[ $# -gt 0 ]]; do + case "$1" in + --kind) KIND="${2:?--kind needs a value}"; shift 2 ;; + --dir) CA_DIR="${2:?--dir needs a value}"; shift 2 ;; + -h|--help) + grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "FATAL: unknown argument '$1' (see --help)" >&2; exit 2 ;; + esac +done + +case "${KIND}" in + device) CA_NAME="device-ca"; CA_CN="web-terminal Device CA"; CA_OU="device-ca" + CA_DIR="${CA_DIR:-/etc/relay/device-ca}" ;; + frp-client) CA_NAME="frp-client-ca"; CA_CN="web-terminal frp-client CA"; CA_OU="frp-client-ca" + CA_DIR="${CA_DIR:-/etc/relay/frp-client-ca}" ;; + *) echo "FATAL: KIND must be 'device' or 'frp-client' (got '${KIND}')." >&2; exit 2 ;; +esac + +command -v openssl >/dev/null 2>&1 || { echo "FATAL: openssl not found on PATH." >&2; exit 3; } + +umask 077 +mkdir -p "${CA_DIR}/newcerts" +# Resolve to an absolute path so `openssl ca` (which we run cd'd into CA_DIR with dir=.) is stable. +CA_DIR="$(cd "${CA_DIR}" && pwd)" + +KEY="${CA_DIR}/${CA_NAME}.key.pem" +CERT="${CA_DIR}/${CA_NAME}.cert.pem" + +echo "== Device/frp-client CA (${KIND}, PRIVATE — not Let's Encrypt) into ${CA_DIR} ==" + +# ---- 1. CA keypair + self-signed cert (P-256, CA:TRUE, keyCertSign+cRLSign) ----------------------- +if [[ -f "${KEY}" && -f "${CERT}" ]]; then + echo ">> CA already exists — reusing (delete ${CA_DIR} to rotate)." +else + openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "${KEY}" + openssl req -x509 -new -key "${KEY}" -days "${CA_DAYS}" \ + -subj "/O=web-terminal-relay/OU=${CA_OU}/CN=${CA_CN}" \ + -addext "basicConstraints=critical,CA:TRUE,pathlen:0" \ + -addext "keyUsage=critical,keyCertSign,cRLSign" \ + -addext "subjectKeyIdentifier=hash" \ + -out "${CERT}" +fi + +# ---- 2. OpenSSL `ca` database (so leaves are tracked → revocable via the CRL) ---------------------- +[[ -f "${CA_DIR}/index.txt" ]] || : > "${CA_DIR}/index.txt" +[[ -f "${CA_DIR}/serial" ]] || echo 1000 > "${CA_DIR}/serial" +[[ -f "${CA_DIR}/crlnumber" ]] || echo 1000 > "${CA_DIR}/crlnumber" +# unique_subject=no allows re-issuing a leaf for the same CN (device/host cert rotation). +[[ -f "${CA_DIR}/index.txt.attr" ]] || echo "unique_subject = no" > "${CA_DIR}/index.txt.attr" + +if [[ ! -f "${CA_DIR}/openssl.cnf" ]]; then + # dir = . → the issue/revoke scripts `cd` into CA_DIR before invoking `openssl ca`, so the db is + # relocatable (works in a mktemp test dir). $dir is expanded by openssl; ${CA_NAME} by bash here. + cat > "${CA_DIR}/openssl.cnf" </dev/null 2>&1 ) +fi + +# ---- 4. Permissions + summary --------------------------------------------------------------------- +chmod 700 "${CA_DIR}" +chmod 600 "${KEY}" +chmod 644 "${CERT}" "${CA_DIR}/crl.pem" + +cat < + revoke a leaf : CA_DIR=${CA_DIR} bash deploy/scripts/revoke-device.sh +SUMMARY diff --git a/deploy/scripts/issue-device-cert.sh b/deploy/scripts/issue-device-cert.sh new file mode 100755 index 0000000..c606428 --- /dev/null +++ b/deploy/scripts/issue-device-cert.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# +# RELAY-PHASE1 · Native mTLS tunnel · V2 — Issue a client-auth LEAF from the device CA. +# +# STAGING TEMPLATE. Signs a P-256 client-certificate (EKU=clientAuth, 825d) with the CA minted by +# deploy/scripts/gen-device-ca.sh and emits BOTH delivery formats: +# .p12 — PKCS#12, `-legacy` (3DES/RC2) for iOS / Android import compatibility (V2) +# .cert.pem — leaf cert ┐ +# .key.pem — leaf private key (0600) ├─ desktop / curl --cert (M1 acceptance) +# .fullchain.pem— leaf + CA cert ┘ +# The leaf is signed via `openssl ca`, so it is recorded in the CA's index.txt and can later be +# revoked (CRL) by deploy/scripts/revoke-device.sh. CN + serial are appended to /issued.log. +# +# NOTE: the SAME script issues the per-host frp-client control certs (V1b) — just point CA_DIR at the +# frp-client CA: CA_DIR=/etc/relay/frp-client-ca bash issue-device-cert.sh +# (frpc uses the .cert.pem/.key.pem; ignore the .p12 for that use.) +# +# Usage: issue-device-cert.sh [OUT_DIR] +# required — the device/host common name (also the filename stem); [A-Za-z0-9._-]+ +# [OUT_DIR] optional — where the leaf artifacts land (default /issued/) +# +# Config via ENV or flags (no hardcoded secrets): +# CA_DIR which CA signs (default /etc/relay/device-ca) — or --ca-dir +# P12_PASSWORD .p12 export passphrase (REQUIRED via env/arg; auto-generated + printed ONCE if +# unset) — or --pass +# LEAF_DAYS leaf lifetime (default 825) +# +# Verify (syntax): bash -n deploy/scripts/issue-device-cert.sh +set -euo pipefail + +CA_DIR="${CA_DIR:-/etc/relay/device-ca}" +LEAF_DAYS="${LEAF_DAYS:-825}" +CN="" +OUT_DIR="" + +# ---- args ---------------------------------------------------------------------------------------- +while [[ $# -gt 0 ]]; do + case "$1" in + --ca-dir) CA_DIR="${2:?--ca-dir needs a value}"; shift 2 ;; + --pass) P12_PASSWORD="${2:?--pass needs a value}"; shift 2 ;; + -h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + -*) echo "FATAL: unknown flag '$1' (see --help)" >&2; exit 2 ;; + *) if [[ -z "${CN}" ]]; then CN="$1"; elif [[ -z "${OUT_DIR}" ]]; then OUT_DIR="$1"; + else echo "FATAL: too many positional args at '$1'" >&2; exit 2; fi; shift ;; + esac +done + +[[ -n "${CN}" ]] || { echo "FATAL: is required. usage: issue-device-cert.sh [OUT_DIR]" >&2; exit 2; } +# Boundary validation: CN becomes a filename — reject anything that could traverse or surprise. +[[ "${CN}" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "FATAL: CN '${CN}' must match [A-Za-z0-9._-]+ (it is used as a filename)." >&2; exit 2; } + +command -v openssl >/dev/null 2>&1 || { echo "FATAL: openssl not found on PATH." >&2; exit 3; } + +CA_CERT="${CA_DIR}/$(basename "${CA_DIR}").cert.pem" +# gen-device-ca names the CA cert after its dir (device-ca.cert.pem / frp-client-ca.cert.pem); if the +# dir was renamed, fall back to the single *.cert.pem that is not a leaf. Keep it explicit + validated. +if [[ ! -f "${CA_CERT}" ]]; then + CA_CERT="$(ls "${CA_DIR}"/*-ca.cert.pem 2>/dev/null | head -1 || true)" +fi +[[ -f "${CA_DIR}/openssl.cnf" && -f "${CA_CERT}" ]] || { + echo "FATAL: no CA db in ${CA_DIR}. Run gen-device-ca.sh first (expected openssl.cnf + *-ca.cert.pem)." >&2 + exit 3; } + +OUT_DIR="${OUT_DIR:-${CA_DIR}/issued/${CN}}" +umask 077 +mkdir -p "${OUT_DIR}" +OUT_DIR="$(cd "${OUT_DIR}" && pwd)" # absolute — we cd into CA_DIR before `openssl ca` + +# ---- passphrase (never hardcoded; auto-generate + print once if not supplied) --------------------- +P12_GENERATED=0 +if [[ -z "${P12_PASSWORD:-}" ]]; then + P12_PASSWORD="$(openssl rand -base64 18 | tr -dc 'A-Za-z0-9' | head -c 24)" + P12_GENERATED=1 +fi + +KEY="${OUT_DIR}/${CN}.key.pem" +CERT="${OUT_DIR}/${CN}.cert.pem" +CHAIN="${OUT_DIR}/${CN}.fullchain.pem" +P12="${OUT_DIR}/${CN}.p12" +CSR="${OUT_DIR}/${CN}.csr.pem" + +echo "== Issue client-auth leaf CN=${CN} (${LEAF_DAYS}d) signed by ${CA_CERT} → ${OUT_DIR} ==" + +# ---- 1. leaf keypair + CSR (P-256) ---------------------------------------------------------------- +openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "${KEY}" +openssl req -new -key "${KEY}" -subj "/O=web-terminal-relay/OU=device/CN=${CN}" -out "${CSR}" + +# ---- 2. sign via `openssl ca` (tracks the leaf in index.txt → revocable) --------------------------- +( cd "${CA_DIR}" && openssl ca -config openssl.cnf -batch -notext \ + -extensions client_cert -days "${LEAF_DAYS}" \ + -in "${CSR}" -out "${CERT}" ) + +cat "${CERT}" "${CA_CERT}" > "${CHAIN}" + +# ---- 3. PKCS#12 for iOS / Android (legacy 3DES/RC2 — imports reliably on mobile) ------------------- +openssl pkcs12 -export -legacy \ + -inkey "${KEY}" -in "${CERT}" -certfile "${CA_CERT}" \ + -name "${CN}" -passout "pass:${P12_PASSWORD}" -out "${P12}" + +# ---- 4. bookkeeping ------------------------------------------------------------------------------- +SERIAL="$(openssl x509 -in "${CERT}" -noout -serial | cut -d= -f2)" +echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') CN=${CN} serial=${SERIAL} out=${OUT_DIR}" >> "${CA_DIR}/issued.log" + +rm -f "${CSR}" +chmod 600 "${KEY}" "${P12}" +chmod 644 "${CERT}" "${CHAIN}" + +cat </crl.pem, which nginx :8470 serves via `ssl_crl` (V4) — so the revoked device is rejected at +# the TLS handshake fleet-wide the moment nginx reloads. This script does NOT touch the remote VPS: it +# only prints the reload hint; run the reload yourself on the box after copying the CRL. +# +# Requires the `openssl ca` database created by deploy/scripts/gen-device-ca.sh (index.txt/serial/…). +# +# Usage: revoke-device.sh # revoke by the issued cert file +# or revoke-device.sh --serial # revoke by serial (uses /newcerts/.pem) +# +# Config via ENV or flags (no secrets): +# CA_DIR which CA to revoke against (default /etc/relay/device-ca) — or --ca-dir +# +# Verify (syntax): bash -n deploy/scripts/revoke-device.sh +set -euo pipefail + +CA_DIR="${CA_DIR:-/etc/relay/device-ca}" +LEAF="" +SERIAL="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --ca-dir) CA_DIR="${2:?--ca-dir needs a value}"; shift 2 ;; + --serial) SERIAL="${2:?--serial needs a value}"; shift 2 ;; + -h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + -*) echo "FATAL: unknown flag '$1' (see --help)" >&2; exit 2 ;; + *) if [[ -z "${LEAF}" ]]; then LEAF="$1"; + else echo "FATAL: too many positional args at '$1'" >&2; exit 2; fi; shift ;; + esac +done + +command -v openssl >/dev/null 2>&1 || { echo "FATAL: openssl not found on PATH." >&2; exit 3; } +[[ -f "${CA_DIR}/openssl.cnf" ]] || { echo "FATAL: no CA db in ${CA_DIR} (run gen-device-ca.sh first)." >&2; exit 3; } + +# ---- resolve the target leaf (by path or by serial) to an absolute path -------------------------- +if [[ -n "${SERIAL}" ]]; then + # Boundary-validate the serial (used to build a path); openssl stores newcerts/.pem. + [[ "${SERIAL}" =~ ^[0-9A-Fa-f]+$ ]] || { echo "FATAL: --serial must be hex." >&2; exit 2; } + LEAF="${CA_DIR}/newcerts/$(echo "${SERIAL}" | tr '[:lower:]' '[:upper:]').pem" +fi +[[ -n "${LEAF}" ]] || { echo "FATAL: pass a or --serial ." >&2; exit 2; } +[[ -f "${LEAF}" ]] || { echo "FATAL: leaf cert not found: ${LEAF}" >&2; exit 3; } +LEAF="$(cd "$(dirname "${LEAF}")" && pwd)/$(basename "${LEAF}")" + +REVOKED_SERIAL="$(openssl x509 -in "${LEAF}" -noout -serial | cut -d= -f2)" +echo "== Revoking serial ${REVOKED_SERIAL} against CA ${CA_DIR} ==" + +# ---- revoke + regenerate the CRL (cd in so openssl.cnf's dir=. resolves to CA_DIR) ---------------- +( + cd "${CA_DIR}" + openssl ca -config openssl.cnf -revoke "${LEAF}" + openssl ca -config openssl.cnf -gencrl -out crl.pem +) +chmod 644 "${CA_DIR}/crl.pem" + +cat <