feat(tunnel): zero-touch tunnel enrollment — control-plane PKI, host agent, iOS, nginx isolation
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>
This commit is contained in:
@@ -10,6 +10,32 @@
|
||||
#
|
||||
# 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).
|
||||
#
|
||||
# A3 / FIX C-native-3 — cert→Host BINDING (the SINGLE load-bearing tenant-isolation control). On top
|
||||
# of `ssl_verify_client on` (which only proves the cert is device-CA-signed), njs parses the leaf's
|
||||
# dNSName SAN and a `map` requires its leftmost label == the leftmost label of the requested SNI,
|
||||
# `return 403` otherwise. A cert stamped `alice.terminal.yaojia.wang` may ONLY reach `alice.*`; a
|
||||
# tenant-A cert presented to tenant-B's host is denied. Rule lives in the `map` (never scattered
|
||||
# `if`s); exactly one `if` guards the single `return 403`.
|
||||
#
|
||||
# PREREQ (VPS main nginx.conf, MAIN context — NOT here): `load_module modules/ngx_http_js_module.so;`
|
||||
# (Debian nginx: `apt-get install nginx-module-njs`). Install the module file to /etc/nginx/njs/
|
||||
# getCertSub.js so the relative `js_import` below resolves against the /etc/nginx prefix.
|
||||
|
||||
js_import certsub from njs/getCertSub.js;
|
||||
js_set $cert_sub certsub.getCertSub; # leftmost dNSName SAN label of the verified client cert ('' on any parse failure)
|
||||
|
||||
# Allow iff the requested SNI is EXACTLY `<cert_sub>.terminal.yaojia.wang` — the cert's leftmost
|
||||
# dNSName label equals the requested subdomain, anchored to the FULL zone. Anchoring the zone here
|
||||
# (not just `<label>.`) makes this map SELF-SUFFICIENT: it does not depend on the `server_name` regex
|
||||
# (line ~46) or the stream SNI routing to constrain the zone — belt-and-suspenders for the one control.
|
||||
# `(?P=s)` is the PCRE named backreference to `s`. An empty $cert_sub (fail-closed njs output) can
|
||||
# never satisfy `[^:]+`, so it always falls through to `default 0` → denied. Case-sensitive (~):
|
||||
# our subdomains and stamped SANs are lowercase.
|
||||
map "$cert_sub:$ssl_server_name" $cert_host_ok {
|
||||
"~^(?<s>[^:]+):(?P=s)\.terminal\.yaojia\.wang$" 1;
|
||||
default 0;
|
||||
}
|
||||
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
@@ -29,6 +55,11 @@ server {
|
||||
ssl_verify_depth 1;
|
||||
ssl_crl /etc/relay/device-ca/crl.pem; # revocation from day one (V2)
|
||||
|
||||
# --- A3 cert→Host binding gate (runs AFTER ssl_verify_client) ---
|
||||
# The one `if` guarding the single `return`; the actual rule is in the $cert_host_ok map above.
|
||||
# Denies before ANY location is reached (protects /, /hook*, /live-sessions/:id/preview, …).
|
||||
if ($cert_host_ok = 0) { return 403; }
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:7080; # frps vhost — routes by Host → subdomain
|
||||
proxy_http_version 1.1;
|
||||
|
||||
22
deploy/nginx/njs/getCertSub.d.ts
vendored
Normal file
22
deploy/nginx/njs/getCertSub.d.ts
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Types for the njs SAN extractor so `tsc --noEmit` resolves `import certsub from './getCertSub.js'`
|
||||
* without `allowJs`. The runtime module is plain njs-compatible ECMAScript (getCertSub.js).
|
||||
*/
|
||||
|
||||
/** The nginx request object surface `getCertSub` touches (only `variables.ssl_client_cert`). */
|
||||
export interface NjsRequestLike {
|
||||
readonly variables: { readonly ssl_client_cert?: string }
|
||||
}
|
||||
|
||||
export interface GetCertSubModule {
|
||||
/**
|
||||
* Leftmost label of the first dNSName SAN in a PEM client cert (e.g. 'alice.terminal…' → 'alice').
|
||||
* Returns '' on ANY parse failure / missing SAN / missing dNSName (fail-closed → the nginx map denies).
|
||||
*/
|
||||
extractLeftmostDnsLabel(pemClientCert: string): string
|
||||
/** njs `js_set` entrypoint: reads `r.variables.ssl_client_cert` and delegates to the extractor. */
|
||||
getCertSub(r: NjsRequestLike): string
|
||||
}
|
||||
|
||||
declare const mod: GetCertSubModule
|
||||
export default mod
|
||||
241
deploy/nginx/njs/getCertSub.js
Normal file
241
deploy/nginx/njs/getCertSub.js
Normal file
@@ -0,0 +1,241 @@
|
||||
/*
|
||||
* A3 / FIX C-native-3 — the SINGLE load-bearing tenant-isolation control.
|
||||
*
|
||||
* Runs INSIDE nginx as njs (an ECMAScript subset). `js_set $cert_sub certsub.getCertSub` exposes the
|
||||
* leftmost dNSName SAN label of the (already CA-verified) client certificate to an nginx `map`, which
|
||||
* compares it against the requested `$ssl_server_name` and `return 403`s on mismatch. A cert stamped
|
||||
* `dNSName alice.terminal.yaojia.wang` may ONLY reach the `alice.*` subdomain; a cert for tenant A
|
||||
* presented to tenant B's host is denied. This binding runs AFTER `ssl_verify_client` — by the time
|
||||
* this code sees the cert, nginx has already proven it was signed by the device-CA.
|
||||
*
|
||||
* DESIGN: `extractLeftmostDnsLabel` is a PURE function using ONLY APIs common to njs AND Node, so the
|
||||
* SAME source is unit-tested under Node/vitest (control-plane/test/getcertsub.test.ts against real
|
||||
* A1-minted P-256 leaves) and runs unchanged under njs. No `Buffer`, no `atob`, no typed-array-only
|
||||
* APIs: base64 decode and X.509/ASN.1 (DER) walking are hand-rolled over plain arrays of byte numbers.
|
||||
*
|
||||
* FAIL-CLOSED: ANY malformed input, missing SAN, missing dNSName, non-hostname bytes, or truncated DER
|
||||
* returns '' (empty). An empty `$cert_sub` can never satisfy the map regex `^(?<s>[^:]+):(?P=s)\.`
|
||||
* (which requires a non-empty label), so an empty result always DENIES. Never throws; never allows on
|
||||
* doubt. Conservative by construction — this is the one control between tenants.
|
||||
*/
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* PEM → base64 body */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Extract the base64 body between the FIRST `-----BEGIN ... CERTIFICATE-----` header and the next
|
||||
* `-----END`. We anchor on `CERTIFICATE-----` (the tail of the BEGIN line) so the header letters
|
||||
* (B,E,G,I,N,C — all valid base64 chars) can never leak into the decoded body. '' on absence.
|
||||
*/
|
||||
function pemBody(pem) {
|
||||
var marker = 'CERTIFICATE-----'
|
||||
var b = pem.indexOf(marker)
|
||||
if (b < 0) return ''
|
||||
b += marker.length
|
||||
var e = pem.indexOf('-----END', b)
|
||||
if (e < 0) return ''
|
||||
return pem.substring(b, e)
|
||||
}
|
||||
|
||||
/** Map one base64 alphabet char code to its 6-bit value; -1 for anything else (whitespace, '=', …). */
|
||||
function b64Val(c) {
|
||||
if (c >= 65 && c <= 90) return c - 65 // A-Z → 0..25
|
||||
if (c >= 97 && c <= 122) return c - 71 // a-z → 26..51
|
||||
if (c >= 48 && c <= 57) return c + 4 // 0-9 → 52..61
|
||||
if (c === 43) return 62 // '+'
|
||||
if (c === 47) return 63 // '/'
|
||||
return -1
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode base64 text to a plain array of byte numbers. Non-alphabet characters (newlines, the TAB that
|
||||
* nginx `$ssl_client_cert` prepends to continuation lines, spaces, '=' padding) are skipped. The bit
|
||||
* accumulator is masked to 24 bits every step so it never grows past double precision.
|
||||
*/
|
||||
function base64ToBytes(s) {
|
||||
var out = []
|
||||
var buffer = 0
|
||||
var bits = 0
|
||||
for (var i = 0; i < s.length; i++) {
|
||||
var v = b64Val(s.charCodeAt(i))
|
||||
if (v < 0) continue
|
||||
buffer = ((buffer << 6) | v) & 0xffffff
|
||||
bits += 6
|
||||
if (bits >= 8) {
|
||||
bits -= 8
|
||||
out.push((buffer >> bits) & 0xff)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Minimal DER (definite-length) reader */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** Read a DER length starting at `pos`. Returns { len, next } or null (indefinite / overlong / OOB). */
|
||||
function readLen(bytes, pos) {
|
||||
var first = bytes[pos]
|
||||
if (first === undefined) return null
|
||||
if (first < 0x80) return { len: first, next: pos + 1 }
|
||||
var numBytes = first & 0x7f
|
||||
if (numBytes === 0 || numBytes > 4) return null // 0 = indefinite (illegal in DER); >4 = absurd
|
||||
var len = 0
|
||||
for (var i = 0; i < numBytes; i++) {
|
||||
var b = bytes[pos + 1 + i]
|
||||
if (b === undefined) return null
|
||||
len = len * 256 + b
|
||||
}
|
||||
return { len: len, next: pos + 1 + numBytes }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one TLV at `pos`, bounded by `parentEnd` (the contentEnd of the immediate container; callers
|
||||
* pass it so a child can never claim to extend past its parent even while still fitting the overall
|
||||
* buffer — a crafted nested-length-inconsistent cert must not let the walk read sibling/trailing bytes
|
||||
* as a child's content). `parentEnd` defaults to the total buffer length for top-level reads. Returns
|
||||
* { tag, contentStart, contentEnd, next } or null on any inconsistency (out of bounds, high-tag-number
|
||||
* form, length overrun past the parent OR the buffer). Single-byte tags only — every tag this walk
|
||||
* cares about (SEQUENCE 0x30, [3] 0xA3, OID 0x06, BOOLEAN 0x01, OCTET STRING 0x04, dNSName 0x82) is
|
||||
* single-byte, so a high-tag-number tag is treated as malformed (fail-closed).
|
||||
*/
|
||||
function readTlv(bytes, pos, parentEnd) {
|
||||
var limit = parentEnd === undefined ? bytes.length : parentEnd
|
||||
if (limit > bytes.length) limit = bytes.length // never trust a parent bound wider than the buffer
|
||||
if (pos < 0 || pos >= limit) return null
|
||||
var tag = bytes[pos]
|
||||
if ((tag & 0x1f) === 0x1f) return null // high-tag-number form — unexpected → fail-closed
|
||||
var l = readLen(bytes, pos + 1)
|
||||
if (l === null) return null
|
||||
var contentStart = l.next
|
||||
var contentEnd = contentStart + l.len
|
||||
if (contentEnd > limit) return null // overruns the parent container (or buffer) → fail-closed
|
||||
return { tag: tag, contentStart: contentStart, contentEnd: contentEnd, next: contentEnd }
|
||||
}
|
||||
|
||||
/** True iff this OID TLV's content is exactly `55 1d 11` = OID 2.5.29.17 (subjectAltName). */
|
||||
function isSanOid(der, oid) {
|
||||
if (oid.contentEnd - oid.contentStart !== 3) return false
|
||||
return (
|
||||
der[oid.contentStart] === 0x55 && der[oid.contentStart + 1] === 0x1d && der[oid.contentStart + 2] === 0x11
|
||||
)
|
||||
}
|
||||
|
||||
/** Valid hostname byte? [0-9A-Za-z.-] only — anything else rejects the whole label (fail-closed). */
|
||||
function isHostByte(c) {
|
||||
return (
|
||||
(c >= 0x30 && c <= 0x39) || // 0-9
|
||||
(c >= 0x41 && c <= 0x5a) || // A-Z
|
||||
(c >= 0x61 && c <= 0x7a) || // a-z
|
||||
c === 0x2d || // '-'
|
||||
c === 0x2e // '.'
|
||||
)
|
||||
}
|
||||
|
||||
/** Decode the dNSName value bytes to a hostname string, or '' if any byte is outside [0-9A-Za-z.-]. */
|
||||
function hostString(der, start, end) {
|
||||
var s = ''
|
||||
for (var i = start; i < end; i++) {
|
||||
var c = der[i]
|
||||
if (!isHostByte(c)) return ''
|
||||
s += String.fromCharCode(c)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the certificate DER structurally to the SubjectAlternativeName extension and return the FIRST
|
||||
* dNSName value. Structure:
|
||||
* Certificate ::= SEQUENCE { tbsCertificate SEQUENCE { ..., extensions [3] EXPLICIT }, ... }
|
||||
* Extensions ::= SEQUENCE OF Extension { extnID OID, critical BOOLEAN OPTIONAL, extnValue OCTET STRING }
|
||||
* extnValue (for SAN) = GeneralNames ::= SEQUENCE OF GeneralName; dNSName is [2] IA5String (tag 0x82)
|
||||
* Returns '' on any structural deviation, missing SAN, or SAN-without-dNSName.
|
||||
*/
|
||||
function firstDnsSan(der) {
|
||||
var cert = readTlv(der, 0)
|
||||
if (cert === null || cert.tag !== 0x30) return ''
|
||||
var tbs = readTlv(der, cert.contentStart, cert.contentEnd)
|
||||
if (tbs === null || tbs.tag !== 0x30) return ''
|
||||
|
||||
// Walk the DIRECT children of TBSCertificate for the [3] (0xA3) explicit extensions container.
|
||||
var pos = tbs.contentStart
|
||||
var extsContainer = null
|
||||
while (pos < tbs.contentEnd) {
|
||||
var child = readTlv(der, pos, tbs.contentEnd)
|
||||
if (child === null) return ''
|
||||
if (child.tag === 0xa3) {
|
||||
extsContainer = child
|
||||
break
|
||||
}
|
||||
pos = child.next
|
||||
}
|
||||
if (extsContainer === null) return '' // no extensions → no SAN
|
||||
|
||||
var exts = readTlv(der, extsContainer.contentStart, extsContainer.contentEnd)
|
||||
if (exts === null || exts.tag !== 0x30) return ''
|
||||
|
||||
// Iterate each Extension looking for the SAN OID.
|
||||
var epos = exts.contentStart
|
||||
while (epos < exts.contentEnd) {
|
||||
var ext = readTlv(der, epos, exts.contentEnd)
|
||||
if (ext === null || ext.tag !== 0x30) return ''
|
||||
var oid = readTlv(der, ext.contentStart, ext.contentEnd)
|
||||
if (oid === null || oid.tag !== 0x06) {
|
||||
epos = ext.next
|
||||
continue
|
||||
}
|
||||
if (isSanOid(der, oid)) {
|
||||
// Skip an optional BOOLEAN `critical`, then require the OCTET STRING `extnValue` — both bounded
|
||||
// to THIS Extension's content so a lying extnValue length can't reach into a sibling extension.
|
||||
var vpos = oid.next
|
||||
var val = readTlv(der, vpos, ext.contentEnd)
|
||||
if (val !== null && val.tag === 0x01) {
|
||||
vpos = val.next
|
||||
val = readTlv(der, vpos, ext.contentEnd)
|
||||
}
|
||||
if (val === null || val.tag !== 0x04) return ''
|
||||
// The GeneralNames SEQUENCE and every GeneralName are bounded to the OCTET STRING's content:
|
||||
// a GeneralNames length that overruns extnValue (but still fits the buffer) must NOT be read.
|
||||
var gnames = readTlv(der, val.contentStart, val.contentEnd)
|
||||
if (gnames === null || gnames.tag !== 0x30) return ''
|
||||
var gpos = gnames.contentStart
|
||||
while (gpos < gnames.contentEnd) {
|
||||
var gn = readTlv(der, gpos, gnames.contentEnd)
|
||||
if (gn === null) return ''
|
||||
if (gn.tag === 0x82) return hostString(der, gn.contentStart, gn.contentEnd) // dNSName [2]
|
||||
gpos = gn.next
|
||||
}
|
||||
return '' // SAN present but carries no dNSName (e.g. URI-only)
|
||||
}
|
||||
epos = ext.next
|
||||
}
|
||||
return '' // no SAN extension
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Public surface */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Decode a PEM client certificate and return the LEFTMOST label of its FIRST dNSName SAN
|
||||
* (e.g. 'alice.terminal.yaojia.wang' → 'alice'). '' on ANY failure (fail-closed → the map denies).
|
||||
*/
|
||||
function extractLeftmostDnsLabel(pemClientCert) {
|
||||
if (!pemClientCert || typeof pemClientCert !== 'string') return ''
|
||||
var body = pemBody(pemClientCert)
|
||||
if (body === '') return ''
|
||||
var der = base64ToBytes(body)
|
||||
if (der.length === 0) return ''
|
||||
var dns = firstDnsSan(der)
|
||||
if (dns === '') return ''
|
||||
var dot = dns.indexOf('.')
|
||||
return dot < 0 ? dns : dns.substring(0, dot)
|
||||
}
|
||||
|
||||
/** njs entrypoint referenced by `js_set $cert_sub certsub.getCertSub`. */
|
||||
function getCertSub(r) {
|
||||
return extractLeftmostDnsLabel(r.variables.ssl_client_cert || '')
|
||||
}
|
||||
|
||||
export default { getCertSub: getCertSub, extractLeftmostDnsLabel: extractLeftmostDnsLabel }
|
||||
7
deploy/nginx/test/integration/Dockerfile
Normal file
7
deploy/nginx/test/integration/Dockerfile
Normal file
@@ -0,0 +1,7 @@
|
||||
# nginx + njs (ngx_http_js_module) for the A3 cert→Host binding integration test.
|
||||
# The official nginx Debian image ships the nginx.org apt repo, so the njs dynamic module installs at
|
||||
# the exact matching version. This mirrors the VPS prereq: `load_module modules/ngx_http_js_module.so`.
|
||||
FROM nginx:1.27
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends nginx-module-njs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
15
deploy/nginx/test/integration/Makefile
Normal file
15
deploy/nginx/test/integration/Makefile
Normal file
@@ -0,0 +1,15 @@
|
||||
# A3 cert→Host binding tests.
|
||||
# make unit → Node/vitest: getCertSub SAN extractor + map-logic (no docker)
|
||||
# make integration → real nginx+njs docker: positive 200 + cross-tenant 403 (needs docker+openssl)
|
||||
# make test → both
|
||||
|
||||
REPO_ROOT := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))/../../../..)
|
||||
|
||||
.PHONY: unit integration test
|
||||
unit:
|
||||
cd $(REPO_ROOT)/control-plane && npx vitest run test/getcertsub.test.ts
|
||||
|
||||
integration:
|
||||
bash $(dir $(lastword $(MAKEFILE_LIST)))run.sh
|
||||
|
||||
test: unit integration
|
||||
39
deploy/nginx/test/integration/nginx.test.conf
Normal file
39
deploy/nginx/test/integration/nginx.test.conf
Normal file
@@ -0,0 +1,39 @@
|
||||
# A3 integration nginx.conf — exercises the REAL binding stack end-to-end: getCertSub.js (njs),
|
||||
# js_set $cert_sub, the $cert_host_ok map, and the single `if → return 403`.
|
||||
#
|
||||
# ONLY delta from the production deploy/nginx/frp-mtls.conf: the location returns a stub `200 ok`
|
||||
# instead of proxying to the frps vhost (no frps needed to prove the AUTH gate), and the listener is
|
||||
# a plain :8470 (not 127.0.0.1) so the published container port is reachable. The binding logic —
|
||||
# ssl_verify_client + njs SAN parse + map + if→403 — is byte-identical to prod.
|
||||
|
||||
load_module modules/ngx_http_js_module.so; # VPS prereq (main context)
|
||||
|
||||
events {}
|
||||
|
||||
http {
|
||||
js_import certsub from njs/getCertSub.js; # mounted at /etc/nginx/njs/getCertSub.js
|
||||
js_set $cert_sub certsub.getCertSub;
|
||||
|
||||
map "$cert_sub:$ssl_server_name" $cert_host_ok {
|
||||
"~^(?<s>[^:]+):(?P=s)\." 1;
|
||||
default 0;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 8470 ssl;
|
||||
server_name ~^.+\.terminal\.yaojia\.wang$;
|
||||
|
||||
ssl_certificate /certs/server.crt;
|
||||
ssl_certificate_key /certs/server.key;
|
||||
|
||||
# data-path gate: only device-CA-signed client certs get past the handshake
|
||||
ssl_verify_client on;
|
||||
ssl_client_certificate /certs/ca.crt;
|
||||
ssl_verify_depth 1;
|
||||
|
||||
# A3 cert→Host binding — the one `if` guarding the single return; rule lives in the map.
|
||||
if ($cert_host_ok = 0) { return 403; }
|
||||
|
||||
location / { return 200 "ok\n"; } # PROD: proxy_pass http://127.0.0.1:7080; (frps vhost)
|
||||
}
|
||||
}
|
||||
139
deploy/nginx/test/integration/run.sh
Executable file
139
deploy/nginx/test/integration/run.sh
Executable file
@@ -0,0 +1,139 @@
|
||||
#!/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)"
|
||||
Reference in New Issue
Block a user