Files
web-terminal/deploy/scripts/issue-device-cert.sh
Yaojia Wang 5337281e85 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).
2026-07-07 09:42:12 +02:00

121 lines
5.8 KiB
Bash
Executable File

#!/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:
# <CN>.p12 — PKCS#12, `-legacy` (3DES/RC2) for iOS / Android import compatibility (V2)
# <CN>.cert.pem — leaf cert ┐
# <CN>.key.pem — leaf private key (0600) ├─ desktop / curl --cert (M1 acceptance)
# <CN>.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 <CA_DIR>/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 <hostname> <out>
# (frpc uses the .cert.pem/.key.pem; ignore the .p12 for that use.)
#
# Usage: issue-device-cert.sh <CN> [OUT_DIR]
# <CN> required — the device/host common name (also the filename stem); [A-Za-z0-9._-]+
# [OUT_DIR] optional — where the leaf artifacts land (default <CA_DIR>/issued/<CN>)
#
# Config via ENV or flags (no hardcoded secrets):
# CA_DIR which CA signs (default /etc/relay/device-ca) — or --ca-dir <path>
# P12_PASSWORD .p12 export passphrase (REQUIRED via env/arg; auto-generated + printed ONCE if
# unset) — or --pass <secret>
# 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: <CN> is required. usage: issue-device-cert.sh <CN> [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 <<SUMMARY
Leaf issued for CN=${CN} (serial ${SERIAL}):
mobile (iOS/Android) : ${P12} (0600) import + passphrase in-app
desktop / curl : ${CERT}
${KEY} (0600)
${CHAIN}
logged : ${CA_DIR}/issued.log
Deliver the .p12 SECURELY (scp / AirDrop / Files — NEVER email).
$( [[ "${P12_GENERATED}" == "1" ]] && printf ' P12 PASSPHRASE (shown ONCE — record it now): %s\n' "${P12_PASSWORD}" )
Revoke later: CA_DIR=${CA_DIR} bash deploy/scripts/revoke-device.sh ${CERT}
SUMMARY