`POST /renew` is authenticated by mTLS with the very leaf it renews, so once that leaf lapsed the host could never renew it and the tunnel stayed down until an operator re-paired by hand. Production hit exactly this: the Mac slept through its 8h renewal window, the 24h leaf expired, and the agent then logged `client certificate has expired; renew before dialling` 6380 times over 8 days without recovering. Three layers independently refused an expired leaf, so all three had to move: - agent `buildTlsOptions` gains an opt-in `expiredGraceMs`. Absent/0 keeps the historical fail-closed behaviour, and the TUNNEL dial never passes it — only the renew transport does. Past the window it throws the new `CertExpiredBeyondGraceError`. - agent rotator routes an already-expired leaf to a separate recovery endpoint and treats "beyond grace" as TERMINAL: report once via `onExhausted`, stop retrying, and name the fix (re-pair) instead of spamming warnings forever. - control-plane `assertPresentedCertTrusted` grants a bounded grace on `notAfter` only. Chain validation, SPIFFE identity, `notBefore`, and the registry `active`/account checks are all unchanged, so revocation still bites. - new `deploy/nginx/recover-mtls.conf` (:8472). The strict enroll vhost cannot host this: under `ssl_verify_client optional` nginx answers a bare 400 as soon as a presented cert fails verification, so an expired leaf never reaches the location — and the directive is server-level, not per-location. Grace defaults to 30 days on both ends and is configurable (`RECOVER_URL`, `expiredRenewGraceMs`). The honest trade is recorded in the code: a stale stolen leaf stays reusable for the window, which widens an existing exposure (an unexpired stolen leaf already renews indefinitely) rather than opening a new one. Verified: agent 296/296, control-plane 286/286, tsc clean on both.
104 lines
3.7 KiB
TypeScript
104 lines
3.7 KiB
TypeScript
/**
|
|
* Agent configuration — PLAN_RELAY_AGENT T2. Zod-validated at the startup boundary (INV9):
|
|
* - relayUrl MUST be wss:// (encrypted tunnel only)
|
|
* - enrollUrl MUST be https:// (enrollment over TLS only)
|
|
* - localTargetUrl MUST be ws:// to a LOOPBACK host (anti-SSRF: the agent forwards ONLY to
|
|
* the local web-terminal, never an arbitrary target).
|
|
* Fail-fast on missing/invalid values — never silently default a security-relevant field.
|
|
*/
|
|
import { homedir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { z } from 'zod'
|
|
import { isLoopbackHostLiteral } from '../net/loopbackLiteral.js'
|
|
|
|
export interface AgentConfig {
|
|
readonly relayUrl: string
|
|
readonly enrollUrl: string
|
|
readonly stateDir: string
|
|
readonly localTargetUrl: string
|
|
readonly subdomain: string | null
|
|
readonly hostId: string | null
|
|
/**
|
|
* Renewal endpoint used ONLY when the current leaf has already expired (the strict `/renew` vhost
|
|
* rejects an expired client cert before it reaches the control-plane). Optional: when unset it is
|
|
* derived from `enrollUrl` by swapping the `enroll.` label for `recover.` — see
|
|
* `certs/rotation.ts` `recoveryRenewalUrlFor`.
|
|
*/
|
|
readonly recoverUrl?: string | null | undefined
|
|
}
|
|
|
|
/**
|
|
* True iff `url` is ws:// to a loopback host (a well-formed 127.0.0.0/8 IPv4 literal, localhost, or
|
|
* ::1). Uses the shared strict check so a crafted suffixed hostname such as
|
|
* `ws://127.0.0.1.attacker.example.com:3000` — which the outbound dial would DNS-resolve and connect
|
|
* to wherever it points — is REJECTED, closing the anti-SSRF bypass (not merely `startsWith('127.')`).
|
|
*/
|
|
export function isLoopbackWsUrl(url: string): boolean {
|
|
let parsed: URL
|
|
try {
|
|
parsed = new URL(url)
|
|
} catch {
|
|
return false
|
|
}
|
|
if (parsed.protocol !== 'ws:') return false
|
|
return isLoopbackHostLiteral(parsed.hostname)
|
|
}
|
|
|
|
function hasScheme(url: string, scheme: string): boolean {
|
|
try {
|
|
return new URL(url).protocol === scheme
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
export const AgentConfigSchema = z
|
|
.object({
|
|
relayUrl: z
|
|
.string()
|
|
.refine((u) => hasScheme(u, 'wss:'), 'relayUrl must be a wss:// URL'),
|
|
enrollUrl: z
|
|
.string()
|
|
.refine((u) => hasScheme(u, 'https:'), 'enrollUrl must be an https:// URL'),
|
|
stateDir: z.string().min(1),
|
|
localTargetUrl: z
|
|
.string()
|
|
.refine(isLoopbackWsUrl, 'localTargetUrl must be a ws:// loopback URL (anti-SSRF)'),
|
|
subdomain: z.string().min(1).nullable(),
|
|
hostId: z.string().min(1).nullable(),
|
|
recoverUrl: z
|
|
.string()
|
|
.refine((u) => hasScheme(u, 'https:'), 'recoverUrl must be an https:// URL')
|
|
.nullable()
|
|
.optional(),
|
|
})
|
|
.strict()
|
|
.readonly()
|
|
|
|
const DEFAULT_LOCAL_TARGET = 'ws://127.0.0.1:3000'
|
|
|
|
/** Default state dir where key/cert/content-secret live (~/.web-terminal-agent). */
|
|
export function defaultStateDir(): string {
|
|
return join(homedir(), '.web-terminal-agent')
|
|
}
|
|
|
|
/**
|
|
* Build + validate an AgentConfig from env + explicit argv overrides (argv wins). Throws a
|
|
* ZodError (fail-fast) if any required field is missing or fails a scheme/loopback refinement.
|
|
*/
|
|
export function loadAgentConfig(
|
|
env: NodeJS.ProcessEnv,
|
|
argv: Partial<AgentConfig> = {},
|
|
): AgentConfig {
|
|
const merged = {
|
|
relayUrl: argv.relayUrl ?? env.RELAY_URL,
|
|
enrollUrl: argv.enrollUrl ?? env.ENROLL_URL,
|
|
stateDir: argv.stateDir ?? env.STATE_DIR ?? defaultStateDir(),
|
|
localTargetUrl: argv.localTargetUrl ?? env.LOCAL_TARGET_URL ?? DEFAULT_LOCAL_TARGET,
|
|
subdomain: argv.subdomain ?? env.SUBDOMAIN ?? null,
|
|
hostId: argv.hostId ?? env.HOST_ID ?? null,
|
|
recoverUrl: argv.recoverUrl ?? env.RECOVER_URL ?? null,
|
|
}
|
|
return AgentConfigSchema.parse(merged)
|
|
}
|