Customers install one command / log in once; hardware-generated keys never leave the device; CSRs return certs + subdomain; frpc + base-app run as durable services. No .p12, no manual cert import. Implements the MVP fast-path of docs/PLAN_TUNNEL_AUTOMATION.md. Control-plane / PKI (control-plane/): - ca/x509-assembler.ts: single KMS-signed real X.509 issuance primitive (Ed25519 + P-256) - ca/csr-ec.ts: P-256 PKCS#10 proof-of-possession (verifyCsrPoPEc) + CSR-key routing - ca/frpclient-issue.ts, ca/device-issue.ts: P-256 frp-client + device leaf signers - ca/rotate.ts + api/renew.ts: real-X.509 /renew + /device/:id/renew (mTLS current cert) - registry/devices.ts: device registry + per-account cap/rate-limit - auth/session.ts: device:enroll capability token mint/verify - api/device-enroll.ts: POST /device/enroll (ownership-gated, deny-by-default) - pairing/native-redeem.ts + shared gateAndConsumePairingCode; api/provision.ts native arm - boot/native-ca.ts + main.ts: wire two P-256 CAs + issuers + routers (dev / KMS fail-fast) Contracts: relay-contracts enroll right; relay-auth SPIFFE /device/ arm + spiffeIdFor(kind) Host agent (agent/): - transport/frpcToml.ts; provision/frpcBinary.ts + untar.ts (verify-download + traversal-safe extract) - keys P-256 keygen/CSR/loadIdentity; service two-unit install + BIND_HOST loopback S-GATE - net/loopbackLiteral.ts strict guard; health/probe.ts + transport/frpSupervise.ts; cli pair --install iOS (ios/Packages/ClientTLS): SecureEnclaveKey + CertificateSigningRequest + DeviceEnrollmentClient + Keychain enroll refactor (SecKey/Security.framework end-to-end, avoids the -25300 trap) Isolation (deploy/nginx): njs/getCertSub.js SAN parser + zone-anchored map -> 403 Verified: 758 tests green (control-plane 246, agent 267, relay-auth 133, relay-contracts 85, iOS ClientTLS 27), all tsc clean; real nginx+njs docker 403/200/400; Swift CSR accepted by the real control-plane verifier; frpc extract byte-identical to `tar -xO`. Cross-validation caught + fixed 5 real defects (1 critical, 4 high). Remaining = infra (KMS, nginx deploy, VPS frps, physical iPhone) per PROGRESS_LOG runbook. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
140 lines
5.4 KiB
Bash
Executable File
140 lines
5.4 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# A3 real nginx+njs integration acceptance (FIX C-native-3 / PLAN §1.2 B1 step 4).
|
|
#
|
|
# Proves the SINGLE load-bearing tenant-isolation control on a REAL nginx with the REAL njs module
|
|
# and the REAL getCertSub.js, using device-CA-signed client certs whose dNSName SAN is stamped
|
|
# exactly as the B1/A4 issuers stamp it:
|
|
#
|
|
# POSITIVE : alice client cert → https://alice.terminal.yaojia.wang → 200 (own host)
|
|
# NEGATIVE : bob client cert → https://alice.terminal.yaojia.wang → 403 (cross-tenant)
|
|
# NEGATIVE : alice client cert → https://bob.terminal.yaojia.wang → 403 (cross-tenant)
|
|
# POSITIVE : bob client cert → https://bob.terminal.yaojia.wang → 200
|
|
# NEGATIVE : NO client cert → https://alice.terminal.yaojia.wang → 400 (ssl_verify_client on)
|
|
#
|
|
# Requires: docker + openssl. If docker is unavailable, this same script is the VPS/CI acceptance
|
|
# step (run it there). Exit 0 iff every assertion holds.
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
NJS_FILE="$(cd "$SCRIPT_DIR/../../njs" && pwd)/getCertSub.js"
|
|
CONF="$SCRIPT_DIR/nginx.test.conf"
|
|
IMAGE="wt-nginx-njs:a3-test"
|
|
CONTAINER="wt-a3-nginx-$$"
|
|
HOST_PORT="${HOST_PORT:-18470}"
|
|
WORK="$(mktemp -d)"
|
|
|
|
cleanup() {
|
|
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
|
|
rm -rf "$WORK"
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
echo "==> workdir: $WORK"
|
|
echo "==> njs source under test: $NJS_FILE"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Mint the device CA, a server cert, and two client leaves with dNSName SANs
|
|
# ---------------------------------------------------------------------------
|
|
gen_ca() {
|
|
openssl ecparam -name prime256v1 -genkey -noout -out "$WORK/ca.key"
|
|
openssl req -x509 -new -key "$WORK/ca.key" -sha256 -days 1 -subj "/CN=device-CA" \
|
|
-addext "basicConstraints=critical,CA:TRUE" \
|
|
-addext "keyUsage=critical,keyCertSign,cRLSign" \
|
|
-out "$WORK/ca.crt"
|
|
}
|
|
|
|
gen_server() {
|
|
openssl ecparam -name prime256v1 -genkey -noout -out "$WORK/server.key"
|
|
openssl req -x509 -new -key "$WORK/server.key" -sha256 -days 1 \
|
|
-subj "/CN=*.terminal.yaojia.wang" \
|
|
-addext "subjectAltName=DNS:*.terminal.yaojia.wang" \
|
|
-out "$WORK/server.crt"
|
|
}
|
|
|
|
# gen_client <name> <subdomain> → mints <name>.key/.crt with SAN dNSName <subdomain>.terminal...
|
|
gen_client() {
|
|
local name="$1" sub="$2"
|
|
openssl ecparam -name prime256v1 -genkey -noout -out "$WORK/$name.key"
|
|
openssl req -new -key "$WORK/$name.key" -subj "/CN=$sub" -out "$WORK/$name.csr"
|
|
openssl x509 -req -in "$WORK/$name.csr" -CA "$WORK/ca.crt" -CAkey "$WORK/ca.key" \
|
|
-CAcreateserial -days 1 -sha256 \
|
|
-extfile <(printf 'subjectAltName=DNS:%s.terminal.yaojia.wang,URI:spiffe://terminal.yaojia.wang/account/a1/host/%s\nextendedKeyUsage=clientAuth\nbasicConstraints=critical,CA:FALSE\n' "$sub" "$sub") \
|
|
-out "$WORK/$name.crt"
|
|
}
|
|
|
|
echo "==> minting CA + server + client (alice, bob) certs"
|
|
gen_ca
|
|
gen_server
|
|
gen_client alice alice
|
|
gen_client bob bob
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Build the nginx+njs image and start the container
|
|
# ---------------------------------------------------------------------------
|
|
echo "==> docker build ($IMAGE)"
|
|
docker build -q -t "$IMAGE" "$SCRIPT_DIR" >/dev/null
|
|
|
|
echo "==> docker run ($CONTAINER) on :$HOST_PORT"
|
|
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
|
|
docker run -d --name "$CONTAINER" \
|
|
-p "$HOST_PORT:8470" \
|
|
-v "$NJS_FILE:/etc/nginx/njs/getCertSub.js:ro" \
|
|
-v "$CONF:/etc/nginx/nginx.conf:ro" \
|
|
-v "$WORK:/certs:ro" \
|
|
"$IMAGE" >/dev/null
|
|
|
|
# nginx -t inside the running image (config sanity on the real binary)
|
|
echo "==> nginx -t (real binary)"
|
|
docker exec "$CONTAINER" nginx -t
|
|
|
|
# wait for readiness
|
|
for i in $(seq 1 30); do
|
|
if curl -sk -o /dev/null "https://alice.terminal.yaojia.wang:$HOST_PORT/" \
|
|
--resolve "alice.terminal.yaojia.wang:$HOST_PORT:127.0.0.1" \
|
|
--cert "$WORK/alice.crt" --key "$WORK/alice.key" 2>/dev/null; then
|
|
break
|
|
fi
|
|
sleep 0.3
|
|
done
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Assert positive (200) and negative (403 / 400) cases
|
|
# ---------------------------------------------------------------------------
|
|
# hit <host> <clientname|-> → prints HTTP status code
|
|
hit() {
|
|
local host="$1" client="$2"
|
|
local args=(-sk -o /dev/null -w '%{http_code}'
|
|
--resolve "$host:$HOST_PORT:127.0.0.1"
|
|
"https://$host:$HOST_PORT/")
|
|
if [ "$client" != "-" ]; then
|
|
args+=(--cert "$WORK/$client.crt" --key "$WORK/$client.key")
|
|
fi
|
|
curl "${args[@]}" || echo "000"
|
|
}
|
|
|
|
FAILED=0
|
|
assert() {
|
|
local label="$1" expected="$2" actual="$3"
|
|
if [ "$actual" = "$expected" ]; then
|
|
echo " PASS $label → $actual"
|
|
else
|
|
echo " FAIL $label → expected $expected, got $actual"
|
|
FAILED=1
|
|
fi
|
|
}
|
|
|
|
echo "==> assertions"
|
|
assert "POSITIVE alice-cert @ alice host" 200 "$(hit alice.terminal.yaojia.wang alice)"
|
|
assert "NEGATIVE bob-cert @ alice host" 403 "$(hit alice.terminal.yaojia.wang bob)"
|
|
assert "NEGATIVE alice-cert @ bob host" 403 "$(hit bob.terminal.yaojia.wang alice)"
|
|
assert "POSITIVE bob-cert @ bob host" 200 "$(hit bob.terminal.yaojia.wang bob)"
|
|
assert "NEGATIVE no-cert @ alice host" 400 "$(hit alice.terminal.yaojia.wang -)"
|
|
|
|
if [ "$FAILED" -ne 0 ]; then
|
|
echo "==> A3 INTEGRATION FAILED"
|
|
docker logs "$CONTAINER" 2>&1 | tail -20 || true
|
|
exit 1
|
|
fi
|
|
echo "==> A3 INTEGRATION PASSED (positive 200 + cross-tenant 403 on real nginx+njs)"
|