feat(deploy): device client-CA + issuance/revocation + frp/nginx mTLS templates (V2/V7)

Committable VPS artifacts for the native reverse-tunnel (no live-ops, no secrets):
- gen-device-ca.sh: EC P-256 self-signed device CA (CA:TRUE pathlen:0, keyCertSign+cRLSign),
  openssl ca db + empty CRL, idempotent, --kind frp-client scaffolds the control-channel CA.
- issue-device-cert.sh: P-256 leaf, EKU=clientAuth only, 825d, emits .p12 (-legacy) + .pem/.key/.fullchain,
  copy_extensions=none prevents CSR-injected serverAuth/CA:TRUE; CN traversal guard.
- revoke-device.sh: openssl ca revoke -> regenerate crl.pem (revocation enforced via -crl_check).
- frp/{frps.toml.example,frpc.example.toml}: control-channel mTLS+token, disableCustomTLSFirstByte=true.
- nginx/frp-mtls.conf: :8470 TLS-term, ssl_verify_client on + device-CA + ssl_crl, WS-upgrade proxy to :7080.
- nginx/stream-sni-additions.md: the two additive SNI map lines (merge-only, coexistence-safe).
Verified: gen->issue->openssl verify->revoke->CRL chain green in a tmp dir (OpenSSL 3.0.18).
This commit is contained in:
Yaojia Wang
2026-07-07 09:42:12 +02:00
parent 6b8269c1c1
commit 5337281e85
8 changed files with 573 additions and 0 deletions

68
deploy/scripts/revoke-device.sh Executable file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env bash
#
# RELAY-PHASE1 · Native mTLS tunnel · V2 — Revoke a device/frp-client leaf, regenerate the CRL.
#
# STAGING TEMPLATE. Marks a previously-issued leaf revoked in the CA database and regenerates
# <CA_DIR>/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 <LEAF_CERT.pem> # revoke by the issued cert file
# or revoke-device.sh --serial <HEX_SERIAL> # revoke by serial (uses <CA_DIR>/newcerts/<serial>.pem)
#
# Config via ENV or flags (no secrets):
# CA_DIR which CA to revoke against (default /etc/relay/device-ca) — or --ca-dir <path>
#
# 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/<UPPERCASE-HEX>.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 <LEAF_CERT.pem> or --serial <HEX>." >&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 <<SUMMARY
Revoked serial ${REVOKED_SERIAL}. Updated CRL: ${CA_DIR}/crl.pem
Confirm: openssl crl -in ${CA_DIR}/crl.pem -noout -text | grep -A1 'Serial Number'
RELOAD HINT (run ON THE VPS after copying the new crl.pem into place — NOT executed here):
nginx -t && nginx -s reload
Until nginx reloads the CRL, the revoked cert is still accepted.
SUMMARY