Compare commits
24 Commits
main
...
cf88e7c588
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf88e7c588 | ||
|
|
34e4a88059 | ||
|
|
99cafdbdbb | ||
|
|
57725f7ef2 | ||
|
|
fc3b849a08 | ||
|
|
a24465623e | ||
|
|
a25633a63b | ||
|
|
5b9ca321d2 | ||
|
|
cb04516d52 | ||
|
|
d0c249c739 | ||
|
|
bb0949553c | ||
|
|
e38e6d1689 | ||
|
|
5337281e85 | ||
|
|
6b8269c1c1 | ||
|
|
c1c837c54f | ||
|
|
89678c7949 | ||
|
|
7af4a68ef5 | ||
|
|
6efed9772e | ||
|
|
1a8984e851 | ||
|
|
d77f1ff62c | ||
|
|
5e7e4b22f2 | ||
|
|
bfe1be1dfe | ||
|
|
aa1912b962 | ||
|
|
95b9cccf07 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -18,3 +18,6 @@ npm-debug.log*
|
||||
# test coverage
|
||||
coverage/
|
||||
.gstack/
|
||||
|
||||
# deploy secrets (RELAY-PHASE1) — .env.example is committed, .env is not
|
||||
deploy/.env
|
||||
|
||||
@@ -107,11 +107,13 @@ npm run setup-hooks # adds the hooks + statusLine to ~/.claude/s
|
||||
```
|
||||
This wires Claude Code's hooks → **live per-tab status**, the **statusLine gauges**, and **push** notifications. The hooks are a no-op outside web-terminal (they only fire when `$WEBTERM_*` env vars are set in spawned shells), so they're safe to leave installed. Then run `claude` inside a tab.
|
||||
|
||||
> **Login shells (why hooks can always find `node`)** — sessions spawn the shell as a **login shell** (`zsh -l`, POSIX only), so it loads your full profile (`~/.zprofile`, `~/.zshrc`, …) and rebuilds `PATH`. Without this, a GUI-launched app (desktop build) or a long-lived tmux keepalive can hand the shell a minimal `PATH`, and hooks that call an nvm-/brew-managed `node` fail with `node: command not found`. If you still hit that on an old session, start a fresh one so it picks up the login-shell `PATH`.
|
||||
|
||||
`USE_TMUX=1 npm start` keeps sessions alive across a server restart.
|
||||
|
||||
### Tests
|
||||
```bash
|
||||
npm test # vitest, all modules (~470 tests, 80% coverage gate)
|
||||
npm test # vitest, all modules (~1470 tests, 80% coverage gate)
|
||||
npm run typecheck # tsc (backend + frontend)
|
||||
npm run build # compile backend to dist/
|
||||
```
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "esbuild src/main.ts --bundle --platform=node --format=esm --outfile=dist/cli.js --banner:js='#!/usr/bin/env node\nimport{createRequire as __cjs}from\"node:module\";const require=__cjs(import.meta.url);'",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
@@ -26,6 +27,7 @@
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/ws": "^8.5.12",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"esbuild": "^0.28.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import type { AgentConfig } from './config/agentConfig.js'
|
||||
import type { AgentIdentity } from './keys/identity.js'
|
||||
import type { Keystore } from './keys/keystore.js'
|
||||
import type { InstallOptions } from './service/install.js'
|
||||
import type { EnrollResult } from 'relay-contracts'
|
||||
|
||||
export type CliCommand = 'pair' | 'run' | 'status' | 'install' | 'uninstall'
|
||||
@@ -30,7 +31,9 @@ export interface CliDeps {
|
||||
generateIdentity(): AgentIdentity
|
||||
redeem(cfg: AgentConfig, code: string, id: AgentIdentity, ks: Keystore): Promise<EnrollResult>
|
||||
runTunnel(cfg: AgentConfig, ks: Keystore): Promise<number>
|
||||
installService(cfg: AgentConfig): Promise<void>
|
||||
/** Resolve per-host install inputs (S0 env incl. loopback BIND_HOST, tunnel origin) from env/flags. */
|
||||
resolveInstallOptions(): InstallOptions
|
||||
installService(cfg: AgentConfig, options: InstallOptions): Promise<void>
|
||||
uninstallService(): Promise<void>
|
||||
print(line: string): void
|
||||
}
|
||||
@@ -70,7 +73,7 @@ export async function runCli(args: CliArgs, deps: CliDeps): Promise<number> {
|
||||
ks.saveIdentity(id)
|
||||
const enroll = await deps.redeem(cfg, args.code!, id, ks)
|
||||
deps.print(`paired: host ${enroll.hostId} subdomain ${enroll.subdomain}`)
|
||||
if (args.flags['install']) await deps.installService(cfg)
|
||||
if (args.flags['install']) await deps.installService(cfg, deps.resolveInstallOptions())
|
||||
return 0
|
||||
}
|
||||
case 'run': {
|
||||
@@ -88,7 +91,7 @@ export async function runCli(args: CliArgs, deps: CliDeps): Promise<number> {
|
||||
return 0
|
||||
}
|
||||
case 'install':
|
||||
await deps.installService(cfg)
|
||||
await deps.installService(cfg, deps.resolveInstallOptions())
|
||||
return 0
|
||||
case 'uninstall':
|
||||
await deps.uninstallService()
|
||||
|
||||
86
agent/src/cli/deps.ts
Normal file
86
agent/src/cli/deps.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* CliDeps factory — PLAN_RELAY_PHASE1 C2. Wires the abstract `CliDeps` seams (consumed by
|
||||
* `runCli`) to their real implementations: env-driven config, the on-disk keystore, Ed25519
|
||||
* identity generation, §4.5 pairing redemption, the supervised tunnel, and OS service install.
|
||||
* All side effects live here so `cli.ts`/`runCli` stay pure and unit-testable.
|
||||
*/
|
||||
import { execFile } from 'node:child_process'
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { homedir, userInfo } from 'node:os'
|
||||
import { dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { CliDeps } from '../cli.js'
|
||||
import type { AgentConfig } from '../config/agentConfig.js'
|
||||
import { loadAgentConfig } from '../config/agentConfig.js'
|
||||
import { openKeystore } from '../keys/keystore.js'
|
||||
import { generateIdentity } from '../keys/identity.js'
|
||||
import { redeemPairingCode } from '../enroll/pair.js'
|
||||
import { runTunnel } from '../transport/runTunnel.js'
|
||||
import {
|
||||
buildInstallOptions,
|
||||
detectPlatform,
|
||||
installService as installServiceUnit,
|
||||
uninstallService as uninstallServiceUnit,
|
||||
type InstallDeps,
|
||||
type ServicePlatform,
|
||||
} from '../service/install.js'
|
||||
|
||||
/** Resolve this process's own executable path (the bundled `dist/cli.js`) for the service unit. */
|
||||
function selfBinPath(): string {
|
||||
return fileURLToPath(import.meta.url)
|
||||
}
|
||||
|
||||
function runCommand(cmd: string, args: readonly string[]): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
execFile(cmd, [...args], (err) => (err ? reject(err) : resolve()))
|
||||
})
|
||||
}
|
||||
|
||||
function realInstallDeps(): InstallDeps {
|
||||
return {
|
||||
writeFile: (path, content) => {
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
writeFileSync(path, content)
|
||||
},
|
||||
runCommand,
|
||||
getuid: () => (typeof process.getuid === 'function' ? process.getuid() : 0),
|
||||
homedir,
|
||||
username: () => userInfo().username,
|
||||
binPath: selfBinPath,
|
||||
}
|
||||
}
|
||||
|
||||
/** Map the current OS to its service manager, or fail fast with a clear message. */
|
||||
function requirePlatform(): ServicePlatform {
|
||||
const platform = detectPlatform(process.platform)
|
||||
if (platform === null) {
|
||||
throw new Error(`service install/uninstall is unsupported on platform '${process.platform}'`)
|
||||
}
|
||||
return platform
|
||||
}
|
||||
|
||||
/** Build the concrete CliDeps used by the real CLI entrypoint. */
|
||||
export function createCliDeps(): CliDeps {
|
||||
return {
|
||||
loadConfig: () => loadAgentConfig(process.env),
|
||||
openKeystore: (stateDir) => openKeystore(stateDir),
|
||||
generateIdentity: () => generateIdentity(),
|
||||
redeem: (cfg: AgentConfig, code, id, ks) => redeemPairingCode(cfg.enrollUrl, code, id, ks),
|
||||
runTunnel: async (cfg, ks) => {
|
||||
const handle = await runTunnel(cfg, ks)
|
||||
const onSignal = (): void => {
|
||||
void handle.stop()
|
||||
}
|
||||
process.once('SIGTERM', onSignal)
|
||||
process.once('SIGINT', onSignal)
|
||||
return handle.done
|
||||
},
|
||||
resolveInstallOptions: () => buildInstallOptions(process.env),
|
||||
installService: (cfg, options) =>
|
||||
installServiceUnit(cfg, requirePlatform(), realInstallDeps(), options),
|
||||
uninstallService: () => uninstallServiceUnit(requirePlatform(), { runCommand, homedir }),
|
||||
print: (line) => {
|
||||
process.stdout.write(`${line}\n`)
|
||||
},
|
||||
}
|
||||
}
|
||||
30
agent/src/main.ts
Normal file
30
agent/src/main.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* CLI bootstrap — PLAN_RELAY_PHASE1 C2. The `dist/cli.js` entrypoint (the `#!/usr/bin/env node`
|
||||
* shebang is prepended at build time via esbuild `--banner`, NOT here). Reads argv, builds the
|
||||
* real CliDeps, dispatches through `runCli`, and maps any error to a clean stderr line + exit code
|
||||
* (usage errors ⇒ 2, everything else ⇒ 1) so no invocation ever crashes with a raw stack trace.
|
||||
*/
|
||||
import { parseArgs, runCli, CliUsageError } from './cli.js'
|
||||
import { createCliDeps } from './cli/deps.js'
|
||||
|
||||
async function main(): Promise<number> {
|
||||
const argv = process.argv.slice(2)
|
||||
const deps = createCliDeps()
|
||||
try {
|
||||
return await runCli(parseArgs(argv), deps)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
process.stderr.write(`web-terminal-agent: ${message}\n`)
|
||||
return err instanceof CliUsageError ? 2 : 1
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then((code) => {
|
||||
process.exitCode = code
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
process.stderr.write(`web-terminal-agent: fatal ${message}\n`)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -9,16 +9,69 @@ import {
|
||||
launchdLoadCommand,
|
||||
launchdPlistPath,
|
||||
launchdUnloadCommand,
|
||||
type ServiceEnv,
|
||||
} from './launchd.js'
|
||||
import {
|
||||
buildSystemdUnit,
|
||||
systemdDisableCommand,
|
||||
systemdEnableCommand,
|
||||
systemdUnitPath,
|
||||
type SystemdUnitOptions,
|
||||
} from './systemd.js'
|
||||
import { DEFAULT_ORIGIN_ZONE, mergeOrigins, subdomainOrigin } from './originConfig.js'
|
||||
|
||||
export type ServicePlatform = 'launchd' | 'systemd'
|
||||
|
||||
/**
|
||||
* Per-host packaging inputs threaded to the unit writers (PLAN_NATIVE_TUNNEL S2). All optional so
|
||||
* existing callers (and relay callers) are unaffected — an empty object writes the historical unit.
|
||||
*/
|
||||
export interface InstallOptions {
|
||||
/** S0 env injected into the supervised process (BIND_HOST, ALLOWED_ORIGINS, PORT, …). */
|
||||
readonly env?: ServiceEnv
|
||||
/** systemd `EnvironmentFile=` path (preferred over inline for values best kept off the unit). */
|
||||
readonly envFile?: string
|
||||
/** DNS zone label for the tunnel origin (`terminal` for native-tunnel hosts, default `term`). */
|
||||
readonly zone?: string
|
||||
/** Parent domain (e.g. `yaojia.wang`); with `cfg.subdomain` derives the tunnel ALLOWED_ORIGINS. */
|
||||
readonly domain?: string
|
||||
}
|
||||
|
||||
/** S0 base-app env vars the install CLI bakes into the per-host unit, passed through verbatim. */
|
||||
const BASE_APP_ENV_KEYS = ['ALLOWED_ORIGINS', 'PORT', 'SHELL_PATH', 'IDLE_TTL', 'USE_TMUX'] as const
|
||||
|
||||
/**
|
||||
* Tunnel hosts MUST bind loopback. At the relay the device-cert mTLS is the ONLY auth gate, so a
|
||||
* default `0.0.0.0` bind (`src/config.ts`) would serve an unauth'd shell on the LAN, bypassing mTLS
|
||||
* entirely (PLAN_NATIVE_TUNNEL S0/R2). `buildInstallOptions` therefore defaults `BIND_HOST` here.
|
||||
*/
|
||||
export const TUNNEL_DEFAULT_BIND_HOST = '127.0.0.1'
|
||||
|
||||
/** Native-tunnel origin zone → `https://<subdomain>.terminal.<domain>`; overridable via TUNNEL_ZONE. */
|
||||
export const TUNNEL_ORIGIN_ZONE = 'terminal'
|
||||
|
||||
/**
|
||||
* Resolve the per-host `InstallOptions` from the process environment (PLAN_NATIVE_TUNNEL S0/S2).
|
||||
* Sources the S0 base-app env — defaulting `BIND_HOST` to loopback so a tunnel install can never
|
||||
* emit a LAN-exposed unit — plus the tunnel-origin `domain`/`zone` used to derive ALLOWED_ORIGINS.
|
||||
* Pure/immutable: `env` is a parameter, so this is unit-testable without touching real process state.
|
||||
*/
|
||||
export function buildInstallOptions(env: NodeJS.ProcessEnv): InstallOptions {
|
||||
const passthrough = Object.fromEntries(
|
||||
BASE_APP_ENV_KEYS.map((key) => [key, env[key]] as [string, string | undefined]).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1].length > 0,
|
||||
),
|
||||
)
|
||||
const serviceEnv: ServiceEnv = { BIND_HOST: env.BIND_HOST || TUNNEL_DEFAULT_BIND_HOST, ...passthrough }
|
||||
const domain = env.TUNNEL_DOMAIN
|
||||
const envFile = env.AGENT_ENV_FILE
|
||||
return {
|
||||
env: serviceEnv,
|
||||
...(domain ? { domain, zone: env.TUNNEL_ZONE || TUNNEL_ORIGIN_ZONE } : {}),
|
||||
...(envFile ? { envFile } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export class RootRefusedError extends Error {
|
||||
constructor() {
|
||||
super('refusing to install the agent service as root — run as the logged-in user (least privilege)')
|
||||
@@ -42,23 +95,40 @@ export function detectPlatform(os: NodeJS.Platform): ServicePlatform | null {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the env map injected into the unit. When a `domain` (+ `cfg.subdomain`) is supplied, the
|
||||
* tunnel origin `https://<subdomain>.<zone>.<domain>` is merged into ALLOWED_ORIGINS so the base app
|
||||
* trusts its own tunnel host — never weakening any origin the caller already provided. Immutable.
|
||||
*/
|
||||
function resolveEnv(cfg: AgentConfig, options: InstallOptions): ServiceEnv {
|
||||
const base = options.env ?? {}
|
||||
if (!options.domain || !cfg.subdomain) return base
|
||||
const origin = subdomainOrigin(cfg.subdomain, options.domain, options.zone ?? DEFAULT_ORIGIN_ZONE)
|
||||
return { ...base, ALLOWED_ORIGINS: mergeOrigins(base.ALLOWED_ORIGINS, origin) }
|
||||
}
|
||||
|
||||
/** Write + load the service unit for `platform`. Throws RootRefusedError if running as root. */
|
||||
export async function installService(
|
||||
_cfg: AgentConfig,
|
||||
cfg: AgentConfig,
|
||||
platform: ServicePlatform,
|
||||
deps: InstallDeps,
|
||||
options: InstallOptions = {},
|
||||
): Promise<void> {
|
||||
if (deps.getuid() === 0) throw new RootRefusedError()
|
||||
const bin = deps.binPath()
|
||||
const env = resolveEnv(cfg, options)
|
||||
if (platform === 'launchd') {
|
||||
const path = launchdPlistPath(deps.homedir())
|
||||
deps.writeFile(path, buildLaunchdPlist(bin))
|
||||
deps.writeFile(path, buildLaunchdPlist(bin, env))
|
||||
const { cmd, args } = launchdLoadCommand(path)
|
||||
await deps.runCommand(cmd, args)
|
||||
return
|
||||
}
|
||||
const path = systemdUnitPath(deps.homedir())
|
||||
deps.writeFile(path, buildSystemdUnit(bin, deps.username()))
|
||||
const systemdOptions: SystemdUnitOptions = options.envFile
|
||||
? { env, envFile: options.envFile }
|
||||
: { env }
|
||||
deps.writeFile(path, buildSystemdUnit(bin, deps.username(), systemdOptions))
|
||||
const { cmd, args } = systemdEnableCommand()
|
||||
await deps.runCommand(cmd, args)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
/**
|
||||
* macOS launchd plist writer — PLAN_RELAY_AGENT T17. The service runs as the LOGGED-IN USER, not
|
||||
* root (EXPLORE §4d least privilege); no secrets in the plist (key/cert stay in the keystore).
|
||||
*
|
||||
* PLAN_NATIVE_TUNNEL S2: the plist can now inject a caller-supplied per-host env map
|
||||
* (BIND_HOST, ALLOWED_ORIGINS, PORT, SHELL_PATH, IDLE_TTL, USE_TMUX, …) as a launchd
|
||||
* `<key>EnvironmentVariables</key><dict>…</dict>` block. Values are XML-escaped and keys are
|
||||
* sorted for deterministic, immutable output.
|
||||
*/
|
||||
|
||||
/** Shared env-map shape for the durable-service writers (launchd + systemd). Immutable. */
|
||||
export type ServiceEnv = Readonly<Record<string, string>>
|
||||
|
||||
const LABEL = 'com.web-terminal.agent'
|
||||
|
||||
export function launchdLabel(): string {
|
||||
@@ -12,24 +21,52 @@ export function launchdPlistPath(homedir: string): string {
|
||||
return `${homedir}/Library/LaunchAgents/${LABEL}.plist`
|
||||
}
|
||||
|
||||
/** Build the plist. ExecStart = `<bin> run`; RunAtLoad + KeepAlive (restart on failure). */
|
||||
export function buildLaunchdPlist(binPath: string): string {
|
||||
/** Escape the five XML-significant characters so env keys/values/paths are plist-safe. */
|
||||
function escapeXml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
/** Build the `<key>EnvironmentVariables</key><dict>…</dict>` lines (empty array when env is empty). */
|
||||
function environmentVariablesBlock(env: ServiceEnv): readonly string[] {
|
||||
const entries = Object.entries(env).sort(([a], [b]) => a.localeCompare(b))
|
||||
if (entries.length === 0) return []
|
||||
const lines = [' <key>EnvironmentVariables</key>', ' <dict>']
|
||||
for (const [key, value] of entries) {
|
||||
lines.push(` <key>${escapeXml(key)}</key>`)
|
||||
lines.push(` <string>${escapeXml(value)}</string>`)
|
||||
}
|
||||
lines.push(' </dict>')
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the plist. ExecStart = `<bin> run`; RunAtLoad + KeepAlive (restart on failure). When `env`
|
||||
* is non-empty, a launchd `EnvironmentVariables` dict is injected so the supervised process
|
||||
* receives the per-host tunnel config. Pure/immutable — returns a fresh string.
|
||||
*/
|
||||
export function buildLaunchdPlist(binPath: string, env: ServiceEnv = {}): string {
|
||||
return [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
||||
'<plist version="1.0">',
|
||||
'<dict>',
|
||||
' <key>Label</key>',
|
||||
` <string>${LABEL}</string>`,
|
||||
` <string>${escapeXml(LABEL)}</string>`,
|
||||
' <key>ProgramArguments</key>',
|
||||
' <array>',
|
||||
` <string>${binPath}</string>`,
|
||||
` <string>${escapeXml(binPath)}</string>`,
|
||||
' <string>run</string>',
|
||||
' </array>',
|
||||
' <key>RunAtLoad</key>',
|
||||
' <true/>',
|
||||
' <key>KeepAlive</key>',
|
||||
' <true/>',
|
||||
...environmentVariablesBlock(env),
|
||||
'</dict>',
|
||||
'</plist>',
|
||||
'',
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
/**
|
||||
* The ONE base-app touch-point — PLAN_RELAY_AGENT T17 (INDEX §0, EXPLORE §3 "Zero code change").
|
||||
* APPENDS `https://<subdomain>.term.<domain>` to the base app's ALLOWED_ORIGINS env (idempotent),
|
||||
* APPENDS `https://<subdomain>.<zone>.<domain>` to the base app's ALLOWED_ORIGINS env (idempotent),
|
||||
* as CONFIG — NO `src/` code edit. AUGMENTS, never weakens, the Origin/CSWSH check: existing
|
||||
* origins are always preserved (EXPLORE §3 "do not weaken the check").
|
||||
*
|
||||
* PLAN_NATIVE_TUNNEL S2: the DNS zone label is PARAMETERIZED. Relay callers keep the historical
|
||||
* `term` zone (default), while native-tunnel hosts pass `terminal` so the base app trusts
|
||||
* `https://<name>.terminal.<domain>`. The default is preserved so existing callers/tests are
|
||||
* unaffected — the zone is opt-in per call, never hard-flipped.
|
||||
*/
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
|
||||
@@ -18,23 +23,40 @@ const defaultFs: OriginFsDeps = {
|
||||
write: (p, c) => writeFileSync(p, c),
|
||||
}
|
||||
|
||||
/** Compose the subdomain origin the base app must trust. */
|
||||
export function subdomainOrigin(subdomain: string, domain: string): string {
|
||||
return `https://${subdomain}.term.${domain}`
|
||||
}
|
||||
/** Default DNS zone label (relay hosts). Native-tunnel hosts pass `terminal`. */
|
||||
export const DEFAULT_ORIGIN_ZONE = 'term'
|
||||
|
||||
const KEY = 'ALLOWED_ORIGINS'
|
||||
|
||||
/** Compose the subdomain origin the base app must trust: `https://<subdomain>.<zone>.<domain>`. */
|
||||
export function subdomainOrigin(
|
||||
subdomain: string,
|
||||
domain: string,
|
||||
zone: string = DEFAULT_ORIGIN_ZONE,
|
||||
): string {
|
||||
return `https://${subdomain}.${zone}.${domain}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge `origin` into a comma-separated ALLOWED_ORIGINS value, preserving every existing origin and
|
||||
* de-duplicating. Returns the merged CSV; never removes an origin. Pure/immutable.
|
||||
*/
|
||||
export function mergeOrigins(current: string | undefined, origin: string): string {
|
||||
const origins = (current ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0)
|
||||
if (origins.includes(origin)) return origins.join(',')
|
||||
return [...origins, origin].join(',')
|
||||
}
|
||||
|
||||
function upsertOriginLine(content: string, origin: string): string {
|
||||
const lines = content.length === 0 ? [] : content.split('\n')
|
||||
let found = false
|
||||
const next = lines.map((line) => {
|
||||
if (!line.startsWith(`${KEY}=`)) return line
|
||||
found = true
|
||||
const current = line.slice(KEY.length + 1)
|
||||
const origins = current.split(',').map((s) => s.trim()).filter((s) => s.length > 0)
|
||||
if (origins.includes(origin)) return line // idempotent — already trusted
|
||||
return `${KEY}=${[...origins, origin].join(',')}`
|
||||
return `${KEY}=${mergeOrigins(line.slice(KEY.length + 1), origin)}`
|
||||
})
|
||||
if (!found) next.push(`${KEY}=${origin}`)
|
||||
return next.join('\n')
|
||||
@@ -42,15 +64,17 @@ function upsertOriginLine(content: string, origin: string): string {
|
||||
|
||||
/**
|
||||
* Idempotently append the subdomain origin to ALLOWED_ORIGINS in `baseAppEnvPath`. Never removes an
|
||||
* existing origin. Creates the file/line if absent.
|
||||
* existing origin. Creates the file/line if absent. `zone` selects the DNS zone label (default
|
||||
* preserves relay callers).
|
||||
*/
|
||||
export function ensureAllowedOrigin(
|
||||
baseAppEnvPath: string,
|
||||
subdomain: string,
|
||||
domain: string,
|
||||
fs: OriginFsDeps = defaultFs,
|
||||
zone: string = DEFAULT_ORIGIN_ZONE,
|
||||
): void {
|
||||
const origin = subdomainOrigin(subdomain, domain)
|
||||
const origin = subdomainOrigin(subdomain, domain, zone)
|
||||
const existing = fs.exists(baseAppEnvPath) ? fs.read(baseAppEnvPath) : ''
|
||||
const updated = upsertOriginLine(existing, origin)
|
||||
fs.write(baseAppEnvPath, updated.endsWith('\n') ? updated : `${updated}\n`)
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
/**
|
||||
* Linux systemd unit writer — PLAN_RELAY_AGENT T17. Runs as the LOGGED-IN USER (never root,
|
||||
* EXPLORE §4d), restart-on-failure; no secrets in the unit (key/cert stay in the keystore).
|
||||
*
|
||||
* PLAN_NATIVE_TUNNEL S2: the unit can now inject the per-host tunnel env via an
|
||||
* `EnvironmentFile=` line (preferred — keeps values out of the world-readable unit) and/or
|
||||
* inline `Environment=` lines from a caller-supplied env map. Inline `Environment=` is emitted
|
||||
* after `EnvironmentFile=` so an explicit value overrides the file on conflict.
|
||||
*/
|
||||
import type { ServiceEnv } from './launchd.js'
|
||||
|
||||
const UNIT_NAME = 'web-terminal-agent.service'
|
||||
|
||||
/** DEL (0x7F) and everything below the printable ASCII range are rejected in env values. */
|
||||
const FIRST_PRINTABLE_ASCII = 0x20
|
||||
const DEL_CODE = 0x7f
|
||||
|
||||
export interface SystemdUnitOptions {
|
||||
/** Inline env map → one `Environment="KEY=value"` line each (keys sorted, values escaped). */
|
||||
readonly env?: ServiceEnv
|
||||
/** Path referenced by a single `EnvironmentFile=` line (preferred over inline for secrets). */
|
||||
readonly envFile?: string
|
||||
}
|
||||
|
||||
export function systemdUnitName(): string {
|
||||
return UNIT_NAME
|
||||
}
|
||||
@@ -12,8 +30,53 @@ export function systemdUnitPath(homedir: string): string {
|
||||
return `${homedir}/.config/systemd/user/${UNIT_NAME}`
|
||||
}
|
||||
|
||||
/** Build the unit. ExecStart = `<bin> run`; Restart=on-failure; User=<user> (never root). */
|
||||
export function buildSystemdUnit(binPath: string, user: string): string {
|
||||
/**
|
||||
* True if `value` contains any control character (C0 range below 0x20, or DEL 0x7F). A raw
|
||||
* newline/CR would terminate the single `Environment=` line and let the remainder inject arbitrary
|
||||
* directives into the `[Service]` section — so such values are rejected rather than escaped.
|
||||
*/
|
||||
function hasControlChar(value: string): boolean {
|
||||
for (const char of value) {
|
||||
const code = char.codePointAt(0)
|
||||
if (code === undefined) continue
|
||||
if (code < FIRST_PRINTABLE_ASCII || code === DEL_CODE) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Quote a systemd `Environment=` value: reject control chars, then double-quote escaping `\` and `"`. */
|
||||
function quoteEnvAssignment(key: string, value: string): string {
|
||||
if (hasControlChar(value)) {
|
||||
throw new Error(
|
||||
`refusing to write systemd Environment for '${key}': value contains a control character ` +
|
||||
'(newline/CR/etc.) that could corrupt the [Service] section',
|
||||
)
|
||||
}
|
||||
const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
||||
return `"${key}=${escaped}"`
|
||||
}
|
||||
|
||||
/** Build the `EnvironmentFile=` / `Environment=` lines (empty array when neither is supplied). */
|
||||
function environmentLines(options: SystemdUnitOptions): readonly string[] {
|
||||
const lines: string[] = []
|
||||
if (options.envFile) lines.push(`EnvironmentFile=${options.envFile}`)
|
||||
const entries = Object.entries(options.env ?? {}).sort(([a], [b]) => a.localeCompare(b))
|
||||
for (const [key, value] of entries) {
|
||||
lines.push(`Environment=${quoteEnvAssignment(key, value)}`)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the unit. ExecStart = `<bin> run`; Restart=on-failure; User=<user> (never root). When
|
||||
* `options.env`/`options.envFile` are supplied, the per-host tunnel env is injected into the
|
||||
* `[Service]` section. Pure/immutable — returns a fresh string.
|
||||
*/
|
||||
export function buildSystemdUnit(
|
||||
binPath: string,
|
||||
user: string,
|
||||
options: SystemdUnitOptions = {},
|
||||
): string {
|
||||
return [
|
||||
'[Unit]',
|
||||
'Description=web-terminal host agent (rendezvous relay)',
|
||||
@@ -25,6 +88,7 @@ export function buildSystemdUnit(binPath: string, user: string): string {
|
||||
'Restart=on-failure',
|
||||
'RestartSec=1',
|
||||
`User=${user}`,
|
||||
...environmentLines(options),
|
||||
'',
|
||||
'[Install]',
|
||||
'WantedBy=default.target',
|
||||
|
||||
152
agent/src/transport/runTunnel.ts
Normal file
152
agent/src/transport/runTunnel.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Long-running tunnel supervisor — PLAN_RELAY_PHASE1 C2. Ports the PROVEN cafeDemo assembly
|
||||
* (dialRelay → holdTunnel → createStreamRouter → dialLoopback, plus heartbeat) into a supervised
|
||||
* loop that survives disconnects: exponential backoff reconnect (T10 policy), heartbeat liveness
|
||||
* (T9), and GOAWAY/revocation-aware teardown (T14, INV12 — a revoked host NEVER reconnects).
|
||||
*
|
||||
* All IO is injectable via `RunTunnelDeps` so the loop is unit-testable with fakes (see cafeDemo);
|
||||
* the two-arg `runTunnel(cfg, ks)` default path wires the real `ws` sockets. INV2 is preserved: the
|
||||
* router splices OPAQUE bytes (identityTransform) — no terminal parsing happens here.
|
||||
*/
|
||||
import { WebSocket } from 'ws'
|
||||
import type { AgentConfig } from '../config/agentConfig.js'
|
||||
import type { Keystore } from '../keys/keystore.js'
|
||||
import { createLogger, type Logger } from '../log/logger.js'
|
||||
import { createRevocationState, applyGoAway } from '../lifecycle/revocation.js'
|
||||
import { dialRelay, type TlsWsConstructor } from './dial.js'
|
||||
import { dialLoopback, type DialLoopback, type WsConstructor } from './loopback.js'
|
||||
import { holdTunnel, type Tunnel } from './tunnel.js'
|
||||
import { createStreamRouter, identityTransform } from './streamRouter.js'
|
||||
import { createHeartbeat } from './heartbeat.js'
|
||||
import { createBackoff, reconnectLoop, type BackoffPolicy, type Sleep } from './backoff.js'
|
||||
import type { TimerLike, WsLike } from './seams.js'
|
||||
|
||||
/** Handle returned by `runTunnel`: stop the supervisor, or await its terminal exit code. */
|
||||
export interface TunnelHandle {
|
||||
/** Request graceful shutdown; resolves once the supervisor loop has fully stopped. */
|
||||
stop(): Promise<void>
|
||||
/** Resolves with a process exit code when the loop ends (stopped or host revoked ⇒ 0). */
|
||||
readonly done: Promise<number>
|
||||
}
|
||||
|
||||
/** Injectable seams for the supervisor. All optional; unset fields default to real `ws` IO. */
|
||||
export interface RunTunnelDeps {
|
||||
connectRelay(): Promise<WsLike>
|
||||
dialLoopback: DialLoopback
|
||||
logger: Logger
|
||||
timer: TimerLike
|
||||
sleep: Sleep
|
||||
backoff: BackoffPolicy
|
||||
}
|
||||
|
||||
const realTimer: TimerLike = {
|
||||
setTimeout: (cb, ms) => setTimeout(cb, ms),
|
||||
clearTimeout: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
|
||||
setInterval: (cb, ms) => setInterval(cb, ms),
|
||||
clearInterval: (h) => clearInterval(h as ReturnType<typeof setInterval>),
|
||||
}
|
||||
const realSleep: Sleep = (ms) => new Promise<void>((r) => setTimeout(r, ms))
|
||||
|
||||
function resolveDeps(cfg: AgentConfig, ks: Keystore, o?: Partial<RunTunnelDeps>): RunTunnelDeps {
|
||||
return {
|
||||
connectRelay:
|
||||
o?.connectRelay ?? (() => dialRelay(cfg, ks, { Ctor: WebSocket as unknown as TlsWsConstructor })),
|
||||
dialLoopback: o?.dialLoopback ?? dialLoopback(cfg.localTargetUrl, WebSocket as unknown as WsConstructor),
|
||||
logger: o?.logger ?? createLogger('info'),
|
||||
timer: o?.timer ?? realTimer,
|
||||
sleep: o?.sleep ?? realSleep,
|
||||
backoff: o?.backoff ?? createBackoff({ jitter: true }),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the supervised tunnel. Returns immediately with a handle; the reconnect loop runs in the
|
||||
* background. Never awaits the first connection (an unreachable relay would otherwise hang the
|
||||
* caller with no way to `stop()`).
|
||||
*/
|
||||
export async function runTunnel(
|
||||
cfg: AgentConfig,
|
||||
ks: Keystore,
|
||||
overrides?: Partial<RunTunnelDeps>,
|
||||
): Promise<TunnelHandle> {
|
||||
const { connectRelay, dialLoopback: dialLb, logger, timer, sleep, backoff } = resolveDeps(cfg, ks, overrides)
|
||||
|
||||
let stopped = false
|
||||
let currentTunnel: Tunnel | null = null
|
||||
let currentSocket: WsLike | null = null
|
||||
|
||||
// INV12: revocation tears the live tunnel down immediately and suppresses all reconnects.
|
||||
const revocation = createRevocationState(() => currentTunnel?.close())
|
||||
const shouldStop = (): boolean => stopped || revocation.isRevoked()
|
||||
|
||||
async function dialTunnel(): Promise<Tunnel> {
|
||||
const socket = await connectRelay()
|
||||
currentSocket = socket
|
||||
return holdTunnel(socket)
|
||||
}
|
||||
|
||||
/** Run ONE tunnel session; resolves when this session ends (dead heartbeat / close / GOAWAY). */
|
||||
function runSession(tunnel: Tunnel, socket: WsLike): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
const router = createStreamRouter(cfg, tunnel, dialLb, identityTransform, logger)
|
||||
const heartbeat = createHeartbeat(tunnel, { timer })
|
||||
tunnel.dispatchTo(router, heartbeat)
|
||||
|
||||
let settled = false
|
||||
const endSession = (): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
heartbeat.stop()
|
||||
resolve()
|
||||
}
|
||||
|
||||
heartbeat.onDead(() => {
|
||||
logger.log('warn', 'heartbeat missed — tunnel presumed down, will reconnect')
|
||||
tunnel.close()
|
||||
endSession()
|
||||
})
|
||||
socket.on('close', () => endSession())
|
||||
socket.on('error', () => {
|
||||
tunnel.close()
|
||||
endSession()
|
||||
})
|
||||
tunnel.onGoAway((reason) => {
|
||||
const action = applyGoAway(reason, revocation) // 'revoked' ⇒ no reconnect (INV12)
|
||||
logger.log('info', 'received GOAWAY', { action })
|
||||
tunnel.close()
|
||||
endSession()
|
||||
})
|
||||
|
||||
heartbeat.start()
|
||||
})
|
||||
}
|
||||
|
||||
async function supervise(): Promise<number> {
|
||||
while (!shouldStop()) {
|
||||
const tunnel = await reconnectLoop(dialTunnel, backoff, shouldStop, sleep)
|
||||
if (tunnel === null || currentSocket === null) break
|
||||
if (shouldStop()) {
|
||||
// stop()/revoke raced with the in-flight dial — discard the fresh tunnel.
|
||||
tunnel.close()
|
||||
break
|
||||
}
|
||||
currentTunnel = tunnel
|
||||
logger.log('info', 'relay tunnel established')
|
||||
await runSession(tunnel, currentSocket)
|
||||
currentTunnel = null
|
||||
currentSocket = null
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const done = supervise()
|
||||
|
||||
return {
|
||||
async stop(): Promise<void> {
|
||||
stopped = true
|
||||
currentTunnel?.close()
|
||||
await done
|
||||
},
|
||||
done,
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ function deps(overrides: Partial<CliDeps> = {}, enrolled = false): { d: CliDeps;
|
||||
hostContentSecret: new Uint8Array([1]),
|
||||
}),
|
||||
runTunnel: async () => 0,
|
||||
resolveInstallOptions: () => ({ env: { BIND_HOST: '127.0.0.1' } }),
|
||||
installService: vi.fn(async () => {}),
|
||||
uninstallService: vi.fn(async () => {}),
|
||||
print: (l) => out.push(l),
|
||||
@@ -88,11 +89,22 @@ describe('runCli (T5)', () => {
|
||||
expect(out.join('\n')).not.toContain('PEM')
|
||||
})
|
||||
|
||||
it('pair --install installs the service', async () => {
|
||||
it('pair --install installs the service with the resolved options', async () => {
|
||||
const options = { env: { BIND_HOST: '127.0.0.1', PORT: '3000' } }
|
||||
const install = vi.fn(async () => {})
|
||||
const { d } = deps({ installService: install })
|
||||
const { d } = deps({ resolveInstallOptions: () => options, installService: install })
|
||||
await runCli(parseArgs(['pair', 'ABCD', '--install']), d)
|
||||
expect(install).toHaveBeenCalledOnce()
|
||||
expect(install).toHaveBeenCalledWith(CFG, options)
|
||||
})
|
||||
|
||||
it('install threads the resolved InstallOptions into installService (S2 env injection)', async () => {
|
||||
const options = { env: { BIND_HOST: '127.0.0.1' }, domain: 'yaojia.wang', zone: 'terminal' }
|
||||
const install = vi.fn(async () => {})
|
||||
const { d } = deps({ resolveInstallOptions: () => options, installService: install })
|
||||
const code = await runCli(parseArgs(['install']), d)
|
||||
expect(code).toBe(0)
|
||||
expect(install).toHaveBeenCalledWith(CFG, options)
|
||||
})
|
||||
|
||||
it('run before pairing fails fast', async () => {
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentConfig } from '../src/config/agentConfig.js'
|
||||
import {
|
||||
RootRefusedError,
|
||||
buildInstallOptions,
|
||||
detectPlatform,
|
||||
installService,
|
||||
uninstallService,
|
||||
type InstallDeps,
|
||||
} from '../src/service/install.js'
|
||||
import { buildLaunchdPlist } from '../src/service/launchd.js'
|
||||
import { buildSystemdUnit } from '../src/service/systemd.js'
|
||||
|
||||
const CFG: AgentConfig = {
|
||||
relayUrl: 'wss://relay/agent',
|
||||
@@ -74,3 +77,193 @@ describe('installService (T17)', () => {
|
||||
expect(d.runs[0]).toEqual(['launchctl', ['unload', '/home/alice/Library/LaunchAgents/com.web-terminal.agent.plist']])
|
||||
})
|
||||
})
|
||||
|
||||
const TUNNEL_ENV = {
|
||||
BIND_HOST: '127.0.0.1',
|
||||
ALLOWED_ORIGINS: 'https://t1.terminal.yaojia.wang',
|
||||
PORT: '3000',
|
||||
} as const
|
||||
|
||||
describe('env injection into the writers (PLAN_NATIVE_TUNNEL S2)', () => {
|
||||
it('launchd: default (no options) omits the EnvironmentVariables block', async () => {
|
||||
const d = deps()
|
||||
await installService(CFG, 'launchd', d)
|
||||
const [, plist] = d.writes[0]!
|
||||
expect(plist).not.toContain('EnvironmentVariables')
|
||||
})
|
||||
|
||||
it('launchd: injects a sorted, XML-escaped EnvironmentVariables dict', async () => {
|
||||
const d = deps()
|
||||
await installService(CFG, 'launchd', d, { env: TUNNEL_ENV })
|
||||
const [, plist] = d.writes[0]!
|
||||
expect(plist).toContain('<key>EnvironmentVariables</key>')
|
||||
expect(plist).toContain('<key>BIND_HOST</key>')
|
||||
expect(plist).toContain('<string>127.0.0.1</string>')
|
||||
// keys are sorted (ALLOWED_ORIGINS before BIND_HOST before PORT)
|
||||
expect(plist.indexOf('ALLOWED_ORIGINS')).toBeLessThan(plist.indexOf('BIND_HOST'))
|
||||
expect(plist.indexOf('BIND_HOST')).toBeLessThan(plist.indexOf('>PORT<'))
|
||||
})
|
||||
|
||||
it('launchd: escapes XML-significant characters in env values', () => {
|
||||
const plist = buildLaunchdPlist('/bin/agent', { X: `a&b<c>d"e'f` })
|
||||
expect(plist).toContain('<string>a&b<c>d"e'f</string>')
|
||||
expect(plist).not.toContain('a&b<c>d')
|
||||
})
|
||||
|
||||
it('systemd: default (no options) omits Environment lines', async () => {
|
||||
const d = deps()
|
||||
await installService(CFG, 'systemd', d)
|
||||
const [, unit] = d.writes[0]!
|
||||
expect(unit).not.toContain('Environment')
|
||||
})
|
||||
|
||||
it('systemd: emits sorted, quoted Environment= lines from the env map', async () => {
|
||||
const d = deps()
|
||||
await installService(CFG, 'systemd', d, { env: TUNNEL_ENV })
|
||||
const [, unit] = d.writes[0]!
|
||||
expect(unit).toContain('Environment="BIND_HOST=127.0.0.1"')
|
||||
expect(unit).toContain('Environment="PORT=3000"')
|
||||
expect(unit.indexOf('ALLOWED_ORIGINS')).toBeLessThan(unit.indexOf('BIND_HOST'))
|
||||
})
|
||||
|
||||
it('systemd: emits EnvironmentFile= (before inline Environment) when a path is given', async () => {
|
||||
const d = deps()
|
||||
await installService(CFG, 'systemd', d, { env: TUNNEL_ENV, envFile: '/etc/web-terminal.env' })
|
||||
const [, unit] = d.writes[0]!
|
||||
expect(unit).toContain('EnvironmentFile=/etc/web-terminal.env')
|
||||
expect(unit.indexOf('EnvironmentFile=')).toBeLessThan(unit.indexOf('Environment='))
|
||||
})
|
||||
|
||||
it('systemd: escapes backslash and double-quote in Environment values', () => {
|
||||
const unit = buildSystemdUnit('/bin/agent', 'alice', { env: { X: 'a"b\\c' } })
|
||||
expect(unit).toContain('Environment="X=a\\"b\\\\c"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tunnel-origin derivation (PLAN_NATIVE_TUNNEL S2)', () => {
|
||||
it('merges https://<subdomain>.<zone>.<domain> into ALLOWED_ORIGINS when domain is given', async () => {
|
||||
const d = deps()
|
||||
await installService(CFG, 'launchd', d, { domain: 'yaojia.wang', zone: 'terminal' })
|
||||
const [, plist] = d.writes[0]!
|
||||
expect(plist).toContain('<key>ALLOWED_ORIGINS</key>')
|
||||
expect(plist).toContain('<string>https://host-42.terminal.yaojia.wang</string>')
|
||||
})
|
||||
|
||||
it('defaults to the `term` zone when only a domain is supplied', async () => {
|
||||
const d = deps()
|
||||
await installService(CFG, 'launchd', d, { domain: 'yaojia.wang' })
|
||||
const [, plist] = d.writes[0]!
|
||||
expect(plist).toContain('<string>https://host-42.term.yaojia.wang</string>')
|
||||
})
|
||||
|
||||
it('preserves a caller-provided ALLOWED_ORIGINS and appends the tunnel origin', async () => {
|
||||
const d = deps()
|
||||
await installService(CFG, 'systemd', d, {
|
||||
env: { ALLOWED_ORIGINS: 'https://keep.me' },
|
||||
domain: 'yaojia.wang',
|
||||
zone: 'terminal',
|
||||
})
|
||||
const [, unit] = d.writes[0]!
|
||||
expect(unit).toContain('https://keep.me,https://host-42.terminal.yaojia.wang')
|
||||
})
|
||||
|
||||
it('does not derive an origin when the config has no subdomain', async () => {
|
||||
const d = deps()
|
||||
await installService({ ...CFG, subdomain: null }, 'launchd', d, { domain: 'yaojia.wang' })
|
||||
const [, plist] = d.writes[0]!
|
||||
expect(plist).not.toContain('ALLOWED_ORIGINS')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildInstallOptions — env → InstallOptions (PLAN_NATIVE_TUNNEL S0/S2)', () => {
|
||||
it('defaults BIND_HOST to loopback so a tunnel install is never LAN-exposed (S0/R2)', () => {
|
||||
const options = buildInstallOptions({})
|
||||
expect(options.env).toEqual({ BIND_HOST: '127.0.0.1' })
|
||||
})
|
||||
|
||||
it('honours an explicit BIND_HOST and passes through the S0 base-app env vars', () => {
|
||||
const options = buildInstallOptions({
|
||||
BIND_HOST: '127.0.0.2',
|
||||
PORT: '3000',
|
||||
SHELL_PATH: '/bin/zsh',
|
||||
IDLE_TTL: '86400',
|
||||
USE_TMUX: '1',
|
||||
ALLOWED_ORIGINS: 'https://keep.me',
|
||||
})
|
||||
expect(options.env).toEqual({
|
||||
BIND_HOST: '127.0.0.2',
|
||||
PORT: '3000',
|
||||
SHELL_PATH: '/bin/zsh',
|
||||
IDLE_TTL: '86400',
|
||||
USE_TMUX: '1',
|
||||
ALLOWED_ORIGINS: 'https://keep.me',
|
||||
})
|
||||
})
|
||||
|
||||
it('omits unset/empty passthrough vars', () => {
|
||||
const options = buildInstallOptions({ PORT: '', SHELL_PATH: '/bin/bash' })
|
||||
expect(options.env).toEqual({ BIND_HOST: '127.0.0.1', SHELL_PATH: '/bin/bash' })
|
||||
})
|
||||
|
||||
it('derives domain + default `terminal` zone from TUNNEL_DOMAIN', () => {
|
||||
const options = buildInstallOptions({ TUNNEL_DOMAIN: 'yaojia.wang' })
|
||||
expect(options.domain).toBe('yaojia.wang')
|
||||
expect(options.zone).toBe('terminal')
|
||||
})
|
||||
|
||||
it('lets TUNNEL_ZONE override the origin zone and carries AGENT_ENV_FILE through', () => {
|
||||
const options = buildInstallOptions({ TUNNEL_DOMAIN: 'yaojia.wang', TUNNEL_ZONE: 'term', AGENT_ENV_FILE: '/etc/wt.env' })
|
||||
expect(options.zone).toBe('term')
|
||||
expect(options.envFile).toBe('/etc/wt.env')
|
||||
})
|
||||
|
||||
it('omits domain/zone when no TUNNEL_DOMAIN is set', () => {
|
||||
const options = buildInstallOptions({})
|
||||
expect(options.domain).toBeUndefined()
|
||||
expect(options.zone).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('install CLI seam end-to-end — resolved env reaches the units (PLAN_NATIVE_TUNNEL S2)', () => {
|
||||
// Env the operator would export before `web-terminal-agent install` on a tunnel host.
|
||||
const ENV = { PORT: '3000', SHELL_PATH: '/bin/zsh', TUNNEL_DOMAIN: 'yaojia.wang' } as const
|
||||
|
||||
it('launchd: the plist carries loopback BIND_HOST + the derived tunnel ALLOWED_ORIGINS', async () => {
|
||||
const d = deps()
|
||||
await installService(CFG, 'launchd', d, buildInstallOptions(ENV))
|
||||
const [, plist] = d.writes[0]!
|
||||
expect(plist).toContain('<key>EnvironmentVariables</key>')
|
||||
expect(plist).toContain('<key>BIND_HOST</key>')
|
||||
expect(plist).toContain('<string>127.0.0.1</string>')
|
||||
expect(plist).toContain('<string>https://host-42.terminal.yaojia.wang</string>')
|
||||
expect(plist).toContain('<key>PORT</key>')
|
||||
expect(plist).not.toContain('0.0.0.0')
|
||||
})
|
||||
|
||||
it('systemd: the unit carries loopback BIND_HOST + the derived tunnel ALLOWED_ORIGINS', async () => {
|
||||
const d = deps()
|
||||
await installService(CFG, 'systemd', d, buildInstallOptions(ENV))
|
||||
const [, unit] = d.writes[0]!
|
||||
expect(unit).toContain('Environment="BIND_HOST=127.0.0.1"')
|
||||
expect(unit).toContain('Environment="ALLOWED_ORIGINS=https://host-42.terminal.yaojia.wang"')
|
||||
expect(unit).toContain('Environment="PORT=3000"')
|
||||
expect(unit).not.toContain('0.0.0.0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('systemd env value hardening (LOW: control-char injection)', () => {
|
||||
it('rejects a newline in an env value so it cannot inject a [Service] directive', () => {
|
||||
expect(() => buildSystemdUnit('/bin/agent', 'alice', { env: { X: 'a\nExecStartPre=/x' } })).toThrow(
|
||||
/control character/,
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a carriage return in an env value', () => {
|
||||
expect(() => buildSystemdUnit('/bin/agent', 'alice', { env: { X: 'a\rb' } })).toThrow(/control character/)
|
||||
})
|
||||
|
||||
it('still accepts ordinary values with quotes and backslashes', () => {
|
||||
const unit = buildSystemdUnit('/bin/agent', 'alice', { env: { X: 'a"b\\c' } })
|
||||
expect(unit).toContain('Environment="X=a\\"b\\\\c"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ensureAllowedOrigin, subdomainOrigin, type OriginFsDeps } from '../src/service/originConfig.js'
|
||||
import {
|
||||
DEFAULT_ORIGIN_ZONE,
|
||||
ensureAllowedOrigin,
|
||||
mergeOrigins,
|
||||
subdomainOrigin,
|
||||
type OriginFsDeps,
|
||||
} from '../src/service/originConfig.js'
|
||||
|
||||
function memFs(initial: string | null): { fs: OriginFsDeps; get(): string } {
|
||||
const store = { content: initial }
|
||||
@@ -41,3 +47,38 @@ describe('ensureAllowedOrigin (T17, EXPLORE §3)', () => {
|
||||
expect(get()).toContain('https://host-42.term.example.com')
|
||||
})
|
||||
})
|
||||
|
||||
describe('zone parameterization (PLAN_NATIVE_TUNNEL S2)', () => {
|
||||
it('defaults to the historical `term` zone', () => {
|
||||
expect(DEFAULT_ORIGIN_ZONE).toBe('term')
|
||||
expect(subdomainOrigin('t1', 'yaojia.wang')).toBe('https://t1.term.yaojia.wang')
|
||||
})
|
||||
|
||||
it('composes the `terminal` zone for native-tunnel hosts', () => {
|
||||
expect(subdomainOrigin('t1', 'yaojia.wang', 'terminal')).toBe('https://t1.terminal.yaojia.wang')
|
||||
})
|
||||
|
||||
it('ensureAllowedOrigin writes the caller-selected zone', () => {
|
||||
const { fs, get } = memFs('PORT=3000\n')
|
||||
ensureAllowedOrigin(PATH, 't1', 'yaojia.wang', fs, 'terminal')
|
||||
expect(get()).toContain('ALLOWED_ORIGINS=https://t1.terminal.yaojia.wang')
|
||||
expect(get()).not.toContain('.term.yaojia.wang')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeOrigins (PLAN_NATIVE_TUNNEL S2)', () => {
|
||||
it('appends to an empty/undefined value', () => {
|
||||
expect(mergeOrigins(undefined, 'https://a.example.com')).toBe('https://a.example.com')
|
||||
expect(mergeOrigins('', 'https://a.example.com')).toBe('https://a.example.com')
|
||||
})
|
||||
|
||||
it('de-duplicates an origin already present', () => {
|
||||
expect(mergeOrigins('https://a.example.com', 'https://a.example.com')).toBe('https://a.example.com')
|
||||
})
|
||||
|
||||
it('appends a new origin, trimming whitespace, preserving existing ones', () => {
|
||||
expect(mergeOrigins(' https://a.example.com , https://b.example.com ', 'https://c.example.com')).toBe(
|
||||
'https://a.example.com,https://b.example.com,https://c.example.com',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
128
agent/test/runTunnel.test.ts
Normal file
128
agent/test/runTunnel.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { decodeMuxFrame, encodeGoaway, encodeMuxFrame, encodeOpen, type MuxOpen } from 'relay-contracts'
|
||||
import type { AgentConfig } from '../src/config/agentConfig.js'
|
||||
import type { Keystore } from '../src/keys/keystore.js'
|
||||
import { createBackoff } from '../src/transport/backoff.js'
|
||||
import { runTunnel, type RunTunnelDeps } from '../src/transport/runTunnel.js'
|
||||
import { FakeTimer, FakeWs } from './fixtures/fakes.js'
|
||||
|
||||
/**
|
||||
* C2 supervisor: proves runTunnel ports the cafeDemo assembly into a supervised loop —
|
||||
* bytes splice both ways, a dead session reconnects, and a `revoked` GOAWAY stops for good (INV12).
|
||||
*/
|
||||
const CFG: AgentConfig = {
|
||||
relayUrl: 'wss://relay/agent',
|
||||
enrollUrl: 'https://x/enroll',
|
||||
stateDir: '/tmp/x',
|
||||
localTargetUrl: 'ws://127.0.0.1:3000',
|
||||
subdomain: 'host-42',
|
||||
hostId: 'h-1',
|
||||
}
|
||||
const OPEN: MuxOpen = {
|
||||
streamId: 5,
|
||||
subdomain: 'host-42',
|
||||
requestPath: '/term?join=abc',
|
||||
originHeader: 'https://host-42.term.example.com',
|
||||
remoteAddrHash: 'x',
|
||||
capabilityTokenRef: 'jti',
|
||||
}
|
||||
const KS = {} as unknown as Keystore // unused when connectRelay/dialLoopback are injected
|
||||
const flush = (): Promise<void> => new Promise((r) => setImmediate(r))
|
||||
|
||||
function emitOpen(upstream: FakeWs, open: MuxOpen): void {
|
||||
const payload = encodeOpen(open)
|
||||
upstream.emitMessage(
|
||||
encodeMuxFrame(
|
||||
{ version: 1, type: 'open', fin: false, rst: false, streamId: open.streamId, payloadLen: payload.length },
|
||||
payload,
|
||||
),
|
||||
)
|
||||
}
|
||||
function emitGoAwayRevoked(upstream: FakeWs): void {
|
||||
const payload = encodeGoaway(0, 'revoked')
|
||||
upstream.emitMessage(
|
||||
encodeMuxFrame(
|
||||
{ version: 1, type: 'goaway', fin: false, rst: false, streamId: 0, payloadLen: payload.length },
|
||||
payload,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function baseDeps(over: Partial<RunTunnelDeps>): Partial<RunTunnelDeps> {
|
||||
return {
|
||||
timer: new FakeTimer(), // never auto-fires ⇒ heartbeat is inert during the test
|
||||
sleep: async () => {},
|
||||
backoff: createBackoff(),
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('runTunnel supervisor (C2)', () => {
|
||||
it('splices bytes both ways through the loopback', async () => {
|
||||
const upstream = new FakeWs()
|
||||
const loopback = new FakeWs()
|
||||
const connectRelay = vi.fn(async () => upstream)
|
||||
const dialLoopback = vi.fn(async () => loopback)
|
||||
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay, dialLoopback: dialLoopback as never }))
|
||||
await flush()
|
||||
|
||||
emitOpen(upstream, OPEN)
|
||||
await flush()
|
||||
expect(dialLoopback).toHaveBeenCalledWith('/term?join=abc', 'https://host-42.term.example.com')
|
||||
|
||||
upstream.emitMessage(encodeMuxFrame({ version: 1, type: 'data', fin: false, rst: false, streamId: 5, payloadLen: 3 }, new Uint8Array([104, 105, 10])))
|
||||
expect(loopback.sent.at(-1)).toEqual(new Uint8Array([104, 105, 10]))
|
||||
|
||||
loopback.emit('message', new Uint8Array([79, 75])) // "OK" echoes back upstream as DATA
|
||||
const last = decodeMuxFrame(upstream.sent.at(-1)!)
|
||||
expect(last.header.type).toBe('data')
|
||||
expect([...last.payload]).toEqual([79, 75])
|
||||
|
||||
await handle.stop()
|
||||
expect(await handle.done).toBe(0)
|
||||
})
|
||||
|
||||
it('reconnects after the tunnel dies', async () => {
|
||||
const sockets = [new FakeWs(), new FakeWs()]
|
||||
let i = 0
|
||||
const connectRelay = vi.fn(async () => sockets[i++]!)
|
||||
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay, dialLoopback: async () => new FakeWs() }))
|
||||
await flush()
|
||||
expect(connectRelay).toHaveBeenCalledTimes(1)
|
||||
|
||||
sockets[0]!.emit('close') // first session dies ⇒ supervisor redials
|
||||
await flush()
|
||||
expect(connectRelay).toHaveBeenCalledTimes(2)
|
||||
|
||||
await handle.stop()
|
||||
})
|
||||
|
||||
it('a revoked GOAWAY tears down and NEVER reconnects (INV12)', async () => {
|
||||
const connectRelay = vi.fn(async () => new FakeWs())
|
||||
let socket: FakeWs | undefined
|
||||
const wrapped = vi.fn(async () => {
|
||||
socket = new FakeWs()
|
||||
return socket
|
||||
})
|
||||
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay: wrapped, dialLoopback: async () => new FakeWs() }))
|
||||
await flush()
|
||||
expect(wrapped).toHaveBeenCalledTimes(1)
|
||||
|
||||
emitGoAwayRevoked(socket!)
|
||||
await flush()
|
||||
|
||||
expect(await handle.done).toBe(0)
|
||||
expect(wrapped).toHaveBeenCalledTimes(1) // no reconnect after revocation
|
||||
expect(connectRelay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stop() ends the loop with exit code 0 and does not reconnect', async () => {
|
||||
const connectRelay = vi.fn(async () => new FakeWs())
|
||||
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay, dialLoopback: async () => new FakeWs() }))
|
||||
await flush()
|
||||
|
||||
await handle.stop()
|
||||
expect(await handle.done).toBe(0)
|
||||
expect(connectRelay).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
15
control-plane/db/migrations/0002_routes.sql
Normal file
15
control-plane/db/migrations/0002_routes.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- A1 — routes table (P3-owned). Not present in 0001_init.sql: the RouteStore port is a
|
||||
-- Redis-like live routing table (INV7 — NOT source of truth). This Postgres adapter needs a
|
||||
-- durable backing for it. Postgres has no per-key TTL, so we store an explicit `expires_at`
|
||||
-- and treat `expires_at <= now()` as ABSENT (fail-closed, INV7); expired rows are lazily
|
||||
-- DELETEd on access. This mirrors `store/memory.ts` memRouteStore() semantics exactly.
|
||||
--
|
||||
-- No FK to hosts(host_id): routes are an ephemeral location cache keyed by host_id, decoupled
|
||||
-- from the ownership source of truth (matches memory.ts, where routes live independently).
|
||||
CREATE TABLE IF NOT EXISTS routes (
|
||||
host_id uuid PRIMARY KEY,
|
||||
relay_node_id text NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
expires_at timestamptz NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS routes_node_idx ON routes(relay_node_id);
|
||||
741
control-plane/package-lock.json
generated
741
control-plane/package-lock.json
generated
@@ -8,9 +8,12 @@
|
||||
"name": "control-plane",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@peculiar/x509": "^2.0.0",
|
||||
"fastify": "^4.28.1",
|
||||
"ioredis": "^5.4.1",
|
||||
"pg": "^8.12.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"relay-auth": "file:../relay-auth",
|
||||
"relay-contracts": "file:../relay-contracts",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
@@ -18,6 +21,23 @@
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/pg": "^8.11.10",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"../relay-auth": {
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"relay-contracts": "file:../relay-contracts",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.3",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.9"
|
||||
},
|
||||
@@ -133,6 +153,448 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/ajv-compiler": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz",
|
||||
@@ -231,6 +693,162 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-cms": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz",
|
||||
"integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.8.0",
|
||||
"@peculiar/asn1-x509": "^2.8.0",
|
||||
"@peculiar/asn1-x509-attr": "^2.8.0",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-csr": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz",
|
||||
"integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.8.0",
|
||||
"@peculiar/asn1-x509": "^2.8.0",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-ecc": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz",
|
||||
"integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.8.0",
|
||||
"@peculiar/asn1-x509": "^2.8.0",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pfx": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz",
|
||||
"integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.8.0",
|
||||
"@peculiar/asn1-pkcs8": "^2.8.0",
|
||||
"@peculiar/asn1-rsa": "^2.8.0",
|
||||
"@peculiar/asn1-schema": "^2.8.0",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pkcs8": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz",
|
||||
"integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.8.0",
|
||||
"@peculiar/asn1-x509": "^2.8.0",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pkcs9": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz",
|
||||
"integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.8.0",
|
||||
"@peculiar/asn1-pfx": "^2.8.0",
|
||||
"@peculiar/asn1-pkcs8": "^2.8.0",
|
||||
"@peculiar/asn1-schema": "^2.8.0",
|
||||
"@peculiar/asn1-x509": "^2.8.0",
|
||||
"@peculiar/asn1-x509-attr": "^2.8.0",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-rsa": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz",
|
||||
"integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.8.0",
|
||||
"@peculiar/asn1-x509": "^2.8.0",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-schema": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz",
|
||||
"integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/utils": "^2.0.2",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-x509": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz",
|
||||
"integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.8.0",
|
||||
"@peculiar/utils": "^2.0.2",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-x509-attr": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz",
|
||||
"integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.8.0",
|
||||
"@peculiar/asn1-x509": "^2.8.0",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/utils": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
|
||||
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/x509": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-2.0.0.tgz",
|
||||
"integrity": "sha512-r10lkuy6BNfRmyYdRAfgu6dq0HOmyIV2OLhXWE3gDEPBdX1b8miztJVyX/UxWhLwemNyDP3CLZHpDxDwSY0xaA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.6.0",
|
||||
"@peculiar/asn1-csr": "^2.6.0",
|
||||
"@peculiar/asn1-ecc": "^2.6.0",
|
||||
"@peculiar/asn1-pkcs9": "^2.6.0",
|
||||
"@peculiar/asn1-rsa": "^2.6.0",
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.0",
|
||||
"pvtsutils": "^1.3.6",
|
||||
"tslib": "^2.8.1",
|
||||
"tsyringe": "^4.10.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@pinojs/redact": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||
@@ -765,6 +1383,20 @@
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/asn1js": {
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
|
||||
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"pvtsutils": "^1.3.6",
|
||||
"pvutils": "^1.1.5",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
@@ -884,6 +1516,48 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.1",
|
||||
"@esbuild/android-arm": "0.28.1",
|
||||
"@esbuild/android-arm64": "0.28.1",
|
||||
"@esbuild/android-x64": "0.28.1",
|
||||
"@esbuild/darwin-arm64": "0.28.1",
|
||||
"@esbuild/darwin-x64": "0.28.1",
|
||||
"@esbuild/freebsd-arm64": "0.28.1",
|
||||
"@esbuild/freebsd-x64": "0.28.1",
|
||||
"@esbuild/linux-arm": "0.28.1",
|
||||
"@esbuild/linux-arm64": "0.28.1",
|
||||
"@esbuild/linux-ia32": "0.28.1",
|
||||
"@esbuild/linux-loong64": "0.28.1",
|
||||
"@esbuild/linux-mips64el": "0.28.1",
|
||||
"@esbuild/linux-ppc64": "0.28.1",
|
||||
"@esbuild/linux-riscv64": "0.28.1",
|
||||
"@esbuild/linux-s390x": "0.28.1",
|
||||
"@esbuild/linux-x64": "0.28.1",
|
||||
"@esbuild/netbsd-arm64": "0.28.1",
|
||||
"@esbuild/netbsd-x64": "0.28.1",
|
||||
"@esbuild/openbsd-arm64": "0.28.1",
|
||||
"@esbuild/openbsd-x64": "0.28.1",
|
||||
"@esbuild/openharmony-arm64": "0.28.1",
|
||||
"@esbuild/sunos-x64": "0.28.1",
|
||||
"@esbuild/win32-arm64": "0.28.1",
|
||||
"@esbuild/win32-ia32": "0.28.1",
|
||||
"@esbuild/win32-x64": "0.28.1"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
@@ -1791,6 +2465,24 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/pvtsutils": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
|
||||
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pvutils": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
|
||||
"integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/quick-format-unescaped": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
||||
@@ -1827,6 +2519,16 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect-metadata": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/relay-auth": {
|
||||
"resolved": "../relay-auth",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/relay-contracts": {
|
||||
"resolved": "../relay-contracts",
|
||||
"link": true
|
||||
@@ -2075,9 +2777,44 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.23.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz",
|
||||
"integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==",
|
||||
"dev": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.28.0"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/tsyringe": {
|
||||
"version": "4.10.0",
|
||||
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
|
||||
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^1.9.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tsyringe/node_modules/tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
|
||||
@@ -9,22 +9,27 @@
|
||||
},
|
||||
"main": "src/main.ts",
|
||||
"scripts": {
|
||||
"start": "tsx src/server.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"relay-contracts": "file:../relay-contracts",
|
||||
"@peculiar/x509": "^2.0.0",
|
||||
"fastify": "^4.28.1",
|
||||
"ioredis": "^5.4.1",
|
||||
"pg": "^8.12.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"relay-auth": "file:../relay-auth",
|
||||
"relay-contracts": "file:../relay-contracts",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/pg": "^8.11.10",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
|
||||
@@ -20,9 +20,12 @@ export class AuthzError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** P5's verifier, injected. Mirrors the frozen `verifyCapabilityToken` signature (§4.3). */
|
||||
/**
|
||||
* P5's verifier, injected. Mirrors the frozen `verifyCapabilityToken` signature (§4.3), which is
|
||||
* ASYNC (Ed25519 verify over WebCrypto) — so this seam returns a Promise and all call sites await.
|
||||
*/
|
||||
export interface CapabilityVerifier {
|
||||
verify(raw: string, expectedAud: string, now: number): CapabilityToken
|
||||
verify(raw: string, expectedAud: string, now: number): Promise<CapabilityToken>
|
||||
}
|
||||
|
||||
/** Minimal request shape we read for the bearer token (never a body account field). */
|
||||
@@ -49,19 +52,19 @@ function extractRawToken(req: AuthRequest): string | null {
|
||||
}
|
||||
|
||||
export interface Authorizer {
|
||||
principalFromRequest(req: AuthRequest): AdminPrincipal
|
||||
principalFromRequest(req: AuthRequest): Promise<AdminPrincipal>
|
||||
requireRight(principal: AdminPrincipal, right: CapabilityRight): void
|
||||
}
|
||||
|
||||
export function createAuthorizer(deps: AuthzDeps): Authorizer {
|
||||
const now = deps.now ?? (() => Math.floor(Date.now() / 1000))
|
||||
return {
|
||||
principalFromRequest(req) {
|
||||
async principalFromRequest(req) {
|
||||
const raw = extractRawToken(req)
|
||||
if (raw === null) throw new AuthzError(401, 'missing capability token')
|
||||
let token: CapabilityToken
|
||||
try {
|
||||
token = deps.verifier.verify(raw, deps.expectedAud, now())
|
||||
token = await deps.verifier.verify(raw, deps.expectedAud, now())
|
||||
} catch (err: unknown) {
|
||||
throw new AuthzError(401, `invalid capability token: ${err instanceof Error ? err.message : 'rejected'}`)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { HostRegistry } from '../registry/hosts.js'
|
||||
import type { PairingIssuer } from '../pairing/issue.js'
|
||||
import type { PairingRedeemer } from '../pairing/redeem.js'
|
||||
import { RedeemError } from '../pairing/redeem.js'
|
||||
import { decodeCsrWire } from '../ca/csr.js'
|
||||
import type { Deprovisioner } from '../deprovision/deprovision.js'
|
||||
|
||||
export interface ProvisionDeps {
|
||||
@@ -30,8 +31,8 @@ const PlanSchema = z.enum(['free', 'personal', 'pro', 'team'])
|
||||
const StatusSchema = z.object({ status: z.enum(['active', 'suspended']) })
|
||||
const EnrollSchema = z.object({
|
||||
code: z.string().min(1),
|
||||
agentPubkey: z.string().min(1), // base64
|
||||
csr: z.string().min(1), // base64
|
||||
agentPubkey: z.string().min(1), // base64 (agent sends base64url; Buffer decodes both)
|
||||
csr: z.string().min(1), // PKCS#10: PEM block (agent) or base64(DER) — see decodeCsrWire
|
||||
})
|
||||
|
||||
function sendError(reply: FastifyReply, err: unknown): void {
|
||||
@@ -56,12 +57,12 @@ function assertOwnAccount(principal: AdminPrincipal, pathAccountId: string): voi
|
||||
|
||||
export function buildRouter(deps: ProvisionDeps): FastifyPluginAsync {
|
||||
return async (app) => {
|
||||
const principal = (req: FastifyRequest): AdminPrincipal =>
|
||||
const principal = (req: FastifyRequest): Promise<AdminPrincipal> =>
|
||||
deps.authorizer.principalFromRequest({ headers: req.headers })
|
||||
|
||||
app.post('/accounts', async (req, reply) => {
|
||||
try {
|
||||
const p = principal(req)
|
||||
const p = await principal(req)
|
||||
deps.authorizer.requireRight(p, 'manage')
|
||||
const plan: PlanTier = PlanSchema.parse((req.body as { plan?: unknown })?.plan ?? 'free')
|
||||
const account = await deps.accounts.createAccount(plan)
|
||||
@@ -73,7 +74,7 @@ export function buildRouter(deps: ProvisionDeps): FastifyPluginAsync {
|
||||
|
||||
app.post('/accounts/:id/pairing-codes', async (req, reply) => {
|
||||
try {
|
||||
const p = principal(req)
|
||||
const p = await principal(req)
|
||||
deps.authorizer.requireRight(p, 'manage')
|
||||
assertOwnAccount(p, (req.params as { id: string }).id)
|
||||
const issued = await deps.pairingIssuer.issuePairingCode(p.accountId)
|
||||
@@ -85,7 +86,7 @@ export function buildRouter(deps: ProvisionDeps): FastifyPluginAsync {
|
||||
|
||||
app.post('/accounts/:id/status', async (req, reply) => {
|
||||
try {
|
||||
const p = principal(req)
|
||||
const p = await principal(req)
|
||||
deps.authorizer.requireRight(p, 'manage')
|
||||
assertOwnAccount(p, (req.params as { id: string }).id)
|
||||
const { status } = StatusSchema.parse(req.body)
|
||||
@@ -98,7 +99,7 @@ export function buildRouter(deps: ProvisionDeps): FastifyPluginAsync {
|
||||
|
||||
app.get('/accounts/:id/hosts', async (req, reply) => {
|
||||
try {
|
||||
const p = principal(req)
|
||||
const p = await principal(req)
|
||||
assertOwnAccount(p, (req.params as { id: string }).id)
|
||||
const hosts = await deps.hosts.listHosts(p.accountId) // ownership-scoped
|
||||
await reply.send(hosts.map((h) => ({ ...h, agentPubkey: Buffer.from(h.agentPubkey).toString('base64') })))
|
||||
@@ -109,7 +110,7 @@ export function buildRouter(deps: ProvisionDeps): FastifyPluginAsync {
|
||||
|
||||
app.delete('/hosts/:hostId', async (req, reply) => {
|
||||
try {
|
||||
const p = principal(req)
|
||||
const p = await principal(req)
|
||||
deps.authorizer.requireRight(p, 'manage')
|
||||
// account_id in the body is IGNORED — authz uses ONLY the token principal (INV3).
|
||||
await deps.deprovisioner.deprovisionHost(p, (req.params as { hostId: string }).hostId)
|
||||
@@ -126,9 +127,10 @@ export function buildRouter(deps: ProvisionDeps): FastifyPluginAsync {
|
||||
const result = await deps.redeemer.redeemPairingCode({
|
||||
code: body.code,
|
||||
agentPubkey: new Uint8Array(Buffer.from(body.agentPubkey, 'base64')),
|
||||
csr: new Uint8Array(Buffer.from(body.csr, 'base64')),
|
||||
csr: decodeCsrWire(body.csr),
|
||||
})
|
||||
await reply.code(201).send({ ...result, hostContentSecret: Buffer.from(result.hostContentSecret).toString('base64') })
|
||||
// base64URL (not base64) — the agent decodes this via decodeBase64UrlBytes (enroll/pair.ts).
|
||||
await reply.code(201).send({ ...result, hostContentSecret: Buffer.from(result.hostContentSecret).toString('base64url') })
|
||||
} catch (err) {
|
||||
sendError(reply, err)
|
||||
}
|
||||
|
||||
32
control-plane/src/boot/redis.ts
Normal file
32
control-plane/src/boot/redis.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* A2 — ioredis client + `RedisPublisher` adapter for the P3 server entrypoint.
|
||||
*
|
||||
* `createRedisClient(url)` constructs the single ioredis connection used for the revocation-bus
|
||||
* PUBLISH side (the FROZEN `relay:revocations` channel; P1 nodes subscribe — see routing/bus.ts).
|
||||
* `createRedisPublisher` narrows that client to the minimal `RedisPublisher` surface
|
||||
* `createRedisRevocationBus` consumes, so the concrete ioredis type never leaks into the bus wiring
|
||||
* and the seam stays unit-testable with a fake. INV9: no secrets pass through here — the URL is
|
||||
* owned/validated by `loadEnv` and never logged.
|
||||
*/
|
||||
// The default export IS `Redis`; imported by name because this package's tsconfig has no
|
||||
// `esModuleInterop`, under which a default import of the CJS module resolves to its namespace.
|
||||
import { Redis } from 'ioredis'
|
||||
import type { RedisPublisher } from '../routing/bus.js'
|
||||
|
||||
/**
|
||||
* Construct the production ioredis client from a validated connection URL. Connection lifecycle
|
||||
* (connect/retry) is managed by ioredis; the caller owns `quit()` on shutdown.
|
||||
*/
|
||||
export function createRedisClient(url: string): Redis {
|
||||
return new Redis(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapt an ioredis client to the `RedisPublisher` seam. Forwards `publish(channel, message)`
|
||||
* verbatim and returns the subscriber count the driver reports (Promise<number>).
|
||||
*/
|
||||
export function createRedisPublisher(redis: Redis): RedisPublisher {
|
||||
return {
|
||||
publish: (channel: string, message: string): Promise<number> => redis.publish(channel, message),
|
||||
}
|
||||
}
|
||||
38
control-plane/src/boot/verifier.ts
Normal file
38
control-plane/src/boot/verifier.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* A3 — REAL capability verifier, backed by relay-auth's `verifyCapabilityToken` (P5). Replaces the
|
||||
* fail-closed `refuseAllVerifier` stub. The frozen §4.3 verify signature is ASYNC (Ed25519 verify
|
||||
* over WebCrypto) and reads its verifying key from relay-auth's startup registry (config/keys.ts),
|
||||
* never as a parameter — so we (a) adapt it to the async `CapabilityVerifier` seam and (b) load the
|
||||
* key at boot from the CP env's already-validated pubkey.
|
||||
*
|
||||
* KEY BRIDGE (INV9): the CP env var is `CAPABILITY_SIGN_PUBKEY_B64` (base64), parsed+validated in
|
||||
* `env.ts` to `env.capabilitySignPubkey` (32 raw Ed25519 bytes). relay-auth's own env loader expects
|
||||
* a DIFFERENT var name (`RELAY_AUTH_VERIFY_PUBKEY`, base64url), so we do NOT use it — we import the
|
||||
* 32 raw bytes to a non-exportable verify-only CryptoKey via WebCrypto and call relay-auth's
|
||||
* `configureVerifyKey(CryptoKey)` directly.
|
||||
*/
|
||||
import { verifyCapabilityToken, configureVerifyKey } from 'relay-auth'
|
||||
import type { CapabilityToken } from 'relay-contracts'
|
||||
import type { CapabilityVerifier } from '../api/authz.js'
|
||||
|
||||
/** The real verifier: delegates verbatim to P5's frozen §4.3 `verifyCapabilityToken`. */
|
||||
export function createCapabilityVerifier(): CapabilityVerifier {
|
||||
return {
|
||||
verify(raw: string, expectedAud: string, now: number): Promise<CapabilityToken> {
|
||||
return verifyCapabilityToken(raw, expectedAud, now)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the §4.3 verifying key into relay-auth's startup registry from the CP env's raw 32-byte
|
||||
* Ed25519 public key (`env.capabilitySignPubkey`). Non-exportable, `verify`-only. Must run before
|
||||
* the real verifier serves any request, else `verifyCapabilityToken` throws `KeyConfigError`.
|
||||
*/
|
||||
export async function configureCapabilityVerifyKey(pubkeyRaw: Uint8Array): Promise<void> {
|
||||
// Copy into a fresh ArrayBuffer-backed view (WebCrypto's BufferSource typing / no shared pool).
|
||||
const bytes = new Uint8Array(pubkeyRaw.length)
|
||||
bytes.set(pubkeyRaw)
|
||||
const key = await globalThis.crypto.subtle.importKey('raw', bytes, { name: 'Ed25519' }, false, ['verify'])
|
||||
configureVerifyKey(key)
|
||||
}
|
||||
@@ -1,53 +1,147 @@
|
||||
/**
|
||||
* CSR proof-of-possession (INV14). Models a PKCS#10 CSR as `embeddedPub(32) || sig(64)` where
|
||||
* `sig = Ed25519(privateKey, CSR_CHALLENGE || embeddedPub)`. Verifying the signature against the
|
||||
* embedded pubkey proves the requester holds the matching private key (the exact property a real
|
||||
* PKCS#10 self-signature provides) — using builtin crypto only.
|
||||
* CSR handling (INV14) — REAL PKCS#10 over the agent's Ed25519 identity.
|
||||
*
|
||||
* INTEGRATION SEAM: production parses a real PKCS#10 DER (`@peculiar/x509`) and checks the
|
||||
* self-signature. Swapping it in does not change `signHostLeaf`'s check ORDER or reject path.
|
||||
* The agent (`agent/src/enroll/csr.ts`) emits a standard PKCS#10 `CertificationRequest` signed by
|
||||
* its in-process Ed25519 key. This module parses that request with `@peculiar/x509`, verifies its
|
||||
* self-signature (proof-of-possession — the requester holds the private key for the embedded
|
||||
* pubkey), and exposes the embedded raw Ed25519 public key so `sign.ts` can gate on
|
||||
* embeddedPub == agentPubkey. Parsing/verification is FAIL-CLOSED and returns a UNIFORM result: any
|
||||
* malformed/forged input yields `{ ok: false }` with no distinguishing detail (see `verifyCsrPoP`).
|
||||
*
|
||||
* `@peculiar/x509` uses `tsyringe`, which requires the `reflect-metadata` polyfill to be loaded
|
||||
* before the library — hence the side-effect import on the first line (must stay first).
|
||||
*/
|
||||
import { ed25519Sign, ed25519Verify, type KeyObject } from '../util/crypto.js'
|
||||
import 'reflect-metadata'
|
||||
import * as x509 from '@peculiar/x509'
|
||||
import { webcrypto } from 'node:crypto'
|
||||
import { ed25519Sign, type KeyObject } from '../util/crypto.js'
|
||||
|
||||
const CSR_CHALLENGE = new TextEncoder().encode('relay-cp/csr-pop/v1|')
|
||||
// Ed25519 signing/verification runs on Node's WebCrypto (Ed25519 supported on Node 20+).
|
||||
x509.cryptoProvider.set(webcrypto)
|
||||
|
||||
/** Test/agent helper: build a CSR proving possession of `privateKey` for `embeddedPub`. */
|
||||
export function buildCsr(privateKey: KeyObject, embeddedPub: Uint8Array): Uint8Array {
|
||||
const message = concat(CSR_CHALLENGE, embeddedPub)
|
||||
const sig = ed25519Sign(privateKey, message)
|
||||
return concat(embeddedPub, sig)
|
||||
}
|
||||
// --- Ed25519 SPKI constants -------------------------------------------------------------------
|
||||
/** Fixed 12-byte SPKI prefix for an Ed25519 SubjectPublicKeyInfo; the raw key is the trailing 32. */
|
||||
const ED25519_SPKI_PREFIX = Uint8Array.from([
|
||||
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
|
||||
])
|
||||
const ED25519_SPKI_LEN = ED25519_SPKI_PREFIX.length + 32 // 44
|
||||
const RAW_ED25519_LEN = 32
|
||||
|
||||
export interface CsrParts {
|
||||
readonly embeddedPub: Uint8Array
|
||||
readonly sig: Uint8Array
|
||||
}
|
||||
// --- minimal DER encoding helpers (test/agent-side CSR construction) ---------------------------
|
||||
|
||||
/** Parse the modelled CSR bytes. Throws on wrong length. */
|
||||
export function parseCsr(csr: Uint8Array): CsrParts {
|
||||
if (csr.length !== 96) throw new Error('malformed csr')
|
||||
return { embeddedPub: csr.subarray(0, 32), sig: csr.subarray(32, 96) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify CSR proof-of-possession: the embedded pubkey's private key signed the challenge.
|
||||
* Independent of any registry state (a forged signature is refused even for a registered key).
|
||||
*/
|
||||
export function verifyCsrPoP(csr: Uint8Array): { ok: boolean; embeddedPub: Uint8Array } {
|
||||
let parts: CsrParts
|
||||
try {
|
||||
parts = parseCsr(csr)
|
||||
} catch {
|
||||
return { ok: false, embeddedPub: new Uint8Array(0) }
|
||||
function derLen(len: number): Uint8Array {
|
||||
if (len < 0x80) return Uint8Array.from([len])
|
||||
const bytes: number[] = []
|
||||
let n = len
|
||||
while (n > 0) {
|
||||
bytes.unshift(n & 0xff)
|
||||
n >>= 8
|
||||
}
|
||||
const message = concat(CSR_CHALLENGE, parts.embeddedPub)
|
||||
const ok = ed25519Verify(parts.embeddedPub, message, parts.sig)
|
||||
return { ok, embeddedPub: parts.embeddedPub }
|
||||
return Uint8Array.from([0x80 | bytes.length, ...bytes])
|
||||
}
|
||||
|
||||
function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
|
||||
const out = new Uint8Array(a.length + b.length)
|
||||
out.set(a, 0)
|
||||
out.set(b, a.length)
|
||||
function tlv(tag: number, value: Uint8Array): Uint8Array {
|
||||
const len = derLen(value.length)
|
||||
const out = new Uint8Array(1 + len.length + value.length)
|
||||
out[0] = tag
|
||||
out.set(len, 1)
|
||||
out.set(value, 1 + len.length)
|
||||
return out
|
||||
}
|
||||
|
||||
function concat(chunks: readonly Uint8Array[]): Uint8Array {
|
||||
const out = new Uint8Array(chunks.reduce((s, c) => s + c.length, 0))
|
||||
let off = 0
|
||||
for (const c of chunks) {
|
||||
out.set(c, off)
|
||||
off += c.length
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const SEQUENCE = 0x30
|
||||
const SET = 0x31
|
||||
const INTEGER = 0x02
|
||||
const BIT_STRING = 0x03
|
||||
const OID = 0x06
|
||||
const UTF8_STRING = 0x0c
|
||||
const CONTEXT_0 = 0xa0
|
||||
const OID_CN = Uint8Array.from([0x55, 0x04, 0x03]) // 2.5.4.3 commonName
|
||||
const OID_ED25519 = Uint8Array.from([0x2b, 0x65, 0x70]) // 1.3.101.112 Ed25519
|
||||
|
||||
function spkiFromRawEd25519(raw: Uint8Array): Uint8Array {
|
||||
const algId = tlv(SEQUENCE, tlv(OID, OID_ED25519))
|
||||
const pubBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), raw]))
|
||||
return tlv(SEQUENCE, concat([algId, pubBits]))
|
||||
}
|
||||
|
||||
function nameFromCn(cn: string): Uint8Array {
|
||||
const atv = tlv(SEQUENCE, concat([tlv(OID, OID_CN), tlv(UTF8_STRING, new TextEncoder().encode(cn))]))
|
||||
return tlv(SEQUENCE, tlv(SET, atv))
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a real PKCS#10 `CertificationRequest` DER for `embeddedPub`, self-signed by `privateKey`
|
||||
* (Ed25519). Mirrors the agent's on-wire request shape (subject CN, empty attributes). Used by the
|
||||
* control-plane's own tests to exercise the real parse/verify path; production requests come from
|
||||
* the agent unchanged.
|
||||
*/
|
||||
export function buildCsr(privateKey: KeyObject, embeddedPub: Uint8Array): Uint8Array {
|
||||
const version = tlv(INTEGER, Uint8Array.from([0x00]))
|
||||
const requestInfo = tlv(
|
||||
SEQUENCE,
|
||||
concat([version, nameFromCn('web-terminal-agent'), spkiFromRawEd25519(embeddedPub), tlv(CONTEXT_0, new Uint8Array(0))]),
|
||||
)
|
||||
const signature = ed25519Sign(privateKey, requestInfo)
|
||||
const sigAlg = tlv(SEQUENCE, tlv(OID, OID_ED25519))
|
||||
const sigBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), signature]))
|
||||
return tlv(SEQUENCE, concat([requestInfo, sigAlg, sigBits]))
|
||||
}
|
||||
|
||||
// --- wire decoding + verification --------------------------------------------------------------
|
||||
|
||||
const PEM_CSR_RE = /-----BEGIN CERTIFICATE REQUEST-----([\s\S]+?)-----END CERTIFICATE REQUEST-----/
|
||||
|
||||
/**
|
||||
* Decode the `csr` field as it arrives on the `/enroll` wire into raw PKCS#10 DER bytes. Accepts
|
||||
* BOTH shapes the codebase produces: the agent sends a PEM `CERTIFICATE REQUEST` block, while the
|
||||
* control-plane's own HTTP tests send `base64(DER)`. Both normalise to the same DER — this is the
|
||||
* single place the wire encoding is interpreted (boundary validation, coding-style §Input).
|
||||
*/
|
||||
export function decodeCsrWire(wire: string): Uint8Array {
|
||||
const pem = PEM_CSR_RE.exec(wire)
|
||||
const body = pem !== null ? pem[1]!.replace(/\s+/g, '') : wire.trim()
|
||||
return new Uint8Array(Buffer.from(body, 'base64'))
|
||||
}
|
||||
|
||||
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
|
||||
}
|
||||
|
||||
/** Extract the raw 32-byte Ed25519 key from a SubjectPublicKeyInfo DER, or null if not Ed25519. */
|
||||
function rawEd25519FromSpki(spki: Uint8Array): Uint8Array | null {
|
||||
if (spki.length !== ED25519_SPKI_LEN) return null
|
||||
for (let i = 0; i < ED25519_SPKI_PREFIX.length; i++) {
|
||||
if (spki[i] !== ED25519_SPKI_PREFIX[i]) return null
|
||||
}
|
||||
return spki.subarray(ED25519_SPKI_PREFIX.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify CSR proof-of-possession: parse the PKCS#10 DER and check its self-signature against the
|
||||
* embedded public key. Returns the embedded raw Ed25519 pubkey on success. FAIL-CLOSED and uniform:
|
||||
* malformed DER, a non-Ed25519 key, or a bad signature all yield `{ ok: false, embeddedPub: [] }`
|
||||
* with no leak of which check failed. Independent of any registry state.
|
||||
*/
|
||||
export async function verifyCsrPoP(csr: Uint8Array): Promise<{ ok: boolean; embeddedPub: Uint8Array }> {
|
||||
const fail = { ok: false, embeddedPub: new Uint8Array(0) }
|
||||
try {
|
||||
const req = new x509.Pkcs10CertificateRequest(toArrayBuffer(csr))
|
||||
const embeddedPub = rawEd25519FromSpki(new Uint8Array(req.publicKey.rawData))
|
||||
if (embeddedPub === null || embeddedPub.length !== RAW_ED25519_LEN) return fail
|
||||
const ok = await req.verify()
|
||||
return ok ? { ok: true, embeddedPub: new Uint8Array(embeddedPub) } : fail
|
||||
} catch {
|
||||
return fail
|
||||
}
|
||||
}
|
||||
|
||||
125
control-plane/src/ca/issue.ts
Normal file
125
control-plane/src/ca/issue.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Real X.509 leaf issuance (INV14). After the shared `assertLeafGate` passes, emit an X.509 v3
|
||||
* Ed25519 leaf whose subject key is the enrolled agent pubkey and whose only SAN is the host's
|
||||
* SPIFFE-ID URI — signed by the intermediate Ed25519 key. This is what `relay-auth`'s
|
||||
* `verifyAgentCert` accepts (it walks leaf → intermediate → self-signed root and parses the
|
||||
* `URI:spiffe://relay.<domain>/account/<a>/host/<h>` SAN).
|
||||
*
|
||||
* The SPIFFE-ID is built with relay-auth's OWN builder (`spiffeIdFor`, deep-imported) so the emitted
|
||||
* SAN can never drift from the verifier's parser. The intermediate PRIVATE key is a WebCrypto
|
||||
* `CryptoKey` imported non-extractable (never serialised back out — INV9).
|
||||
*
|
||||
* `reflect-metadata` must load before `@peculiar/x509` (tsyringe polyfill) — keep it first.
|
||||
*/
|
||||
import 'reflect-metadata'
|
||||
import * as x509 from '@peculiar/x509'
|
||||
import { webcrypto, randomBytes } from 'node:crypto'
|
||||
import { spiffeIdFor } from 'relay-auth/src/agent/spiffe.js'
|
||||
import type { HostStore } from '../store/ports.js'
|
||||
import { assertLeafGate, DEFAULT_LEAF_TTL_SEC, type LeafSigner } from './sign.js'
|
||||
|
||||
x509.cryptoProvider.set(webcrypto)
|
||||
|
||||
/** Backdate notBefore slightly to tolerate small clock skew between control-plane and relay. */
|
||||
const CLOCK_SKEW_SEC = 60
|
||||
|
||||
export interface RealLeafSignerDeps {
|
||||
readonly hosts: HostStore
|
||||
/** Intermediate Ed25519 PRIVATE signing key (WebCrypto, non-extractable). */
|
||||
readonly intermediateKey: CryptoKey
|
||||
/** Intermediate subject as a Name — used verbatim as the leaf issuer so `checkIssued` matches. */
|
||||
readonly issuerName: x509.Name
|
||||
/** DER of [intermediate, root] returned to the agent as its CA bundle (INV14). */
|
||||
readonly caChainDer: readonly Uint8Array[]
|
||||
/** Bare trust domain; the SPIFFE builder prepends `relay.`. */
|
||||
readonly trustDomain: string
|
||||
readonly leafTtlSec?: number
|
||||
}
|
||||
|
||||
/** Import a raw 32-byte Ed25519 public key as a verifying WebCrypto CryptoKey (via SPKI DER). */
|
||||
async function importEd25519Public(raw: Uint8Array): Promise<CryptoKey> {
|
||||
const prefix = Uint8Array.from([
|
||||
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
|
||||
])
|
||||
const spki = new Uint8Array(prefix.length + raw.length)
|
||||
spki.set(prefix, 0)
|
||||
spki.set(raw, prefix.length)
|
||||
return webcrypto.subtle.importKey('spki', spki, { name: 'Ed25519' }, true, ['verify'])
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the production leaf signer. Every issued leaf: X.509 v3, Ed25519 subject = agentPubkey,
|
||||
* URI SAN = the host's SPIFFE-ID, CA:false, KeyUsage digitalSignature, EKU clientAuth, validity
|
||||
* [now-skew, now+ttl]. Returns leaf DER + the injected CA chain DER; `redeem.ts` PEM-wraps both.
|
||||
*/
|
||||
export function createRealLeafSigner(deps: RealLeafSignerDeps): LeafSigner {
|
||||
const ttl = deps.leafTtlSec ?? DEFAULT_LEAF_TTL_SEC
|
||||
return {
|
||||
async signHostLeaf(hostId, agentPubkey, csr) {
|
||||
const host = await assertLeafGate(deps.hosts, hostId, agentPubkey, csr)
|
||||
const spiffe = spiffeIdFor(host.accountId, host.hostId, deps.trustDomain)
|
||||
const subjectKey = await importEd25519Public(agentPubkey)
|
||||
const now = Date.now()
|
||||
const leaf = await x509.X509CertificateGenerator.create({
|
||||
serialNumber: randomBytes(16).toString('hex'),
|
||||
subject: `CN=${host.hostId}`,
|
||||
issuer: deps.issuerName,
|
||||
notBefore: new Date(now - CLOCK_SKEW_SEC * 1000),
|
||||
notAfter: new Date(now + ttl * 1000),
|
||||
publicKey: subjectKey,
|
||||
signingKey: deps.intermediateKey,
|
||||
signingAlgorithm: { name: 'Ed25519' },
|
||||
extensions: [
|
||||
new x509.SubjectAlternativeNameExtension([{ type: 'url', value: spiffe }]),
|
||||
new x509.BasicConstraintsExtension(false, undefined, true),
|
||||
new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature, true),
|
||||
new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage.clientAuth]),
|
||||
],
|
||||
})
|
||||
return { cert: new Uint8Array(leaf.rawData), caChain: deps.caChainDer }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export interface LoadRealLeafSignerInput {
|
||||
readonly hosts: HostStore
|
||||
/** Intermediate Ed25519 private key, PKCS#8 PEM. */
|
||||
readonly intermediateKeyPem: string
|
||||
/** Intermediate certificate, PEM (single block). */
|
||||
readonly intermediateCertPem: string
|
||||
/** Self-signed root certificate, PEM (single block). */
|
||||
readonly rootCertPem: string
|
||||
readonly trustDomain: string
|
||||
readonly leafTtlSec?: number
|
||||
}
|
||||
|
||||
function pemToDer(pem: string): ArrayBuffer {
|
||||
const body = pem.replace(/-----BEGIN [^-]+-----/g, '').replace(/-----END [^-]+-----/g, '').replace(/\s+/g, '')
|
||||
const bytes = Buffer.from(body, 'base64')
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a real leaf signer from PEM material (boot path). Imports the intermediate private key
|
||||
* (non-extractable — never re-serialised, INV9) and derives the issuer Name + CA chain DER from the
|
||||
* certs. THROWS on unreadable/malformed material so the control-plane fails fast at boot.
|
||||
*/
|
||||
export async function loadRealLeafSigner(input: LoadRealLeafSignerInput): Promise<LeafSigner> {
|
||||
const intermediateKey = await webcrypto.subtle.importKey(
|
||||
'pkcs8',
|
||||
pemToDer(input.intermediateKeyPem),
|
||||
{ name: 'Ed25519' },
|
||||
false, // non-extractable: the raw private key can never leave the process (INV9)
|
||||
['sign'],
|
||||
)
|
||||
const intermediateCert = new x509.X509Certificate(input.intermediateCertPem)
|
||||
const rootCert = new x509.X509Certificate(input.rootCertPem)
|
||||
return createRealLeafSigner({
|
||||
hosts: input.hosts,
|
||||
intermediateKey,
|
||||
issuerName: intermediateCert.subjectName,
|
||||
caChainDer: [new Uint8Array(intermediateCert.rawData), new Uint8Array(rootCert.rawData)],
|
||||
trustDomain: input.trustDomain,
|
||||
...(input.leafTtlSec !== undefined ? { leafTtlSec: input.leafTtlSec } : {}),
|
||||
})
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export function createLeafRenewer(deps: LeafRenewerDeps): LeafRenewer {
|
||||
// A revoked/absent host cannot renew (INV12 + INV14).
|
||||
if (host === null || host.status === 'revoked') throw new LeafSignError('not_registered')
|
||||
|
||||
const pop = verifyCsrPoP(csr)
|
||||
const pop = await verifyCsrPoP(csr)
|
||||
if (!pop.ok) throw new LeafSignError('csr_rejected')
|
||||
// embedded pubkey must equal the host's REGISTERED pubkey (no key substitution on renewal).
|
||||
if (!timingSafeEqualBytes(pop.embeddedPub, host.agentPubkey)) throw new LeafSignError('csr_rejected')
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
/**
|
||||
* T8 — bind-time mTLS leaf signer, registry-gated (INV14 registry half). ORDER OF CHECKS (all
|
||||
* T8 — bind-time mTLS leaf signing, registry-gated (INV14 registry half). ORDER OF CHECKS (all
|
||||
* must pass, SAME reject path):
|
||||
* 1. CSR proof-of-possession — verify the CSR self-signature against its embedded pubkey.
|
||||
* 1. CSR proof-of-possession — verify the PKCS#10 self-signature against its embedded pubkey.
|
||||
* 2. embedded pubkey == caller-supplied `agentPubkey` (no substitution).
|
||||
* 3. (hostId, agentPubkey) is an ACTIVE, non-revoked row in the host registry.
|
||||
* A failure at ANY step rejects identically; the KMS `sign()` is NEVER invoked when any check
|
||||
* fails. Signing itself is `CaSigner.sign()` (KMS, §3.1) — never a raw private key in memory.
|
||||
* A failure at ANY step rejects identically; issuance is NEVER reached when any check fails.
|
||||
*
|
||||
* Two `LeafSigner` implementations share the `assertLeafGate` gate below:
|
||||
* - `createLeafSigner` (this file) — DEV/legacy placeholder cert (a signed JSON blob, NOT X.509);
|
||||
* used only when no real CA material is configured (see `main.ts` fallback).
|
||||
* - `createRealLeafSigner` (`ca/issue.ts`) — the production issuer that emits a real X.509 v3
|
||||
* Ed25519 leaf with a SPIFFE SAN, signed by the intermediate key.
|
||||
*/
|
||||
import type { HostStore } from '../store/ports.js'
|
||||
import type { HostRecord } from '../model/records.js'
|
||||
import type { CaSigner } from '../boot/ca-wiring.js'
|
||||
import { verifyCsrPoP } from './csr.js'
|
||||
import { timingSafeEqualBytes, bytesToBase64 } from '../util/bytes.js'
|
||||
@@ -18,14 +24,6 @@ export class LeafSignError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export interface LeafSignerDeps {
|
||||
readonly hosts: HostStore
|
||||
readonly signer: CaSigner
|
||||
readonly caChainDer: readonly Uint8Array[]
|
||||
/** Leaf validity in seconds (short-lived, INV14). Default 24h. */
|
||||
readonly leafTtlSec?: number
|
||||
}
|
||||
|
||||
export interface LeafSigner {
|
||||
signHostLeaf(
|
||||
hostId: string,
|
||||
@@ -34,28 +32,56 @@ export interface LeafSigner {
|
||||
): Promise<{ cert: Uint8Array; caChain: readonly Uint8Array[] }>
|
||||
}
|
||||
|
||||
const DEFAULT_LEAF_TTL_SEC = 24 * 60 * 60
|
||||
/** Leaf validity in seconds (short-lived, INV14). Default 24h. */
|
||||
export const DEFAULT_LEAF_TTL_SEC = 24 * 60 * 60
|
||||
|
||||
/**
|
||||
* Shared registry + proof-of-possession gate (INV14). Runs the three ordered checks and, on
|
||||
* success, returns the gated host record so the issuer can build the SPIFFE SAN from the
|
||||
* authoritative `accountId`/`hostId` binding. Throws `LeafSignError` (uniform) on any failure.
|
||||
*/
|
||||
export async function assertLeafGate(
|
||||
hosts: HostStore,
|
||||
hostId: string,
|
||||
agentPubkey: Uint8Array,
|
||||
csr: Uint8Array,
|
||||
): Promise<HostRecord> {
|
||||
// 1. proof-of-possession (independent of registry state)
|
||||
const pop = await verifyCsrPoP(csr)
|
||||
if (!pop.ok) throw new LeafSignError('csr_rejected')
|
||||
// 2. no substitution: CSR pubkey must equal the presented agentPubkey
|
||||
if (!timingSafeEqualBytes(pop.embeddedPub, agentPubkey)) throw new LeafSignError('csr_rejected')
|
||||
// 3. registry gate: host bound, active, non-revoked, pubkey matches (INV14)
|
||||
const host = await hosts.get(hostId)
|
||||
if (host === null || host.status === 'revoked') throw new LeafSignError('not_registered')
|
||||
if (!timingSafeEqualBytes(host.agentPubkey, agentPubkey)) throw new LeafSignError('not_registered')
|
||||
return host
|
||||
}
|
||||
|
||||
export interface LeafSignerDeps {
|
||||
readonly hosts: HostStore
|
||||
readonly signer: CaSigner
|
||||
readonly caChainDer: readonly Uint8Array[]
|
||||
readonly leafTtlSec?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* DEV/legacy signer — emits a signed JSON placeholder (NOT real X.509). Retained so tests and dev
|
||||
* boots without configured CA material still exercise the gate/reject path. Production uses
|
||||
* `createRealLeafSigner` (`ca/issue.ts`).
|
||||
*/
|
||||
export function createLeafSigner(deps: LeafSignerDeps): LeafSigner {
|
||||
const ttl = deps.leafTtlSec ?? DEFAULT_LEAF_TTL_SEC
|
||||
return {
|
||||
async signHostLeaf(hostId, agentPubkey, csr) {
|
||||
// 1. proof-of-possession (independent of registry state)
|
||||
const pop = verifyCsrPoP(csr)
|
||||
if (!pop.ok) throw new LeafSignError('csr_rejected')
|
||||
// 2. no substitution: CSR pubkey must equal the presented agentPubkey
|
||||
if (!timingSafeEqualBytes(pop.embeddedPub, agentPubkey)) throw new LeafSignError('csr_rejected')
|
||||
// 3. registry gate: host bound, active, non-revoked, pubkey matches (INV14)
|
||||
const host = await deps.hosts.get(hostId)
|
||||
if (host === null || host.status === 'revoked') throw new LeafSignError('not_registered')
|
||||
if (!timingSafeEqualBytes(host.agentPubkey, agentPubkey)) throw new LeafSignError('not_registered')
|
||||
const host = await assertLeafGate(deps.hosts, hostId, agentPubkey, csr)
|
||||
|
||||
// Only now do we invoke KMS sign() over the to-be-signed leaf.
|
||||
// Only now do we invoke KMS sign() over the to-be-signed placeholder.
|
||||
const notAfter = Math.floor(Date.now() / 1000) + ttl
|
||||
const tbs = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
v: 1,
|
||||
hostId,
|
||||
hostId: host.hostId,
|
||||
subjectSpki: bytesToBase64(agentPubkey), // subject pubkey == agentPubkey (assertable in tests)
|
||||
notAfter,
|
||||
}),
|
||||
|
||||
48
control-plane/src/db/migrate.ts
Normal file
48
control-plane/src/db/migrate.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* A1 — migration runner. Reads every `db/migrations/*.sql` in filename order and executes it
|
||||
* over the parameterized `query` wrapper (db/pool.ts). All migrations are `IF NOT EXISTS`, so
|
||||
* `runMigrations` is idempotent and safe to run at every boot / test setup.
|
||||
*
|
||||
* `createQuery` ALWAYS routes through the extended (parameterized) protocol — even with `[]`
|
||||
* params — which rejects multi-statement command strings. So each file is split into its
|
||||
* top-level statements (on `;`, after stripping `-- line comments`) and each statement is run
|
||||
* as its own single-command `query(stmt, [])`. Our migrations are plain IF-NOT-EXISTS DDL with
|
||||
* no `;` inside string literals and no dollar-quoted bodies, so this split is safe.
|
||||
*/
|
||||
import { readdir, readFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { QueryFn } from './pool.js'
|
||||
|
||||
/** Absolute path to `control-plane/db/migrations`, resolved relative to this source file. */
|
||||
function migrationsDir(): string {
|
||||
const here = dirname(fileURLToPath(import.meta.url)) // .../control-plane/src/db
|
||||
return join(here, '..', '..', 'db', 'migrations') // .../control-plane/db/migrations
|
||||
}
|
||||
|
||||
/** Strip `-- line comments`, then split into non-empty top-level statements on `;`. */
|
||||
export function splitStatements(sql: string): readonly string[] {
|
||||
const withoutComments = sql
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const idx = line.indexOf('--')
|
||||
return idx >= 0 ? line.slice(0, idx) : line
|
||||
})
|
||||
.join('\n')
|
||||
return withoutComments
|
||||
.split(';')
|
||||
.map((stmt) => stmt.trim())
|
||||
.filter((stmt) => stmt.length > 0)
|
||||
}
|
||||
|
||||
/** Apply all `*.sql` migrations in filename order. Idempotent (every migration is IF NOT EXISTS). */
|
||||
export async function runMigrations(query: QueryFn): Promise<void> {
|
||||
const dir = migrationsDir()
|
||||
const files = (await readdir(dir)).filter((f) => f.endsWith('.sql')).sort()
|
||||
for (const file of files) {
|
||||
const sql = await readFile(join(dir, file), 'utf8')
|
||||
for (const stmt of splitStatements(sql)) {
|
||||
await query(stmt, [])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
* live KMS call; `loadEnv` validates only the presence/shape of the ref here.
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { base64ToBytes } from './util/bytes.js'
|
||||
|
||||
/** Pairing-code TTL default: 10 minutes (§5 T7). */
|
||||
@@ -24,6 +25,16 @@ export interface ControlPlaneEnv {
|
||||
readonly caIntermediateKmsKeyRef: string
|
||||
/** Intermediate cert (public) + chain up to the offline root. */
|
||||
readonly caIntermediateCertPath: string
|
||||
/**
|
||||
* Intermediate Ed25519 PRIVATE key (PKCS#8 PEM) used by the REAL leaf issuer. Optional: when the
|
||||
* file is present the control-plane issues real X.509 leaves; when absent it falls back to the
|
||||
* dev placeholder signer. Defaults to `caIntermediateCertPath` with `.cert.pem`/`.pem` → `.key.pem`.
|
||||
*/
|
||||
readonly caIntermediateKeyPath: string
|
||||
/** Self-signed root cert (PEM) for the agent CA bundle. Defaults to a sibling `root.cert.pem`. */
|
||||
readonly caRootCertPath: string
|
||||
/** Bare trust domain for the SPIFFE SAN (`spiffe://relay.<domain>/...`). */
|
||||
readonly relayTrustDomain: string
|
||||
/** CA bundle used to VERIFY relay-node mTLS client certs (T9 node-auth). */
|
||||
readonly nodeMtlsTrustBundlePath: string
|
||||
/** 'term.<domain>' for subdomain assembly. */
|
||||
@@ -45,12 +56,22 @@ const intWithDefault = (fallback: number) =>
|
||||
const requiredString = (name: string) =>
|
||||
z.string({ required_error: `${name} is required` }).trim().min(1, `${name} must not be empty`)
|
||||
|
||||
/** Default intermediate KEY path from its CERT path: `*.cert.pem`/`*.pem` → `*.key.pem`. */
|
||||
function deriveKeyPath(certPath: string): string {
|
||||
if (certPath.endsWith('.cert.pem')) return certPath.slice(0, -'.cert.pem'.length) + '.key.pem'
|
||||
if (certPath.endsWith('.pem')) return certPath.slice(0, -'.pem'.length) + '.key.pem'
|
||||
return certPath + '.key.pem'
|
||||
}
|
||||
|
||||
const EnvSchema = z.object({
|
||||
PG_URL: requiredString('PG_URL').url('PG_URL must be a valid connection URL'),
|
||||
REDIS_URL: requiredString('REDIS_URL').url('REDIS_URL must be a valid connection URL'),
|
||||
CAPABILITY_SIGN_PUBKEY_B64: requiredString('CAPABILITY_SIGN_PUBKEY_B64'),
|
||||
CA_INTERMEDIATE_KMS_KEY_REF: requiredString('CA_INTERMEDIATE_KMS_KEY_REF'),
|
||||
CA_INTERMEDIATE_CERT_PATH: requiredString('CA_INTERMEDIATE_CERT_PATH'),
|
||||
CA_INTERMEDIATE_KEY_PATH: z.string().trim().optional(),
|
||||
CA_ROOT_CERT_PATH: z.string().trim().optional(),
|
||||
RELAY_TRUST_DOMAIN: z.string().trim().optional(),
|
||||
NODE_MTLS_TRUST_BUNDLE_PATH: requiredString('NODE_MTLS_TRUST_BUNDLE_PATH'),
|
||||
BASE_DOMAIN: requiredString('BASE_DOMAIN'),
|
||||
HEARTBEAT_TTL_SEC: intWithDefault(15),
|
||||
@@ -81,12 +102,24 @@ export function loadEnv(source: NodeJS.ProcessEnv): ControlPlaneEnv {
|
||||
if (capabilitySignPubkey.length !== 32) {
|
||||
throw new Error('Invalid control-plane env: CAPABILITY_SIGN_PUBKEY_B64 must decode to 32 bytes (Ed25519)')
|
||||
}
|
||||
const caIntermediateKeyPath =
|
||||
e.CA_INTERMEDIATE_KEY_PATH !== undefined && e.CA_INTERMEDIATE_KEY_PATH.length > 0
|
||||
? e.CA_INTERMEDIATE_KEY_PATH
|
||||
: deriveKeyPath(e.CA_INTERMEDIATE_CERT_PATH)
|
||||
const caRootCertPath =
|
||||
e.CA_ROOT_CERT_PATH !== undefined && e.CA_ROOT_CERT_PATH.length > 0
|
||||
? e.CA_ROOT_CERT_PATH
|
||||
: join(dirname(e.CA_INTERMEDIATE_CERT_PATH), 'root.cert.pem')
|
||||
return {
|
||||
pgUrl: e.PG_URL,
|
||||
redisUrl: e.REDIS_URL,
|
||||
capabilitySignPubkey,
|
||||
caIntermediateKmsKeyRef: e.CA_INTERMEDIATE_KMS_KEY_REF,
|
||||
caIntermediateCertPath: e.CA_INTERMEDIATE_CERT_PATH,
|
||||
caIntermediateKeyPath,
|
||||
caRootCertPath,
|
||||
relayTrustDomain:
|
||||
e.RELAY_TRUST_DOMAIN !== undefined && e.RELAY_TRUST_DOMAIN.length > 0 ? e.RELAY_TRUST_DOMAIN : 'example.com',
|
||||
nodeMtlsTrustBundlePath: e.NODE_MTLS_TRUST_BUNDLE_PATH,
|
||||
baseDomain: e.BASE_DOMAIN,
|
||||
heartbeatTtlSec: e.HEARTBEAT_TTL_SEC,
|
||||
|
||||
@@ -12,9 +12,10 @@
|
||||
* Ed25519 signer (DEV ONLY — NOT a real KMS).
|
||||
*/
|
||||
import Fastify, { type FastifyInstance } from 'fastify'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import type { ControlPlaneEnv } from './env.js'
|
||||
import { createMemoryStores } from './store/memory.js'
|
||||
import type { Stores } from './store/ports.js'
|
||||
import type { Stores, HostStore } from './store/ports.js'
|
||||
import { createAuditLog } from './audit/log.js'
|
||||
import { createAccountRegistry } from './registry/accounts.js'
|
||||
import { createHostRegistry } from './registry/hosts.js'
|
||||
@@ -22,14 +23,16 @@ import { createSessionRegistry } from './registry/sessions.js'
|
||||
import { createSubdomainAssigner } from './subdomain/assign.js'
|
||||
import { createPairingIssuer } from './pairing/issue.js'
|
||||
import { createPairingRedeemer } from './pairing/redeem.js'
|
||||
import { createLeafSigner } from './ca/sign.js'
|
||||
import { createLeafSigner, type LeafSigner } from './ca/sign.js'
|
||||
import { loadRealLeafSigner } from './ca/issue.js'
|
||||
import { createRoutingTable } from './routing/table.js'
|
||||
import { createInMemoryRevocationBus, type TestableRevocationBus } from './routing/bus.js'
|
||||
import { createMeteringCollector } from './metering/collect.js'
|
||||
import { createDeprovisioner } from './deprovision/deprovision.js'
|
||||
import { createAuthorizer, type CapabilityVerifier } from './api/authz.js'
|
||||
import { buildRouter } from './api/provision.js'
|
||||
import { buildCaSigner, inProcessCaSigner, type KmsResolver } from './boot/ca-wiring.js'
|
||||
import { buildCaSigner, inProcessCaSigner, type KmsResolver, type CaSigner } from './boot/ca-wiring.js'
|
||||
import { configureCapabilityVerifyKey } from './boot/verifier.js'
|
||||
import type { RevocationBus } from 'relay-contracts'
|
||||
|
||||
export interface ControlPlaneOverrides {
|
||||
@@ -40,13 +43,47 @@ export interface ControlPlaneOverrides {
|
||||
readonly caChainDer?: readonly Uint8Array[]
|
||||
}
|
||||
|
||||
/** Default fail-closed verifier — refuses everything until P5 is wired (INV6). */
|
||||
/**
|
||||
* Default fail-closed verifier — refuses everything until a real (P5) verifier is injected (INV6).
|
||||
* Async-shaped to match `CapabilityVerifier`: an async body that throws rejects the promise, so the
|
||||
* authorizer's `await` surfaces it as a 401.
|
||||
*/
|
||||
const refuseAllVerifier: CapabilityVerifier = {
|
||||
verify() {
|
||||
async verify(): Promise<never> {
|
||||
throw new Error('capability verification not configured (P5 integration point)')
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the leaf signer. When the intermediate PRIVATE key file is present on disk, issue REAL
|
||||
* X.509 leaves (production); when absent, fall back to the dev placeholder signer so tests and
|
||||
* key-less dev boots still run. A present-but-unreadable/malformed key FAILS FAST (INV9).
|
||||
*/
|
||||
async function buildLeafSigner(
|
||||
env: ControlPlaneEnv,
|
||||
hosts: HostStore,
|
||||
caSigner: CaSigner,
|
||||
caChainDer: readonly Uint8Array[],
|
||||
): Promise<LeafSigner> {
|
||||
if (!existsSync(env.caIntermediateKeyPath)) {
|
||||
return createLeafSigner({ hosts, signer: caSigner, caChainDer })
|
||||
}
|
||||
try {
|
||||
return await loadRealLeafSigner({
|
||||
hosts,
|
||||
intermediateKeyPem: readFileSync(env.caIntermediateKeyPath, 'utf8'),
|
||||
intermediateCertPem: readFileSync(env.caIntermediateCertPath, 'utf8'),
|
||||
rootCertPem: readFileSync(env.caRootCertPath, 'utf8'),
|
||||
trustDomain: env.relayTrustDomain,
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
// Never echo key material — only that loading failed and which path shape was configured (INV9).
|
||||
throw new Error(
|
||||
`failed to load CA leaf-signing material: ${err instanceof Error ? err.message : 'unknown'}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** In-process KMS resolver — DEV ONLY. Production injects a real non-exportable KMS key (§3.1). */
|
||||
function devKmsResolver(): KmsResolver {
|
||||
const signer = inProcessCaSigner()
|
||||
@@ -72,7 +109,7 @@ export async function buildControlPlane(
|
||||
const subdomains = createSubdomainAssigner({ subdomains: stores.subdomains, audit })
|
||||
|
||||
const caSigner = await buildCaSigner(env, overrides.kmsResolver ?? devKmsResolver())
|
||||
const leafSigner = createLeafSigner({ hosts: stores.hosts, signer: caSigner, caChainDer })
|
||||
const leafSigner = await buildLeafSigner(env, stores.hosts, caSigner, caChainDer)
|
||||
|
||||
const pairingIssuer = createPairingIssuer({ pairing: stores.pairing, pairingTtlSec: env.pairingTtlSec, audit })
|
||||
const redeemer = createPairingRedeemer({
|
||||
@@ -92,8 +129,16 @@ export async function buildControlPlane(
|
||||
const deprovisioner = createDeprovisioner({ hosts, routing })
|
||||
void bus // reserved for the node-coordinator / revoker wiring (drain + revoke publishers)
|
||||
|
||||
// A real (injected) verifier is P5's async `verifyCapabilityToken`, which reads its Ed25519 key
|
||||
// from relay-auth's startup registry — so load that key from env at boot. The fail-closed default
|
||||
// never reads a key, so leave relay-auth's registry untouched when nothing is injected (INV6).
|
||||
const verifier = overrides.verifier ?? refuseAllVerifier
|
||||
if (overrides.verifier !== undefined) {
|
||||
await configureCapabilityVerifyKey(env.capabilitySignPubkey)
|
||||
}
|
||||
|
||||
const authorizer = createAuthorizer({
|
||||
verifier: overrides.verifier ?? refuseAllVerifier,
|
||||
verifier,
|
||||
expectedAud: env.baseDomain,
|
||||
})
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ export function createPairingRedeemer(deps: RedeemDeps): PairingRedeemer {
|
||||
if (row.record.redeemedAt !== null) throw new RedeemError('already_redeemed')
|
||||
|
||||
// CSR proof-of-possession + no-substitution. A failure counts toward the code-scoped lockout.
|
||||
const pop = verifyCsrPoP(input.csr)
|
||||
const pop = await verifyCsrPoP(input.csr)
|
||||
if (!pop.ok || !timingSafeEqualBytes(pop.embeddedPub, input.agentPubkey)) {
|
||||
await deps.pairing.registerFailure(codeHash)
|
||||
throw new RedeemError('bad_csr')
|
||||
|
||||
99
control-plane/src/server.ts
Normal file
99
control-plane/src/server.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* A2 — P3 control-plane server entrypoint. Wires validated env → Postgres pool/stores → migrations →
|
||||
* ioredis revocation bus → real capability verifier → Fastify app, then binds the ADMIN provisioning
|
||||
* API to a LOOPBACK host by default (CP_BIND_HOST). This process owns no terminal/PTY state, so
|
||||
* PTY!=WS restart-safety (INV7) is unaffected: it only serves provisioning/pairing/revocation.
|
||||
*
|
||||
* Config is env-only (no hardcoded hosts/ports/secrets): control-plane secrets via `loadEnv`
|
||||
* (fail-fast, never echoes values — INV9); bind address via CP_BIND_HOST / CP_BIND_PORT.
|
||||
* accountId is only ever derived from the authenticated capability token inside buildControlPlane
|
||||
* (INV3) — this entrypoint never fabricates identity.
|
||||
*/
|
||||
import { loadEnv } from './env.js'
|
||||
import { createPgPool, createQuery } from './db/pool.js'
|
||||
import { createPgStores } from './store/pg.js'
|
||||
import { runMigrations } from './db/migrate.js'
|
||||
import { createRedisRevocationBus } from './routing/bus.js'
|
||||
import { createCapabilityVerifier, configureCapabilityVerifyKey } from './boot/verifier.js'
|
||||
import { createRedisClient, createRedisPublisher } from './boot/redis.js'
|
||||
import { buildControlPlane } from './main.js'
|
||||
|
||||
/** Admin API binds to loopback by default — never expose the provisioning API on a public interface. */
|
||||
const DEFAULT_CP_BIND_HOST = '127.0.0.1'
|
||||
const DEFAULT_CP_BIND_PORT = 8080
|
||||
const MAX_TCP_PORT = 65535
|
||||
|
||||
function resolveBindHost(source: NodeJS.ProcessEnv): string {
|
||||
const raw = source.CP_BIND_HOST?.trim()
|
||||
return raw === undefined || raw === '' ? DEFAULT_CP_BIND_HOST : raw
|
||||
}
|
||||
|
||||
function resolveBindPort(source: NodeJS.ProcessEnv): number {
|
||||
const raw = source.CP_BIND_PORT?.trim()
|
||||
if (raw === undefined || raw === '') return DEFAULT_CP_BIND_PORT
|
||||
const port = Number(raw)
|
||||
if (!Number.isInteger(port) || port < 1 || port > MAX_TCP_PORT) {
|
||||
throw new Error('Invalid CP_BIND_PORT: must be an integer 1-65535')
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const env = loadEnv(process.env)
|
||||
|
||||
// Postgres: parameterized-only query wrapper + PG-backed repository ports, migrations applied
|
||||
// idempotently at boot.
|
||||
const pool = createPgPool(env.pgUrl)
|
||||
const query = createQuery(pool)
|
||||
const stores = createPgStores(query)
|
||||
await runMigrations(query)
|
||||
|
||||
// Redis: single client drives the revocation-bus publish side (relay:revocations).
|
||||
const redis = createRedisClient(env.redisUrl)
|
||||
const bus = createRedisRevocationBus(createRedisPublisher(redis))
|
||||
|
||||
// Capability verifier: load the §4.3 verifying key into relay-auth's registry BEFORE serving,
|
||||
// then delegate to relay-auth's async verifyCapabilityToken (INV3).
|
||||
await configureCapabilityVerifyKey(env.capabilitySignPubkey)
|
||||
const verifier = createCapabilityVerifier()
|
||||
|
||||
const { app } = await buildControlPlane(env, { stores, bus, verifier })
|
||||
|
||||
const host = resolveBindHost(process.env)
|
||||
const port = resolveBindPort(process.env)
|
||||
|
||||
let shuttingDown = false
|
||||
const shutdown = async (signal: string): Promise<void> => {
|
||||
if (shuttingDown) return
|
||||
shuttingDown = true
|
||||
process.stdout.write(`control-plane received ${signal}, shutting down\n`)
|
||||
try {
|
||||
await app.close()
|
||||
} finally {
|
||||
// Best-effort Redis close; force-disconnect if a graceful QUIT cannot complete.
|
||||
await redis.quit().catch(() => redis.disconnect())
|
||||
await pool.end()
|
||||
}
|
||||
}
|
||||
|
||||
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
||||
process.once(signal, () => {
|
||||
void shutdown(signal).then(
|
||||
() => process.exit(0),
|
||||
(err: unknown) => {
|
||||
process.stderr.write(`shutdown failed: ${err instanceof Error ? err.message : String(err)}\n`)
|
||||
process.exit(1)
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
await app.listen({ host, port })
|
||||
// Startup breadcrumb — bind address only, never secrets (INV9).
|
||||
process.stdout.write(`control-plane listening on ${host}:${port}\n`)
|
||||
}
|
||||
|
||||
main().catch((err: unknown) => {
|
||||
process.stderr.write(`control-plane failed to start: ${err instanceof Error ? err.message : String(err)}\n`)
|
||||
process.exit(1)
|
||||
})
|
||||
600
control-plane/src/store/pg.ts
Normal file
600
control-plane/src/store/pg.ts
Normal file
@@ -0,0 +1,600 @@
|
||||
/**
|
||||
* A1 — Postgres adapter for the repository ports (store/ports.ts). Each store maps its port
|
||||
* methods to PARAMETERIZED SQL over the injected `query` wrapper (db/pool.ts) — values ALWAYS
|
||||
* travel as bound params, never string-interpolated (SQLi structurally impossible, §7).
|
||||
*
|
||||
* This adapter MUST reproduce store/memory.ts observable semantics EXACTLY:
|
||||
* - INV8 versioning: `swapStatus` appends a `*_status_versions` row AND bumps the single-row
|
||||
* `status`/`status_version` pointer ATOMICALLY. Postgres has real transactions, but we get
|
||||
* the same all-or-nothing with a single writable-CTE statement (no explicit tx / PoolClient).
|
||||
* - `insert` duplicates throw (PK / UNIQUE violation, pg code 23505 → `throw new Error`).
|
||||
* - `casRedeem` is a single-winner CAS of `redeemed_at` from null.
|
||||
* - `RouteStore` fails closed on TTL: `expires_at <= now()` ⇒ absent (INV7), lazily DELETEd.
|
||||
* - metering/audit are append-only with inclusive `[from,to]` time-window queries.
|
||||
*
|
||||
* Timestamps: DB columns are `timestamptz` (node-pg returns them as JS `Date`); records use ISO
|
||||
* strings. `toIso` normalizes Date|string → ISO so returned records match memory.ts string shapes.
|
||||
*/
|
||||
import type {
|
||||
AccountRecord,
|
||||
AccountStatus,
|
||||
AccountStatusVersionRow,
|
||||
HostRecord,
|
||||
HostStatus,
|
||||
HostStatusVersionRow,
|
||||
MeteringSampleRow,
|
||||
PairingCodeRecord,
|
||||
PlanTier,
|
||||
RouteEntry,
|
||||
SessionRecord,
|
||||
} from '../model/records.js'
|
||||
import type { QueryFn } from '../db/pool.js'
|
||||
import type {
|
||||
AccountStore,
|
||||
AuditRow,
|
||||
AuditStore,
|
||||
CasOutcome,
|
||||
HostStore,
|
||||
MeteringStore,
|
||||
NodeRow,
|
||||
NodeStatus,
|
||||
NodeStore,
|
||||
PairingRow,
|
||||
PairingStore,
|
||||
RouteStore,
|
||||
SessionStore,
|
||||
Stores,
|
||||
SubdomainStore,
|
||||
} from './ports.js'
|
||||
|
||||
// ---- helpers ----------------------------------------------------------------------------------
|
||||
|
||||
const toIso = (v: Date | string): string => (v instanceof Date ? v : new Date(v)).toISOString()
|
||||
const toIsoN = (v: Date | string | null): string | null => (v === null ? null : toIso(v))
|
||||
|
||||
/** pg unique/PK violation. */
|
||||
function isUniqueViolation(err: unknown): err is { code: string; constraint?: string } {
|
||||
return typeof err === 'object' && err !== null && (err as { code?: unknown }).code === '23505'
|
||||
}
|
||||
|
||||
// ---- raw DB row shapes ------------------------------------------------------------------------
|
||||
|
||||
interface AccountRow {
|
||||
account_id: string
|
||||
plan: PlanTier
|
||||
created_at: Date | string
|
||||
status: AccountStatus
|
||||
}
|
||||
interface HostRow {
|
||||
host_id: string
|
||||
account_id: string
|
||||
subdomain: string
|
||||
agent_pubkey: Buffer
|
||||
enroll_fpr: string
|
||||
status: HostStatus
|
||||
last_seen: Date | string
|
||||
created_at: Date | string
|
||||
revoked_at: Date | string | null
|
||||
}
|
||||
interface SessionRow {
|
||||
session_id: string
|
||||
host_id: string
|
||||
account_id: string
|
||||
created_at: Date | string
|
||||
last_attach_at: Date | string
|
||||
}
|
||||
interface AccountVersionRow {
|
||||
account_id: string
|
||||
version: number
|
||||
status: AccountStatus
|
||||
changed_at: Date | string
|
||||
changed_by: string
|
||||
}
|
||||
interface HostVersionRow {
|
||||
host_id: string
|
||||
version: number
|
||||
status: HostStatus
|
||||
revoked_at: Date | string | null
|
||||
changed_at: Date | string
|
||||
changed_by: string
|
||||
}
|
||||
|
||||
function mapAccount(r: AccountRow): AccountRecord {
|
||||
return { accountId: r.account_id, plan: r.plan, createdAt: toIso(r.created_at), status: r.status }
|
||||
}
|
||||
function mapHost(r: HostRow): HostRecord {
|
||||
return {
|
||||
hostId: r.host_id,
|
||||
accountId: r.account_id,
|
||||
subdomain: r.subdomain,
|
||||
agentPubkey: new Uint8Array(r.agent_pubkey),
|
||||
enrollFpr: r.enroll_fpr,
|
||||
status: r.status,
|
||||
lastSeen: toIso(r.last_seen),
|
||||
createdAt: toIso(r.created_at),
|
||||
revokedAt: toIsoN(r.revoked_at),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- AccountStore -----------------------------------------------------------------------------
|
||||
|
||||
function pgAccountStore(query: QueryFn): AccountStore {
|
||||
return {
|
||||
async insert(rec) {
|
||||
// Writable CTE: create the row AND its INV8 version-1 companion in ONE statement.
|
||||
// version-1 changed_at = created_at, changed_by = 'system' (matches memory.ts).
|
||||
try {
|
||||
await query(
|
||||
`WITH ins AS (
|
||||
INSERT INTO accounts (account_id, plan, created_at, status, status_version)
|
||||
VALUES ($1, $2, $3, $4, 1)
|
||||
RETURNING account_id, created_at, status
|
||||
)
|
||||
INSERT INTO account_status_versions (account_id, version, status, changed_at, changed_by)
|
||||
SELECT account_id, 1, status, created_at, 'system' FROM ins`,
|
||||
[rec.accountId, rec.plan, rec.createdAt, rec.status],
|
||||
)
|
||||
} catch (err) {
|
||||
if (isUniqueViolation(err)) throw new Error('duplicate accountId')
|
||||
throw err
|
||||
}
|
||||
},
|
||||
async get(accountId) {
|
||||
const rows = await query<AccountRow>(
|
||||
`SELECT account_id, plan, created_at, status FROM accounts WHERE account_id = $1`,
|
||||
[accountId],
|
||||
)
|
||||
const row = rows[0]
|
||||
return row === undefined ? null : mapAccount(row)
|
||||
},
|
||||
async swapStatus(accountId, status, changedBy) {
|
||||
// Atomic INV8: bump pointer + append version row in one writable-CTE statement.
|
||||
const rows = await query<AccountRow>(
|
||||
`WITH upd AS (
|
||||
UPDATE accounts SET status = $2, status_version = status_version + 1
|
||||
WHERE account_id = $1
|
||||
RETURNING account_id, plan, created_at, status, status_version
|
||||
),
|
||||
ins AS (
|
||||
INSERT INTO account_status_versions (account_id, version, status, changed_by)
|
||||
SELECT account_id, status_version, status, $3 FROM upd
|
||||
)
|
||||
SELECT account_id, plan, created_at, status FROM upd`,
|
||||
[accountId, status, changedBy],
|
||||
)
|
||||
const row = rows[0]
|
||||
if (row === undefined) throw new Error('account not found')
|
||||
return mapAccount(row)
|
||||
},
|
||||
async versions(accountId) {
|
||||
const rows = await query<AccountVersionRow>(
|
||||
`SELECT account_id, version, status, changed_at, changed_by
|
||||
FROM account_status_versions WHERE account_id = $1 ORDER BY version ASC`,
|
||||
[accountId],
|
||||
)
|
||||
return rows.map(
|
||||
(r): AccountStatusVersionRow => ({
|
||||
accountId: r.account_id,
|
||||
version: r.version,
|
||||
status: r.status,
|
||||
changedAt: toIso(r.changed_at),
|
||||
changedBy: r.changed_by,
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---- HostStore --------------------------------------------------------------------------------
|
||||
|
||||
function pgHostStore(query: QueryFn): HostStore {
|
||||
return {
|
||||
async insert(rec) {
|
||||
try {
|
||||
await query(
|
||||
`WITH ins AS (
|
||||
INSERT INTO hosts
|
||||
(host_id, account_id, subdomain, agent_pubkey, enroll_fpr, status, last_seen, created_at, revoked_at, status_version)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 1)
|
||||
RETURNING host_id, created_at, status, revoked_at
|
||||
)
|
||||
INSERT INTO host_status_versions (host_id, version, status, revoked_at, changed_at, changed_by)
|
||||
SELECT host_id, 1, status, revoked_at, created_at, 'system' FROM ins`,
|
||||
[
|
||||
rec.hostId,
|
||||
rec.accountId,
|
||||
rec.subdomain,
|
||||
Buffer.from(rec.agentPubkey),
|
||||
rec.enrollFpr,
|
||||
rec.status,
|
||||
rec.lastSeen,
|
||||
rec.createdAt,
|
||||
rec.revokedAt,
|
||||
],
|
||||
)
|
||||
} catch (err) {
|
||||
if (isUniqueViolation(err)) {
|
||||
// subdomain UNIQUE is the subdomain single-winner substrate; PK is host_id.
|
||||
throw new Error(err.constraint?.includes('subdomain') ? 'duplicate subdomain' : 'duplicate hostId')
|
||||
}
|
||||
throw err
|
||||
}
|
||||
},
|
||||
async get(hostId) {
|
||||
const rows = await query<HostRow>(`SELECT * FROM hosts WHERE host_id = $1`, [hostId])
|
||||
const row = rows[0]
|
||||
return row === undefined ? null : mapHost(row)
|
||||
},
|
||||
async getBySubdomain(subdomain) {
|
||||
const rows = await query<HostRow>(`SELECT * FROM hosts WHERE subdomain = $1`, [subdomain])
|
||||
const row = rows[0]
|
||||
return row === undefined ? null : mapHost(row)
|
||||
},
|
||||
async listByAccount(accountId) {
|
||||
const rows = await query<HostRow>(`SELECT * FROM hosts WHERE account_id = $1 ORDER BY created_at ASC`, [
|
||||
accountId,
|
||||
])
|
||||
return rows.map(mapHost)
|
||||
},
|
||||
async swapStatus(hostId, status, revokedAt, changedBy) {
|
||||
const rows = await query<HostRow>(
|
||||
`WITH upd AS (
|
||||
UPDATE hosts SET status = $2, revoked_at = $3, status_version = status_version + 1
|
||||
WHERE host_id = $1
|
||||
RETURNING host_id, account_id, subdomain, agent_pubkey, enroll_fpr, status, last_seen, created_at, revoked_at, status_version
|
||||
),
|
||||
ins AS (
|
||||
INSERT INTO host_status_versions (host_id, version, status, revoked_at, changed_by)
|
||||
SELECT host_id, status_version, status, revoked_at, $4 FROM upd
|
||||
)
|
||||
SELECT host_id, account_id, subdomain, agent_pubkey, enroll_fpr, status, last_seen, created_at, revoked_at FROM upd`,
|
||||
[hostId, status, revokedAt, changedBy],
|
||||
)
|
||||
const row = rows[0]
|
||||
if (row === undefined) throw new Error('host not found')
|
||||
return mapHost(row)
|
||||
},
|
||||
async touchLastSeen(hostId, ts) {
|
||||
// Advisory cache — NO version row (INV7). Silent no-op if absent (matches memory.ts).
|
||||
await query(`UPDATE hosts SET last_seen = $2 WHERE host_id = $1`, [hostId, ts])
|
||||
},
|
||||
async versions(hostId) {
|
||||
const rows = await query<HostVersionRow>(
|
||||
`SELECT host_id, version, status, revoked_at, changed_at, changed_by
|
||||
FROM host_status_versions WHERE host_id = $1 ORDER BY version ASC`,
|
||||
[hostId],
|
||||
)
|
||||
return rows.map(
|
||||
(r): HostStatusVersionRow => ({
|
||||
hostId: r.host_id,
|
||||
version: r.version,
|
||||
status: r.status,
|
||||
revokedAt: toIsoN(r.revoked_at),
|
||||
changedAt: toIso(r.changed_at),
|
||||
changedBy: r.changed_by,
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---- SessionStore -----------------------------------------------------------------------------
|
||||
|
||||
function pgSessionStore(query: QueryFn): SessionStore {
|
||||
return {
|
||||
async insert(rec) {
|
||||
try {
|
||||
await query(
|
||||
`INSERT INTO sessions (session_id, host_id, account_id, created_at, last_attach_at)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[rec.sessionId, rec.hostId, rec.accountId, rec.createdAt, rec.lastAttachAt],
|
||||
)
|
||||
} catch (err) {
|
||||
if (isUniqueViolation(err)) throw new Error('duplicate sessionId')
|
||||
throw err
|
||||
}
|
||||
},
|
||||
async get(sessionId) {
|
||||
const rows = await query<SessionRow>(`SELECT * FROM sessions WHERE session_id = $1`, [sessionId])
|
||||
const row = rows[0]
|
||||
if (row === undefined) return null
|
||||
return {
|
||||
sessionId: row.session_id,
|
||||
hostId: row.host_id,
|
||||
accountId: row.account_id,
|
||||
createdAt: toIso(row.created_at),
|
||||
lastAttachAt: toIso(row.last_attach_at),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---- SubdomainStore ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* No separate reservations table exists in the schema — the `hosts.subdomain UNIQUE` constraint
|
||||
* is the single-winner substrate (the true winner is decided at host insert). So `reserve`/`isTaken`
|
||||
* just report whether a host already owns the label (matches memory.ts intent). Two concurrent
|
||||
* `reserve()`s can both see "free" before any host row exists; that is fine, because the actual
|
||||
* single-winner is enforced when the losing insert hits the UNIQUE violation (INV1).
|
||||
*/
|
||||
function pgSubdomainStore(query: QueryFn): SubdomainStore {
|
||||
const taken = async (subdomain: string): Promise<boolean> => {
|
||||
const rows = await query<{ one: number }>(`SELECT 1 AS one FROM hosts WHERE subdomain = $1 LIMIT 1`, [
|
||||
subdomain,
|
||||
])
|
||||
return rows.length > 0
|
||||
}
|
||||
return {
|
||||
async reserve(subdomain) {
|
||||
return !(await taken(subdomain))
|
||||
},
|
||||
async isTaken(subdomain) {
|
||||
return taken(subdomain)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---- PairingStore -----------------------------------------------------------------------------
|
||||
|
||||
interface PairingCodeRow {
|
||||
code_hash: string
|
||||
account_id: string
|
||||
expires_at: Date | string
|
||||
redeemed_at: Date | string | null
|
||||
redeem_attempts: number
|
||||
}
|
||||
|
||||
function pgPairingStore(query: QueryFn): PairingStore {
|
||||
return {
|
||||
async insert(rec) {
|
||||
try {
|
||||
await query(
|
||||
`INSERT INTO pairing_codes (code_hash, account_id, expires_at, redeemed_at, redeem_attempts)
|
||||
VALUES ($1, $2, $3, $4, 0)`,
|
||||
[rec.codeHash, rec.accountId, rec.expiresAt, rec.redeemedAt],
|
||||
)
|
||||
} catch (err) {
|
||||
if (isUniqueViolation(err)) throw new Error('duplicate code_hash')
|
||||
throw err
|
||||
}
|
||||
},
|
||||
async get(codeHash): Promise<PairingRow | null> {
|
||||
const rows = await query<PairingCodeRow>(`SELECT * FROM pairing_codes WHERE code_hash = $1`, [codeHash])
|
||||
const row = rows[0]
|
||||
if (row === undefined) return null
|
||||
const record: PairingCodeRecord = {
|
||||
codeHash: row.code_hash,
|
||||
accountId: row.account_id,
|
||||
expiresAt: toIso(row.expires_at),
|
||||
redeemedAt: toIsoN(row.redeemed_at),
|
||||
}
|
||||
return { record, redeemAttempts: row.redeem_attempts }
|
||||
},
|
||||
async casRedeem(codeHash, nowIso): Promise<CasOutcome> {
|
||||
// Single-winner CAS: only the row with redeemed_at IS NULL is updated; exactly one wins.
|
||||
const won = await query<{ code_hash: string }>(
|
||||
`UPDATE pairing_codes SET redeemed_at = $2
|
||||
WHERE code_hash = $1 AND redeemed_at IS NULL
|
||||
RETURNING code_hash`,
|
||||
[codeHash, nowIso],
|
||||
)
|
||||
if (won.length === 1) return 'ok'
|
||||
// 0 rows: either already redeemed or unknown — distinguish by existence.
|
||||
const exists = await query<{ one: number }>(`SELECT 1 AS one FROM pairing_codes WHERE code_hash = $1`, [
|
||||
codeHash,
|
||||
])
|
||||
return exists.length > 0 ? 'already_redeemed' : 'unknown'
|
||||
},
|
||||
async registerFailure(codeHash) {
|
||||
const rows = await query<{ redeem_attempts: number }>(
|
||||
`UPDATE pairing_codes SET redeem_attempts = redeem_attempts + 1
|
||||
WHERE code_hash = $1
|
||||
RETURNING redeem_attempts`,
|
||||
[codeHash],
|
||||
)
|
||||
const row = rows[0]
|
||||
return row === undefined ? 0 : row.redeem_attempts // 0 for unknown code (matches memory.ts)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---- RouteStore -------------------------------------------------------------------------------
|
||||
|
||||
interface RouteRow {
|
||||
host_id: string
|
||||
relay_node_id: string
|
||||
updated_at: Date | string
|
||||
}
|
||||
|
||||
function pgRouteStore(query: QueryFn): RouteStore {
|
||||
const mapEntry = (r: RouteRow): RouteEntry => ({ relayNodeId: r.relay_node_id, updatedAt: toIso(r.updated_at) })
|
||||
return {
|
||||
async set(hostId, entry, ttlSec) {
|
||||
// Postgres has no per-key TTL: store an explicit expires_at = now() + ttl (server clock).
|
||||
await query(
|
||||
`INSERT INTO routes (host_id, relay_node_id, updated_at, expires_at)
|
||||
VALUES ($1, $2, $3, now() + make_interval(secs => $4))
|
||||
ON CONFLICT (host_id) DO UPDATE SET
|
||||
relay_node_id = EXCLUDED.relay_node_id,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
expires_at = EXCLUDED.expires_at`,
|
||||
[hostId, entry.relayNodeId, entry.updatedAt, ttlSec],
|
||||
)
|
||||
},
|
||||
async refreshTtl(hostId, ttlSec) {
|
||||
// Refresh only a still-live key (fail closed on already-expired/absent, INV7).
|
||||
const rows = await query<{ host_id: string }>(
|
||||
`UPDATE routes SET expires_at = now() + make_interval(secs => $2)
|
||||
WHERE host_id = $1 AND expires_at > now()
|
||||
RETURNING host_id`,
|
||||
[hostId, ttlSec],
|
||||
)
|
||||
return rows.length === 1
|
||||
},
|
||||
async get(hostId) {
|
||||
// Lazily reap the expired row, then a surviving row is guaranteed live (INV7 fail-closed).
|
||||
await query(`DELETE FROM routes WHERE host_id = $1 AND expires_at <= now()`, [hostId])
|
||||
const rows = await query<RouteRow>(
|
||||
`SELECT host_id, relay_node_id, updated_at FROM routes WHERE host_id = $1`,
|
||||
[hostId],
|
||||
)
|
||||
const row = rows[0]
|
||||
return row === undefined ? null : mapEntry(row)
|
||||
},
|
||||
async delete(hostId) {
|
||||
await query(`DELETE FROM routes WHERE host_id = $1`, [hostId])
|
||||
},
|
||||
async listByNode(nodeId) {
|
||||
await query(`DELETE FROM routes WHERE expires_at <= now()`, [])
|
||||
const rows = await query<RouteRow>(
|
||||
`SELECT host_id, relay_node_id, updated_at FROM routes
|
||||
WHERE relay_node_id = $1 AND expires_at > now() ORDER BY host_id ASC`,
|
||||
[nodeId],
|
||||
)
|
||||
return rows.map((r) => ({ hostId: r.host_id, entry: mapEntry(r) }))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---- NodeStore --------------------------------------------------------------------------------
|
||||
|
||||
interface NodeDbRow {
|
||||
node_id: string
|
||||
addr: string
|
||||
status: NodeStatus
|
||||
last_seen: Date | string
|
||||
}
|
||||
|
||||
function pgNodeStore(query: QueryFn): NodeStore {
|
||||
return {
|
||||
async upsert(row) {
|
||||
await query(
|
||||
`INSERT INTO relay_nodes (node_id, addr, status, last_seen)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (node_id) DO UPDATE SET
|
||||
addr = EXCLUDED.addr, status = EXCLUDED.status, last_seen = EXCLUDED.last_seen`,
|
||||
[row.nodeId, row.addr, row.status, row.lastSeen],
|
||||
)
|
||||
},
|
||||
async get(nodeId) {
|
||||
const rows = await query<NodeDbRow>(`SELECT * FROM relay_nodes WHERE node_id = $1`, [nodeId])
|
||||
const row = rows[0]
|
||||
if (row === undefined) return null
|
||||
return { nodeId: row.node_id, addr: row.addr, status: row.status, lastSeen: toIso(row.last_seen) }
|
||||
},
|
||||
async setStatus(nodeId, status) {
|
||||
const rows = await query<{ node_id: string }>(
|
||||
`UPDATE relay_nodes SET status = $2 WHERE node_id = $1 RETURNING node_id`,
|
||||
[nodeId, status],
|
||||
)
|
||||
if (rows.length === 0) throw new Error('node not found')
|
||||
},
|
||||
async touch(nodeId, ts) {
|
||||
// Silent no-op if absent (matches memory.ts).
|
||||
await query(`UPDATE relay_nodes SET last_seen = $2 WHERE node_id = $1`, [nodeId, ts])
|
||||
},
|
||||
async delete(nodeId) {
|
||||
await query(`DELETE FROM relay_nodes WHERE node_id = $1`, [nodeId])
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---- MeteringStore ----------------------------------------------------------------------------
|
||||
|
||||
interface MeteringDbRow {
|
||||
host_id: string
|
||||
account_id: string
|
||||
node_id: string
|
||||
concurrent_viewers: number
|
||||
sampled_at: Date | string
|
||||
}
|
||||
|
||||
function pgMeteringStore(query: QueryFn): MeteringStore {
|
||||
return {
|
||||
async append(row) {
|
||||
await query(
|
||||
`INSERT INTO metering_samples (host_id, account_id, node_id, concurrent_viewers, sampled_at)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[row.hostId, row.accountId, row.nodeId, row.concurrentViewers, row.sampledAt],
|
||||
)
|
||||
},
|
||||
async query(accountId, fromIso, toIso2) {
|
||||
const rows = await query<MeteringDbRow>(
|
||||
`SELECT host_id, account_id, node_id, concurrent_viewers, sampled_at
|
||||
FROM metering_samples
|
||||
WHERE account_id = $1 AND sampled_at >= $2 AND sampled_at <= $3
|
||||
ORDER BY id ASC`,
|
||||
[accountId, fromIso, toIso2],
|
||||
)
|
||||
return rows.map(
|
||||
(r): MeteringSampleRow => ({
|
||||
hostId: r.host_id,
|
||||
accountId: r.account_id,
|
||||
nodeId: r.node_id,
|
||||
concurrentViewers: r.concurrent_viewers,
|
||||
sampledAt: toIso(r.sampled_at),
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---- AuditStore -------------------------------------------------------------------------------
|
||||
|
||||
interface AuditDbRow {
|
||||
action: string
|
||||
principal_id: string
|
||||
account_id: string
|
||||
host_id: string | null
|
||||
ts: Date | string
|
||||
meta: Record<string, string>
|
||||
}
|
||||
|
||||
function pgAuditStore(query: QueryFn): AuditStore {
|
||||
return {
|
||||
async append(row) {
|
||||
await query(
|
||||
`INSERT INTO audit_log (action, principal_id, account_id, host_id, ts, meta)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb)`,
|
||||
[row.action, row.principalId, row.accountId, row.hostId, row.ts, JSON.stringify(row.meta)],
|
||||
)
|
||||
},
|
||||
async query(accountId, fromIso, toIso2) {
|
||||
const rows = await query<AuditDbRow>(
|
||||
`SELECT action, principal_id, account_id, host_id, ts, meta
|
||||
FROM audit_log
|
||||
WHERE account_id = $1 AND ts >= $2 AND ts <= $3
|
||||
ORDER BY id ASC`,
|
||||
[accountId, fromIso, toIso2],
|
||||
)
|
||||
return rows.map(
|
||||
(r): AuditRow => ({
|
||||
action: r.action,
|
||||
principalId: r.principal_id,
|
||||
accountId: r.account_id,
|
||||
hostId: r.host_id,
|
||||
ts: toIso(r.ts),
|
||||
meta: r.meta,
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---- assembly ---------------------------------------------------------------------------------
|
||||
|
||||
/** Build a full Postgres-backed store set (all ports mapped to parameterized SQL over `query`). */
|
||||
export function createPgStores(query: QueryFn): Stores {
|
||||
return {
|
||||
accounts: pgAccountStore(query),
|
||||
hosts: pgHostStore(query),
|
||||
sessions: pgSessionStore(query),
|
||||
subdomains: pgSubdomainStore(query),
|
||||
pairing: pgPairingStore(query),
|
||||
routes: pgRouteStore(query),
|
||||
nodes: pgNodeStore(query),
|
||||
metering: pgMeteringStore(query),
|
||||
audit: pgAuditStore(query),
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ const env = loadEnv({
|
||||
})
|
||||
|
||||
const verifier: CapabilityVerifier = {
|
||||
verify(raw, expectedAud, now): CapabilityToken {
|
||||
async verify(raw, expectedAud, now): Promise<CapabilityToken> {
|
||||
if (raw !== 'tokenA') throw new Error('invalid token')
|
||||
return { sub: ACCOUNT_A, aud: expectedAud, host: 'x', rights: ['manage'] as CapabilityRight[], iat: now, exp: now + 3600, jti: 'jti-A' }
|
||||
},
|
||||
|
||||
@@ -25,8 +25,9 @@ const env = loadEnv({
|
||||
})
|
||||
|
||||
// Fake P5 verifier: 'tokenA'→account A (manage), 'attachA'→account A (attach only). Else reject.
|
||||
// Async to match the `CapabilityVerifier` seam (real §4.3 verify is async).
|
||||
const verifier: CapabilityVerifier = {
|
||||
verify(raw, expectedAud, now): CapabilityToken {
|
||||
async verify(raw, expectedAud, now): Promise<CapabilityToken> {
|
||||
const base = { aud: expectedAud, host: 'x', iat: now, exp: now + 3600, jti: `jti-${raw}` }
|
||||
if (raw === 'tokenA') return { ...base, sub: ACCOUNT_A, rights: ['manage'] as CapabilityRight[] }
|
||||
if (raw === 'attachA') return { ...base, sub: ACCOUNT_A, rights: ['attach'] as CapabilityRight[] }
|
||||
|
||||
59
control-plane/test/boot-redis.test.ts
Normal file
59
control-plane/test/boot-redis.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* A2 — unit tests for the ioredis → RedisPublisher adapter (boot/redis.ts). The adapter is the only
|
||||
* non-trivial logic in the server entrypoint's Redis wiring: it must forward publish(channel, message)
|
||||
* verbatim to the underlying client and surface the driver's subscriber-count result unchanged. The
|
||||
* live `createRedisClient` (a thin `new Redis(url)`) needs a real broker and is covered by the boot
|
||||
* smoke, not here.
|
||||
*/
|
||||
import { describe, test, expect } from 'vitest'
|
||||
import type { Redis } from 'ioredis'
|
||||
import { createRedisPublisher } from '../src/boot/redis.js'
|
||||
import { createRedisRevocationBus } from '../src/routing/bus.js'
|
||||
import { RELAY_REVOCATIONS_CHANNEL, type KillSignal } from 'relay-contracts'
|
||||
|
||||
/** Minimal ioredis stand-in recording publish calls; cast to Redis at the seam (only `publish` is used). */
|
||||
function fakeRedis(returnValue = 1): { calls: Array<[string, string]>; client: Redis } {
|
||||
const calls: Array<[string, string]> = []
|
||||
const client = {
|
||||
publish: async (channel: string, message: string): Promise<number> => {
|
||||
calls.push([channel, message])
|
||||
return returnValue
|
||||
},
|
||||
} as unknown as Redis
|
||||
return { calls, client }
|
||||
}
|
||||
|
||||
describe('A2 createRedisPublisher (RedisPublisher adapter)', () => {
|
||||
test('forwards channel + message verbatim and returns the driver subscriber count', async () => {
|
||||
// Arrange
|
||||
const { calls, client } = fakeRedis(3)
|
||||
const publisher = createRedisPublisher(client)
|
||||
|
||||
// Act
|
||||
const receivers = await publisher.publish('relay:revocations', 'payload')
|
||||
|
||||
// Assert
|
||||
expect(receivers).toBe(3)
|
||||
expect(calls).toEqual([['relay:revocations', 'payload']])
|
||||
})
|
||||
|
||||
test('drives createRedisRevocationBus onto the FROZEN relay:revocations channel with JSON payload', async () => {
|
||||
// Arrange
|
||||
const { calls, client } = fakeRedis()
|
||||
const bus = createRedisRevocationBus(createRedisPublisher(client))
|
||||
const signal: KillSignal = {
|
||||
scope: { kind: 'host', hostId: '11111111-1111-4111-8111-111111111111' },
|
||||
at: 1_700_000_000,
|
||||
reason: 'revoked',
|
||||
}
|
||||
|
||||
// Act
|
||||
await bus.publish(signal)
|
||||
|
||||
// Assert — bus publishes exactly one message on the shared channel, JSON-encoded.
|
||||
expect(calls).toHaveLength(1)
|
||||
const [channel, message] = calls[0]!
|
||||
expect(channel).toBe(RELAY_REVOCATIONS_CHANNEL)
|
||||
expect(JSON.parse(message)).toMatchObject({ scope: { kind: 'host', hostId: signal.scope.kind === 'host' ? signal.scope.hostId : '' } })
|
||||
})
|
||||
})
|
||||
@@ -35,7 +35,7 @@ describe('T8 signHostLeaf — INV14 registry-gated + CSR PoP', () => {
|
||||
const forged = new Uint8Array(96)
|
||||
forged.set(publicKeyRaw, 0)
|
||||
forged.set(randomBytes(64), 32)
|
||||
expect(verifyCsrPoP(forged).ok).toBe(false)
|
||||
expect((await verifyCsrPoP(forged)).ok).toBe(false)
|
||||
await expect(signer.signHostLeaf(host.hostId, publicKeyRaw, forged)).rejects.toBeInstanceOf(LeafSignError)
|
||||
expect(signSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
220
control-plane/test/interop.test.ts
Normal file
220
control-plane/test/interop.test.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* OBJECTIVE ORACLE (INV4/INV14) — proves the control-plane's REAL leaf issuer emits an X.509 leaf
|
||||
* that relay-auth's `verifyAgentCert` accepts, WITHOUT deploying either service.
|
||||
*
|
||||
* A throwaway Ed25519 root→intermediate chain is generated in-test; a leaf is issued through
|
||||
* `createRealLeafSigner` for a random account/host + agent pubkey; then relay-auth verifies the
|
||||
* leaf against the intermediate+root bundle with a fake host registry. Positive path asserts
|
||||
* `{ ok: true, hostId, accountId }`; negatives assert the exact reject reasons.
|
||||
*/
|
||||
import 'reflect-metadata'
|
||||
import { describe, test, expect } from 'vitest'
|
||||
import * as x509 from '@peculiar/x509'
|
||||
import { webcrypto, randomUUID } from 'node:crypto'
|
||||
import { verifyAgentCert, defaultParseX509 } from 'relay-auth/src/agent/verify-mtls.js'
|
||||
import { spiffeIdFor } from 'relay-auth/src/agent/spiffe.js'
|
||||
import type { HostRecord } from 'relay-contracts'
|
||||
import { createMemoryStores } from '../src/store/memory.js'
|
||||
import { createHostRegistry } from '../src/registry/hosts.js'
|
||||
import { createRealLeafSigner, loadRealLeafSigner } from '../src/ca/issue.js'
|
||||
import { buildCsr } from '../src/ca/csr.js'
|
||||
import { fingerprint } from '../src/ca/fingerprint.js'
|
||||
import { generateEd25519 } from '../src/util/crypto.js'
|
||||
|
||||
x509.cryptoProvider.set(webcrypto)
|
||||
|
||||
const TRUST_DOMAIN = 'example.com'
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
interface Ca {
|
||||
readonly intKey: CryptoKey
|
||||
readonly issuerName: x509.Name
|
||||
readonly caChainDer: readonly Uint8Array[]
|
||||
readonly caChainPem: string
|
||||
readonly intermediateKeyPem: string
|
||||
readonly intermediateCertPem: string
|
||||
readonly rootCertPem: string
|
||||
}
|
||||
|
||||
type KeyPair = { readonly publicKey: CryptoKey; readonly privateKey: CryptoKey }
|
||||
|
||||
function derToPemLabeled(der: Uint8Array, label: string): string {
|
||||
const b64 = Buffer.from(der).toString('base64').match(/.{1,64}/g)?.join('\n') ?? ''
|
||||
return `-----BEGIN ${label}-----\n${b64}\n-----END ${label}-----\n`
|
||||
}
|
||||
|
||||
async function makeCa(): Promise<Ca> {
|
||||
const subtle = webcrypto.subtle
|
||||
const rootKeys = (await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as unknown as KeyPair
|
||||
const intKeys = (await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as unknown as KeyPair
|
||||
const now = Date.now()
|
||||
const rootCert = await x509.X509CertificateGenerator.createSelfSigned({
|
||||
serialNumber: '01',
|
||||
name: 'CN=relay-root',
|
||||
notBefore: new Date(now - DAY_MS),
|
||||
notAfter: new Date(now + DAY_MS),
|
||||
keys: rootKeys,
|
||||
signingAlgorithm: { name: 'Ed25519' },
|
||||
extensions: [new x509.BasicConstraintsExtension(true, undefined, true)],
|
||||
})
|
||||
const intCert = await x509.X509CertificateGenerator.create({
|
||||
serialNumber: '02',
|
||||
subject: 'CN=relay-intermediate',
|
||||
issuer: rootCert.subjectName,
|
||||
notBefore: new Date(now - DAY_MS),
|
||||
notAfter: new Date(now + DAY_MS),
|
||||
publicKey: intKeys.publicKey,
|
||||
signingKey: rootKeys.privateKey,
|
||||
signingAlgorithm: { name: 'Ed25519' },
|
||||
extensions: [new x509.BasicConstraintsExtension(true, undefined, true)],
|
||||
})
|
||||
const intKeyPkcs8 = new Uint8Array(await subtle.exportKey('pkcs8', intKeys.privateKey))
|
||||
return {
|
||||
intKey: intKeys.privateKey,
|
||||
issuerName: intCert.subjectName,
|
||||
caChainDer: [new Uint8Array(intCert.rawData), new Uint8Array(rootCert.rawData)],
|
||||
caChainPem: `${intCert.toString('pem')}\n${rootCert.toString('pem')}`,
|
||||
intermediateKeyPem: derToPemLabeled(intKeyPkcs8, 'PRIVATE KEY'),
|
||||
intermediateCertPem: intCert.toString('pem'),
|
||||
rootCertPem: rootCert.toString('pem'),
|
||||
}
|
||||
}
|
||||
|
||||
function derToPem(der: Uint8Array): string {
|
||||
return new x509.X509Certificate(der).toString('pem')
|
||||
}
|
||||
|
||||
/** Minimal relay-contracts HostRecord for the fake registry (only accountId/status are read). */
|
||||
function fakeHost(hostId: string, accountId: string, status: HostRecord['status']): HostRecord {
|
||||
return {
|
||||
hostId,
|
||||
accountId,
|
||||
subdomain: 'host',
|
||||
agentPubkey: new Uint8Array(32),
|
||||
enrollFpr: 'fpr',
|
||||
status,
|
||||
lastSeen: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
revokedAt: status === 'revoked' ? new Date().toISOString() : null,
|
||||
} as HostRecord
|
||||
}
|
||||
|
||||
async function issueLeaf() {
|
||||
const ca = await makeCa()
|
||||
const stores = createMemoryStores()
|
||||
const hosts = createHostRegistry({ hosts: stores.hosts })
|
||||
const accountId = randomUUID()
|
||||
const { publicKeyRaw, privateKey } = generateEd25519()
|
||||
const host = await hosts.bindHost({
|
||||
accountId,
|
||||
subdomain: 'alice',
|
||||
agentPubkey: publicKeyRaw,
|
||||
enrollFpr: fingerprint(publicKeyRaw),
|
||||
})
|
||||
const signer = createRealLeafSigner({
|
||||
hosts: stores.hosts,
|
||||
intermediateKey: ca.intKey,
|
||||
issuerName: ca.issuerName,
|
||||
caChainDer: ca.caChainDer,
|
||||
trustDomain: TRUST_DOMAIN,
|
||||
})
|
||||
const { cert, caChain } = await signer.signHostLeaf(host.hostId, publicKeyRaw, buildCsr(privateKey, publicKeyRaw))
|
||||
return { ca, accountId, hostId: host.hostId, publicKeyRaw, leafPem: derToPem(cert), caChain }
|
||||
}
|
||||
|
||||
describe('interop: control-plane real leaf ⇄ relay-auth verifyAgentCert', () => {
|
||||
test('valid leaf for an active enrolled host → { ok: true, hostId, accountId }', async () => {
|
||||
const { ca, accountId, hostId, leafPem, caChain } = await issueLeaf()
|
||||
expect(caChain).toHaveLength(2) // intermediate + root DER
|
||||
const registry = { getById: async (id: string) => (id === hostId ? fakeHost(hostId, accountId, 'online') : null) }
|
||||
const res = await verifyAgentCert(leafPem, ca.caChainPem, Math.floor(Date.now() / 1000), registry, defaultParseX509)
|
||||
expect(res).toEqual({ ok: true, hostId, accountId })
|
||||
})
|
||||
|
||||
test('SPIFFE accountId ≠ registry accountId → spiffe_account_mismatch', async () => {
|
||||
const { ca, hostId, leafPem } = await issueLeaf()
|
||||
const registry = { getById: async (id: string) => (id === hostId ? fakeHost(hostId, randomUUID(), 'online') : null) }
|
||||
const res = await verifyAgentCert(leafPem, ca.caChainPem, Math.floor(Date.now() / 1000), registry, defaultParseX509)
|
||||
expect(res.ok).toBe(false)
|
||||
expect(res.reason).toBe('spiffe_account_mismatch')
|
||||
})
|
||||
|
||||
test('revoked host → host_revoked', async () => {
|
||||
const { ca, accountId, hostId, leafPem } = await issueLeaf()
|
||||
const registry = { getById: async (id: string) => (id === hostId ? fakeHost(hostId, accountId, 'revoked') : null) }
|
||||
const res = await verifyAgentCert(leafPem, ca.caChainPem, Math.floor(Date.now() / 1000), registry, defaultParseX509)
|
||||
expect(res.ok).toBe(false)
|
||||
expect(res.reason).toBe('host_revoked')
|
||||
})
|
||||
|
||||
test('expired leaf → expired', async () => {
|
||||
const ca = await makeCa()
|
||||
const accountId = randomUUID()
|
||||
const hostId = randomUUID()
|
||||
const { publicKeyRaw } = generateEd25519()
|
||||
const spiffe = spiffeIdFor(accountId, hostId, TRUST_DOMAIN)
|
||||
const prefix = Uint8Array.from([0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00])
|
||||
const spki = new Uint8Array(44)
|
||||
spki.set(prefix, 0)
|
||||
spki.set(publicKeyRaw, 12)
|
||||
const subjectKey = await webcrypto.subtle.importKey('spki', spki, { name: 'Ed25519' }, true, ['verify'])
|
||||
const now = Date.now()
|
||||
const expiredLeaf = await x509.X509CertificateGenerator.create({
|
||||
serialNumber: '0e',
|
||||
subject: `CN=${hostId}`,
|
||||
issuer: ca.issuerName,
|
||||
notBefore: new Date(now - 2 * DAY_MS),
|
||||
notAfter: new Date(now - DAY_MS), // already expired
|
||||
publicKey: subjectKey,
|
||||
signingKey: ca.intKey,
|
||||
signingAlgorithm: { name: 'Ed25519' },
|
||||
extensions: [new x509.SubjectAlternativeNameExtension([{ type: 'url', value: spiffe }])],
|
||||
})
|
||||
const registry = { getById: async (id: string) => (id === hostId ? fakeHost(hostId, accountId, 'online') : null) }
|
||||
const res = await verifyAgentCert(
|
||||
expiredLeaf.toString('pem'),
|
||||
ca.caChainPem,
|
||||
Math.floor(Date.now() / 1000),
|
||||
registry,
|
||||
defaultParseX509,
|
||||
)
|
||||
expect(res.ok).toBe(false)
|
||||
expect(res.reason).toBe('expired')
|
||||
})
|
||||
|
||||
test('boot path: loadRealLeafSigner (PEM material) issues a verifiable leaf', async () => {
|
||||
const ca = await makeCa()
|
||||
const stores = createMemoryStores()
|
||||
const hosts = createHostRegistry({ hosts: stores.hosts })
|
||||
const accountId = randomUUID()
|
||||
const { publicKeyRaw, privateKey } = generateEd25519()
|
||||
const host = await hosts.bindHost({
|
||||
accountId,
|
||||
subdomain: 'boot',
|
||||
agentPubkey: publicKeyRaw,
|
||||
enrollFpr: fingerprint(publicKeyRaw),
|
||||
})
|
||||
const signer = await loadRealLeafSigner({
|
||||
hosts: stores.hosts,
|
||||
intermediateKeyPem: ca.intermediateKeyPem,
|
||||
intermediateCertPem: ca.intermediateCertPem,
|
||||
rootCertPem: ca.rootCertPem,
|
||||
trustDomain: TRUST_DOMAIN,
|
||||
})
|
||||
const { cert } = await signer.signHostLeaf(host.hostId, publicKeyRaw, buildCsr(privateKey, publicKeyRaw))
|
||||
const registry = {
|
||||
getById: async (id: string) => (id === host.hostId ? fakeHost(host.hostId, accountId, 'online') : null),
|
||||
}
|
||||
const res = await verifyAgentCert(derToPem(cert), ca.caChainPem, Math.floor(Date.now() / 1000), registry, defaultParseX509)
|
||||
expect(res).toEqual({ ok: true, hostId: host.hostId, accountId })
|
||||
})
|
||||
|
||||
test('intermediate-only bundle (no root) → chain_invalid', async () => {
|
||||
const { ca, accountId, hostId, leafPem } = await issueLeaf()
|
||||
const intermediateOnly = ca.caChainPem.split('-----END CERTIFICATE-----')[0]! + '-----END CERTIFICATE-----\n'
|
||||
const registry = { getById: async (id: string) => (id === hostId ? fakeHost(hostId, accountId, 'online') : null) }
|
||||
const res = await verifyAgentCert(leafPem, intermediateOnly, Math.floor(Date.now() / 1000), registry, defaultParseX509)
|
||||
expect(res.ok).toBe(false)
|
||||
expect(res.reason).toBe('chain_invalid')
|
||||
})
|
||||
})
|
||||
390
control-plane/test/store/pg.test.ts
Normal file
390
control-plane/test/store/pg.test.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* A1 — integration tests for the Postgres store adapter (src/store/pg.ts) + migration runner
|
||||
* (src/db/migrate.ts). Exercises EVERY port method and the tricky semantics that must match
|
||||
* store/memory.ts: INV8 version history, casRedeem single-winner, registerFailure, subdomain
|
||||
* reserve/isTaken, route TTL fail-closed + listByNode, append-only metering/audit time windows.
|
||||
*
|
||||
* Gated on PG_TEST_URL. If unset, a disposable postgres:16 container is started on port 5433.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'vitest'
|
||||
import { execSync } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { setTimeout as sleep } from 'node:timers/promises'
|
||||
import { createPgPool, createQuery, type QueryFn } from '../../src/db/pool.js'
|
||||
import { runMigrations } from '../../src/db/migrate.js'
|
||||
import { createPgStores } from '../../src/store/pg.js'
|
||||
import type { Stores } from '../../src/store/ports.js'
|
||||
import type { AccountRecord, HostRecord } from '../../src/model/records.js'
|
||||
|
||||
const CONTAINER = 'cp_pg_a1_test'
|
||||
const PORT = 5433
|
||||
const READY_TIMEOUT_MS = 60_000
|
||||
|
||||
let pool: ReturnType<typeof createPgPool> | undefined
|
||||
let query: QueryFn
|
||||
let stores: Stores
|
||||
let startedContainer = false
|
||||
|
||||
beforeAll(async () => {
|
||||
let url = process.env.PG_TEST_URL
|
||||
if (!url) {
|
||||
try {
|
||||
execSync(`docker rm -f ${CONTAINER}`, { stdio: 'ignore' })
|
||||
} catch {
|
||||
/* nothing to remove */
|
||||
}
|
||||
execSync(
|
||||
`docker run -d --rm --name ${CONTAINER} -e POSTGRES_PASSWORD=test -e POSTGRES_DB=cp -p ${PORT}:5432 postgres:16`,
|
||||
{ stdio: 'ignore' },
|
||||
)
|
||||
startedContainer = true
|
||||
url = `postgres://postgres:test@127.0.0.1:${PORT}/cp`
|
||||
}
|
||||
|
||||
pool = createPgPool(url)
|
||||
query = createQuery(pool)
|
||||
|
||||
// Poll until the server accepts queries.
|
||||
const deadline = Date.now() + READY_TIMEOUT_MS
|
||||
for (;;) {
|
||||
try {
|
||||
await query('SELECT 1', [])
|
||||
break
|
||||
} catch (err) {
|
||||
if (Date.now() > deadline) throw err
|
||||
await sleep(500)
|
||||
}
|
||||
}
|
||||
|
||||
await runMigrations(query)
|
||||
// Idempotency: a second run must not throw (all migrations are IF NOT EXISTS).
|
||||
await runMigrations(query)
|
||||
stores = createPgStores(query)
|
||||
}, 180_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await pool?.end()
|
||||
if (startedContainer) {
|
||||
try {
|
||||
execSync(`docker rm -f ${CONTAINER}`, { stdio: 'ignore' })
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ---- fixtures ---------------------------------------------------------------------------------
|
||||
|
||||
function newAccount(): AccountRecord {
|
||||
return { accountId: randomUUID(), plan: 'free', createdAt: new Date().toISOString(), status: 'active' }
|
||||
}
|
||||
async function makeAccount(): Promise<AccountRecord> {
|
||||
const rec = newAccount()
|
||||
await stores.accounts.insert(rec)
|
||||
return rec
|
||||
}
|
||||
async function makeHost(accountId: string, subdomain = `sub-${randomUUID().slice(0, 8)}`): Promise<HostRecord> {
|
||||
const rec: HostRecord = {
|
||||
hostId: randomUUID(),
|
||||
accountId,
|
||||
subdomain,
|
||||
agentPubkey: new Uint8Array([1, 2, 3, 4]),
|
||||
enrollFpr: `fpr-${subdomain}`,
|
||||
status: 'offline',
|
||||
lastSeen: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
revokedAt: null,
|
||||
}
|
||||
await stores.hosts.insert(rec)
|
||||
return rec
|
||||
}
|
||||
|
||||
// ---- AccountStore -----------------------------------------------------------------------------
|
||||
|
||||
describe('AccountStore', () => {
|
||||
test('insert + get round-trips the record', async () => {
|
||||
const rec = await makeAccount()
|
||||
expect(await stores.accounts.get(rec.accountId)).toEqual(rec)
|
||||
})
|
||||
|
||||
test('get returns null for unknown account', async () => {
|
||||
expect(await stores.accounts.get(randomUUID())).toBeNull()
|
||||
})
|
||||
|
||||
test('duplicate insert throws', async () => {
|
||||
const rec = await makeAccount()
|
||||
await expect(stores.accounts.insert(rec)).rejects.toThrow('duplicate accountId')
|
||||
})
|
||||
|
||||
test('swapStatus bumps status + appends INV8 version history atomically', async () => {
|
||||
const rec = await makeAccount()
|
||||
expect(await stores.accounts.versions(rec.accountId)).toEqual([
|
||||
{ accountId: rec.accountId, version: 1, status: 'active', changedAt: rec.createdAt, changedBy: 'system' },
|
||||
])
|
||||
|
||||
const swapped = await stores.accounts.swapStatus(rec.accountId, 'suspended', 'admin-1')
|
||||
expect(swapped.status).toBe('suspended')
|
||||
expect(swapped.accountId).toBe(rec.accountId)
|
||||
expect((await stores.accounts.get(rec.accountId))?.status).toBe('suspended')
|
||||
|
||||
const versions = await stores.accounts.versions(rec.accountId)
|
||||
expect(versions.length).toBe(2)
|
||||
expect(versions[1]).toMatchObject({ version: 2, status: 'suspended', changedBy: 'admin-1' })
|
||||
})
|
||||
|
||||
test('swapStatus on unknown account throws', async () => {
|
||||
await expect(stores.accounts.swapStatus(randomUUID(), 'suspended', 'x')).rejects.toThrow('account not found')
|
||||
})
|
||||
})
|
||||
|
||||
// ---- HostStore --------------------------------------------------------------------------------
|
||||
|
||||
describe('HostStore', () => {
|
||||
test('insert + get + getBySubdomain + listByAccount', async () => {
|
||||
const acct = await makeAccount()
|
||||
const host = await makeHost(acct.accountId)
|
||||
expect(await stores.hosts.get(host.hostId)).toEqual(host)
|
||||
expect(await stores.hosts.getBySubdomain(host.subdomain)).toEqual(host)
|
||||
expect(await stores.hosts.listByAccount(acct.accountId)).toEqual([host])
|
||||
})
|
||||
|
||||
test('get / getBySubdomain return null when absent', async () => {
|
||||
expect(await stores.hosts.get(randomUUID())).toBeNull()
|
||||
expect(await stores.hosts.getBySubdomain(`missing-${randomUUID()}`)).toBeNull()
|
||||
})
|
||||
|
||||
test('duplicate hostId and duplicate subdomain throw distinctly', async () => {
|
||||
const acct = await makeAccount()
|
||||
const host = await makeHost(acct.accountId)
|
||||
await expect(stores.hosts.insert(host)).rejects.toThrow('duplicate hostId')
|
||||
|
||||
const clash: HostRecord = { ...host, hostId: randomUUID() }
|
||||
await expect(stores.hosts.insert(clash)).rejects.toThrow('duplicate subdomain')
|
||||
})
|
||||
|
||||
test('swapStatus to revoked sets revokedAt + version history', async () => {
|
||||
const acct = await makeAccount()
|
||||
const host = await makeHost(acct.accountId)
|
||||
const revokedAt = new Date().toISOString()
|
||||
const swapped = await stores.hosts.swapStatus(host.hostId, 'revoked', revokedAt, 'admin-2')
|
||||
expect(swapped.status).toBe('revoked')
|
||||
expect(swapped.revokedAt).toBe(revokedAt)
|
||||
|
||||
const versions = await stores.hosts.versions(host.hostId)
|
||||
expect(versions.length).toBe(2)
|
||||
expect(versions[0]).toMatchObject({ version: 1, status: 'offline', revokedAt: null, changedBy: 'system' })
|
||||
expect(versions[1]).toMatchObject({ version: 2, status: 'revoked', revokedAt, changedBy: 'admin-2' })
|
||||
})
|
||||
|
||||
test('swapStatus on unknown host throws', async () => {
|
||||
await expect(stores.hosts.swapStatus(randomUUID(), 'online', null, 'x')).rejects.toThrow('host not found')
|
||||
})
|
||||
|
||||
test('touchLastSeen updates the advisory cache (no version row)', async () => {
|
||||
const acct = await makeAccount()
|
||||
const host = await makeHost(acct.accountId)
|
||||
const ts = new Date(Date.now() + 5000).toISOString()
|
||||
await stores.hosts.touchLastSeen(host.hostId, ts)
|
||||
expect((await stores.hosts.get(host.hostId))?.lastSeen).toBe(ts)
|
||||
expect((await stores.hosts.versions(host.hostId)).length).toBe(1) // unchanged
|
||||
await stores.hosts.touchLastSeen(randomUUID(), ts) // no-op on absent host, no throw
|
||||
})
|
||||
})
|
||||
|
||||
// ---- SessionStore -----------------------------------------------------------------------------
|
||||
|
||||
describe('SessionStore', () => {
|
||||
test('insert + get + duplicate + missing', async () => {
|
||||
const acct = await makeAccount()
|
||||
const host = await makeHost(acct.accountId)
|
||||
const rec = {
|
||||
sessionId: randomUUID(),
|
||||
hostId: host.hostId,
|
||||
accountId: acct.accountId,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastAttachAt: new Date().toISOString(),
|
||||
}
|
||||
await stores.sessions.insert(rec)
|
||||
expect(await stores.sessions.get(rec.sessionId)).toEqual(rec)
|
||||
await expect(stores.sessions.insert(rec)).rejects.toThrow('duplicate sessionId')
|
||||
expect(await stores.sessions.get(randomUUID())).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ---- SubdomainStore ---------------------------------------------------------------------------
|
||||
|
||||
describe('SubdomainStore', () => {
|
||||
test('reserve/isTaken reflect host ownership of the label', async () => {
|
||||
const sub = `claim-${randomUUID().slice(0, 8)}`
|
||||
expect(await stores.subdomains.isTaken(sub)).toBe(false)
|
||||
expect(await stores.subdomains.reserve(sub)).toBe(true) // free (no host owns it yet)
|
||||
|
||||
const acct = await makeAccount()
|
||||
await makeHost(acct.accountId, sub)
|
||||
|
||||
expect(await stores.subdomains.isTaken(sub)).toBe(true)
|
||||
expect(await stores.subdomains.reserve(sub)).toBe(false) // a host now owns it
|
||||
})
|
||||
})
|
||||
|
||||
// ---- PairingStore -----------------------------------------------------------------------------
|
||||
|
||||
describe('PairingStore', () => {
|
||||
test('insert + get + casRedeem single-winner + registerFailure', async () => {
|
||||
const acct = await makeAccount()
|
||||
const codeHash = `code-${randomUUID()}`
|
||||
const rec = {
|
||||
codeHash,
|
||||
accountId: acct.accountId,
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
redeemedAt: null,
|
||||
}
|
||||
await stores.pairing.insert(rec)
|
||||
await expect(stores.pairing.insert(rec)).rejects.toThrow('duplicate code_hash')
|
||||
|
||||
const got = await stores.pairing.get(codeHash)
|
||||
expect(got).toEqual({ record: rec, redeemAttempts: 0 })
|
||||
|
||||
const now = new Date().toISOString()
|
||||
expect(await stores.pairing.casRedeem(codeHash, now)).toBe('ok')
|
||||
expect(await stores.pairing.casRedeem(codeHash, now)).toBe('already_redeemed')
|
||||
expect(await stores.pairing.casRedeem(`unknown-${randomUUID()}`, now)).toBe('unknown')
|
||||
expect((await stores.pairing.get(codeHash))?.record.redeemedAt).toBe(now)
|
||||
})
|
||||
|
||||
test('registerFailure increments; unknown code returns 0', async () => {
|
||||
const acct = await makeAccount()
|
||||
const codeHash = `code-${randomUUID()}`
|
||||
await stores.pairing.insert({
|
||||
codeHash,
|
||||
accountId: acct.accountId,
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
redeemedAt: null,
|
||||
})
|
||||
expect(await stores.pairing.registerFailure(codeHash)).toBe(1)
|
||||
expect(await stores.pairing.registerFailure(codeHash)).toBe(2)
|
||||
expect((await stores.pairing.get(codeHash))?.redeemAttempts).toBe(2)
|
||||
expect(await stores.pairing.registerFailure(`unknown-${randomUUID()}`)).toBe(0)
|
||||
})
|
||||
|
||||
test('get returns null for unknown code', async () => {
|
||||
expect(await stores.pairing.get(`nope-${randomUUID()}`)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ---- RouteStore -------------------------------------------------------------------------------
|
||||
|
||||
describe('RouteStore', () => {
|
||||
test('set → get → refreshTtl → delete', async () => {
|
||||
const hostId = randomUUID()
|
||||
const entry = { relayNodeId: 'node-A', updatedAt: new Date().toISOString() }
|
||||
await stores.routes.set(hostId, entry, 60)
|
||||
expect(await stores.routes.get(hostId)).toEqual(entry)
|
||||
expect(await stores.routes.refreshTtl(hostId, 60)).toBe(true)
|
||||
await stores.routes.delete(hostId)
|
||||
expect(await stores.routes.get(hostId)).toBeNull()
|
||||
})
|
||||
|
||||
test('expired route fails closed (get null, refreshTtl false)', async () => {
|
||||
const hostId = randomUUID()
|
||||
const entry = { relayNodeId: 'node-A', updatedAt: new Date().toISOString() }
|
||||
await stores.routes.set(hostId, entry, -1) // already expired
|
||||
expect(await stores.routes.get(hostId)).toBeNull()
|
||||
expect(await stores.routes.refreshTtl(hostId, 60)).toBe(false)
|
||||
expect(await stores.routes.refreshTtl(randomUUID(), 60)).toBe(false) // absent
|
||||
})
|
||||
|
||||
test('listByNode returns only live routes targeting the node', async () => {
|
||||
const node = `node-${randomUUID().slice(0, 8)}`
|
||||
const live1 = randomUUID()
|
||||
const live2 = randomUUID()
|
||||
const expired = randomUUID()
|
||||
const otherNode = randomUUID()
|
||||
await stores.routes.set(live1, { relayNodeId: node, updatedAt: new Date().toISOString() }, 60)
|
||||
await stores.routes.set(live2, { relayNodeId: node, updatedAt: new Date().toISOString() }, 60)
|
||||
await stores.routes.set(expired, { relayNodeId: node, updatedAt: new Date().toISOString() }, -1)
|
||||
await stores.routes.set(otherNode, { relayNodeId: 'someone-else', updatedAt: new Date().toISOString() }, 60)
|
||||
|
||||
const listed = await stores.routes.listByNode(node)
|
||||
const hostIds = listed.map((r) => r.hostId).sort()
|
||||
expect(hostIds).toEqual([live1, live2].sort())
|
||||
expect(listed.every((r) => r.entry.relayNodeId === node)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ---- NodeStore --------------------------------------------------------------------------------
|
||||
|
||||
describe('NodeStore', () => {
|
||||
test('upsert + get + setStatus + touch + delete', async () => {
|
||||
const nodeId = `relay-${randomUUID().slice(0, 8)}`
|
||||
const row = { nodeId, addr: '10.0.0.1:9000', status: 'active' as const, lastSeen: new Date().toISOString() }
|
||||
await stores.nodes.upsert(row)
|
||||
expect(await stores.nodes.get(nodeId)).toEqual(row)
|
||||
|
||||
await stores.nodes.upsert({ ...row, addr: '10.0.0.2:9000' }) // upsert overwrites
|
||||
expect((await stores.nodes.get(nodeId))?.addr).toBe('10.0.0.2:9000')
|
||||
|
||||
await stores.nodes.setStatus(nodeId, 'draining')
|
||||
expect((await stores.nodes.get(nodeId))?.status).toBe('draining')
|
||||
|
||||
const later = new Date(Date.now() + 1000).toISOString()
|
||||
await stores.nodes.touch(nodeId, later)
|
||||
expect((await stores.nodes.get(nodeId))?.lastSeen).toBe(later)
|
||||
|
||||
await stores.nodes.delete(nodeId)
|
||||
expect(await stores.nodes.get(nodeId)).toBeNull()
|
||||
})
|
||||
|
||||
test('setStatus on unknown node throws; touch is a silent no-op', async () => {
|
||||
await expect(stores.nodes.setStatus(`ghost-${randomUUID()}`, 'draining')).rejects.toThrow('node not found')
|
||||
await stores.nodes.touch(`ghost-${randomUUID()}`, new Date().toISOString()) // no throw
|
||||
})
|
||||
})
|
||||
|
||||
// ---- MeteringStore ----------------------------------------------------------------------------
|
||||
|
||||
describe('MeteringStore', () => {
|
||||
test('append-only + inclusive [from,to] time-window query filtered by account', async () => {
|
||||
const acct = await makeAccount()
|
||||
const other = await makeAccount()
|
||||
const host = await makeHost(acct.accountId)
|
||||
const otherHost = await makeHost(other.accountId)
|
||||
|
||||
const t0 = '2026-01-01T00:00:00.000Z'
|
||||
const t1 = '2026-01-01T01:00:00.000Z'
|
||||
const t2 = '2026-01-01T02:00:00.000Z'
|
||||
|
||||
await stores.metering.append({ hostId: host.hostId, accountId: acct.accountId, nodeId: 'n1', concurrentViewers: 3, sampledAt: t0 })
|
||||
await stores.metering.append({ hostId: host.hostId, accountId: acct.accountId, nodeId: 'n1', concurrentViewers: 5, sampledAt: t1 })
|
||||
await stores.metering.append({ hostId: host.hostId, accountId: acct.accountId, nodeId: 'n1', concurrentViewers: 9, sampledAt: t2 })
|
||||
await stores.metering.append({ hostId: otherHost.hostId, accountId: other.accountId, nodeId: 'n1', concurrentViewers: 1, sampledAt: t1 })
|
||||
|
||||
const inWindow = await stores.metering.query(acct.accountId, t0, t1)
|
||||
expect(inWindow.map((r) => r.concurrentViewers)).toEqual([3, 5]) // t2 excluded, other acct excluded
|
||||
expect(inWindow[0]).toMatchObject({ hostId: host.hostId, accountId: acct.accountId, sampledAt: t0 })
|
||||
|
||||
const all = await stores.metering.query(acct.accountId, t0, t2)
|
||||
expect(all.length).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
// ---- AuditStore -------------------------------------------------------------------------------
|
||||
|
||||
describe('AuditStore', () => {
|
||||
test('append-only + time-window query + null hostId + meta jsonb round-trip', async () => {
|
||||
const accountId = randomUUID()
|
||||
const t0 = '2026-02-01T00:00:00.000Z'
|
||||
const t1 = '2026-02-01T01:00:00.000Z'
|
||||
const t2 = '2026-02-01T02:00:00.000Z'
|
||||
|
||||
await stores.audit.append({ action: 'provision', principalId: 'p1', accountId, hostId: 'h1', ts: t0, meta: { ip: '1.2.3.4' } })
|
||||
await stores.audit.append({ action: 'revoke', principalId: 'p2', accountId, hostId: null, ts: t1, meta: {} })
|
||||
await stores.audit.append({ action: 'late', principalId: 'p3', accountId, hostId: null, ts: t2, meta: {} })
|
||||
await stores.audit.append({ action: 'other', principalId: 'p9', accountId: randomUUID(), hostId: null, ts: t1, meta: {} })
|
||||
|
||||
const rows = await stores.audit.query(accountId, t0, t1)
|
||||
expect(rows.map((r) => r.action)).toEqual(['provision', 'revoke']) // t2 + other account excluded
|
||||
expect(rows[0]).toEqual({ action: 'provision', principalId: 'p1', accountId, hostId: 'h1', ts: t0, meta: { ip: '1.2.3.4' } })
|
||||
expect(rows[1]?.hostId).toBeNull()
|
||||
})
|
||||
})
|
||||
78
control-plane/test/verifier.test.ts
Normal file
78
control-plane/test/verifier.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* A3 — REAL capability verifier (boot/verifier.ts) backed by relay-auth's async `verifyCapabilityToken`.
|
||||
* Mints tokens with the paired Ed25519 private key via P5's `issueCapabilityToken`, configures the
|
||||
* matching public key, and asserts: a good token verifies; wrong `aud`, past `exp`, and a token
|
||||
* signed by a NON-matching key all reject (deny-by-default, INV6).
|
||||
*/
|
||||
import { describe, test, expect, beforeAll } from 'vitest'
|
||||
import { issueCapabilityToken } from 'relay-auth'
|
||||
import type { AuthenticatedPrincipal } from 'relay-auth'
|
||||
import { encodeBase64UrlBytes } from 'relay-contracts'
|
||||
import { createCapabilityVerifier, configureCapabilityVerifyKey } from '../src/boot/verifier.js'
|
||||
import type { CapabilityVerifier } from '../src/api/authz.js'
|
||||
|
||||
const ACCOUNT_A = '11111111-1111-4111-8111-111111111111'
|
||||
const AUD = 'term.example.com'
|
||||
const NOW = 1_000_000
|
||||
// A base64url SHA-256 JWK thumbprint is exactly 43 chars of [A-Za-z0-9_-]; 32 bytes → 43 base64url chars.
|
||||
const CNF_JKT = encodeBase64UrlBytes(new Uint8Array(32).fill(7))
|
||||
|
||||
const principal: AuthenticatedPrincipal = {
|
||||
kind: 'human',
|
||||
accountId: ACCOUNT_A,
|
||||
principalId: 'cred-1',
|
||||
amr: ['passkey'],
|
||||
authAt: NOW - 10,
|
||||
stepUpAt: null,
|
||||
}
|
||||
|
||||
type KeyPair = { publicKey: CryptoKey; privateKey: CryptoKey }
|
||||
async function genKeyPair(): Promise<KeyPair> {
|
||||
// generateKey's named-algorithm overload is typed as CryptoKey; Ed25519 yields a pair at runtime.
|
||||
return (await globalThis.crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as unknown as KeyPair
|
||||
}
|
||||
|
||||
function mint(signingKey: CryptoKey, now: number): Promise<string> {
|
||||
return issueCapabilityToken(
|
||||
{ principal, aud: AUD, host: 'host-1', rights: ['manage'], ttlSeconds: 60, cnfJkt: CNF_JKT },
|
||||
signingKey,
|
||||
now,
|
||||
)
|
||||
}
|
||||
|
||||
describe('A3 real capability verifier (relay-auth verifyCapabilityToken)', () => {
|
||||
let signingKey: CryptoKey
|
||||
let verifier: CapabilityVerifier
|
||||
|
||||
beforeAll(async () => {
|
||||
const pair = await genKeyPair()
|
||||
signingKey = pair.privateKey
|
||||
const rawPub = new Uint8Array(await globalThis.crypto.subtle.exportKey('raw', pair.publicKey))
|
||||
await configureCapabilityVerifyKey(rawPub)
|
||||
verifier = createCapabilityVerifier()
|
||||
})
|
||||
|
||||
test('verifies a token signed by the configured key and returns the account principal', async () => {
|
||||
const raw = await mint(signingKey, NOW)
|
||||
const token = await verifier.verify(raw, AUD, NOW)
|
||||
expect(token.sub).toBe(ACCOUNT_A)
|
||||
expect(token.aud).toBe(AUD)
|
||||
expect(token.rights).toContain('manage')
|
||||
})
|
||||
|
||||
test('rejects a token minted for a different aud (Host-confusion guard)', async () => {
|
||||
const raw = await mint(signingKey, NOW)
|
||||
await expect(verifier.verify(raw, 'evil.example.com', NOW)).rejects.toThrow()
|
||||
})
|
||||
|
||||
test('rejects an expired token', async () => {
|
||||
const raw = await mint(signingKey, NOW) // exp = NOW + 60
|
||||
await expect(verifier.verify(raw, AUD, NOW + 120)).rejects.toThrow()
|
||||
})
|
||||
|
||||
test('rejects a token signed by a NON-matching key (bad signature)', async () => {
|
||||
const other = await genKeyPair()
|
||||
const raw = await mint(other.privateKey, NOW)
|
||||
await expect(verifier.verify(raw, AUD, NOW)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
43
deploy/.env.example
Normal file
43
deploy/.env.example
Normal file
@@ -0,0 +1,43 @@
|
||||
# RELAY-PHASE1 env template. Copy to deploy/.env (gitignored) and fill. NEVER commit real secrets.
|
||||
# Convert relative → absolute paths on the VPS. See docs/PLAN_RELAY_PHASE1.md §3.
|
||||
|
||||
# ── Docker Compose (deploy/docker-compose.yml) ────────────────────────────────────────────────
|
||||
POSTGRES_DB=relay
|
||||
POSTGRES_USER=relay
|
||||
POSTGRES_PASSWORD= # REQUIRED — strong random
|
||||
|
||||
# ── Shared datastores (loopback; both processes) ─────────────────────────────────────────────
|
||||
PG_URL=postgres://relay:CHANGEME@127.0.0.1:5432/relay
|
||||
REDIS_URL=redis://127.0.0.1:6379
|
||||
|
||||
# ── Deployment identity ──────────────────────────────────────────────────────────────────────
|
||||
BASE_DOMAIN=term.example.com # your ICP-filed domain; browser hits <sub>.<BASE_DOMAIN>
|
||||
RELAY_NODE_ID=relay-1
|
||||
RELAY_TRUST_DOMAIN=relay.example.com # SPIFFE trust domain for agent certs
|
||||
|
||||
# ── P5 capability keypair — CONTROL-PLANE SIGNS, RELAY VERIFIES. Same public key on both. ────
|
||||
# Generate an Ed25519 keypair; keep the PRIVATE key only where tokens are minted (never on disk here).
|
||||
CAPABILITY_SIGN_PUBKEY_B64= # control-plane env: raw 32-byte Ed25519 pubkey, base64
|
||||
RELAY_AUTH_VERIFY_PUBKEY= # relay env: SAME key, base64url
|
||||
|
||||
# ── Control-plane (P3) ───────────────────────────────────────────────────────────────────────
|
||||
CP_BIND_HOST=127.0.0.1 # admin API stays loopback-only (front only via the relay)
|
||||
CP_BIND_PORT=8080
|
||||
CA_INTERMEDIATE_KMS_KEY_REF=dev-local # Phase 1: dev in-process signer accepts any ref (KMS → Phase 2)
|
||||
CA_INTERMEDIATE_CERT_PATH=/etc/relay/ca/intermediate.cert.pem
|
||||
NODE_MTLS_TRUST_BUNDLE_PATH=/etc/relay/ca/agent-ca.bundle.pem
|
||||
# HEARTBEAT_TTL_SEC=15 PAIRING_TTL_SEC=600 PAIRING_MAX_REDEEM_ATTEMPTS=5 (defaults)
|
||||
|
||||
# ── Relay data-plane (P1, relay-run Phase 1) ─────────────────────────────────────────────────
|
||||
BIND_HOST=0.0.0.0
|
||||
BIND_PORT=443 # browser WSS (open in the Aliyun security group)
|
||||
TLS_CERT_PATH=/etc/relay/tls/fullchain.pem # Let's Encrypt for <sub>.<BASE_DOMAIN>
|
||||
TLS_KEY_PATH=/etc/relay/tls/privkey.pem
|
||||
AGENT_BIND_PORT=8444 # agent mTLS (open in the security group)
|
||||
AGENT_CA_CERT_PATH=/etc/relay/ca/agent-ca.cert.pem # private enrollment CA — NOT Let's Encrypt
|
||||
AGENT_CA_CHAIN_PATH=/etc/relay/ca/agent-ca.bundle.pem
|
||||
|
||||
# ── Agent (P2) — runs on YOUR laptop, not the VPS. Shown here for reference. ──────────────────
|
||||
# RELAY_URL=wss://<sub>.term.example.com:8444
|
||||
# ENROLL_URL=https://<sub>.term.example.com/enroll (or the CP host)
|
||||
# HOST_ID=... SUBDOMAIN=<sub> LOCAL_TARGET_URL=ws://127.0.0.1:3000 STATE_DIR=~/.web-terminal-agent
|
||||
233
deploy/README.md
Normal file
233
deploy/README.md
Normal file
@@ -0,0 +1,233 @@
|
||||
# Native mTLS Reverse-Tunnel — Deployment Guide
|
||||
|
||||
Master runbook for exposing native iOS / Android / desktop clients to a **local** web-terminal host
|
||||
through the user's VPS (`8.138.1.192`), gated **only** by device-certificate mTLS.
|
||||
|
||||
- **Design / rationale:** [`docs/PLAN_NATIVE_TUNNEL.md`](../docs/PLAN_NATIVE_TUNNEL.md) (the *why*, risk register, milestones).
|
||||
- **Per-host onboarding (repeatable):** [`frp/README.md`](./frp/README.md) (V7 checklist).
|
||||
- **This file:** what shipped, and the **one-time VPS deploy** end-to-end.
|
||||
|
||||
> **Trust model A — single-owner fleet.** Every device cert and host belongs to one operator. The
|
||||
> shared device-CA means "the closed set of *my* devices"; there is **no cross-tenant isolation**.
|
||||
> Do **not** enroll distrusting third parties on this CA — that is Model B, out of scope for v1.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture
|
||||
|
||||
Everything multiplexes onto the single open port `:443`, SNI-routed by the existing nginx `stream`
|
||||
(`ssl_preread`, TLS **passthrough**), coexisting untouched with the browser relay and xray-Reality.
|
||||
|
||||
```
|
||||
Public internet ── :443 (only open port) ──► nginx stream ssl_preread (no TLS term)
|
||||
map $ssl_preread_server_name:
|
||||
frp.terminal.yaojia.wang → 127.0.0.1:7000 frps control (mTLS + token)
|
||||
*.terminal.yaojia.wang → 127.0.0.1:8470 nginx TLS-term + device-CA mTLS + CRL ─► :7080 frps vhost
|
||||
*.term.yaojia.wang → 127.0.0.1:8443 E2E browser relay (EXISTING, preserved)
|
||||
default → 127.0.0.1:10443 xray Reality (EXISTING, preserved)
|
||||
|
||||
frps vhost :7080 ──(frp tunnel; frpc dials OUT :443)──► LOCAL host: frpc → base app 127.0.0.1:3000
|
||||
BIND_HOST=127.0.0.1 (loopback ONLY)
|
||||
native client (iOS/Android/desktop) presents a DEVICE CERT → https://<name>.terminal.yaojia.wang
|
||||
```
|
||||
|
||||
**Why mTLS is non-negotiable:** the base app has no login of its own. The device client-cert is the
|
||||
one and only gate. Two invariants make it airtight: `ssl_verify_client on` (never `optional`) at
|
||||
`:8470`, and `BIND_HOST=127.0.0.1` on every host (default `0.0.0.0` would serve an unauth'd shell on
|
||||
the LAN, bypassing mTLS entirely).
|
||||
|
||||
---
|
||||
|
||||
## 2. What shipped — 2026-07-07 (branch `feat/relay-phase1`)
|
||||
|
||||
Four file-disjoint tracks implemented via multi-agent build→review, then two HIGH review findings
|
||||
closed. **Zero `src/` (base-app core) changes** — a working tunnel needs none. All self-verified green.
|
||||
|
||||
| Track | Commit | What | Verified |
|
||||
|---|---|---|---|
|
||||
| **VPS scripts** (V2/V7) | `5337281` | device CA + issue + revoke scripts, frps/frpc templates, nginx `:8470` mTLS conf, SNI-merge doc | `gen→issue→openssl verify→revoke→CRL` green in tmp dir (OpenSSL 3.0.18) |
|
||||
| **iOS client mTLS** (C-iOS) | `e38e6d1` | `ClientTLS` SPM package (identity / PKCS12 import / keychain / challenge-responder), both transports take a lazy identity provider (no relaunch after install), `设备证书` install screen | ClientTLS 14/14 · SessionCore 93/93 · app `BUILD SUCCEEDED` |
|
||||
| **Desktop** (D-1/D-2) | `bb09495` | remote-host mode (origin-locked nav) + `select-client-certificate` from OS keychain | `npm run typecheck` clean |
|
||||
| **Host packaging** (S2) | `d0c249c` | launchd/systemd env injection wired through the `install` CLI; `BIND_HOST` defaults `127.0.0.1`; systemd control-char rejection | agent build clean · vitest 166/166 |
|
||||
|
||||
Adversarial review: Desktop + VPS scripts passed clean; the iOS "install screen unreachable" and
|
||||
"S2 env-injection not wired to the CLI" HIGH findings were both closed and re-verified.
|
||||
|
||||
**Not yet done:** the **live VPS deploy** below (touches production nginx/xray/relay — done serially by
|
||||
an operator, not by an agent). The clients cannot reach a tunneled host until §3 is complete.
|
||||
|
||||
---
|
||||
|
||||
## 3. Deploy — one-time VPS setup (V0 → V5)
|
||||
|
||||
**Prerequisites**
|
||||
- DNS: `*.terminal.yaojia.wang` and `frp.terminal.yaojia.wang` → `8.138.1.192` (confirmed resolving).
|
||||
- Aliyun DNS API creds for Let's Encrypt DNS-01: `Ali_Key` / `Ali_Secret`.
|
||||
- `frps` binary ≥ v0.52 (needs `disableCustomTLSFirstByte`, ≥ v0.50). Mainland fetch may be blocked →
|
||||
`scp` from a laptop.
|
||||
- `acme.sh` installed on the VPS.
|
||||
- **Coexistence rule (every step):** never retype the existing `map` lines; merge additively; deploy
|
||||
only via `nginx -t && systemctl reload nginx`; re-run the SNI oracle (§5) after each nginx change.
|
||||
|
||||
> Repo scripts referenced below are run **from the repo root**. Push the repo to the VPS first
|
||||
> (`git push root@8.138.1.192:/root/web-terminal.git feat/relay-phase1`) or `scp` the `deploy/` tree.
|
||||
|
||||
### V0 — snapshot (reversible safety net)
|
||||
```bash
|
||||
cp /etc/nginx/stream-relay.conf{,.bak.$(date +%s)}
|
||||
nginx -T > /root/nginx-dump.pre.txt
|
||||
ss -ltnp | sort > /root/ports.pre.txt
|
||||
nginx -v # expect 1.24 → do NOT add `http2 off;` (invalid on 1.24)
|
||||
dig +short foo.terminal.yaojia.wang frp.terminal.yaojia.wang # both → 8.138.1.192
|
||||
```
|
||||
|
||||
### V1 — frps: control `:7000` (mTLS) + vhost `:7080`
|
||||
```bash
|
||||
# dedicated unprivileged user (loopback ports need no root)
|
||||
useradd -r -s /usr/sbin/nologin frp 2>/dev/null || true
|
||||
install -d -o frp -g frp -m 750 /etc/relay/frp /var/log/frp
|
||||
|
||||
# config from the template — set the token, keep everything else
|
||||
install -m 600 -o frp -g frp deploy/frp/frps.toml.example /etc/relay/frp/frps.toml
|
||||
openssl rand -hex 32 >> /etc/relay/secrets.env # FRP_TOKEN — paste into frps.toml auth.token
|
||||
# frps.toml already sets: bindAddr 127.0.0.1 / bindPort 7000 / vhostHTTPPort 7080 /
|
||||
# subDomainHost terminal.yaojia.wang / transport.tls.force = true (control-channel mTLS)
|
||||
|
||||
# systemd unit: NoNewPrivileges / ProtectSystem=strict / ReadWritePaths=/var/log/frp, User=frp
|
||||
systemctl enable --now frps
|
||||
journalctl -u frps -n30 --no-pager # clean start
|
||||
curl -s -H 'Host: probe.terminal.yaojia.wang' http://127.0.0.1:7080/ # → frp 404 "no proxy" = vhost up
|
||||
```
|
||||
|
||||
### V1b + V2 — the two CAs (control-channel + device data-path)
|
||||
Separate trust roots from Let's Encrypt and from the agent-enrollment CA.
|
||||
```bash
|
||||
bash deploy/scripts/gen-device-ca.sh --kind frp-client # → /etc/relay/frp-client-ca (frpc control mTLS)
|
||||
bash deploy/scripts/gen-device-ca.sh # → /etc/relay/device-ca (KIND=device) + EMPTY crl.pem
|
||||
```
|
||||
- `frps.toml transport.tls.trustedCaFile` → `/etc/relay/frp-client-ca/frp-client-ca.cert.pem`.
|
||||
- Issue the frps control **server** cert + each host's frp-client cert (V1b) from the frp-client CA:
|
||||
```bash
|
||||
CA_DIR=/etc/relay/frp-client-ca bash deploy/scripts/issue-device-cert.sh <hostname> ./out-<hostname>
|
||||
# frpc uses out-<hostname>/<hostname>.{cert,key}.pem — ignore the .p12 for control mTLS
|
||||
```
|
||||
- **Effect:** a leaked `auth.token` alone can no longer register a subdomain (needs a valid frp-client
|
||||
cert too) — closes the subdomain-takeover / MITM hole (R3).
|
||||
|
||||
### V3 — Let's Encrypt wildcard server cert (hard iOS prerequisite — self-signed fails iOS ATS)
|
||||
```bash
|
||||
export Ali_Key=... Ali_Secret=...
|
||||
acme.sh --issue --dns dns_ali -d '*.terminal.yaojia.wang' -d terminal.yaojia.wang
|
||||
install -d -m 700 /etc/relay/frp-tls
|
||||
acme.sh --install-cert -d '*.terminal.yaojia.wang' \
|
||||
--fullchain-file /etc/relay/frp-tls/fullchain.pem \
|
||||
--key-file /etc/relay/frp-tls/privkey.pem \
|
||||
--reloadcmd 'nginx -s reload'
|
||||
openssl x509 -in /etc/relay/frp-tls/fullchain.pem -noout -text | grep -A1 'Subject Alternative Name'
|
||||
# → *.terminal.yaojia.wang (separate cert from *.term — different parent label; don't try to cover both)
|
||||
```
|
||||
|
||||
### V4 — nginx `:8470` = TLS-term + device-CA mTLS + CRL + WS proxy
|
||||
```bash
|
||||
cp deploy/nginx/frp-mtls.conf /etc/nginx/conf.d/frp-mtls.conf
|
||||
nginx -t
|
||||
# frp-mtls.conf enforces: ssl_verify_client ON (never optional), ssl_client_certificate = device-CA,
|
||||
# ssl_crl = device-ca/crl.pem (revocation day one), WS upgrade, proxy_read/send_timeout 3600s
|
||||
# (idle-WS kill fix), proxy_pass 127.0.0.1:7080. NO `http2 off;`.
|
||||
```
|
||||
**Loopback verify before touching the stream** (issue a throwaway device cert with V2's issue script):
|
||||
```bash
|
||||
curl -sI --resolve x.terminal.yaojia.wang:8470:127.0.0.1 https://x.terminal.yaojia.wang:8470/ # no cert → TLS handshake FAILURE (must fail)
|
||||
curl -sI --cert dev.cert.pem --key dev.key.pem ... # → frp 404 (reaches frps) = gate works
|
||||
```
|
||||
|
||||
### V5 — stream SNI additions (the only edit to the existing shared conf)
|
||||
Edit `/etc/nginx/stream-relay.conf`, **merge** the two lines from
|
||||
[`nginx/stream-sni-additions.md`](./nginx/stream-sni-additions.md) into the existing `map` body — do
|
||||
**not** retype the existing `*.term`→8443 / `default`→10443 lines:
|
||||
```nginx
|
||||
frp.terminal.yaojia.wang 127.0.0.1:7000; # exact wins over wildcard
|
||||
*.terminal.yaojia.wang 127.0.0.1:8470;
|
||||
```
|
||||
```bash
|
||||
nginx -t && systemctl reload nginx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Deploy — hosts and clients
|
||||
|
||||
- **Per local host (S0/S1):** follow [`frp/README.md`](./frp/README.md) — base-app env
|
||||
(`BIND_HOST=127.0.0.1`, `ALLOWED_ORIGINS=https://<name>.terminal.yaojia.wang`, `PORT=3000`), `frpc.toml`
|
||||
from `frp/frpc.example.toml`, and a per-host frp-client cert. Durable service packaging (S2) via the
|
||||
agent `install` CLI now injects that env into launchd/systemd units.
|
||||
- **iOS:** deliver `<device>.p12` (AirDrop/Files) → app → Settings → **设备证书** → Import + passphrase →
|
||||
pair `https://<name>.terminal.yaojia.wang`. (Cert takes effect on the next connect, no relaunch.)
|
||||
- **Desktop:** double-click `<device>.p12` into the login keychain (macOS) / Personal store (Windows);
|
||||
the app auto-selects it by issuer CN. Switch to the remote host in the tray picker.
|
||||
- **Android:** WebView shell — not yet built (Track C-Android, deferred).
|
||||
|
||||
Issue a device cert (data-path mTLS) and deliver it **securely** (never email):
|
||||
```bash
|
||||
P12_PASSWORD='...' bash deploy/scripts/issue-device-cert.sh <person-or-device> ./out-dev
|
||||
# → out-dev/<...>.p12 (iOS/Android) + <...>.cert.pem/.key.pem/.fullchain.pem (desktop/curl)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Verify — S-GATE + M1 acceptance
|
||||
|
||||
**S-GATE (BLOCKING — before any `frpc` starts on any host):** with **no** client cert,
|
||||
`curl -sI https://<name>.terminal.yaojia.wang/live-sessions` must be a **TLS failure**. If it returns
|
||||
`200`, **STOP — the shell is world-open.**
|
||||
|
||||
**M1 acceptance matrix** (`t1.terminal.yaojia.wang`, base app `BIND_HOST=127.0.0.1`, throwaway frpc):
|
||||
1. no device cert → TLS handshake failure.
|
||||
2. `curl --cert dev.cert.pem --key dev.key.pem -sI …/live-sessions` → `200` JSON.
|
||||
3. WS `attach`→`attached` on `wss://t1.terminal.yaojia.wang/term`.
|
||||
4. 2nd frpc with the token but no/other frp-client cert claiming `t1` → refused at control handshake.
|
||||
5. revoked device cert (added to CRL) → rejected.
|
||||
6. first byte to `:443` is `0x16` (`tcpdump` — proves `disableCustomTLSFirstByte`).
|
||||
7. 4-zone SNI oracle green + V0 coexistence preserved; `journalctl -u xray -u nginx` clean.
|
||||
|
||||
**SNI oracle** (run after every nginx change — proves coexistence):
|
||||
```bash
|
||||
for sni in frp.terminal.yaojia.wang t1.terminal.yaojia.wang x.term.yaojia.wang www.microsoft.com; do
|
||||
echo "== $sni =="; openssl s_client -connect 8.138.1.192:443 -servername "$sni" </dev/null 2>/dev/null \
|
||||
| openssl x509 -noout -subject 2>/dev/null || echo "(passthrough / no cert at :443)"
|
||||
done
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Operational notes
|
||||
|
||||
- **Revoke a device:**
|
||||
```bash
|
||||
CA_DIR=/etc/relay/device-ca bash deploy/scripts/revoke-device.sh /path/<dev>.cert.pem
|
||||
# copy the regenerated /etc/relay/device-ca/crl.pem to the VPS, then ON THE VPS: nginx -t && nginx -s reload
|
||||
```
|
||||
- **Never** port-forward this to the public internet beyond `:443` mTLS; frps `:7000`/`:7080` and
|
||||
nginx `:8470` are **loopback-only** and must stay absent from the Aliyun security group (`nmap` them
|
||||
closed).
|
||||
- **`/live-sessions/:id/preview` leaks scrollback** to any valid cert-holder (no Origin guard). Accepted
|
||||
under Model A (all certs are yours); it sets Model B's blast radius.
|
||||
- **SSH to the mainland VPS is intermittently DPI-throttled** (banner-exchange timeout while bare TCP
|
||||
`:22` still connects — the box and key are fine, the SSH *protocol path* is throttled). Workarounds:
|
||||
retry later; route SSH through an overseas proxy node (e.g. flip ClashX to **Global** mode so the CN
|
||||
IP isn't matched by a `GEOIP,CN → DIRECT` rule, then `ssh -o ProxyCommand="nc -X 5 -x 127.0.0.1:7890 %h %p"`);
|
||||
or use the Aliyun web console. The `git push` remote (`root@8.138.1.192:/root/web-terminal.git`) uses
|
||||
the same `:22` path and is affected identically.
|
||||
|
||||
## 7. File map (this directory)
|
||||
|
||||
| Path | Role |
|
||||
|---|---|
|
||||
| `scripts/gen-device-ca.sh` | EC P-256 CA (`--kind device` \| `frp-client`), openssl `ca` db, empty CRL |
|
||||
| `scripts/issue-device-cert.sh` | P-256 clientAuth leaf → `.p12` (`-legacy`) + `.pem/.key/.fullchain` |
|
||||
| `scripts/revoke-device.sh` | revoke by cert or `--serial` → regenerate `crl.pem` |
|
||||
| `frp/frps.toml.example` | frps control `:7000` (mTLS+token) + vhost `:7080` |
|
||||
| `frp/frpc.example.toml` | per-host: dials `:443`, `disableCustomTLSFirstByte=true`, exposes `127.0.0.1:3000` |
|
||||
| `frp/README.md` | per-host onboarding checklist (V7) |
|
||||
| `nginx/frp-mtls.conf` | `:8470` TLS-term + `ssl_verify_client on` + `ssl_crl` + WS proxy → `:7080` |
|
||||
| `nginx/stream-sni-additions.md` | the two additive stream SNI `map` lines (merge-only) |
|
||||
277
deploy/RUNBOOK.md
Normal file
277
deploy/RUNBOOK.md
Normal file
@@ -0,0 +1,277 @@
|
||||
# RUNBOOK — deploy the rendezvous-relay to VPS `8.138.1.192` (Alibaba Cloud, mainland)
|
||||
|
||||
> **Staging, durable single-tenant** (PLAN_RELAY_PHASE1 §0). Real Postgres + Redis (Docker Compose on
|
||||
> the VPS), real Let's Encrypt TLS on `:443` against an **already ICP-filed** domain, agent dials OUT
|
||||
> from the operator's laptop. Dev in-process CA signer is accepted for staging (KMS → Phase 2).
|
||||
>
|
||||
> Everything below is a **staging template** — replace every `<placeholder>` and confirm each path on
|
||||
> the box. Config is **env-only**; no host/port/secret is hardcoded in code or units.
|
||||
|
||||
Two independent trust chains — never cross-wire them:
|
||||
|
||||
| Chain | Issued by | Protects | Script |
|
||||
|---|---|---|---|
|
||||
| **Public web PKI** | Let's Encrypt | browser `:443` WSS (`TLS_CERT_PATH`) | `issue-tls-cert.sh` |
|
||||
| **Private enrollment CA** | your offline root | agent mTLS + relay agent-server cert (`AGENT_CA_*`) | `gen-agent-ca.sh` |
|
||||
|
||||
And one shared key: the **P5 capability keypair** — control-plane **signs**, relay + admin API
|
||||
**verify** (`gen-capability-key.sh`). `CAPABILITY_SIGN_PUBKEY_B64` (CP) and `RELAY_AUTH_VERIFY_PUBKEY`
|
||||
(relay) are the **same public key**, two encodings.
|
||||
|
||||
---
|
||||
|
||||
## 0. Prerequisites (on `8.138.1.192`)
|
||||
|
||||
```bash
|
||||
# Docker Engine + Compose plugin
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
sudo systemctl enable --now docker
|
||||
|
||||
# Node 20 (satisfies control-plane>=18, relay-run>=20, agent>=18) via NodeSource
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo bash -
|
||||
sudo apt-get install -y nodejs git openssl
|
||||
|
||||
# Clone (this runbook assumes /opt/web-terminal; adjust the systemd --prefix if you change it)
|
||||
sudo git clone <REPO_URL> /opt/web-terminal
|
||||
cd /opt/web-terminal
|
||||
npm ci # root deps
|
||||
npm --prefix control-plane ci
|
||||
npm --prefix relay-run ci
|
||||
npm --prefix relay-web ci
|
||||
|
||||
# Dedicated non-root service user + secret dir
|
||||
sudo useradd --system --home /opt/web-terminal relay || true
|
||||
sudo mkdir -p /etc/relay
|
||||
```
|
||||
|
||||
**DNS (do this first — LE HTTP-01 and the browser both need it):** create an A-record
|
||||
`<SUBDOMAIN>.<BASE_DOMAIN>` → `8.138.1.192`. The domain must be **ICP-filed** (mainland Aliyun blocks
|
||||
`:80/:443` on unfiled domains; if unfiled, you must use DNS-01 in step 2).
|
||||
|
||||
---
|
||||
|
||||
## 1. Bring up Postgres + Redis (Docker Compose, loopback-only)
|
||||
|
||||
```bash
|
||||
cd /opt/web-terminal
|
||||
cp deploy/.env.example deploy/.env # gitignored; fill POSTGRES_PASSWORD (strong random)
|
||||
docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d
|
||||
docker compose -f deploy/docker-compose.yml ps # both healthy; bound to 127.0.0.1 only
|
||||
```
|
||||
|
||||
Both services publish to `127.0.0.1` only — never the public interface (they are the relay's private
|
||||
state).
|
||||
|
||||
---
|
||||
|
||||
## 2. Generate the CA, the capability key, and the TLS cert
|
||||
|
||||
```bash
|
||||
# (a) PRIVATE enrollment CA (agent mTLS) — NOT Let's Encrypt.
|
||||
sudo AGENT_FACING_HOST="<SUBDOMAIN>.<BASE_DOMAIN>" \
|
||||
AGENT_FACING_IP="8.138.1.192" \
|
||||
RELAY_TRUST_DOMAIN="<RELAY_TRUST_DOMAIN>" \
|
||||
bash deploy/scripts/gen-agent-ca.sh
|
||||
# writes /etc/relay/ca/{root,intermediate,agent-ca.*,relay-agent-server.*}
|
||||
|
||||
# (b) P5 capability keypair — prints BOTH public-key encodings; private key -> 0600 file.
|
||||
sudo bash deploy/scripts/gen-capability-key.sh
|
||||
# -> note CAPABILITY_SIGN_PUBKEY_B64 (base64) and RELAY_AUTH_VERIFY_PUBKEY (base64url); same key.
|
||||
|
||||
# (c) PUBLIC TLS cert for the browser :443 (Let's Encrypt).
|
||||
# http-01 needs inbound :80 open DURING issuance (step 4); dns-01 needs no inbound port.
|
||||
sudo BASE_DOMAIN="<BASE_DOMAIN>" SUBDOMAIN="<SUBDOMAIN>" ACME_EMAIL="<you@example.com>" \
|
||||
ACME_METHOD="http-01" \
|
||||
TLS_CERT_PATH="/etc/relay/tls/fullchain.pem" TLS_KEY_PATH="/etc/relay/tls/privkey.pem" \
|
||||
bash deploy/scripts/issue-tls-cert.sh
|
||||
|
||||
sudo chown -R relay:relay /etc/relay
|
||||
```
|
||||
|
||||
> **Staging CA seam (A-wave, not this task):** the control-plane's dev in-process signer must sign
|
||||
> agent leaves with `/etc/relay/ca/intermediate.key.pem` so they chain to `agent-ca.bundle.pem`.
|
||||
> `CA_INTERMEDIATE_KMS_KEY_REF=dev-local` selects the dev signer; point it at that key. Phase 2 = KMS.
|
||||
> After first boot, move `/etc/relay/ca/root.key.pem` **off** the host (offline anchor).
|
||||
|
||||
---
|
||||
|
||||
## 3. Fill the two env files
|
||||
|
||||
Copy the relevant blocks of `deploy/.env.example` into two 0600 files. **The linchpin:**
|
||||
`CAPABILITY_SIGN_PUBKEY_B64` (control-plane) and `RELAY_AUTH_VERIFY_PUBKEY` (relay) are the SAME key
|
||||
from step 2b — paste both.
|
||||
|
||||
```bash
|
||||
sudo install -m 600 -o relay -g relay /dev/null /etc/relay/control-plane.env
|
||||
sudo install -m 600 -o relay -g relay /dev/null /etc/relay/relay.env
|
||||
```
|
||||
|
||||
`/etc/relay/control-plane.env` (P3):
|
||||
|
||||
```ini
|
||||
PG_URL=postgres://relay:<POSTGRES_PASSWORD>@127.0.0.1:5432/relay
|
||||
REDIS_URL=redis://127.0.0.1:6379
|
||||
CAPABILITY_SIGN_PUBKEY_B64=<base64 from step 2b>
|
||||
CA_INTERMEDIATE_KMS_KEY_REF=dev-local
|
||||
CA_INTERMEDIATE_CERT_PATH=/etc/relay/ca/intermediate.cert.pem
|
||||
NODE_MTLS_TRUST_BUNDLE_PATH=/etc/relay/ca/agent-ca.bundle.pem
|
||||
BASE_DOMAIN=<BASE_DOMAIN>
|
||||
CP_BIND_HOST=127.0.0.1
|
||||
CP_BIND_PORT=8080
|
||||
# HEARTBEAT_TTL_SEC / PAIRING_TTL_SEC / PAIRING_MAX_REDEEM_ATTEMPTS use defaults if unset
|
||||
```
|
||||
|
||||
`/etc/relay/relay.env` (P1 data-plane):
|
||||
|
||||
```ini
|
||||
BASE_DOMAIN=<BASE_DOMAIN>
|
||||
BIND_HOST=0.0.0.0
|
||||
BIND_PORT=443
|
||||
TLS_CERT_PATH=/etc/relay/tls/fullchain.pem
|
||||
TLS_KEY_PATH=/etc/relay/tls/privkey.pem
|
||||
AGENT_BIND_PORT=8444
|
||||
AGENT_CA_CERT_PATH=/etc/relay/ca/agent-ca.cert.pem
|
||||
AGENT_CA_CHAIN_PATH=/etc/relay/ca/agent-ca.bundle.pem
|
||||
RELAY_NODE_ID=relay-1
|
||||
RELAY_AUTH_VERIFY_PUBKEY=<base64url from step 2b — SAME key as CAPABILITY_SIGN_PUBKEY_B64>
|
||||
RELAY_TRUST_DOMAIN=<RELAY_TRUST_DOMAIN>
|
||||
PG_URL=postgres://relay:<POSTGRES_PASSWORD>@127.0.0.1:5432/relay
|
||||
REDIS_URL=redis://127.0.0.1:6379
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Open the Aliyun security group (inbound)
|
||||
|
||||
Open **only** these, in the ECS instance's security group:
|
||||
|
||||
| Port | Who | Note |
|
||||
|---|---|---|
|
||||
| `443/tcp` | browsers (WSS) | permanent |
|
||||
| `8444/tcp` (`AGENT_BIND_PORT`) | agents (mTLS) | permanent |
|
||||
| `80/tcp` | Let's Encrypt HTTP-01 | **temporary** — only during cert issue/renew; skip entirely if using DNS-01 |
|
||||
|
||||
**Keep loopback-only (never open):** Postgres `5432`, Redis `6379`, control-plane admin
|
||||
`8080`. Reach the admin API from your laptop via SSH tunnel: `ssh -L 8080:127.0.0.1:8080 root@8.138.1.192`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Build the browser bundle
|
||||
|
||||
```bash
|
||||
cd /opt/web-terminal
|
||||
npm --prefix relay-web run build # emits relay-web/public/build/, served same-origin by D1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Enable + start both units
|
||||
|
||||
```bash
|
||||
sudo cp deploy/systemd/relay-control-plane.service deploy/systemd/relay-data-plane.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now relay-control-plane.service # migrates PG, listens on 127.0.0.1:8080
|
||||
sudo systemctl enable --now relay-data-plane.service # :443 browser WSS + :8444 agent mTLS
|
||||
sudo systemctl status relay-control-plane.service relay-data-plane.service
|
||||
journalctl -u relay-data-plane.service -f # watch for bind + verify-key load
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Create an account + pairing code (control-plane admin)
|
||||
|
||||
The admin API is **deny-by-default**: `POST /accounts` and `POST /accounts/:id/pairing-codes` require a
|
||||
**capability token** with the `manage` right whose **`aud` equals `BASE_DOMAIN`** (control-plane
|
||||
`authz.ts` / `main.ts` `expectedAud: env.baseDomain`). `accountId` is taken ONLY from the verified
|
||||
token (INV3) — never from the body.
|
||||
|
||||
Mint the token with the **capability PRIVATE key** from step 2b (the B6 token-mint owns the signing
|
||||
helper; it imports `/etc/relay/capability/capability-sign.key.pem`). Token claims:
|
||||
`rights=['manage']`, `aud=<BASE_DOMAIN>`. Send it as `Authorization: Bearer <token>`.
|
||||
|
||||
Bootstrap has a two-token nuance (grounded in `provision.ts`):
|
||||
|
||||
1. `POST /accounts` checks `manage` only (no account-ownership check) → mint a `manage` token, create
|
||||
the account, read back its `id`.
|
||||
2. `POST /accounts/:id/pairing-codes` additionally enforces `token.sub == :id` (own-account). Mint a
|
||||
second `manage` token with `sub=<the new accountId>`, then request the pairing code.
|
||||
|
||||
Over the SSH tunnel from step 4:
|
||||
|
||||
```bash
|
||||
# 1) create account
|
||||
curl -sS -X POST http://127.0.0.1:8080/accounts \
|
||||
-H "Authorization: Bearer <MANAGE_TOKEN aud=BASE_DOMAIN>" \
|
||||
-H 'content-type: application/json' -d '{"plan":"personal"}'
|
||||
# -> { "id": "<ACCOUNT_ID>", ... }
|
||||
|
||||
# 2) pairing code (token.sub must equal <ACCOUNT_ID>)
|
||||
curl -sS -X POST http://127.0.0.1:8080/accounts/<ACCOUNT_ID>/pairing-codes \
|
||||
-H "Authorization: Bearer <MANAGE_TOKEN sub=ACCOUNT_ID aud=BASE_DOMAIN>"
|
||||
# -> { "code": "<PAIRING_CODE>", ... } (single-use, TTL 600s default)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. On the LAPTOP — build the agent, pair, run
|
||||
|
||||
The agent dials OUT; nothing inbound is opened on the laptop.
|
||||
|
||||
```bash
|
||||
cd <repo>/agent
|
||||
npm ci && npm run build # emits dist/cli.js (C1)
|
||||
export RELAY_URL="wss://<SUBDOMAIN>.<BASE_DOMAIN>:8444" # AGENT_BIND_PORT
|
||||
export ENROLL_URL="https://<SUBDOMAIN>.<BASE_DOMAIN>/enroll"
|
||||
export HOST_ID="<pick-a-host-id>"
|
||||
export SUBDOMAIN="<SUBDOMAIN>"
|
||||
export LOCAL_TARGET_URL="ws://127.0.0.1:3000" # the base web-terminal app
|
||||
export STATE_DIR="$HOME/.web-terminal-agent"
|
||||
|
||||
node dist/cli.js pair <PAIRING_CODE> # redeems the code -> SPIFFE mTLS cert + hostContentSecret
|
||||
node dist/cli.js run # dials the relay, holds the mux tunnel
|
||||
```
|
||||
|
||||
`pair` redeems the single-use code and stores the enrolled SPIFFE cert under `STATE_DIR`; `run` opens
|
||||
the outbound mTLS tunnel and keeps it alive (heartbeat/backoff).
|
||||
|
||||
---
|
||||
|
||||
## 9. Browser — log in and click through to the shell
|
||||
|
||||
1. Open `https://<SUBDOMAIN>.<BASE_DOMAIN>` (served same-origin as the WSS endpoint).
|
||||
2. Operator login (B6) mints a short-lived connect capability token.
|
||||
3. Pick the host → the relay splices browser ↔ agent. The relay only sees **ciphertext** (INV2); the
|
||||
E2E is browser ↔ agent.
|
||||
|
||||
---
|
||||
|
||||
## 10. Verify restart-safety + revocation teardown
|
||||
|
||||
```bash
|
||||
# Restart-safety (INV7): bounce the relay while a shell is open — the PTY and host registration survive.
|
||||
sudo systemctl restart relay-data-plane.service
|
||||
# the browser auto-reconnects; the session is still there (state is in PG/Redis, not the process).
|
||||
|
||||
# Revocation teardown (INV12): revoke the host and watch the tunnel drop within budget.
|
||||
curl -sS -X DELETE http://127.0.0.1:8080/hosts/<HOST_ID> \
|
||||
-H "Authorization: Bearer <MANAGE_TOKEN sub=ACCOUNT_ID aud=BASE_DOMAIN>"
|
||||
# -> control-plane publishes a KillSignal on Redis relay:revocations; the data-plane subscriber
|
||||
# closes the spliced stream (browser WS closes 4403). Measure revoke -> close latency.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Meaning | Fix |
|
||||
|---|---|---|
|
||||
| Browser WS closes **1013** | `WS_TRY_LATER` — **no agent tunnel** for that host (agent offline / not dialed) | Start the agent (`node dist/cli.js run`); check `RELAY_URL` host+port; confirm `:8444` open; watch `journalctl -u relay-data-plane`. |
|
||||
| Browser WS closes **401** | **bad Origin** (CSWSH exact-match failed) — the `Origin` header is not in the relay's `allowedOrigins` | Open via `https://<SUBDOMAIN>.<BASE_DOMAIN>` (not the raw IP, not `http:`, no stray port). `allowedOrigins` is the port-less `https://<sub>.<BASE_DOMAIN>` on `:443`; must match EXACTLY. Verify `BASE_DOMAIN` in `relay.env`. |
|
||||
| Agent mTLS closes **4401** | cert chains to the CA but the **pubkey is not enrolled** in the host registry (INV14 registry-gated) | The host was never enrolled or was revoked. Re-run `pair <CODE>` with a fresh code; confirm the CP signed the leaf with `/etc/relay/ca/intermediate.key.pem` (chains to `agent-ca.bundle.pem`). |
|
||||
| Browser WS closes **4403** | `WS_REVOKED` — the host/session was revoked (expected after step 10) | Re-enroll the host to reconnect. |
|
||||
| CP fails to boot: "CAPABILITY_SIGN_PUBKEY_B64 must decode to 32 bytes" | wrong key encoding in `control-plane.env` | Use the **base64** value (not base64url) from step 2b for the CP; base64url is the relay's `RELAY_AUTH_VERIFY_PUBKEY`. |
|
||||
| Admin API returns **401** on `POST /accounts` | missing/expired/foreign-`aud` capability token | Mint a `manage` token with `aud=<BASE_DOMAIN>`, signed by the step-2b private key; send `Authorization: Bearer`. |
|
||||
| Admin API returns **403** on `/pairing-codes` | token lacks `manage` right, or `token.sub != :id` | Mint with `rights:['manage']` and `sub=<ACCOUNT_ID>` (own-account rule). |
|
||||
| LE issuance fails (timeout/connection) | HTTP-01 `:80` not reachable, or domain not ICP-filed | Open `:80` in the security group for issuance, or switch `ACME_METHOD=dns-01`; confirm the A-record + ICP filing. |
|
||||
| `docker compose ps` unhealthy | PG/Redis not up | `docker compose -f deploy/docker-compose.yml logs`; confirm `POSTGRES_PASSWORD` set in `deploy/.env`. |
|
||||
| Mixed-content / `ws:` blocked in browser | page is HTTPS but WS scheme resolved to `ws:` | Serve over HTTPS; the client follows page scheme (`wss:` on HTTPS, M6). Confirm the LE cert is valid for the FQDN. |
|
||||
48
deploy/docker-compose.yml
Normal file
48
deploy/docker-compose.yml
Normal file
@@ -0,0 +1,48 @@
|
||||
# RELAY-PHASE1 · E1 — Postgres + Redis for the rendezvous-relay control-plane (P3) on the VPS.
|
||||
#
|
||||
# Both services bind to 127.0.0.1 ONLY — they are the relay's private state, never exposed on the
|
||||
# public interface (INV9-adjacent; open only :443 + AGENT_PORT in the cloud security group). The
|
||||
# control-plane / relay processes reach them over loopback.
|
||||
#
|
||||
# Usage on 8.138.1.192:
|
||||
# cp deploy/.env.example deploy/.env # then fill secrets
|
||||
# docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d
|
||||
#
|
||||
# PG_URL for the app = postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:5432/${POSTGRES_DB}
|
||||
# REDIS_URL for the app = redis://127.0.0.1:6379
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-relay}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-relay}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in deploy/.env}
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432" # loopback-only
|
||||
volumes:
|
||||
- relay_pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-relay} -d ${POSTGRES_DB:-relay}"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
redis:
|
||||
image: redis:7
|
||||
restart: unless-stopped
|
||||
command: ["redis-server", "--appendonly", "yes"]
|
||||
ports:
|
||||
- "127.0.0.1:6379:6379" # loopback-only
|
||||
volumes:
|
||||
- relay_redisdata:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
relay_pgdata:
|
||||
relay_redisdata:
|
||||
73
deploy/frp/README.md
Normal file
73
deploy/frp/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Native mTLS Tunnel — per-host runbook (V7)
|
||||
|
||||
Bring one local host onto the tunnel so native clients (iOS / Android / desktop) can reach its base
|
||||
app at `https://<name>.terminal.yaojia.wang` over the VPS, gated **only** by device-certificate mTLS.
|
||||
See `docs/PLAN_NATIVE_TUNNEL.md` for the full design; this is the short operational checklist.
|
||||
|
||||
**Trust model: A — single-owner fleet** (all devices/hosts are the operator's). A shared device-CA
|
||||
means "the closed set of my devices"; there is **no cross-tenant isolation** — do not enroll
|
||||
distrusting third parties on this CA (that is Model B, out of scope for v1).
|
||||
|
||||
## Files here
|
||||
|
||||
| File | Install target on the VPS | Purpose |
|
||||
|---|---|---|
|
||||
| `frps.toml.example` | `/etc/relay/frp/frps.toml` (0600) | frps control `:7000` + vhost `:7080` |
|
||||
| `frpc.example.toml` | on each LOCAL host (0600) | dials the VPS, exposes `127.0.0.1:3000` |
|
||||
| `../nginx/frp-mtls.conf` | `/etc/nginx/conf.d/frp-mtls.conf` | `:8470` TLS-term + device-CA mTLS + WS proxy |
|
||||
| `../nginx/stream-sni-additions.md` | (merge into the live stream map) | the two additive SNI routes |
|
||||
|
||||
## One-time VPS setup (summarised — see PLAN V1–V5)
|
||||
|
||||
1. **frps** (`frps.toml.example` → `/etc/relay/frp/frps.toml`): set `auth.token` = `openssl rand -hex 32`.
|
||||
2. **frp-client CA** (control-channel mTLS): `bash deploy/scripts/gen-device-ca.sh --kind frp-client`.
|
||||
3. **device CA** (data-path mTLS): `bash deploy/scripts/gen-device-ca.sh` (KIND=device default).
|
||||
4. **LE wildcard** `*.terminal.yaojia.wang` (V3) → `/etc/relay/frp-tls/{fullchain,privkey}.pem`.
|
||||
5. **nginx** `:8470` (`frp-mtls.conf`) + the two stream SNI lines (`stream-sni-additions.md`), then
|
||||
`nginx -t && systemctl reload nginx`.
|
||||
|
||||
## Onboard a host (the repeatable part)
|
||||
|
||||
1. **frp-client cert** for this host (control mTLS), issued from the frp-client CA:
|
||||
```bash
|
||||
CA_DIR=/etc/relay/frp-client-ca bash deploy/scripts/issue-device-cert.sh <name> ./out-<name>
|
||||
# use out-<name>/<name>.cert.pem + <name>.key.pem for frpc.certFile/keyFile (ignore the .p12)
|
||||
```
|
||||
2. **frpc.toml** on the host from `frpc.example.toml`: set `subdomain=<name>`, the shared
|
||||
`auth.token`, and the frp-client cert/key paths; `chmod 600 frpc.toml`.
|
||||
3. **Base app** on the host — the tunnel is safe ONLY with these:
|
||||
| Var | Value | Why |
|
||||
|---|---|---|
|
||||
| `BIND_HOST` | `127.0.0.1` | **Mandatory.** Default `0.0.0.0` exposes an unauth'd shell on the LAN, bypassing mTLS. |
|
||||
| `PORT` | `3000` | frpc dials `127.0.0.1:3000`. |
|
||||
| `ALLOWED_ORIGINS` | `https://<name>.terminal.yaojia.wang` | bare host; `:443` also matches (normalized). |
|
||||
| `IDLE_TTL` | `≥ 86400` | walk-away survival. |
|
||||
| `USE_TMUX` | `1` (*nix) / `0` (Windows) | cross-restart PTY survival. |
|
||||
4. **Device cert** for whoever will connect (data-path mTLS), issued from the device CA:
|
||||
```bash
|
||||
P12_PASSWORD='...' bash deploy/scripts/issue-device-cert.sh <person-or-device> ./out-dev
|
||||
# deliver out-dev/<...>.p12 SECURELY: scp / AirDrop / Files — NEVER email.
|
||||
```
|
||||
5. **Verify** (M1): no-cert → TLS handshake failure; `curl --cert …/<dev>.cert.pem --key …/<dev>.key.pem
|
||||
-sI https://<name>.terminal.yaojia.wang/live-sessions` → `200`.
|
||||
|
||||
## Revoke a device
|
||||
|
||||
```bash
|
||||
CA_DIR=/etc/relay/device-ca bash deploy/scripts/revoke-device.sh /path/to/<dev>.cert.pem
|
||||
# copy the regenerated /etc/relay/device-ca/crl.pem to the VPS, then ON THE VPS:
|
||||
nginx -t && nginx -s reload
|
||||
```
|
||||
|
||||
The revoked cert is rejected fleet-wide the moment nginx reloads the CRL.
|
||||
|
||||
## Trust-boundary note (Model A)
|
||||
|
||||
- The **frp-client cert + token is a trusted secret**: leaking a host's frp-client cert **and** the
|
||||
shared token lets an attacker register a subdomain (subdomain-takeover / MITM). Keep both `chmod 600`;
|
||||
deliver over scp, never email. Revoke a compromised host by rotating the `frp-client-ca` trust
|
||||
(re-issue the remaining hosts) — device certs are unaffected.
|
||||
- The **device cert** is the data-path gate. Under Model A any valid device cert can reach any
|
||||
subdomain and can read `/live-sessions/:id/preview` scrollback — acceptable because every device is
|
||||
yours. Do not extend this CA to third parties without moving to Model B (per-tenant CA).
|
||||
- **P-256, never Ed25519** for both CAs (nginx `ssl_verify_client` + iOS client-cert compatibility).
|
||||
31
deploy/frp/frpc.example.toml
Normal file
31
deploy/frp/frpc.example.toml
Normal file
@@ -0,0 +1,31 @@
|
||||
# frpc.toml — per-host client (Native mTLS tunnel · V7 / M1 / S1).
|
||||
#
|
||||
# Copy to the LOCAL host that runs the base app, fill the <PLACEHOLDERS>, chmod 600.
|
||||
# TEMPLATE ONLY — NO REAL SECRETS committed. frpc dials OUT to the VPS :443; nothing is opened
|
||||
# inbound on the local host.
|
||||
#
|
||||
# frps ≥ v0.52 required (`disableCustomTLSFirstByte` exists ≥ v0.50) — it makes frpc send a real TLS
|
||||
# ClientHello first byte (0x16) so the VPS nginx `ssl_preread` stream can SNI-route it (PLAN M1 #6).
|
||||
|
||||
serverAddr = "8.138.1.192"
|
||||
serverPort = 443
|
||||
|
||||
auth.method = "token"
|
||||
auth.token = "<FRP_TOKEN>" # same token as frps.toml (/etc/relay/secrets.env)
|
||||
|
||||
# --- control-channel TLS: present THIS host's frp-client cert to frps; verify the frps server cert ---
|
||||
transport.tls.enable = true
|
||||
transport.tls.serverName = "frp.terminal.yaojia.wang" # SNI the stream routes to frps :7000
|
||||
transport.tls.disableCustomTLSFirstByte = true # CRITICAL for ssl_preread (real ClientHello first)
|
||||
transport.tls.certFile = "<host-frp-client.cert.pem>" # issued by gen-device-ca.sh --kind frp-client + issue-device-cert.sh
|
||||
transport.tls.keyFile = "<host-frp-client.key.pem>" # (0600)
|
||||
transport.tls.trustedCaFile = "<frps-ctrl-ca.cert.pem>" # CA that signed frps-ctrl.cert.pem (V1)
|
||||
|
||||
loginFailExit = false # keep retrying if the VPS/frps is briefly down
|
||||
|
||||
[[proxies]]
|
||||
name = "<name>" # unique per host; frps rejects duplicate subdomains
|
||||
type = "http"
|
||||
localIP = "127.0.0.1" # base app must bind loopback (BIND_HOST=127.0.0.1)
|
||||
localPort = 3000
|
||||
subdomain = "<name>" # reachable at https://<name>.terminal.yaojia.wang
|
||||
29
deploy/frp/frps.toml.example
Normal file
29
deploy/frp/frps.toml.example
Normal file
@@ -0,0 +1,29 @@
|
||||
# frps.toml — VPS control server (Native mTLS tunnel · V1).
|
||||
#
|
||||
# Install target on the VPS: /etc/relay/frp/frps.toml (chmod 600, owned frp:frp).
|
||||
# This is a TEMPLATE with PLACEHOLDERS ONLY — NO REAL SECRETS are committed.
|
||||
#
|
||||
# auth.token: replace <FRP_TOKEN> with `openssl rand -hex 32`, stored in /etc/relay/secrets.env
|
||||
# (0600). The token is NOT the sole gate — control-channel mTLS below is required too,
|
||||
# so a leaked token alone cannot register a subdomain (PLAN R3 / V1b, Model A).
|
||||
#
|
||||
# Both ports are LOOPBACK-only (nginx stream terminates/routes :443 → these; they must stay absent
|
||||
# from the Aliyun security group — PLAN P-B).
|
||||
|
||||
bindAddr = "127.0.0.1"
|
||||
bindPort = 7000 # control channel (SNI frp.terminal.yaojia.wang → here, TLS passthrough)
|
||||
vhostHTTPPort = 7080 # vhost HTTP (nginx :8470 proxies verified requests here by Host)
|
||||
|
||||
auth.method = "token"
|
||||
auth.token = "<FRP_TOKEN>"
|
||||
|
||||
subDomainHost = "terminal.yaojia.wang"
|
||||
|
||||
# --- control-channel mTLS: token is NOT the sole gate (per-host frp-client CA) ---
|
||||
transport.tls.force = true
|
||||
transport.tls.certFile = "/etc/relay/frp-tls/frps-ctrl.cert.pem" # frps control SERVER cert (V1)
|
||||
transport.tls.keyFile = "/etc/relay/frp-tls/frps-ctrl.key.pem" # (0600)
|
||||
transport.tls.trustedCaFile = "/etc/relay/frp-client-ca/frp-client-ca.cert.pem" # verifies each host's frpc cert
|
||||
|
||||
log.to = "/var/log/frp/frps.log"
|
||||
log.level = "info"
|
||||
44
deploy/nginx/frp-mtls.conf
Normal file
44
deploy/nginx/frp-mtls.conf
Normal file
@@ -0,0 +1,44 @@
|
||||
# frp-mtls.conf — nginx :8470 TLS-termination + device-CA mTLS + WS-upgrade proxy to frps vhost.
|
||||
#
|
||||
# Native mTLS tunnel · V4. Install target: /etc/nginx/conf.d/frp-mtls.conf
|
||||
# This is a conf.d snippet (safe to ADD — it only introduces a NEW loopback :8470 server + one map;
|
||||
# it does NOT touch the existing stream :443 map, which is edited separately per stream-sni-additions.md).
|
||||
#
|
||||
# Data-path security gate: `ssl_verify_client on` against the DEVICE CA (with CRL) is the ONE gate for
|
||||
# the tunnelled base app. `on`, NEVER `optional` (PLAN P-D / R2). The public :8470 SERVER cert is the
|
||||
# LE wildcard (V3); the CLIENT trust anchor is the private device CA (V2) — two different chains.
|
||||
#
|
||||
# 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).
|
||||
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 127.0.0.1:8470 ssl;
|
||||
server_name ~^.+\.terminal\.yaojia\.wang$;
|
||||
|
||||
ssl_certificate /etc/relay/frp-tls/fullchain.pem; # LE wildcard *.terminal.yaojia.wang (V3)
|
||||
ssl_certificate_key /etc/relay/frp-tls/privkey.pem;
|
||||
|
||||
# --- the data-path security gate ---
|
||||
ssl_verify_client on; # ON, never `optional`
|
||||
ssl_client_certificate /etc/relay/device-ca/device-ca.cert.pem;
|
||||
ssl_verify_depth 1;
|
||||
ssl_crl /etc/relay/device-ca/crl.pem; # revocation from day one (V2)
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:7080; # frps vhost — routes by Host → subdomain
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Origin https://$host; # needed for Android/curl; harmless for iOS
|
||||
proxy_set_header X-Client-Cert-CN $ssl_client_s_dn;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s; # prevents idle-WS proxy kill (default 60s, R6)
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
}
|
||||
45
deploy/nginx/stream-sni-additions.md
Normal file
45
deploy/nginx/stream-sni-additions.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Stream SNI additions (Native mTLS tunnel · V5)
|
||||
|
||||
Two SNI routes must be **merged into the existing** `stream { map $ssl_preread_server_name … }`
|
||||
block on the VPS (`/etc/nginx/stream-relay.conf`). They add the frp control channel and the
|
||||
`*.terminal` data path to the single shared `:443` `ssl_preread` listener.
|
||||
|
||||
> **This is a MERGE, not a file to ship.** Do **not** drop a full stream conf here — that would
|
||||
> clobber the live one. Only the two `map` body lines below are new.
|
||||
|
||||
## The two additive lines
|
||||
|
||||
Add these **inside the existing `map` body** (exact match wins over wildcard, so order is not
|
||||
load-bearing, but keep the exact `frp.terminal` line above the `*.terminal` wildcard for clarity):
|
||||
|
||||
```nginx
|
||||
frp.terminal.yaojia.wang 127.0.0.1:7000; # frps control channel (TLS passthrough)
|
||||
*.terminal.yaojia.wang 127.0.0.1:8470; # nginx :8470 TLS-term + device-CA mTLS (frp-mtls.conf)
|
||||
```
|
||||
|
||||
## Coexistence warning (do NOT retype the existing lines)
|
||||
|
||||
The existing `map` body already contains — and MUST be preserved **verbatim**:
|
||||
|
||||
```nginx
|
||||
# ... existing hostnames directive ...
|
||||
*.term.yaojia.wang 127.0.0.1:8443; # E2E browser relay — DO NOT RETYPE / REORDER
|
||||
default 127.0.0.1:10443; # xray Reality — DO NOT RETYPE / REORDER
|
||||
```
|
||||
|
||||
- `*.term.yaojia.wang` (browser relay) and `*.terminal.yaojia.wang` (this tunnel) are **different
|
||||
parent labels** — `term` vs `terminal`. `ssl_preread` matches the full SNI, so they never collide.
|
||||
- Re-type nothing you did not author. Capture the current body first
|
||||
(`nginx -T | sed -n '/map \$ssl_preread_server_name/,/}/p'`), then append only the two lines above.
|
||||
|
||||
## Deploy gate
|
||||
|
||||
```bash
|
||||
cp /etc/nginx/stream-relay.conf{,.bak.$(date +%s)} # snapshot first
|
||||
# ...edit: append the two lines into the map body...
|
||||
nginx -t && systemctl reload nginx # NEVER reload on a failed -t
|
||||
```
|
||||
|
||||
After reload, run the 4-zone SNI oracle (`openssl s_client -servername …` for
|
||||
`frp.terminal` / `<name>.terminal` / `<name>.term` / default) to confirm all four routes still
|
||||
resolve to the right backend (PLAN V5 / M1 #7).
|
||||
139
deploy/scripts/bootstrap-staging.sh
Executable file
139
deploy/scripts/bootstrap-staging.sh
Executable file
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# RELAY-PHASE1 · staging bootstrap (mode B — self-signed TLS, no Let's Encrypt yet).
|
||||
#
|
||||
# Idempotent one-shot that prepares EVERYTHING the two services need to boot, EXCEPT starting them:
|
||||
# 1. strong random POSTGRES_PASSWORD + OPERATOR_PASSWORD (persisted 0600, reused on re-run)
|
||||
# 2. P5 capability keypair (deploy/scripts/gen-capability-key.sh)
|
||||
# 3. private agent-enrollment CA (deploy/scripts/gen-agent-ca.sh)
|
||||
# 4. self-signed browser :443 cert (mode B stand-in for issue-tls-cert.sh)
|
||||
# 5. /etc/relay/control-plane.env + /etc/relay/relay.env — the REAL env contract read by the code
|
||||
# (control-plane/src/env.ts + relay-run/src/main-phase1.ts), which is a SUPERSET of .env.example:
|
||||
# · relay needs AGENT_SERVER_CERT_PATH / AGENT_SERVER_KEY_PATH / ALLOWED_ORIGINS (template omits)
|
||||
# · relay hosts the STAGING /auth/mint, so it needs CAPABILITY_SIGN_PRIVKEY (base64 PKCS#8 DER)
|
||||
# + MINT_RATE_SALT. In Phase 1 the relay IS a token minter (staging shortcut); Phase 2 = WebAuthn.
|
||||
# 6. deploy/.env for docker-compose, then `docker compose up -d` Postgres + Redis (loopback-only).
|
||||
#
|
||||
# Secrets are generated in place (0600); NOTHING secret is printed except OPERATOR_PASSWORD (once).
|
||||
# Private keys are NEVER printed. Config via ENV (staging defaults for THIS deploy):
|
||||
# BASE_DOMAIN (yaojia.wang) SUBDOMAIN (kai) SERVER_IP (8.138.1.192)
|
||||
# RELAY_TRUST_DOMAIN (relay.yaojia.wang) RELAY_NODE_ID (relay-1) REPO (repo root)
|
||||
#
|
||||
# Verify (syntax): bash -n deploy/scripts/bootstrap-staging.sh
|
||||
set -euo pipefail
|
||||
|
||||
BASE_DOMAIN="${BASE_DOMAIN:-yaojia.wang}"
|
||||
SUBDOMAIN="${SUBDOMAIN:-kai}"
|
||||
SERVER_IP="${SERVER_IP:-8.138.1.192}"
|
||||
RELAY_TRUST_DOMAIN="${RELAY_TRUST_DOMAIN:-relay.${BASE_DOMAIN}}"
|
||||
RELAY_NODE_ID="${RELAY_NODE_ID:-relay-1}"
|
||||
FQDN="${SUBDOMAIN}.${BASE_DOMAIN}"
|
||||
|
||||
# Repo root = two levels up from this script, unless overridden.
|
||||
SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
REPO="${REPO:-$SELF}"
|
||||
|
||||
ETC=/etc/relay
|
||||
CAP_DIR="${ETC}/capability"
|
||||
CA_DIR="${ETC}/ca"
|
||||
TLS_DIR="${ETC}/tls"
|
||||
SECRETS="${ETC}/secrets.env"
|
||||
|
||||
command -v openssl >/dev/null 2>&1 || { echo "FATAL: openssl not on PATH" >&2; exit 3; }
|
||||
command -v docker >/dev/null 2>&1 || { echo "FATAL: docker not on PATH" >&2; exit 3; }
|
||||
|
||||
umask 077
|
||||
mkdir -p "${ETC}" "${TLS_DIR}"
|
||||
|
||||
echo "== [1/6] passwords (persisted, reused on re-run) =="
|
||||
# shellcheck disable=SC1090
|
||||
[[ -f "${SECRETS}" ]] && source "${SECRETS}"
|
||||
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9' | head -c 32)}"
|
||||
OPERATOR_PASSWORD="${OPERATOR_PASSWORD:-$(openssl rand -base64 18 | tr -dc 'A-Za-z0-9' | head -c 24)}"
|
||||
MINT_RATE_SALT="${MINT_RATE_SALT:-$(openssl rand -hex 16)}"
|
||||
{
|
||||
echo "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
|
||||
echo "OPERATOR_PASSWORD=${OPERATOR_PASSWORD}"
|
||||
echo "MINT_RATE_SALT=${MINT_RATE_SALT}"
|
||||
} > "${SECRETS}"
|
||||
chmod 600 "${SECRETS}"
|
||||
|
||||
echo "== [2/6] P5 capability keypair =="
|
||||
KEY_DIR="${CAP_DIR}" bash "${REPO}/deploy/scripts/gen-capability-key.sh" > "${ETC}/capability-summary.txt" 2>&1 \
|
||||
|| { echo "FATAL: gen-capability-key.sh failed:" >&2; cat "${ETC}/capability-summary.txt" >&2; exit 1; }
|
||||
CAP_B64="$(grep -oE 'CAPABILITY_SIGN_PUBKEY_B64=[^ ]+' "${ETC}/capability-summary.txt" | head -1 | cut -d= -f2-)"
|
||||
CAP_B64URL="$(grep -oE 'RELAY_AUTH_VERIFY_PUBKEY=[^ ]+' "${ETC}/capability-summary.txt" | head -1 | cut -d= -f2-)"
|
||||
[[ -n "${CAP_B64}" && -n "${CAP_B64URL}" ]] || { echo "FATAL: could not parse capability pubkeys" >&2; exit 1; }
|
||||
# The relay ALSO mints operator tokens (staging /auth/mint) → it needs the private key as base64 PKCS#8 DER.
|
||||
CAP_PRIV_B64="$(openssl pkey -in "${CAP_DIR}/capability-sign.key.pem" -outform DER | base64 | tr -d '\n')"
|
||||
|
||||
echo "== [3/6] private agent-enrollment CA (NOT Let's Encrypt) =="
|
||||
CA_DIR="${CA_DIR}" AGENT_FACING_HOST="${FQDN}" AGENT_FACING_IP="${SERVER_IP}" \
|
||||
RELAY_TRUST_DOMAIN="${RELAY_TRUST_DOMAIN}" \
|
||||
bash "${REPO}/deploy/scripts/gen-agent-ca.sh" > "${ETC}/ca-summary.txt" 2>&1 \
|
||||
|| { echo "FATAL: gen-agent-ca.sh failed:" >&2; cat "${ETC}/ca-summary.txt" >&2; exit 1; }
|
||||
|
||||
echo "== [4/6] self-signed browser :443 cert for ${FQDN} (mode B) =="
|
||||
if [[ ! -f "${TLS_DIR}/privkey.pem" ]]; then
|
||||
openssl req -x509 -newkey rsa:2048 -nodes -days 825 \
|
||||
-keyout "${TLS_DIR}/privkey.pem" -out "${TLS_DIR}/fullchain.pem" \
|
||||
-subj "/CN=${FQDN}" -addext "subjectAltName=DNS:${FQDN},IP:${SERVER_IP}"
|
||||
fi
|
||||
chmod 600 "${TLS_DIR}/privkey.pem"; chmod 644 "${TLS_DIR}/fullchain.pem"
|
||||
|
||||
echo "== [5/6] env files (REAL contract from env.ts + main-phase1.ts) =="
|
||||
cat > "${ETC}/control-plane.env" <<EOF
|
||||
PG_URL=postgres://relay:${POSTGRES_PASSWORD}@127.0.0.1:5432/relay
|
||||
REDIS_URL=redis://127.0.0.1:6379
|
||||
CAPABILITY_SIGN_PUBKEY_B64=${CAP_B64}
|
||||
CA_INTERMEDIATE_KMS_KEY_REF=dev-local
|
||||
CA_INTERMEDIATE_CERT_PATH=${CA_DIR}/intermediate.cert.pem
|
||||
NODE_MTLS_TRUST_BUNDLE_PATH=${CA_DIR}/agent-ca.bundle.pem
|
||||
BASE_DOMAIN=${BASE_DOMAIN}
|
||||
CP_BIND_HOST=127.0.0.1
|
||||
CP_BIND_PORT=8080
|
||||
EOF
|
||||
chmod 600 "${ETC}/control-plane.env"
|
||||
|
||||
cat > "${ETC}/relay.env" <<EOF
|
||||
BIND_HOST=0.0.0.0
|
||||
BIND_PORT=443
|
||||
AGENT_BIND_PORT=8444
|
||||
TLS_CERT_PATH=${TLS_DIR}/fullchain.pem
|
||||
TLS_KEY_PATH=${TLS_DIR}/privkey.pem
|
||||
AGENT_SERVER_CERT_PATH=${CA_DIR}/relay-agent-server.cert.pem
|
||||
AGENT_SERVER_KEY_PATH=${CA_DIR}/relay-agent-server.key.pem
|
||||
AGENT_CA_CERT_PATH=${CA_DIR}/agent-ca.cert.pem
|
||||
AGENT_CA_CHAIN_PATH=${CA_DIR}/agent-ca.bundle.pem
|
||||
BASE_DOMAIN=${BASE_DOMAIN}
|
||||
RELAY_NODE_ID=${RELAY_NODE_ID}
|
||||
RELAY_TRUST_DOMAIN=${RELAY_TRUST_DOMAIN}
|
||||
ALLOWED_ORIGINS=https://${FQDN}
|
||||
PG_URL=postgres://relay:${POSTGRES_PASSWORD}@127.0.0.1:5432/relay
|
||||
REDIS_URL=redis://127.0.0.1:6379
|
||||
RELAY_AUTH_VERIFY_PUBKEY=${CAP_B64URL}
|
||||
OPERATOR_PASSWORD=${OPERATOR_PASSWORD}
|
||||
CAPABILITY_SIGN_PRIVKEY=${CAP_PRIV_B64}
|
||||
MINT_RATE_SALT=${MINT_RATE_SALT}
|
||||
EOF
|
||||
chmod 600 "${ETC}/relay.env"
|
||||
|
||||
echo "== [6/6] docker compose up -d (Postgres + Redis, loopback-only) =="
|
||||
cat > "${REPO}/deploy/.env" <<EOF
|
||||
POSTGRES_DB=relay
|
||||
POSTGRES_USER=relay
|
||||
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
EOF
|
||||
chmod 600 "${REPO}/deploy/.env"
|
||||
docker compose --env-file "${REPO}/deploy/.env" -f "${REPO}/deploy/docker-compose.yml" up -d
|
||||
|
||||
cat <<SUMMARY
|
||||
|
||||
=== bootstrap OK ===
|
||||
FQDN : ${FQDN}
|
||||
capability pubkey : b64 ${#CAP_B64} chars / b64url ${#CAP_B64URL} chars (same 32 bytes)
|
||||
env files (0600) : ${ETC}/control-plane.env ${ETC}/relay.env
|
||||
OPERATOR_PASSWORD : ${OPERATOR_PASSWORD} <-- browser login; shown once, also in ${SECRETS} (0600)
|
||||
|
||||
Next: start control-plane + relay, then mint a manage token to create an account + pairing code.
|
||||
SUMMARY
|
||||
145
deploy/scripts/gen-agent-ca.sh
Executable file
145
deploy/scripts/gen-agent-ca.sh
Executable file
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# RELAY-PHASE1 · E (TASK E) — Private ENROLLMENT CA for agent mTLS.
|
||||
#
|
||||
# STAGING TEMPLATE. This mints the PRIVATE trust chain the rendezvous-relay uses to (a) verify agent
|
||||
# client certs (SPIFFE mTLS, INV14) and (b) present the relay's agent-facing SERVER cert on
|
||||
# AGENT_BIND_PORT so the dialing agent can authenticate the relay.
|
||||
#
|
||||
# *** THIS IS NOT LET'S ENCRYPT. *** The browser-facing :443 cert is public-web PKI and is issued
|
||||
# by deploy/scripts/issue-tls-cert.sh. This CA is a SEPARATE, private trust root that must NEVER be
|
||||
# a public CA — mixing them would let any web-PKI leaf enroll as an agent.
|
||||
#
|
||||
# Hierarchy produced (Ed25519, to match the control-plane dev signer's algorithm):
|
||||
# root (self-signed, offline anchor)
|
||||
# └── intermediate (signs agent SPIFFE leaves at CP /enroll, AND the relay agent-server cert)
|
||||
# ├── relay agent-server leaf (TLS serverAuth, CN/SAN = the agent-facing host)
|
||||
# └── (agent leaves are issued at runtime by the control-plane, not here)
|
||||
#
|
||||
# Outputs (0600 keys, 0644 certs) under CA_DIR (default /etc/relay/ca):
|
||||
# root.key.pem root.cert.pem
|
||||
# intermediate.key.pem intermediate.cert.pem <- CA_INTERMEDIATE_CERT_PATH (+ its key = the CP signer)
|
||||
# agent-ca.bundle.pem = intermediate + root <- AGENT_CA_CHAIN_PATH / NODE_MTLS_TRUST_BUNDLE_PATH
|
||||
# agent-ca.cert.pem = intermediate cert <- AGENT_CA_CERT_PATH
|
||||
# relay-agent-server.key.pem relay-agent-server.cert.pem (present on AGENT_BIND_PORT)
|
||||
#
|
||||
# Config via ENV only (no hardcoded hosts/ports/secrets):
|
||||
# CA_DIR output dir (default /etc/relay/ca)
|
||||
# AGENT_FACING_HOST REQUIRED — CN/SAN for the relay agent-server cert; the host the agent dials,
|
||||
# e.g. "<sub>.<BASE_DOMAIN>" (must match RELAY_URL's host in the agent config).
|
||||
# AGENT_FACING_IP OPTIONAL — extra IP SAN (e.g. 8.138.1.192) if the agent dials the raw IP.
|
||||
# RELAY_TRUST_DOMAIN OPTIONAL — SPIFFE trust domain (recorded in a NOTE only; agent SPIFFE leaves
|
||||
# are minted by the control-plane /enroll, not by this script).
|
||||
# CA_DAYS_ROOT / CA_DAYS_INT / CA_DAYS_LEAF cert lifetimes (defaults 3650 / 1825 / 825).
|
||||
#
|
||||
# Verify (syntax): bash -n deploy/scripts/gen-agent-ca.sh
|
||||
set -euo pipefail
|
||||
|
||||
CA_DIR="${CA_DIR:-/etc/relay/ca}"
|
||||
CA_DAYS_ROOT="${CA_DAYS_ROOT:-3650}"
|
||||
CA_DAYS_INT="${CA_DAYS_INT:-1825}"
|
||||
CA_DAYS_LEAF="${CA_DAYS_LEAF:-825}"
|
||||
|
||||
if [[ -z "${AGENT_FACING_HOST:-}" ]]; then
|
||||
echo "FATAL: AGENT_FACING_HOST is required (CN/SAN of the relay agent-server cert)." >&2
|
||||
echo " e.g. AGENT_FACING_HOST=term1.example.com bash $0" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
command -v openssl >/dev/null 2>&1 || { echo "FATAL: openssl not found on PATH." >&2; exit 3; }
|
||||
|
||||
umask 077
|
||||
mkdir -p "${CA_DIR}"
|
||||
cd "${CA_DIR}"
|
||||
|
||||
echo "== Enrollment CA (PRIVATE — not Let's Encrypt) into ${CA_DIR} =="
|
||||
|
||||
# ---- 1. Root CA (offline anchor) ------------------------------------------------------------------
|
||||
if [[ ! -f root.key.pem ]]; then
|
||||
openssl genpkey -algorithm ed25519 -out root.key.pem
|
||||
fi
|
||||
openssl req -x509 -new -key root.key.pem -days "${CA_DAYS_ROOT}" \
|
||||
-subj "/O=web-terminal-relay/OU=agent-enrollment/CN=web-terminal Agent Enrollment Root" \
|
||||
-addext "basicConstraints=critical,CA:TRUE" \
|
||||
-addext "keyUsage=critical,keyCertSign,cRLSign" \
|
||||
-addext "subjectKeyIdentifier=hash" \
|
||||
-out root.cert.pem
|
||||
|
||||
# ---- 2. Intermediate CA (the control-plane's leaf-signing key in staging) --------------------------
|
||||
# The CP dev in-process signer (CA_INTERMEDIATE_KMS_KEY_REF=dev-local) signs agent SPIFFE leaves with
|
||||
# THIS intermediate key. In Phase 2 the intermediate key moves into a non-exportable KMS/HSM (§3.1).
|
||||
if [[ ! -f intermediate.key.pem ]]; then
|
||||
openssl genpkey -algorithm ed25519 -out intermediate.key.pem
|
||||
fi
|
||||
openssl req -new -key intermediate.key.pem \
|
||||
-subj "/O=web-terminal-relay/OU=agent-enrollment/CN=web-terminal Agent Enrollment Intermediate" \
|
||||
-out intermediate.csr.pem
|
||||
cat > intermediate.ext <<'EXT'
|
||||
basicConstraints = critical,CA:TRUE,pathlen:0
|
||||
keyUsage = critical,keyCertSign,cRLSign
|
||||
subjectKeyIdentifier = hash
|
||||
authorityKeyIdentifier = keyid:always
|
||||
EXT
|
||||
openssl x509 -req -in intermediate.csr.pem \
|
||||
-CA root.cert.pem -CAkey root.key.pem -CAcreateserial \
|
||||
-days "${CA_DAYS_INT}" -extfile intermediate.ext \
|
||||
-out intermediate.cert.pem
|
||||
|
||||
# ---- 3. Trust bundle (intermediate + root) --------------------------------------------------------
|
||||
# The relay walks leaf -> intermediate -> root, so the anchor (root) MUST be in the bundle
|
||||
# (relay-auth verifyChain terminates at a self-signed root present in the set, INV14).
|
||||
cat intermediate.cert.pem root.cert.pem > agent-ca.bundle.pem
|
||||
cp intermediate.cert.pem agent-ca.cert.pem
|
||||
|
||||
# ---- 4. Relay agent-facing SERVER cert (presented on AGENT_BIND_PORT) -----------------------------
|
||||
# The agent dials wss://<AGENT_FACING_HOST>:<AGENT_BIND_PORT> and pins THIS CA to authenticate the
|
||||
# relay server. CN/SAN must equal the host the agent dials.
|
||||
if [[ ! -f relay-agent-server.key.pem ]]; then
|
||||
openssl genpkey -algorithm ed25519 -out relay-agent-server.key.pem
|
||||
fi
|
||||
openssl req -new -key relay-agent-server.key.pem \
|
||||
-subj "/O=web-terminal-relay/OU=agent-data-plane/CN=${AGENT_FACING_HOST}" \
|
||||
-out relay-agent-server.csr.pem
|
||||
{
|
||||
echo "basicConstraints = critical,CA:FALSE"
|
||||
echo "keyUsage = critical,digitalSignature"
|
||||
echo "extendedKeyUsage = serverAuth"
|
||||
echo "subjectKeyIdentifier = hash"
|
||||
echo "authorityKeyIdentifier = keyid,issuer"
|
||||
if [[ -n "${AGENT_FACING_IP:-}" ]]; then
|
||||
echo "subjectAltName = DNS:${AGENT_FACING_HOST},IP:${AGENT_FACING_IP}"
|
||||
else
|
||||
echo "subjectAltName = DNS:${AGENT_FACING_HOST}"
|
||||
fi
|
||||
} > relay-agent-server.ext
|
||||
openssl x509 -req -in relay-agent-server.csr.pem \
|
||||
-CA intermediate.cert.pem -CAkey intermediate.key.pem -CAcreateserial \
|
||||
-days "${CA_DAYS_LEAF}" -extfile relay-agent-server.ext \
|
||||
-out relay-agent-server.cert.pem
|
||||
|
||||
# ---- 5. Permissions + summary ---------------------------------------------------------------------
|
||||
chmod 600 ./*.key.pem
|
||||
chmod 644 ./*.cert.pem ./*.bundle.pem
|
||||
rm -f ./*.csr.pem ./*.ext
|
||||
|
||||
cat <<SUMMARY
|
||||
|
||||
Enrollment CA ready in ${CA_DIR}. Wire these env paths (relay.env / control-plane.env):
|
||||
|
||||
CA_INTERMEDIATE_CERT_PATH = ${CA_DIR}/intermediate.cert.pem
|
||||
NODE_MTLS_TRUST_BUNDLE_PATH = ${CA_DIR}/agent-ca.bundle.pem
|
||||
AGENT_CA_CERT_PATH = ${CA_DIR}/agent-ca.cert.pem
|
||||
AGENT_CA_CHAIN_PATH = ${CA_DIR}/agent-ca.bundle.pem
|
||||
|
||||
relay agent-server cert = ${CA_DIR}/relay-agent-server.cert.pem
|
||||
relay agent-server key = ${CA_DIR}/relay-agent-server.key.pem (0600)
|
||||
intermediate signing key = ${CA_DIR}/intermediate.key.pem (0600 — the CP dev signer)
|
||||
|
||||
NOTE (staging integration seam, owned by the A-wave CP boot/ca-wiring, NOT by this script):
|
||||
The control-plane's dev in-process signer must sign agent leaves with intermediate.key.pem so the
|
||||
leaves chain to this bundle. CA_INTERMEDIATE_KMS_KEY_REF=dev-local selects the dev signer; point it
|
||||
at ${CA_DIR}/intermediate.key.pem. Phase 2 replaces this with a KMS/HSM ref (§3.1).
|
||||
SPIFFE trust domain for agent leaves = ${RELAY_TRUST_DOMAIN:-<RELAY_TRUST_DOMAIN unset>}.
|
||||
|
||||
*** root.key.pem is the offline trust anchor — after first use, move it OFF this host. ***
|
||||
SUMMARY
|
||||
67
deploy/scripts/gen-capability-key.sh
Executable file
67
deploy/scripts/gen-capability-key.sh
Executable file
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# RELAY-PHASE1 · E (TASK E) — P5 capability-token keypair (Ed25519).
|
||||
#
|
||||
# STAGING TEMPLATE. One Ed25519 keypair underpins the whole authz split:
|
||||
#
|
||||
# CONTROL-PLANE SIGNS ──(mints capability tokens with the PRIVATE key, B6 token-mint)──▶
|
||||
# RELAY VERIFIES
|
||||
# The CP admin API (POST /accounts, /pairing-codes) ALSO verifies operator "manage" tokens with
|
||||
# the SAME public key. So the public key is deployed to BOTH processes; the private key lives ONLY
|
||||
# where tokens are minted (the B6 mint), never on the relay, never committed.
|
||||
#
|
||||
# The public key is printed in TWO encodings of the SAME raw 32 bytes (the split is encoding-only):
|
||||
# CAPABILITY_SIGN_PUBKEY_B64 raw 32B, standard base64 -> control-plane env (env.ts base64ToBytes)
|
||||
# RELAY_AUTH_VERIFY_PUBKEY raw 32B, base64url (no pad) -> relay env (loadVerifyKeyFromEnv)
|
||||
# These MUST be the SAME key (PLAN §3 linchpin). This script prints both from one keypair so they
|
||||
# cannot drift.
|
||||
#
|
||||
# The PRIVATE key is written PKCS#8 PEM to a 0600 file for the B6 mint to import (WebCrypto Ed25519).
|
||||
# It is NEVER printed and NEVER placed in relay.env / control-plane.env.
|
||||
#
|
||||
# Config via ENV only:
|
||||
# KEY_DIR output dir for the private key (default /etc/relay/capability)
|
||||
#
|
||||
# Verify (syntax): bash -n deploy/scripts/gen-capability-key.sh
|
||||
set -euo pipefail
|
||||
|
||||
KEY_DIR="${KEY_DIR:-/etc/relay/capability}"
|
||||
PRIV="${KEY_DIR}/capability-sign.key.pem"
|
||||
|
||||
command -v openssl >/dev/null 2>&1 || { echo "FATAL: openssl not found on PATH." >&2; exit 3; }
|
||||
|
||||
umask 077
|
||||
mkdir -p "${KEY_DIR}"
|
||||
|
||||
if [[ -f "${PRIV}" ]]; then
|
||||
echo "WARN: ${PRIV} already exists — reusing it (delete it to rotate). Rotating INVALIDATES every" >&2
|
||||
echo " minted token AND requires re-deploying the public key to BOTH processes." >&2
|
||||
else
|
||||
openssl genpkey -algorithm ed25519 -out "${PRIV}"
|
||||
fi
|
||||
chmod 600 "${PRIV}"
|
||||
|
||||
# Raw 32-byte Ed25519 public key = last 32 bytes of the SPKI DER.
|
||||
PUB_STD_B64="$(openssl pkey -in "${PRIV}" -pubout -outform DER | tail -c 32 | openssl base64 -A)"
|
||||
# base64url of the SAME bytes, padding stripped (decodeBase64UrlBytes tolerates missing padding).
|
||||
PUB_B64URL="$(printf '%s' "${PUB_STD_B64}" | tr '+/' '-_' | tr -d '=')"
|
||||
|
||||
cat <<SUMMARY
|
||||
|
||||
P5 capability keypair ready.
|
||||
|
||||
PRIVATE key (B6 token-mint only; 0600; NEVER commit / NEVER on the relay):
|
||||
${PRIV}
|
||||
|
||||
Put the SAME public key in BOTH env files (they are one key, two encodings):
|
||||
|
||||
# control-plane.env
|
||||
CAPABILITY_SIGN_PUBKEY_B64=${PUB_STD_B64}
|
||||
|
||||
# relay.env
|
||||
RELAY_AUTH_VERIFY_PUBKEY=${PUB_B64URL}
|
||||
|
||||
Sanity check they decode to the same 32 bytes:
|
||||
diff <(openssl pkey -in "${PRIV}" -pubout -outform DER | tail -c 32 | xxd -p) \\
|
||||
<(printf '%s' "${PUB_STD_B64}" | openssl base64 -d -A | xxd -p) && echo OK
|
||||
SUMMARY
|
||||
163
deploy/scripts/gen-device-ca.sh
Executable file
163
deploy/scripts/gen-device-ca.sh
Executable file
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# RELAY-PHASE1 · Native mTLS tunnel · V2 — Device client-CA (and frp-client control-CA).
|
||||
#
|
||||
# STAGING TEMPLATE. Mints a PRIVATE client-authentication CA whose leaves the native clients (iOS /
|
||||
# Android / desktop) present at nginx :8470 (`ssl_verify_client on`). This CA is the ONE AND ONLY
|
||||
# security gate for the tunnelled base app (the base app has no login of its own — PLAN §1), so it
|
||||
# MUST be a SEPARATE trust root from Let's Encrypt (the public :8470 SERVER cert) and from the
|
||||
# agent-enrollment CA (deploy/scripts/gen-agent-ca.sh). Mixing them would let a foreign leaf enroll.
|
||||
#
|
||||
# *** P-256 (prime256v1), NEVER Ed25519. *** nginx `ssl_verify_client` + iOS client-certificate
|
||||
# selection require ECDSA-P256/RSA; Ed25519 client certs are not universally accepted (PLAN V2).
|
||||
#
|
||||
# Two KINDs (same structure, different name/subject/default dir):
|
||||
# device → /etc/relay/device-ca (device-ca.cert.pem) leaves = client devices (V2/V4)
|
||||
# frp-client → /etc/relay/frp-client-ca (frp-client-ca.cert.pem) leaves = per-host frpc control mTLS (V1/V1b)
|
||||
#
|
||||
# Produces an OpenSSL `ca` database (index.txt / serial / crlnumber / newcerts / openssl.cnf) so that
|
||||
# deploy/scripts/issue-device-cert.sh can sign tracked leaves and deploy/scripts/revoke-device.sh can
|
||||
# revoke by serial and regenerate the CRL. An EMPTY crl.pem is initialised NOW so V4's `ssl_crl` has a
|
||||
# file to reference from day one.
|
||||
#
|
||||
# Outputs (0600 key, 0644 cert) under CA_DIR:
|
||||
# <CA_NAME>.key.pem <CA_NAME>.cert.pem crl.pem
|
||||
# index.txt serial crlnumber index.txt.attr newcerts/ openssl.cnf (the `openssl ca` db)
|
||||
#
|
||||
# Config via ENV or flags (no hardcoded secrets — this CA key is generated in place, never committed):
|
||||
# KIND device | frp-client (default device) — or --kind <k>
|
||||
# CA_DIR output dir (default per-KIND above) — or --dir <path>
|
||||
# CA_DAYS CA lifetime in days (default 3650)
|
||||
#
|
||||
# Idempotent: an existing CA (key+cert) is REUSED, never overwritten (delete the dir to rotate).
|
||||
#
|
||||
# Verify (syntax): bash -n deploy/scripts/gen-device-ca.sh
|
||||
set -euo pipefail
|
||||
|
||||
KIND="${KIND:-device}"
|
||||
CA_DIR="${CA_DIR:-}"
|
||||
CA_DAYS="${CA_DAYS:-3650}"
|
||||
|
||||
# ---- args (flags override env) --------------------------------------------------------------------
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--kind) KIND="${2:?--kind needs a value}"; shift 2 ;;
|
||||
--dir) CA_DIR="${2:?--dir needs a value}"; shift 2 ;;
|
||||
-h|--help)
|
||||
grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "FATAL: unknown argument '$1' (see --help)" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "${KIND}" in
|
||||
device) CA_NAME="device-ca"; CA_CN="web-terminal Device CA"; CA_OU="device-ca"
|
||||
CA_DIR="${CA_DIR:-/etc/relay/device-ca}" ;;
|
||||
frp-client) CA_NAME="frp-client-ca"; CA_CN="web-terminal frp-client CA"; CA_OU="frp-client-ca"
|
||||
CA_DIR="${CA_DIR:-/etc/relay/frp-client-ca}" ;;
|
||||
*) echo "FATAL: KIND must be 'device' or 'frp-client' (got '${KIND}')." >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
command -v openssl >/dev/null 2>&1 || { echo "FATAL: openssl not found on PATH." >&2; exit 3; }
|
||||
|
||||
umask 077
|
||||
mkdir -p "${CA_DIR}/newcerts"
|
||||
# Resolve to an absolute path so `openssl ca` (which we run cd'd into CA_DIR with dir=.) is stable.
|
||||
CA_DIR="$(cd "${CA_DIR}" && pwd)"
|
||||
|
||||
KEY="${CA_DIR}/${CA_NAME}.key.pem"
|
||||
CERT="${CA_DIR}/${CA_NAME}.cert.pem"
|
||||
|
||||
echo "== Device/frp-client CA (${KIND}, PRIVATE — not Let's Encrypt) into ${CA_DIR} =="
|
||||
|
||||
# ---- 1. CA keypair + self-signed cert (P-256, CA:TRUE, keyCertSign+cRLSign) -----------------------
|
||||
if [[ -f "${KEY}" && -f "${CERT}" ]]; then
|
||||
echo ">> CA already exists — reusing (delete ${CA_DIR} to rotate)."
|
||||
else
|
||||
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "${KEY}"
|
||||
openssl req -x509 -new -key "${KEY}" -days "${CA_DAYS}" \
|
||||
-subj "/O=web-terminal-relay/OU=${CA_OU}/CN=${CA_CN}" \
|
||||
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
|
||||
-addext "keyUsage=critical,keyCertSign,cRLSign" \
|
||||
-addext "subjectKeyIdentifier=hash" \
|
||||
-out "${CERT}"
|
||||
fi
|
||||
|
||||
# ---- 2. OpenSSL `ca` database (so leaves are tracked → revocable via the CRL) ----------------------
|
||||
[[ -f "${CA_DIR}/index.txt" ]] || : > "${CA_DIR}/index.txt"
|
||||
[[ -f "${CA_DIR}/serial" ]] || echo 1000 > "${CA_DIR}/serial"
|
||||
[[ -f "${CA_DIR}/crlnumber" ]] || echo 1000 > "${CA_DIR}/crlnumber"
|
||||
# unique_subject=no allows re-issuing a leaf for the same CN (device/host cert rotation).
|
||||
[[ -f "${CA_DIR}/index.txt.attr" ]] || echo "unique_subject = no" > "${CA_DIR}/index.txt.attr"
|
||||
|
||||
if [[ ! -f "${CA_DIR}/openssl.cnf" ]]; then
|
||||
# dir = . → the issue/revoke scripts `cd` into CA_DIR before invoking `openssl ca`, so the db is
|
||||
# relocatable (works in a mktemp test dir). $dir is expanded by openssl; ${CA_NAME} by bash here.
|
||||
cat > "${CA_DIR}/openssl.cnf" <<EOF
|
||||
[ ca ]
|
||||
default_ca = CA_default
|
||||
|
||||
[ CA_default ]
|
||||
dir = .
|
||||
database = \$dir/index.txt
|
||||
serial = \$dir/serial
|
||||
new_certs_dir = \$dir/newcerts
|
||||
certificate = \$dir/${CA_NAME}.cert.pem
|
||||
private_key = \$dir/${CA_NAME}.key.pem
|
||||
crlnumber = \$dir/crlnumber
|
||||
crl = \$dir/crl.pem
|
||||
default_md = sha256
|
||||
default_days = 825
|
||||
default_crl_days = 30
|
||||
policy = policy_anything
|
||||
copy_extensions = none
|
||||
x509_extensions = client_cert
|
||||
crl_extensions = crl_ext
|
||||
|
||||
[ policy_anything ]
|
||||
countryName = optional
|
||||
stateOrProvinceName = optional
|
||||
localityName = optional
|
||||
organizationName = optional
|
||||
organizationalUnitName = optional
|
||||
commonName = supplied
|
||||
emailAddress = optional
|
||||
|
||||
# Leaf profile: client-authentication ONLY (never serverAuth, never CA).
|
||||
[ client_cert ]
|
||||
basicConstraints = critical, CA:FALSE
|
||||
keyUsage = critical, digitalSignature
|
||||
extendedKeyUsage = clientAuth
|
||||
subjectKeyIdentifier = hash
|
||||
authorityKeyIdentifier = keyid,issuer
|
||||
|
||||
[ crl_ext ]
|
||||
authorityKeyIdentifier = keyid:always
|
||||
EOF
|
||||
fi
|
||||
|
||||
# ---- 3. Empty CRL now (V4 `ssl_crl` needs the file to exist even with zero revocations) ------------
|
||||
if [[ ! -f "${CA_DIR}/crl.pem" ]]; then
|
||||
( cd "${CA_DIR}" && openssl ca -config openssl.cnf -gencrl -out crl.pem >/dev/null 2>&1 )
|
||||
fi
|
||||
|
||||
# ---- 4. Permissions + summary ---------------------------------------------------------------------
|
||||
chmod 700 "${CA_DIR}"
|
||||
chmod 600 "${KEY}"
|
||||
chmod 644 "${CERT}" "${CA_DIR}/crl.pem"
|
||||
|
||||
cat <<SUMMARY
|
||||
|
||||
${KIND} CA ready in ${CA_DIR}:
|
||||
CA cert = ${CERT} (0644 — the trust anchor to deploy)
|
||||
CA key = ${KEY} (0600 — NEVER commit / NEVER leave a shared host)
|
||||
CRL = ${CA_DIR}/crl.pem (empty; regenerated by revoke-device.sh)
|
||||
ca db = index.txt / serial / crlnumber / newcerts / openssl.cnf
|
||||
|
||||
Wire it:
|
||||
device → nginx :8470 ssl_client_certificate ${CERT} ; ssl_crl ${CA_DIR}/crl.pem ;
|
||||
frp-client → frps.toml transport.tls.trustedCaFile = ${CERT}
|
||||
|
||||
Next:
|
||||
issue a leaf : CA_DIR=${CA_DIR} bash deploy/scripts/issue-device-cert.sh <CN> <out-dir>
|
||||
revoke a leaf : CA_DIR=${CA_DIR} bash deploy/scripts/revoke-device.sh <leaf.cert.pem>
|
||||
SUMMARY
|
||||
120
deploy/scripts/issue-device-cert.sh
Executable file
120
deploy/scripts/issue-device-cert.sh
Executable file
@@ -0,0 +1,120 @@
|
||||
#!/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
|
||||
115
deploy/scripts/issue-tls-cert.sh
Executable file
115
deploy/scripts/issue-tls-cert.sh
Executable file
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# RELAY-PHASE1 · E (TASK E) — PUBLIC-web TLS cert for the browser :443 endpoint (Let's Encrypt).
|
||||
#
|
||||
# STAGING TEMPLATE. This is the ONLY public-CA cert in the deployment: the browser hits
|
||||
# https://<SUBDOMAIN>.<BASE_DOMAIN> (WSS same-origin), so it must chain to a public root. It is a
|
||||
# DIFFERENT trust chain from the private agent-enrollment CA (deploy/scripts/gen-agent-ca.sh) — do
|
||||
# NOT cross-wire them.
|
||||
#
|
||||
# ICP-備案 ASSUMPTION (mainland Alibaba Cloud): BASE_DOMAIN is ALREADY ICP-filed and its A-record
|
||||
# <SUBDOMAIN>.<BASE_DOMAIN> -> 8.138.1.192 resolves. Serving :80/:443 on an unfiled domain in
|
||||
# mainland CN is blocked upstream; HTTP-01 will then fail. If unfiled, use DNS-01 (no inbound :80).
|
||||
#
|
||||
# Two challenge methods (pick with ACME_METHOD):
|
||||
# http-01 — a one-shot listener on :80 answers the challenge. REQUIRES inbound :80 open in the
|
||||
# Aliyun security group DURING issuance (you can close it again after; renewals reopen).
|
||||
# dns-01 — a TXT record under _acme-challenge.<SUBDOMAIN>.<BASE_DOMAIN>. No inbound :80. Needs the
|
||||
# acme.sh DNS-API creds for your provider (e.g. Ali_Key/Ali_Secret for the Aliyun DNS API).
|
||||
#
|
||||
# Installs to TLS_CERT_PATH (fullchain) + TLS_KEY_PATH, matching relay.env.
|
||||
#
|
||||
# Config via ENV only:
|
||||
# BASE_DOMAIN REQUIRED (e.g. term.example.com)
|
||||
# SUBDOMAIN REQUIRED (the tenant sub; FQDN = <SUBDOMAIN>.<BASE_DOMAIN>)
|
||||
# ACME_EMAIL REQUIRED account/registration email
|
||||
# ACME_METHOD http-01 | dns-01 (default http-01)
|
||||
# ACME_CLIENT acme.sh | certbot (default acme.sh)
|
||||
# ACME_DNS_PROVIDER acme.sh dnsapi id for dns-01 (e.g. dns_ali) (dns-01 only)
|
||||
# TLS_CERT_PATH install target for fullchain (default /etc/relay/tls/fullchain.pem)
|
||||
# TLS_KEY_PATH install target for privkey (default /etc/relay/tls/privkey.pem)
|
||||
# ACME_STAGING 1 to use the LE staging CA (untrusted, avoids rate limits while testing)
|
||||
#
|
||||
# Verify (syntax): bash -n deploy/scripts/issue-tls-cert.sh
|
||||
set -euo pipefail
|
||||
|
||||
: "${BASE_DOMAIN:?FATAL: BASE_DOMAIN is required (your ICP-filed domain)}"
|
||||
: "${SUBDOMAIN:?FATAL: SUBDOMAIN is required}"
|
||||
: "${ACME_EMAIL:?FATAL: ACME_EMAIL is required}"
|
||||
ACME_METHOD="${ACME_METHOD:-http-01}"
|
||||
ACME_CLIENT="${ACME_CLIENT:-acme.sh}"
|
||||
TLS_CERT_PATH="${TLS_CERT_PATH:-/etc/relay/tls/fullchain.pem}"
|
||||
TLS_KEY_PATH="${TLS_KEY_PATH:-/etc/relay/tls/privkey.pem}"
|
||||
|
||||
FQDN="${SUBDOMAIN}.${BASE_DOMAIN}"
|
||||
echo "== Issuing Let's Encrypt cert for ${FQDN} via ${ACME_CLIENT} (${ACME_METHOD}) =="
|
||||
|
||||
mkdir -p "$(dirname "${TLS_CERT_PATH}")" "$(dirname "${TLS_KEY_PATH}")"
|
||||
|
||||
case "${ACME_CLIENT}" in
|
||||
acme.sh)
|
||||
command -v acme.sh >/dev/null 2>&1 || { echo "FATAL: acme.sh not installed. curl https://get.acme.sh | sh" >&2; exit 3; }
|
||||
acme.sh --register-account -m "${ACME_EMAIL}" >/dev/null 2>&1 || true
|
||||
STAGING_FLAG=""
|
||||
[[ "${ACME_STAGING:-0}" == "1" ]] && STAGING_FLAG="--staging"
|
||||
|
||||
case "${ACME_METHOD}" in
|
||||
http-01)
|
||||
echo ">> Ensure inbound :80 is OPEN in the Aliyun security group for the duration of issuance."
|
||||
acme.sh --issue ${STAGING_FLAG} -d "${FQDN}" --standalone --httpport 80
|
||||
;;
|
||||
dns-01)
|
||||
: "${ACME_DNS_PROVIDER:?FATAL: ACME_DNS_PROVIDER required for dns-01 (e.g. dns_ali); export the DNS-API creds too}"
|
||||
acme.sh --issue ${STAGING_FLAG} -d "${FQDN}" --dns "${ACME_DNS_PROVIDER}"
|
||||
;;
|
||||
*)
|
||||
echo "FATAL: ACME_METHOD must be http-01 or dns-01" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
# --install-cert copies the current material AND registers the renew-reload hook.
|
||||
acme.sh --install-cert -d "${FQDN}" \
|
||||
--key-file "${TLS_KEY_PATH}" \
|
||||
--fullchain-file "${TLS_CERT_PATH}" \
|
||||
--reloadcmd "systemctl try-reload-or-restart relay-data-plane.service"
|
||||
;;
|
||||
|
||||
certbot)
|
||||
command -v certbot >/dev/null 2>&1 || { echo "FATAL: certbot not installed (apt-get install certbot)." >&2; exit 3; }
|
||||
STAGING_FLAG=""
|
||||
[[ "${ACME_STAGING:-0}" == "1" ]] && STAGING_FLAG="--staging"
|
||||
case "${ACME_METHOD}" in
|
||||
http-01)
|
||||
echo ">> Ensure inbound :80 is OPEN in the Aliyun security group for the duration of issuance."
|
||||
certbot certonly ${STAGING_FLAG} --standalone --non-interactive --agree-tos \
|
||||
-m "${ACME_EMAIL}" -d "${FQDN}"
|
||||
;;
|
||||
dns-01)
|
||||
echo "FATAL: certbot dns-01 needs a provider plugin; prefer ACME_CLIENT=acme.sh for Aliyun DNS." >&2
|
||||
exit 2 ;;
|
||||
*)
|
||||
echo "FATAL: ACME_METHOD must be http-01 or dns-01" >&2; exit 2 ;;
|
||||
esac
|
||||
install -m 644 "/etc/letsencrypt/live/${FQDN}/fullchain.pem" "${TLS_CERT_PATH}"
|
||||
install -m 600 "/etc/letsencrypt/live/${FQDN}/privkey.pem" "${TLS_KEY_PATH}"
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "FATAL: ACME_CLIENT must be acme.sh or certbot" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
chmod 600 "${TLS_KEY_PATH}" || true
|
||||
chmod 644 "${TLS_CERT_PATH}" || true
|
||||
|
||||
cat <<SUMMARY
|
||||
|
||||
Installed:
|
||||
TLS_CERT_PATH = ${TLS_CERT_PATH}
|
||||
TLS_KEY_PATH = ${TLS_KEY_PATH}
|
||||
|
||||
RENEWAL (LE certs last ~90 days):
|
||||
- acme.sh installs its OWN cron on install ('acme.sh --cron'); --install-cert registered the
|
||||
reload hook above, so renewals auto-reload the data-plane. Verify: 'crontab -l | grep acme'.
|
||||
- certbot: add a cron/timer, e.g.
|
||||
0 3 * * * certbot renew --quiet --deploy-hook 'systemctl try-reload-or-restart relay-data-plane.service'
|
||||
- http-01 renewals need :80 reachable at renew time; dns-01 does not.
|
||||
SUMMARY
|
||||
68
deploy/scripts/revoke-device.sh
Executable file
68
deploy/scripts/revoke-device.sh
Executable 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
|
||||
52
deploy/systemd/relay-control-plane.service
Normal file
52
deploy/systemd/relay-control-plane.service
Normal file
@@ -0,0 +1,52 @@
|
||||
# RELAY-PHASE1 · E (TASK E) — control-plane (P3) systemd unit. STAGING TEMPLATE.
|
||||
#
|
||||
# Runs the P3 admin/registry/CA/pairing API (control-plane/src/server.ts, added in A2) on the
|
||||
# loopback admin port (CP_BIND_HOST/CP_BIND_PORT in the env file). It is NEVER exposed publicly —
|
||||
# the security group keeps 8080 loopback-only; operators reach it via SSH tunnel.
|
||||
#
|
||||
# INSTALL (adjust the placeholder paths to your VPS):
|
||||
# 1) clone the repo to /opt/web-terminal (or edit --prefix below)
|
||||
# 2) install: sudo cp deploy/systemd/relay-control-plane.service /etc/systemd/system/
|
||||
# 3) put the filled env at /etc/relay/control-plane.env (0600, owned by the service user)
|
||||
# 4) sudo systemctl daemon-reload && sudo systemctl enable --now relay-control-plane.service
|
||||
#
|
||||
# The env file supplies PG_URL/REDIS_URL/CAPABILITY_SIGN_PUBKEY_B64/CA_* /BASE_DOMAIN (see .env.example).
|
||||
|
||||
[Unit]
|
||||
Description=web-terminal rendezvous-relay CONTROL PLANE (P3)
|
||||
Documentation=file:///opt/web-terminal/docs/PLAN_RELAY_PHASE1.md
|
||||
# Postgres + Redis run under Docker Compose (deploy/docker-compose.yml); wait for docker + network.
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# Dedicated non-root service user (create once: sudo useradd --system --home /opt/web-terminal relay).
|
||||
User=relay
|
||||
Group=relay
|
||||
WorkingDirectory=/opt/web-terminal
|
||||
# Secrets/config come ONLY from the env file (INV9 — no secrets inline in the unit).
|
||||
EnvironmentFile=/etc/relay/control-plane.env
|
||||
# /usr/bin/env resolves npm via PATH (works for NodeSource /usr/bin/npm and nvm shims alike).
|
||||
# Runs the "start" script (control-plane/package.json -> tsx src/server.ts).
|
||||
ExecStart=/usr/bin/env npm --prefix /opt/web-terminal/control-plane start
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
# Give in-flight requests / graceful SIGTERM (server.ts closes the http listener + pg/redis) time.
|
||||
TimeoutStopSec=20
|
||||
KillSignal=SIGTERM
|
||||
|
||||
# --- hardening (INV9: shrink the blast radius around the CA + capability material) ---
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
ProtectControlGroups=true
|
||||
ProtectKernelTunables=true
|
||||
RestrictSUIDSGID=true
|
||||
# The CA/capability material under /etc/relay is READ-ONLY to this service.
|
||||
ReadOnlyPaths=/etc/relay
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
53
deploy/systemd/relay-data-plane.service
Normal file
53
deploy/systemd/relay-data-plane.service
Normal file
@@ -0,0 +1,53 @@
|
||||
# RELAY-PHASE1 · E (TASK E) — relay data-plane (P1) systemd unit. STAGING TEMPLATE.
|
||||
#
|
||||
# Runs the Phase-1 relay entry (relay-run/src/main-phase1.ts via `start:phase1`, added in B5): browser
|
||||
# WSS on :443 (BIND_PORT) and agent mTLS on AGENT_BIND_PORT. Stateless / restart-safe (INV7) — all
|
||||
# state is in Postgres/Redis, so bouncing this unit does NOT drop host registrations or kill PTYs.
|
||||
#
|
||||
# INSTALL (adjust the placeholder paths to your VPS):
|
||||
# 1) clone the repo to /opt/web-terminal (or edit --prefix below)
|
||||
# 2) install: sudo cp deploy/systemd/relay-data-plane.service /etc/systemd/system/
|
||||
# 3) put the filled env at /etc/relay/relay.env (0600, owned by the service user)
|
||||
# 4) sudo systemctl daemon-reload && sudo systemctl enable --now relay-data-plane.service
|
||||
#
|
||||
# The env file supplies BIND_HOST/BIND_PORT/TLS_*/AGENT_BIND_PORT/AGENT_CA_*/BASE_DOMAIN/
|
||||
# RELAY_NODE_ID/RELAY_AUTH_VERIFY_PUBKEY/RELAY_TRUST_DOMAIN/PG_URL/REDIS_URL (see .env.example).
|
||||
|
||||
[Unit]
|
||||
Description=web-terminal rendezvous-relay DATA PLANE (P1, Phase 1)
|
||||
Documentation=file:///opt/web-terminal/docs/PLAN_RELAY_PHASE1.md
|
||||
# Reads the SAME Postgres/Redis as the control-plane (routes, revocations, registries).
|
||||
After=network-online.target docker.service relay-control-plane.service
|
||||
Wants=network-online.target
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=relay
|
||||
Group=relay
|
||||
WorkingDirectory=/opt/web-terminal
|
||||
EnvironmentFile=/etc/relay/relay.env
|
||||
# Runs relay-run "start:phase1" (relay-run/package.json -> tsx src/main-phase1.ts).
|
||||
ExecStart=/usr/bin/env npm --prefix /opt/web-terminal/relay-run run start:phase1
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
TimeoutStopSec=20
|
||||
KillSignal=SIGTERM
|
||||
|
||||
# Bind the privileged browser port :443 WITHOUT running as root (least privilege).
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||
|
||||
# --- hardening ---
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
ProtectControlGroups=true
|
||||
ProtectKernelTunables=true
|
||||
RestrictSUIDSGID=true
|
||||
# TLS keys + the pinned agent-CA bundle are read-only to the relay.
|
||||
ReadOnlyPaths=/etc/relay
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -11,7 +11,8 @@
|
||||
*
|
||||
* __dirname is available because esbuild emits this as a CJS bundle (build.mjs).
|
||||
*/
|
||||
import { app, BrowserWindow, Menu, Notification, Tray } from 'electron'
|
||||
import { app, BrowserWindow, Menu, Notification, Tray, dialog } from 'electron'
|
||||
import type { Certificate } from 'electron'
|
||||
import path from 'node:path'
|
||||
|
||||
import { createLogger } from './logger.js'
|
||||
@@ -20,11 +21,21 @@ import { startEmbeddedServer } from './embedded-server.js'
|
||||
import { computeNotifications } from './notifications.js'
|
||||
import { mapLiveSessions } from './live-poll.js'
|
||||
import { parseDeepLink, deepLinkToPath, DEEP_LINK_PROTOCOL } from './deep-link.js'
|
||||
import { createMainWindow } from './window.js'
|
||||
import { createMainWindow, originOf } from './window.js'
|
||||
import { buildAppMenu } from './menu.js'
|
||||
import { createTray } from './tray.js'
|
||||
import { createTray, buildTrayMenu } from './tray.js'
|
||||
import type { TrayHandlers } from './tray.js'
|
||||
import {
|
||||
selectHost,
|
||||
selectedRemoteHost,
|
||||
remoteHostOrigin,
|
||||
makeRemoteHost,
|
||||
addRemoteHost,
|
||||
removeRemoteHost,
|
||||
TERMINAL_ZONE_SUFFIX,
|
||||
} from './remote-hosts.js'
|
||||
import type { NotifyOptions } from './notify-policy.js'
|
||||
import type { EmbeddedServer, SessionStatusSnapshot } from './types.js'
|
||||
import type { DesktopPrefs, EmbeddedServer, SessionStatusSnapshot } from './types.js'
|
||||
|
||||
/** Live-session poll cadence — cheap loopback GET, so a few seconds is plenty. */
|
||||
const POLL_INTERVAL_MS = 4000
|
||||
@@ -35,12 +46,75 @@ const PRELOAD_FILE = 'preload.cjs'
|
||||
/** Tray icon location relative to the build/ output dir (shipped under assets/). */
|
||||
const TRAY_ICON_SUBPATH = path.join('..', 'assets', 'trayTemplate.png')
|
||||
|
||||
/**
|
||||
* DEVICE_CA_CN — Common Name of the device client-certificate CA (VPS Track V2,
|
||||
* gen-device-ca.sh). The device `.p12` lives in the OS keychain (macOS login
|
||||
* keychain / Windows Personal store); Chromium sources client certificates ONLY
|
||||
* from the OS store — reading a `.p12` in-app is infeasible — so on an mTLS
|
||||
* handshake we select the cert whose issuer CN matches this. Override for a
|
||||
* differently-named CA via the WEBTERM_DEVICE_CA_CN env var.
|
||||
*/
|
||||
const DEVICE_CA_CN = process.env.WEBTERM_DEVICE_CA_CN ?? 'WebTerminal Device CA'
|
||||
|
||||
/** Add-remote-host prompt window dimensions. */
|
||||
const PROMPT_WIDTH = 460
|
||||
const PROMPT_HEIGHT = 260
|
||||
|
||||
/**
|
||||
* Minimal self-contained HTML for the "Add remote host" prompt window. It runs no
|
||||
* Node (sandboxed, no preload) and returns its result by setting `location.hash`
|
||||
* (read back via did-navigate-in-page); user text is JSON+URI-encoded into the
|
||||
* hash, never interpolated into markup, so there is no injection surface. The CSP
|
||||
* allows only inline style/script from this trusted, app-authored document.
|
||||
*/
|
||||
const PROMPT_HTML = `<!doctype html>
|
||||
<html><head><meta charset="utf-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'">
|
||||
<style>
|
||||
:root { color-scheme: dark light; }
|
||||
body { font: 13px -apple-system, Segoe UI, system-ui, sans-serif; margin: 0; padding: 18px;
|
||||
background: #0e0f13; color: #e6e6e6; }
|
||||
h1 { font-size: 15px; margin: 0 0 14px; }
|
||||
label { display: block; margin-bottom: 10px; }
|
||||
span { display: block; margin-bottom: 4px; color: #9aa0ab; }
|
||||
input { width: 100%; box-sizing: border-box; padding: 7px 8px; border-radius: 6px;
|
||||
border: 1px solid #333; background: #16181d; color: #e6e6e6; font: inherit; }
|
||||
.row { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
|
||||
button { padding: 7px 14px; border-radius: 6px; border: 1px solid #333;
|
||||
background: #22252c; color: #e6e6e6; font: inherit; cursor: pointer; }
|
||||
button[type=submit] { background: #2d6cdf; border-color: #2d6cdf; color: #fff; }
|
||||
</style></head>
|
||||
<body>
|
||||
<h1>Add remote host</h1>
|
||||
<form id="f">
|
||||
<label><span>Name</span><input id="name" autofocus placeholder="Laptop"></label>
|
||||
<label><span>URL</span><input id="url" value="https://" placeholder="https://t1.terminal.yaojia.wang"></label>
|
||||
<div class="row">
|
||||
<button type="button" id="cancel">Cancel</button>
|
||||
<button type="submit">Add</button>
|
||||
</div>
|
||||
</form>
|
||||
<script>
|
||||
var form = document.getElementById('f');
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var payload = { name: document.getElementById('name').value, url: document.getElementById('url').value };
|
||||
location.hash = 'ok:' + encodeURIComponent(JSON.stringify(payload));
|
||||
});
|
||||
document.getElementById('cancel').addEventListener('click', function () { location.hash = 'cancel'; });
|
||||
</script>
|
||||
</body></html>`
|
||||
|
||||
const logger = createLogger('main')
|
||||
const settings = createSettingsStore(app.getPath('userData'), process.platform, logger)
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let tray: Tray | null = null
|
||||
let server: EmbeddedServer | null = null
|
||||
// The origin the main window is currently locked to (local embedded server, or a
|
||||
// selected remote tunnel host). Read live by window.ts's will-navigate guard;
|
||||
// reassigned (never mutated) whenever we point the window at a different host.
|
||||
let activeOrigin: string | null = null
|
||||
let pollTimer: NodeJS.Timeout | null = null
|
||||
let isQuitting = false
|
||||
let isShuttingDown = false
|
||||
@@ -66,6 +140,193 @@ function focusWindow(): void {
|
||||
mainWindow.focus()
|
||||
}
|
||||
|
||||
/** True iff `value` is a plain, indexable object (not null, not an array). */
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/** The embedded local server's base URL (`http://127.0.0.1:<port>`), or null. */
|
||||
function localBaseUrl(): string | null {
|
||||
return server ? `http://127.0.0.1:${server.port}` : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the page URL + origin the window should currently be on, given prefs:
|
||||
* the selected remote host, else the local embedded server. Returns null only if
|
||||
* neither is resolvable (e.g. server not up yet and no valid remote selected).
|
||||
*/
|
||||
function resolveTarget(prefs: DesktopPrefs): { url: string; origin: string } | null {
|
||||
const remote = selectedRemoteHost(prefs)
|
||||
if (remote) {
|
||||
const origin = remoteHostOrigin(remote)
|
||||
if (origin !== null) return { url: remote.url, origin }
|
||||
}
|
||||
const base = localBaseUrl()
|
||||
if (base === null) return null
|
||||
return { url: `${base}/`, origin: base }
|
||||
}
|
||||
|
||||
/** Rebuild the tray context menu from the current host list + selection. */
|
||||
function refreshTray(): void {
|
||||
if (!tray) return
|
||||
const prefs = settings.get()
|
||||
tray.setContextMenu(
|
||||
buildTrayMenu(
|
||||
{ remoteHosts: prefs.remoteHosts, selectedHostId: prefs.selectedHostId },
|
||||
trayHandlers,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the window to a host (null → local). Persists the selection, updates the
|
||||
* origin lock, navigates the window (programmatic loadURL does NOT trip
|
||||
* will-navigate), refreshes the tray, and focuses. A no-op if nothing resolves.
|
||||
*/
|
||||
function switchToHost(id: string | null): void {
|
||||
const prefs = settings.set(selectHost(settings.get(), id))
|
||||
const target = resolveTarget(prefs)
|
||||
if (!target) {
|
||||
logger.warn('cannot switch host: no resolvable target (server not ready?)')
|
||||
return
|
||||
}
|
||||
activeOrigin = target.origin
|
||||
if (mainWindow) void mainWindow.loadURL(target.url)
|
||||
refreshTray()
|
||||
focusWindow()
|
||||
}
|
||||
|
||||
/** Prompt for a new remote host, validate it, persist it, and switch to it. */
|
||||
async function addRemoteHostFlow(): Promise<void> {
|
||||
const input = await promptRemoteHost()
|
||||
if (!input) return
|
||||
const host = makeRemoteHost(input.name, input.url)
|
||||
if (!host) {
|
||||
showError(
|
||||
'Invalid remote host',
|
||||
'That is not a valid tunnel URL.',
|
||||
`Enter an https URL under ${TERMINAL_ZONE_SUFFIX}, ` +
|
||||
'e.g. https://t1.terminal.yaojia.wang',
|
||||
)
|
||||
return
|
||||
}
|
||||
settings.set(addRemoteHost(settings.get(), host))
|
||||
switchToHost(host.id)
|
||||
}
|
||||
|
||||
/** Remove a remote host; if it was selected, selection falls back to local. */
|
||||
function removeRemoteHostFlow(id: string): void {
|
||||
const prefs = settings.set(removeRemoteHost(settings.get(), id))
|
||||
const target = resolveTarget(prefs)
|
||||
if (target) {
|
||||
activeOrigin = target.origin
|
||||
if (mainWindow) void mainWindow.loadURL(target.url)
|
||||
}
|
||||
refreshTray()
|
||||
}
|
||||
|
||||
/** Handlers wired into the tray host-picker (stable object; reads prefs live). */
|
||||
const trayHandlers: TrayHandlers = {
|
||||
show: () => focusWindow(),
|
||||
quit: () => {
|
||||
isQuitting = true
|
||||
app.quit()
|
||||
},
|
||||
selectHost: (id) => switchToHost(id),
|
||||
addHost: () => {
|
||||
void addRemoteHostFlow()
|
||||
},
|
||||
removeHost: (id) => removeRemoteHostFlow(id),
|
||||
}
|
||||
|
||||
/** Show a native error dialog (parented to the main window when one exists). */
|
||||
function showError(title: string, message: string, detail: string): void {
|
||||
const options = { type: 'error' as const, title, message, detail }
|
||||
if (mainWindow) void dialog.showMessageBox(mainWindow, options)
|
||||
else void dialog.showMessageBox(options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal prompt for a remote-host name + URL. Resolves to the entered values, or
|
||||
* null if cancelled/closed. The window returns its result via location.hash (no
|
||||
* Node, no preload, no IPC surface); see PROMPT_HTML.
|
||||
*/
|
||||
function promptRemoteHost(): Promise<{ name: string; url: string } | null> {
|
||||
return new Promise((resolve) => {
|
||||
const win = new BrowserWindow({
|
||||
parent: mainWindow ?? undefined,
|
||||
modal: mainWindow !== null,
|
||||
width: PROMPT_WIDTH,
|
||||
height: PROMPT_HEIGHT,
|
||||
resizable: false,
|
||||
minimizable: false,
|
||||
maximizable: false,
|
||||
title: 'Add remote host',
|
||||
backgroundColor: '#0e0f13',
|
||||
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
|
||||
})
|
||||
let settled = false
|
||||
const finish = (result: { name: string; url: string } | null): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve(result)
|
||||
if (!win.isDestroyed()) win.close()
|
||||
}
|
||||
win.webContents.on('did-navigate-in-page', (_event, navUrl) => {
|
||||
let hash: string
|
||||
try {
|
||||
hash = new URL(navUrl).hash
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (hash === '#cancel') {
|
||||
finish(null)
|
||||
return
|
||||
}
|
||||
if (!hash.startsWith('#ok:')) return
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(decodeURIComponent(hash.slice('#ok:'.length)))
|
||||
if (isRecord(parsed) && typeof parsed['name'] === 'string' && typeof parsed['url'] === 'string') {
|
||||
finish({ name: parsed['name'], url: parsed['url'] })
|
||||
} else {
|
||||
finish(null)
|
||||
}
|
||||
} catch {
|
||||
finish(null)
|
||||
}
|
||||
})
|
||||
win.on('closed', () => finish(null))
|
||||
void win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(PROMPT_HTML)}`)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* D-2: choose the client certificate whose issuer is our device CA. The OS
|
||||
* keychain may hold unrelated certs, so match on issuer CN rather than picking
|
||||
* blindly. Returns null when nothing in the list was issued by DEVICE_CA_CN.
|
||||
*/
|
||||
function pickCertByIssuer(list: readonly Certificate[], issuerCn: string): Certificate | null {
|
||||
return (
|
||||
list.find(
|
||||
(cert) => cert.issuerName === issuerCn || cert.issuer.commonName === issuerCn,
|
||||
) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
/** Tell the user their device cert isn't installed (D-2: never fail silently). */
|
||||
function showDeviceCertMissingDialog(requestUrl: string): void {
|
||||
const host = originOf(requestUrl) ?? requestUrl
|
||||
showError(
|
||||
'Device certificate not installed',
|
||||
'No matching device certificate was found in the OS keychain.',
|
||||
`Connecting to ${host} requires your device client certificate (issued by ` +
|
||||
`"${DEVICE_CA_CN}"). Install the provided device.p12 into the login keychain ` +
|
||||
'(macOS: double-click the file) or the Personal certificate store (Windows), ' +
|
||||
'then reconnect. On macOS you will be prompted once to "Always Allow" access ' +
|
||||
'to the private key.',
|
||||
)
|
||||
}
|
||||
|
||||
/** Find the first terminalapp:// argument in a process argv list. */
|
||||
function findDeepLinkArg(argv: readonly string[]): string | null {
|
||||
const prefix = `${DEEP_LINK_PROTOCOL}://`
|
||||
@@ -90,9 +351,13 @@ function dispatchDeepLink(url: string): void {
|
||||
}
|
||||
|
||||
const targetPath = deepLinkToPath(link)
|
||||
const base = `http://127.0.0.1:${server.port}`
|
||||
// A deep link joins a LOCAL session, so navigate the window back to the local
|
||||
// origin and move the lock with it (in case a remote host was active).
|
||||
activeOrigin = base
|
||||
focusWindow()
|
||||
mainWindow.webContents.send(DEEP_LINK_CHANNEL, targetPath)
|
||||
void mainWindow.loadURL(`http://127.0.0.1:${server.port}${targetPath}`)
|
||||
void mainWindow.loadURL(`${base}${targetPath}`)
|
||||
}
|
||||
|
||||
/** Hide-to-tray on window close (keep the server alive) unless we're quitting. */
|
||||
@@ -154,18 +419,23 @@ async function onReady(): Promise<void> {
|
||||
server = await startEmbeddedServer({ prefs, logger })
|
||||
const base = `http://127.0.0.1:${server.port}`
|
||||
|
||||
mainWindow = createMainWindow(`${base}/`, path.join(__dirname, PRELOAD_FILE))
|
||||
// Honor a previously-selected remote host across restarts; else land on local.
|
||||
const initial = resolveTarget(prefs) ?? { url: `${base}/`, origin: base }
|
||||
activeOrigin = initial.origin
|
||||
mainWindow = createMainWindow(
|
||||
initial.url,
|
||||
path.join(__dirname, PRELOAD_FILE),
|
||||
() => activeOrigin,
|
||||
)
|
||||
installWindowCloseGuard(mainWindow)
|
||||
|
||||
Menu.setApplicationMenu(buildAppMenu())
|
||||
try {
|
||||
tray = createTray(path.join(__dirname, TRAY_ICON_SUBPATH), {
|
||||
show: () => focusWindow(),
|
||||
quit: () => {
|
||||
isQuitting = true
|
||||
app.quit()
|
||||
},
|
||||
})
|
||||
tray = createTray(
|
||||
path.join(__dirname, TRAY_ICON_SUBPATH),
|
||||
{ remoteHosts: prefs.remoteHosts, selectedHostId: prefs.selectedHostId },
|
||||
trayHandlers,
|
||||
)
|
||||
} catch (err: unknown) {
|
||||
// A missing/invalid tray icon (cosmetic) must not take down a working app.
|
||||
logger.warn(`tray unavailable (continuing without it): ${errorMessage(err)}`)
|
||||
@@ -216,6 +486,34 @@ if (!app.requestSingleInstanceLock()) {
|
||||
} else {
|
||||
app.setAsDefaultProtocolClient(DEEP_LINK_PROTOCOL)
|
||||
|
||||
// ── D-2: mTLS device-certificate selection (from the OS keychain) ──────────
|
||||
// When a remote tunnel host (nginx `ssl_verify_client on`) requests a client
|
||||
// cert, Chromium asks us which of the OS-store certs to present. preventDefault
|
||||
// is MANDATORY: without it Electron auto-picks list[0] (any cert, no control).
|
||||
// We deliberately choose the one issued by our device CA; if none is installed
|
||||
// we show a clear dialog (never a silent callback) and proceed cert-less so the
|
||||
// TLS handshake fails cleanly rather than hanging.
|
||||
app.on('select-client-certificate', (event, _webContents, url, list, callback) => {
|
||||
event.preventDefault()
|
||||
const cert = pickCertByIssuer(list, DEVICE_CA_CN)
|
||||
if (!cert) {
|
||||
showDeviceCertMissingDialog(url)
|
||||
callback() // no cert → nginx rejects the handshake (clean, non-hanging failure)
|
||||
return
|
||||
}
|
||||
callback(cert)
|
||||
})
|
||||
|
||||
// ── D-3 (deferred — documented TODO; coordinate with Track V/S) ────────────
|
||||
// This box can also be its OWN tunnel source. A follow-up may optionally spawn
|
||||
// + supervise a local `frpc` from here (PLAN_NATIVE_TUNNEL §"C-Desktop / D-3")
|
||||
// and inject the embedded server's ALLOWED_ORIGINS=
|
||||
// https://<name>.terminal.yaojia.wang (additive via the backend config.ts). The
|
||||
// embedded server already defaults BIND_HOST=127.0.0.1 (server-config.ts), which
|
||||
// satisfies precondition P-A — EXCEPT when `lanSharing` is enabled: that binds
|
||||
// 0.0.0.0 and re-opens an unauthenticated shell on the LAN, bypassing mTLS. So a
|
||||
// tunnel-source host must keep lanSharing OFF. Not implemented here.
|
||||
|
||||
app.on('second-instance', (_event, argv) => {
|
||||
focusWindow()
|
||||
const url = findDeepLinkArg(argv)
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
* CRITICAL rule); they never mutate their arguments.
|
||||
*/
|
||||
|
||||
import type { DesktopPrefs } from './types.js'
|
||||
import type { DesktopPrefs, RemoteHost } from './types.js'
|
||||
import { normalizeRemoteUrl } from './remote-hosts.js'
|
||||
|
||||
/** Valid TCP port range (inclusive); mirrors src/config.ts's PORT bounds. */
|
||||
const MIN_PORT = 1
|
||||
@@ -36,6 +37,8 @@ export function defaultPrefs(platform: NodeJS.Platform): DesktopPrefs {
|
||||
openAtLogin: false,
|
||||
notifyOnApproval: true,
|
||||
notifyOnStatusChange: true,
|
||||
remoteHosts: [],
|
||||
selectedHostId: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +73,51 @@ function pickShellPath(value: unknown, fallback: string | null): string | null {
|
||||
return fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one untrusted remote-host record. Requires string id/name and a URL
|
||||
* that survives normalization (https + tunnel zone); the stored URL is replaced
|
||||
* with its canonical form. Returns null for anything malformed, so a corrupt or
|
||||
* hand-edited entry is dropped rather than trusted downstream.
|
||||
*/
|
||||
function pickRemoteHost(value: unknown): RemoteHost | null {
|
||||
if (!isRecord(value)) return null
|
||||
const { id, name, url } = value
|
||||
if (typeof id !== 'string' || id === '') return null
|
||||
if (typeof name !== 'string' || name.trim() === '') return null
|
||||
if (typeof url !== 'string') return null
|
||||
const normalizedUrl = normalizeRemoteUrl(url)
|
||||
if (normalizedUrl === null) return null
|
||||
return { id, name: name.trim(), url: normalizedUrl }
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt a list of remote hosts from untrusted input: keep only records that
|
||||
* validate, and drop duplicate ids (first wins) so lookups are unambiguous. A
|
||||
* non-array yields an empty list.
|
||||
*/
|
||||
function pickRemoteHosts(value: unknown): readonly RemoteHost[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
const seen = new Set<string>()
|
||||
const hosts: RemoteHost[] = []
|
||||
for (const entry of value) {
|
||||
const host = pickRemoteHost(entry)
|
||||
if (host === null || seen.has(host.id)) continue
|
||||
seen.add(host.id)
|
||||
hosts.push(host)
|
||||
}
|
||||
return hosts
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt the selected host id: null (local), or a string that refers to a host
|
||||
* actually present in the validated list. A dangling id degrades to null so we
|
||||
* never point selection at a host that was dropped or never existed.
|
||||
*/
|
||||
function pickSelectedHostId(value: unknown, hosts: readonly RemoteHost[]): string | null {
|
||||
if (typeof value !== 'string') return null
|
||||
return hosts.some((host) => host.id === value) ? value : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-derive a fully-formed DesktopPrefs from untrusted parsed-JSON input.
|
||||
* Starts from defaults and adopts each field only when it is the right type and
|
||||
@@ -78,6 +126,7 @@ function pickShellPath(value: unknown, fallback: string | null): string | null {
|
||||
export function validatePrefs(raw: unknown, platform: NodeJS.Platform): DesktopPrefs {
|
||||
const defaults = defaultPrefs(platform)
|
||||
if (!isRecord(raw)) return defaults
|
||||
const remoteHosts = pickRemoteHosts(raw['remoteHosts'])
|
||||
return {
|
||||
port: pickPort(raw['port'], defaults.port),
|
||||
lanSharing: pickBoolean(raw['lanSharing'], defaults.lanSharing),
|
||||
@@ -85,6 +134,8 @@ export function validatePrefs(raw: unknown, platform: NodeJS.Platform): DesktopP
|
||||
openAtLogin: pickBoolean(raw['openAtLogin'], defaults.openAtLogin),
|
||||
notifyOnApproval: pickBoolean(raw['notifyOnApproval'], defaults.notifyOnApproval),
|
||||
notifyOnStatusChange: pickBoolean(raw['notifyOnStatusChange'], defaults.notifyOnStatusChange),
|
||||
remoteHosts,
|
||||
selectedHostId: pickSelectedHostId(raw['selectedHostId'], remoteHosts),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
125
desktop/src/remote-hosts.ts
Normal file
125
desktop/src/remote-hosts.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* desktop/src/remote-hosts.ts — D-1: remote-host mode (PLAN_NATIVE_TUNNEL,
|
||||
* Track C-Desktop). Pure, Electron-free helpers over the RemoteHost list held in
|
||||
* DesktopPrefs, plus the URL validation that guards what the BrowserWindow is
|
||||
* allowed to load.
|
||||
*
|
||||
* A "remote host" is one of the user's own machines exposed through the mTLS
|
||||
* reverse tunnel at `https://<name>.terminal.yaojia.wang/`. The desktop app can
|
||||
* switch the main window between "This machine (local)" (the embedded loopback
|
||||
* server, which keeps running) and any configured remote host; the device client
|
||||
* certificate from the OS keychain (see main.ts / D-2) is the only auth gate.
|
||||
*
|
||||
* Every function is PURE and returns NEW objects (immutability — coding-style
|
||||
* CRITICAL rule); none mutate their arguments. No `electron` import, so this file
|
||||
* is unit-testable in plain Node.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { DesktopPrefs, RemoteHost } from './types.js'
|
||||
|
||||
/**
|
||||
* The tunnel DNS zone (PLAN_NATIVE_TUNNEL: wildcard `*.terminal.yaojia.wang`).
|
||||
* Overridable so a differently-named deployment isn't hard-blocked. A remote host
|
||||
* URL must be https (mixed-content + mTLS both require TLS) and, by default, live
|
||||
* under this zone — a boundary sanity check, not the security boundary itself
|
||||
* (that is mTLS at nginx + the will-navigate origin lock).
|
||||
*/
|
||||
export const TERMINAL_ZONE_SUFFIX = process.env.WEBTERM_TUNNEL_ZONE ?? '.terminal.yaojia.wang'
|
||||
|
||||
/** Parse an origin, returning null for anything malformed. */
|
||||
function originOf(url: string): string | null {
|
||||
try {
|
||||
return new URL(url).origin
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate + normalize a user-entered remote-host URL. Returns the canonical
|
||||
* `https://<host>/` form, or null when the input is not a usable https tunnel URL
|
||||
* (unparseable, non-https, or — by default — outside the tunnel zone). Validation
|
||||
* at the boundary: everything downstream may trust the returned string.
|
||||
*/
|
||||
export function normalizeRemoteUrl(raw: string): string | null {
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(raw.trim())
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (parsed.protocol !== 'https:') return null
|
||||
if (parsed.hostname === '') return null
|
||||
if (TERMINAL_ZONE_SUFFIX !== '' && !parsed.hostname.endsWith(TERMINAL_ZONE_SUFFIX)) {
|
||||
return null
|
||||
}
|
||||
// Canonical form: origin + trailing slash (path/query/hash stripped — the
|
||||
// frontend is served from the root, and the origin is all the lock compares).
|
||||
return `${parsed.origin}/`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a RemoteHost from user input, or null if the name is blank or the URL is
|
||||
* not a valid https tunnel URL. The id is a fresh uuid (stable across renames).
|
||||
*/
|
||||
export function makeRemoteHost(name: string, url: string): RemoteHost | null {
|
||||
const trimmedName = name.trim()
|
||||
const normalizedUrl = normalizeRemoteUrl(url)
|
||||
if (trimmedName === '' || normalizedUrl === null) return null
|
||||
return { id: randomUUID(), name: trimmedName, url: normalizedUrl }
|
||||
}
|
||||
|
||||
/** Look up a configured host by id. */
|
||||
export function findRemoteHost(
|
||||
prefs: Readonly<DesktopPrefs>,
|
||||
id: string,
|
||||
): RemoteHost | undefined {
|
||||
return prefs.remoteHosts.find((host) => host.id === id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutably append a host, de-duplicating by URL so re-adding the same machine
|
||||
* replaces the old entry (and its name) rather than piling up duplicates.
|
||||
*/
|
||||
export function addRemoteHost(
|
||||
prefs: Readonly<DesktopPrefs>,
|
||||
host: RemoteHost,
|
||||
): DesktopPrefs {
|
||||
const withoutDup = prefs.remoteHosts.filter((existing) => existing.url !== host.url)
|
||||
return { ...prefs, remoteHosts: [...withoutDup, host] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutably remove a host by id. If the removed host was selected, selection
|
||||
* falls back to local (selectedHostId → null) so we never point at a gone host.
|
||||
*/
|
||||
export function removeRemoteHost(prefs: Readonly<DesktopPrefs>, id: string): DesktopPrefs {
|
||||
const remoteHosts = prefs.remoteHosts.filter((host) => host.id !== id)
|
||||
const selectedHostId = prefs.selectedHostId === id ? null : prefs.selectedHostId
|
||||
return { ...prefs, remoteHosts, selectedHostId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutably select a host (null → local). Selecting an unknown id is a no-op —
|
||||
* the caller stays on whatever was selected, never a dangling reference.
|
||||
*/
|
||||
export function selectHost(prefs: Readonly<DesktopPrefs>, id: string | null): DesktopPrefs {
|
||||
if (id === null) return { ...prefs, selectedHostId: null }
|
||||
if (findRemoteHost(prefs, id) === undefined) return { ...prefs }
|
||||
return { ...prefs, selectedHostId: id }
|
||||
}
|
||||
|
||||
/**
|
||||
* The currently-selected remote host, or null when local is selected (either
|
||||
* selectedHostId is null, or it dangles at a host that no longer exists).
|
||||
*/
|
||||
export function selectedRemoteHost(prefs: Readonly<DesktopPrefs>): RemoteHost | null {
|
||||
if (prefs.selectedHostId === null) return null
|
||||
return findRemoteHost(prefs, prefs.selectedHostId) ?? null
|
||||
}
|
||||
|
||||
/** The origin a remote host's window should be locked to, or null if malformed. */
|
||||
export function remoteHostOrigin(host: Readonly<RemoteHost>): string | null {
|
||||
return originOf(host.url)
|
||||
}
|
||||
@@ -3,30 +3,92 @@
|
||||
* after its window is hidden (the embedded server must keep running so remote
|
||||
* devices stay connected — the vibe-coding scenario).
|
||||
*
|
||||
* The context menu also hosts the D-1 host-picker (PLAN_NATIVE_TUNNEL /
|
||||
* C-Desktop): "This machine (local)" ↔ each configured remote tunnel host, as a
|
||||
* radio group, plus add/remove. The menu is rebuilt (never mutated) whenever the
|
||||
* host list or selection changes — call buildTrayMenu() and setContextMenu().
|
||||
*
|
||||
* Icon requirement: `iconPath` MUST point at an existing image. new Tray() with a
|
||||
* missing/invalid path can throw on some platforms; that failure surfaces to the
|
||||
* caller rather than being swallowed (a broken install should be visible, not
|
||||
* silently degrade). The caller owns whether to guard startup around it.
|
||||
*/
|
||||
import { Menu, Tray } from 'electron'
|
||||
import type { MenuItemConstructorOptions } from 'electron'
|
||||
import type { RemoteHost } from './types.js'
|
||||
|
||||
const TRAY_TOOLTIP = 'Web Terminal'
|
||||
/** Label for the local (embedded server) entry — selectedHostId === null. */
|
||||
const LOCAL_HOST_LABEL = 'This machine (local)'
|
||||
|
||||
export interface TrayHandlers {
|
||||
show(): void
|
||||
quit(): void
|
||||
/** Switch the active host; null → "This machine (local)". */
|
||||
selectHost(id: string | null): void
|
||||
/** Prompt for + add a new remote host. */
|
||||
addHost(): void
|
||||
/** Remove a configured remote host by id. */
|
||||
removeHost(id: string): void
|
||||
}
|
||||
|
||||
export function createTray(iconPath: string, handlers: TrayHandlers): Tray {
|
||||
const tray = new Tray(iconPath)
|
||||
/** The host-picker state the menu renders (the local entry is implicit). */
|
||||
export interface TrayHostState {
|
||||
readonly remoteHosts: readonly RemoteHost[]
|
||||
/** Selected host id, or null → local. */
|
||||
readonly selectedHostId: string | null
|
||||
}
|
||||
|
||||
const menu = Menu.buildFromTemplate([
|
||||
{ label: 'Show Web Terminal', click: handlers.show },
|
||||
/** Build the tray context menu for the given host state (pure construction). */
|
||||
export function buildTrayMenu(state: TrayHostState, handlers: TrayHandlers): Menu {
|
||||
const localItem: MenuItemConstructorOptions = {
|
||||
label: LOCAL_HOST_LABEL,
|
||||
type: 'radio',
|
||||
checked: state.selectedHostId === null,
|
||||
click: () => handlers.selectHost(null),
|
||||
}
|
||||
|
||||
const hostItems: MenuItemConstructorOptions[] = state.remoteHosts.map((host) => ({
|
||||
label: host.name,
|
||||
type: 'radio',
|
||||
checked: state.selectedHostId === host.id,
|
||||
click: () => handlers.selectHost(host.id),
|
||||
}))
|
||||
|
||||
const selectedRemoteId =
|
||||
state.selectedHostId !== null &&
|
||||
state.remoteHosts.some((host) => host.id === state.selectedHostId)
|
||||
? state.selectedHostId
|
||||
: null
|
||||
|
||||
const template: MenuItemConstructorOptions[] = [
|
||||
{ label: 'Host', enabled: false },
|
||||
localItem,
|
||||
...hostItems,
|
||||
{ type: 'separator' },
|
||||
{ label: 'Quit', click: handlers.quit },
|
||||
])
|
||||
{ label: 'Add remote host…', click: () => handlers.addHost() },
|
||||
{
|
||||
label: 'Remove selected host',
|
||||
enabled: selectedRemoteId !== null,
|
||||
click: () => {
|
||||
if (selectedRemoteId !== null) handlers.removeHost(selectedRemoteId)
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ label: 'Show Web Terminal', click: () => handlers.show() },
|
||||
{ label: 'Quit', click: () => handlers.quit() },
|
||||
]
|
||||
|
||||
return Menu.buildFromTemplate(template)
|
||||
}
|
||||
|
||||
export function createTray(
|
||||
iconPath: string,
|
||||
state: TrayHostState,
|
||||
handlers: TrayHandlers,
|
||||
): Tray {
|
||||
const tray = new Tray(iconPath)
|
||||
tray.setToolTip(TRAY_TOOLTIP)
|
||||
tray.setContextMenu(menu)
|
||||
tray.setContextMenu(buildTrayMenu(state, handlers))
|
||||
return tray
|
||||
}
|
||||
|
||||
@@ -21,6 +21,22 @@ export type DesktopClaudeStatus = 'working' | 'waiting' | 'idle' | 'unknown' | '
|
||||
/** Approval gate kind, mirrored from backend PermissionGate (src/types.ts:101). */
|
||||
export type DesktopPermissionGate = 'tool' | 'plan'
|
||||
|
||||
/**
|
||||
* A remote web-terminal host reachable over the mTLS reverse tunnel
|
||||
* (Track C-Desktop, PLAN_NATIVE_TUNNEL §"C-Desktop / D-1"). `url` is the https
|
||||
* origin the BrowserWindow loads, e.g. `https://t1.terminal.yaojia.wang/`; the
|
||||
* device client certificate (D-2, sourced from the OS keychain) is the only auth
|
||||
* gate. `id` is a stable opaque handle used by the tray host-picker + prefs.
|
||||
*/
|
||||
export interface RemoteHost {
|
||||
/** Stable opaque id (uuid); the tray/prefs key, never shown to the user. */
|
||||
readonly id: string
|
||||
/** Human label shown in the tray host-picker. */
|
||||
readonly name: string
|
||||
/** The https origin URL to load, normalized to `https://<host>/`. */
|
||||
readonly url: string
|
||||
}
|
||||
|
||||
/**
|
||||
* User preferences, persisted as JSON in the app's userData dir (hand-rolled
|
||||
* store — matches the project's minimal-deps convention; no electron-store).
|
||||
@@ -38,6 +54,10 @@ export interface DesktopPrefs {
|
||||
readonly notifyOnApproval: boolean
|
||||
/** Fire a native notification when a session's Claude status changes. */
|
||||
readonly notifyOnStatusChange: boolean
|
||||
/** Configured remote tunnel hosts (D-1); empty on a fresh install. */
|
||||
readonly remoteHosts: readonly RemoteHost[]
|
||||
/** Selected host id, or null → "This machine (local)" (the embedded server). */
|
||||
readonly selectedHostId: string | null
|
||||
}
|
||||
|
||||
/** Handle returned by the embedded server, for lifecycle + window wiring. */
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
/**
|
||||
* desktop/src/window.ts — creates the single BrowserWindow that hosts the
|
||||
* unchanged web frontend, loaded from the embedded localhost server.
|
||||
* unchanged web frontend, loaded from either the embedded localhost server or a
|
||||
* selected remote tunnel host (D-1, PLAN_NATIVE_TUNNEL / C-Desktop).
|
||||
*
|
||||
* Hardening (DESKTOP_PLAN §8 / TECH_DOC §7): contextIsolation on, nodeIntegration
|
||||
* off, sandbox on, preload restricted to a minimal contextBridge. Because the
|
||||
* only page ever loaded is the trusted embedded http://127.0.0.1:<port> origin,
|
||||
* we deny every new-window request and block navigation to any foreign origin —
|
||||
* a defence-in-depth guard against a hijacked page trying to escape localhost.
|
||||
* off, sandbox on, preload restricted to a minimal contextBridge. We deny every
|
||||
* new-window request and block navigation to any foreign origin — a defence-in-
|
||||
* depth guard against a hijacked page trying to escape the active origin.
|
||||
*
|
||||
* D-1 note: the origin lock is DYNAMIC. The window may be pointed (by main.ts,
|
||||
* via a programmatic loadURL — which does NOT fire will-navigate) at exactly one
|
||||
* host at a time: the embedded `http://127.0.0.1:<port>` origin OR the selected
|
||||
* remote `https://<name>.terminal.yaojia.wang` origin. `getAllowedOrigin` returns
|
||||
* whichever is active NOW, so renderer-initiated navigation stays locked to that
|
||||
* single origin and everything else is still blocked.
|
||||
*/
|
||||
import { BrowserWindow } from 'electron'
|
||||
|
||||
@@ -15,7 +22,7 @@ const WINDOW_HEIGHT = 720
|
||||
const BACKGROUND_COLOR = '#0e0f13'
|
||||
|
||||
/** Parse the origin of a URL, returning null for anything malformed. */
|
||||
function originOf(url: string): string | null {
|
||||
export function originOf(url: string): string | null {
|
||||
try {
|
||||
return new URL(url).origin
|
||||
} catch {
|
||||
@@ -23,7 +30,16 @@ function originOf(url: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function createMainWindow(url: string, preloadPath: string): BrowserWindow {
|
||||
/**
|
||||
* Create the main window. `url` is the initial page to load; `getAllowedOrigin`
|
||||
* is queried live on every navigation attempt and must return the origin the
|
||||
* window is currently allowed on (or null to allow nothing / fail closed).
|
||||
*/
|
||||
export function createMainWindow(
|
||||
url: string,
|
||||
preloadPath: string,
|
||||
getAllowedOrigin: () => string | null,
|
||||
): BrowserWindow {
|
||||
const win = new BrowserWindow({
|
||||
width: WINDOW_WIDTH,
|
||||
height: WINDOW_HEIGHT,
|
||||
@@ -36,15 +52,14 @@ export function createMainWindow(url: string, preloadPath: string): BrowserWindo
|
||||
},
|
||||
})
|
||||
|
||||
const allowedOrigin = originOf(url)
|
||||
|
||||
// Never spawn child windows; the frontend has no legitimate reason to.
|
||||
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||
|
||||
// Block navigation away from the embedded localhost origin.
|
||||
// Block navigation away from the currently-active (local or remote) origin.
|
||||
win.webContents.on('will-navigate', (event, targetUrl) => {
|
||||
if (allowedOrigin === null) return
|
||||
if (originOf(targetUrl) !== allowedOrigin) {
|
||||
const allowedOrigin = getAllowedOrigin()
|
||||
// Fail closed: if we can't determine the active origin, allow nothing.
|
||||
if (allowedOrigin === null || originOf(targetUrl) !== allowedOrigin) {
|
||||
event.preventDefault()
|
||||
}
|
||||
})
|
||||
|
||||
564
docs/ANDROID_CLIENT_PLAN.md
Normal file
564
docs/ANDROID_CLIENT_PLAN.md
Normal file
@@ -0,0 +1,564 @@
|
||||
# Android Client Implementation Plan
|
||||
|
||||
> Version: v1.0 (final) · Target: functional parity with the WebTerm **iOS** client
|
||||
> Lives under a new `android/` directory at the repo root. The app does not exist yet.
|
||||
> Companion to [`ios/README.md`](../ios/README.md), [`TECH_DOC.md`](./TECH_DOC.md) (§4 protocol, §5.2 session model, §7 security), [`ARCHITECTURE.md`](./ARCHITECTURE.md) (invariants §8), and the multi-agent [`PLAN.md`](./PLAN.md).
|
||||
> Same working discipline as the rest of the repo: phased waves, file-disjoint tasks with stable IDs and `Owns:` lists for multi-agent parallelism, TDD, progress tracked in `PROGRESS_LOG.md`.
|
||||
>
|
||||
> **Framing note (per architecture review):** this plan mirrors the **iOS package *set*** and its "dependencies flow down" rule. It is **not** a file-for-file port. Two deliberate restructurings: (1) both transports (iOS keeps `URLSessionTermTransport` inside `SessionCore` and `URLSessionHTTPTransport` in `App/Wiring`) are **consolidated into `:transport-okhttp`** so `:session-core` becomes genuinely pure and runs under `runTest` virtual time; (2) a `:terminal-view` module is added for the Termux wrap. These are improvements, called out where they relocate iOS logic.
|
||||
|
||||
---
|
||||
|
||||
## 0. First principles (inherit verbatim from the server contract)
|
||||
|
||||
Four invariants govern every decision below. The first three are copied from the server contract, not reinterpreted; the fourth is the Android concurrency contract that replaces the Swift actor model.
|
||||
|
||||
1. **The client is a byte-shuttle half, not a terminal.** The app never parses ANSI/terminal semantics — the emulator library does. Output bytes are an opaque blob fed verbatim to the emulator; `input.data` is raw keyboard bytes passed through unfiltered (ARCHITECTURE invariant #1, #9).
|
||||
2. **PTY lifecycle ≠ WebSocket lifecycle.** A WS disconnect must NEVER kill the server PTY; `close()` == detach. Sessions are keyed by `sessionId`; reconnect carries it so the server replays the same ring buffer (TECH_DOC §5.2). This is what makes the Android background story (§ push) work: the phone reconnects and replays on resume; push is the wake channel while away.
|
||||
3. **Origin-header validation on the WS handshake is the one defense that cannot be skipped** (CSWSH, TECH_DOC §7). The `Origin` string must byte-match the browser's `new URL()` serialization or the server 401s the upgrade. There is exactly ONE place that derives it (`HostEndpoint`, frozen in `:wire-protocol`), stamped on the WS handshake and the guarded HTTP routes only.
|
||||
4. **Confinement contract (the actor analogue).** Engine state lives only on the engine's `limitedParallelism(1)` dispatcher; emulator/scrollback state lives only on `Dispatchers.Main.immediate`. The **only** cross-boundary calls are `engine.send()` (UI→engine) and the event fan-out (engine→UI, per-consumer `Channel`s). This is written down so a future edit cannot touch emulator state off-Main or engine state off-confinement and reintroduce the data races the actor model prevented.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal & parity scope
|
||||
|
||||
Reach functional parity with the iOS client's shipped feature set (P0 + P1). Every iOS feature is enumerated below and marked **IN** (build now), **IN·adapted** (build, but the platform forces a different mechanism), or **DEFERRED** (post-parity), with rationale. Feature-parity review surfaced five user-facing surfaces missing from the draft; all are now **IN** with owning tasks (§5): the **reconnect/EXIT banner**, the **activity-timeline sheet**, the **device-certificate management screen**, **new-session-in-cwd** (phone toolbar + exit-banner action), and the **continue-last-session cold-start UX**.
|
||||
|
||||
### P0 — daily-usable pocket cockpit
|
||||
|
||||
| iOS feature | Android status | Rationale / adaptation |
|
||||
|---|---|---|
|
||||
| Native terminal (SwiftTerm), full ANSI/true-color, scrollback replay on attach | **IN·adapted** | No SwiftTerm on JVM. Use **Termux `terminal-emulator` + `terminal-view`** (the battle-tested analogue). The single hardest port — see §6. Approach validated by throwaway spike **S1** before AW3 commits; license gated by **R2**. |
|
||||
| Pairing: QR scan / manual URL, confirm-before-network, two-step probe, secure save, §5.4 warning tiers | **IN·adapted** | CameraX + ML Kit for QR; Tink AEAD for storage. Confirm-gate + probe + tier logic port verbatim as pure Kotlin. **Cert import is NOT part of pairing** (matches iOS — pairing is URL/QR+probe only); it lives in the separate device-cert screen (A27). |
|
||||
| Session list (chooser + dashboard): status badge, telemetry chips, preview thumbnail, cols×rows, swipe-to-kill, pull-to-refresh, multi-host + host menu | **IN** | MVVM/HTTP port; thumbnails are the hard sub-piece (§6.7). Host menu (the app's only settings surface) explicitly enumerated in A20. `lastOutputAt` is already serialized server-side (`src/session/manager.ts`), so unread dots are unblocked. |
|
||||
| Reconnect / connection-status / EXIT banner | **IN** | ios `ReconnectBanner`: `connecting` / `reconnecting(attempt, countdown)` / `failed(replayTooLarge, actionable copy, no spinner)` / `exited(code, reason, spawn-failure −1)` + on-exit "new session" action. Terminal phases (failed/exited) outrank transient phases (connecting/reconnecting), mirroring `TerminalViewModel.bannerModel`. Owner: A21. |
|
||||
| Remote approve/reject: tool 2-button card + plan 3-way sheet, haptic on arrival | **IN** | Compose `Card` + `ModalBottomSheet`; the two-line epoch stale-guard is security-load-bearing and ports 1:1. |
|
||||
| Away digest on reattach | **IN** | Pure `AwayDigest` reducer ports directly. Its "expand" affordance opens the activity-timeline sheet (A28). |
|
||||
| Sessions survive background/kill/network loss; 1 live WS + HTTP poll; 1s→30s backoff; 25s ping | **IN·adapted** | Foreground-only WS (STARTED-scoped); background wake via FCM. **No background foreground-service** (see §9 R9 decision). |
|
||||
| Mobile key-bar (Esc/Esc²/⇧Tab/arrows/Enter=`\r`/^C^R^O^L^T^B^D/Tab/`/`), raw send, hardware chords | **IN** | `KeyByteMap` ports byte-for-byte; key-bar is a Compose overlay pinned above IME (`WindowInsets.ime`). |
|
||||
| New session in current cwd (`attach(null, cwd)`) | **IN** | ios TerminalScreen toolbar "在当前目录开新会话" + the exit-banner "开新会话" action, both routing to `attach(null, cwd)`. Both the phone toolbar entry and the exit-banner action are owned by A21 (the iPad pointer-menu variant is A26). |
|
||||
| Privacy shade when app inactive | **IN·adapted** | `FLAG_SECURE` + a cover Composable on `ON_STOP`; blanks the recents thumbnail. |
|
||||
| Notifications P0 (existing ntfy bridge, zero new code, default-off) | **IN (free)** | ntfy is server-side + the ntfy Android app; nothing to build. The pre-FCM stopgap. |
|
||||
|
||||
### P1 — walk-away complete
|
||||
|
||||
| iOS feature | Android status | Rationale / adaptation |
|
||||
|---|---|---|
|
||||
| Push + lock-screen Allow/Deny (single-use token, no full app open on Deny) | **IN·adapted (FCM)** | FCM has no server-defined action buttons → **data-only high-priority** messages; the client builds the notification + buttons locally. **Deny → `BroadcastReceiver`** (`goAsync()` + expedited POST). **Allow → a translucent, `excludeFromRecents` trampoline `Activity`** that hosts `BiometricPrompt`, then POSTs — a `BroadcastReceiver`/`Service` **cannot** present `BiometricPrompt` (see R1 mustFix). Requires a new server sender (§4.5). |
|
||||
| Deep links `webterminal://open?host=&join=`, UUID-whitelist, cold/warm, push taps share the router | **IN** | Custom scheme intent-filter + verified App Links; one ported `DeepLinkRouter`. |
|
||||
| Device-certificate management screen (import / rotate / remove + issuer-CN/expiry/expired-warning summary) | **IN** | ios `ClientCertScreen` + `ClientCertViewModel`, reached from the session-list host menu "设备证书". A **distinct screen**, not folded into pairing. Owner: A27. |
|
||||
| Projects: namespaced repos, favourites/collapse synced via `/prefs` (unknown-key preserving), dirty badge, detail page, "open Claude here", iPad multi-column grid | **IN** | `ProjectGrouping` must produce **byte-identical group keys** to web/iOS (shared `/prefs`). Regular-width grid + adaptive sheet detents (ios `ProjectsLayout`) included in A23. |
|
||||
| Multi-session switcher: unread dots (`lastOutputAt`), sanitized OSC titles | **IN** | `UnreadLedger` + `TitleSanitizer` port 1:1 (both pure reducers in `:session-core`). |
|
||||
| Activity timeline (`/events` drill-down) | **IN** | ios `TimelineSheet` + `TimelineViewModel` over `GET /live-sessions/:id/events`, reachable from the away-digest expand affordance. Owner: A28. |
|
||||
| Diff viewer (read-only, staged/unstaged, per-file hunks) | **IN** | staged flag sent as `'1'/'0'` (server matches `=== '1'`), like iOS — not `true/false`. |
|
||||
| Quick-reply chips + editable palette (floats while a gate is held) | **IN** | `QuickReplyPalette` CRUD ports 1:1; DataStore replaces UserDefaults. |
|
||||
| Continue-last-session cold-start | **IN** | `ColdStartPolicy` route (no host → pairing, else sessions) + "继续上次会话" re-entry banner (stack + sidebar) + `LastSessionStore` lifecycle (`SessionActivityBridge` sets on `.adopted`, clears on `.exited`). Owner: A29. |
|
||||
| Session thumbnails: off-screen render, concurrency-capped, cached `(sessionId, lastOutputAt)` | **IN·adapted** | Off-screen `TerminalEmulator` buffer + manual `Canvas` cell painter (avoids iOS's off-screen-view snapshot / `CADisplayLink` leak). |
|
||||
|
||||
### iPad-equivalent (large screen / foldable)
|
||||
|
||||
| iOS feature | Android status | Rationale / adaptation |
|
||||
|---|---|---|
|
||||
| Adaptive split (regular = sidebar+detail; compact = stack), one `LayoutPolicy` | **IN·adapted** | Material 3 Adaptive: `NavigationSuiteScaffold` + `ListDetailPaneScaffold`, keyed on `currentWindowAdaptiveInfo().windowSizeClass`. |
|
||||
| Hardware-keyboard auto-hide of key-bar | **IN·adapted** | `InputDevice`/`onKeyEvent` heuristic (no `GCKeyboard` equivalent — less reliable, accepted). |
|
||||
| Pointer context menu (open-in-cwd / kill / copy) | **IN·adapted** | `Modifier.pointerInput` detecting `PointerButton.Secondary` → anchored `DropdownMenu` (Compose `ContextMenuArea` is a Compose-Desktop construct, not stable on Android — corrected per platform review). Gated on `Expanded` + `smallestScreenWidthDp >= 600`. |
|
||||
|
||||
### Design system
|
||||
|
||||
| iOS feature | Android status |
|
||||
|---|---|
|
||||
| Frozen design system: amber-gold accent `#E3A64A` dark / `#C9892F` light, semantic status colors, 8pt scale, tabular monospace numerals, reduce-motion-gated animation/haptics, dark-first | **IN** — Compose Material 3 theme, one token file, dark-appearance-first. Terminal canvas fixed `#100F0D`/`#ECE9E3` gold caret independent of app theme (reads identically to desktop). |
|
||||
|
||||
### Explicitly DEFERRED (post-parity, called out so scope is closed)
|
||||
|
||||
- **`DELETE /live-sessions` kill-all / a "manage" page** — **iOS does NOT consume this route**; its swipe-to-kill and pointer-menu kill both use per-session `DELETE /live-sessions/:id`. Out of parity; do not build a kill-all surface iOS lacks.
|
||||
- **On-device WebView/xterm.js fidelity fallback** — only if Termux emulation fails QA (§6.8). Not built by default.
|
||||
- **System-`KeyChain` MDM cert path** — documented fallback only; wrong trust model for a per-device pinned identity.
|
||||
- **Self-signed-LAN `wss://` custom trust / pinning** — bare LAN uses `ws://` cleartext (allowlisted CIDRs); `wss` is only for the tunnel/Tailscale, which present real CA-signed certs. A `CertificatePinner`/custom `X509TrustManager` path is documented but not built (§6.9, §8).
|
||||
- **Cross-device palette sync** — matches current design (iOS/web/Android stores are independent).
|
||||
- **Voice commands / editor-open / worktree-create** — server has routes (`/open-in-editor`, `/projects/worktree`) the iOS client does not consume; out of parity scope.
|
||||
|
||||
---
|
||||
|
||||
## 2. Tech stack decision (locked)
|
||||
|
||||
| Concern | Choice | One-line rationale |
|
||||
|---|---|---|
|
||||
| Language | **Kotlin 2.x** (K2), JVM target 17 | Coroutines/Flow map cleanly onto Swift actors/AsyncStreams; pure modules stay JVM-testable. |
|
||||
| Min / target SDK | **minSdk 29** (Android 10) / **targetSdk 35** (Android 15) | 29 gives modern TLS + scoped storage without legacy branches; 35 for App Links + FGS/notification rules. |
|
||||
| UI toolkit | **Jetpack Compose** + **Material 3 Adaptive** (`androidx.compose.material3.adaptive`) | Direct analogue of SwiftUI + size-class adaptive split. |
|
||||
| Terminal renderer | **Termux `terminal-emulator` + `terminal-view`** (primary; **Apache-2.0**, consumable via **JitPack** `com.termux:termux-app:terminal-view:0.118.0` — vendoring optional for version-pinning) | Only battle-tested JVM VT100/xterm emulator with 24-bit true-color; the direct SwiftTerm analogue. **These two libs are Apache-2.0** (only `termux-shared`/app module are GPLv3 — do NOT depend on those). Actively maintained (0.119.0-beta.3, 2025-06). License de-risked → §9 R2; seam proof → S1 spike. |
|
||||
| WebSocket + HTTP | **OkHttp 4.x** (`WebSocket` + `WebSocketListener`, and REST) — one shared `OkHttpClient` | One client → mTLS `SSLSocketFactory` applies to both WS and REST uniformly; supports a custom `Origin` header on the WS handshake. |
|
||||
| JSON | **kotlinx.serialization** (`Json { ignoreUnknownKeys = true; isLenient = true }`) for **decode**; **hand-rolled `StringBuilder` codec** for **encode** | Decode tolerant; encode must be byte-identical to `JSON.stringify` (explicit `sessionId:null`, top-level `approve.mode`, JS control-char escaping) — defaults won't guarantee that (R3). |
|
||||
| Concurrency | **kotlinx-coroutines** — `Dispatchers.Default.limitedParallelism(1)` for engine confinement; **per-consumer `Channel`s** for the event bus (not a single `SharedFlow` — see R10) | Structured concurrency replaces the Swift actor; single-thread confinement replaces actor isolation. |
|
||||
| DI | **Hilt** (Dagger) | One shared `OkHttpClient` singleton, testable seams, standard Android DI. |
|
||||
| Persistence (non-secret) | **Jetpack DataStore (Preferences + Proto)** | Host list, last-session id, unread watermarks, quick-reply palette, prefs blob. |
|
||||
| Persistence (secret / device cert) | **Google Tink AEAD + AndroidKeystore master key**; **the private key is imported non-exportable into `AndroidKeyStore`** (the runtime home). Tink AEAD encrypts only the **cert chain + metadata** blob at rest. **No `.p12` blob or passphrase is persisted.** | Non-exportable, device-bound = the `…AfterFirstUnlockThisDeviceOnly` analogue. **Not `EncryptedFile`/`security-crypto`** (that Jetpack Security API is deprecated — draft inconsistency resolved). |
|
||||
| Push | **Firebase Cloud Messaging (FCM) v1**, data-only high-priority | Only way to reproduce the lock-screen two-tap Allow/Deny loop (§4.5, §9 R1). |
|
||||
| mTLS | **`AndroidKeyStore`-imported non-exportable key + a custom re-reading `X509KeyManager`** (returns the AndroidKeyStore `PrivateKey`/chain per handshake) → `SSLContext` → OkHttp `sslSocketFactory`. **`KeyStore("PKCS12")` is used transiently only to *parse the import file*, never as the runtime key home.** | App-private identity (NOT system `KeyChain`); re-reading KeyManager + `connectionPool.evictAll()` gives the iOS "no-relaunch cert rotation" behavior. One key home, no contradiction (R4/mustFix). |
|
||||
| Server-cert trust | **Default system trust** (tunnel `*.terminal.yaojia.wang` + Tailscale MagicDNS present real LE certs). Bare LAN uses **`ws://` cleartext** via a `network_security_config` CIDR allowlist. | Closes the "how do we trust the server cert" gap without a custom trust manager for the common case. |
|
||||
| QR scan | **CameraX + ML Kit Barcode** (`com.google.mlkit:barcode-scanning`) | On-device, one validator shared with manual entry. |
|
||||
| Biometric | **`androidx.biometric:BiometricPrompt`** hosted in a `FragmentActivity` (the Allow trampoline) | Gates the Allow action. |
|
||||
| Testing | **JUnit5 + kotlinx-coroutines-test (virtual time) + Turbine + MockK** (unit); **Espresso + Compose UI Test** (instrumented; Robolectric only where it faithfully emulates); **Kover** (coverage) | Mirrors iOS's injected-clock/fake-transport discipline. `AndroidKeyStore`/Tink tests are **instrumented on a real emulator/device** — Robolectric does not provide the AndroidKeyStore provider (platform review). |
|
||||
|
||||
---
|
||||
|
||||
## 3. Module / package architecture (mirrors the iOS package set)
|
||||
|
||||
Gradle multi-module. Mapping to the iOS packages, with the two additions noted in the framing block.
|
||||
|
||||
```
|
||||
iOS SPM package Android Gradle module Kind
|
||||
──────────────────────────────────────────────────────────────────────────────
|
||||
WireProtocol → :wire-protocol pure Kotlin/JVM (testable)
|
||||
(HostEndpoint here) — Origin derivation frozen in the contract
|
||||
SessionCore (reducers) → :session-core pure Kotlin/JVM (testable)
|
||||
— incl. TitleSanitizer (pure, security-relevant)
|
||||
URLSessionTermTransport→ :transport-okhttp JVM (OkHttp) — impls interfaces
|
||||
URLSessionHTTPTransport→ :transport-okhttp (relocated here from iOS App/Wiring)
|
||||
APIClient → :api-client pure Kotlin/JVM (testable)
|
||||
HostRegistry → :host-registry Kotlin + DataStore (storage behind iface)
|
||||
ClientTLS → :client-tls SPLIT: pure half (JVM) + framework half
|
||||
(SwiftTerm host view) → :terminal-view Android-framework-bound (Termux wrap)
|
||||
TestSupport → :test-support pure Kotlin/JVM (fakes)
|
||||
App/WebTerm → :app Android app (Compose, Hilt, FCM)
|
||||
```
|
||||
|
||||
### Dependency graph (arrows = "depends on"; nothing points upward)
|
||||
|
||||
```
|
||||
:app (Compose UI, ViewModels, DI, FCM, DeepLinkRouter, DesignSystem, EventBus)
|
||||
┌───────────────┬───┴────┬──────────────┬───────────────┐
|
||||
▼ ▼ ▼ ▼ ▼
|
||||
:terminal-view :session-core :api-client :host-registry :client-tls (framework half)
|
||||
│ │ │ │ │
|
||||
│ │ │ │ ▼
|
||||
│ │ │ │ :client-tls (pure half)
|
||||
└───────┬───────┴────────┴──────┬───────┴───────────────┘
|
||||
▼ ▼
|
||||
:wire-protocol ◀────── :transport-okhttp
|
||||
(models, HostEndpoint, (impls TermTransport + HttpTransport,
|
||||
TermTransport/HttpTransport, consumes ClientIdentityProvider)
|
||||
PingableTermTransport)
|
||||
▲
|
||||
└──────── :test-support (FakeTransport / FakeHttpTransport / FakeTimeSource) → test source sets only
|
||||
```
|
||||
|
||||
**Boundary note (`:terminal-view`):** `:terminal-view` depends **only on `:wire-protocol`** — `RemoteTerminalSession` consumes `ClientMessage` and raw `ByteArray`. The `SessionEvent → ByteArray` decode happens in `:app`'s `TerminalSessionController` (A21), **not** in the terminal module. There is **no** `:terminal-view → :session-core` edge (draft ambiguity resolved per architecture review — A16 depends on the wiring freeze, not on the engine module).
|
||||
|
||||
Rules (mirroring ARCHITECTURE §1 "dependencies only flow down"):
|
||||
|
||||
- **`:wire-protocol` is the frozen shared contract** — the Android analogue of `src/types.ts` + WireProtocol. It owns `ClientMessage`/`ServerMessage` sealed interfaces, `MessageCodec`, `Validation`, `WireConstants`, **`HostEndpoint` (Origin/wsURL derivation — moved here from the draft's A4 so both transport and api-client can depend on it without a same-wave sibling dependency)**, and the boundary interfaces `TermTransport` / `HttpTransport` / **`PingableTermTransport`** (a sub-interface so the pure `PingScheduler` drives a ping only through transports that support it, matching iOS's `transport as? any PingableTermTransport`). No Android imports. **New wire types are added here only** (coordination point).
|
||||
- **`:session-core` is pure** — `SessionEngine`, `ReconnectMachine`, `PingScheduler`, `GateTracker`/`GateState`, `AwayDigest`, `UnreadLedger`, `KeyByteMap`, **`TitleSanitizer`**, `SessionEvent`. Depends only on `:wire-protocol`; consumes `TermTransport` by interface — never touches OkHttp/Android; runs entirely under `runTest` virtual time. `TitleSanitizer` lives here (not behind `:terminal-view`) because it is a pure, security-relevant reducer consumed by both the terminal and the session-list/switcher UIs.
|
||||
- **`:transport-okhttp`** implements `TermTransport`/`PingableTermTransport` (WS) and `HttpTransport` (REST). It takes an injected `ClientIdentityProvider` (from `:client-tls`) so mTLS is wired without `:session-core` knowing about it.
|
||||
- **`:client-tls` is split like `:host-registry`** — a **pure Kotlin/JVM half** (PKCS#12 structural parse via `KeyStore("PKCS12")`, `X509KeyManager` alias/`getPrivateKey` selection logic, `CertificateSummary` parsing, `PairingError`/`HostClassifier` warning-tier mapping) tested at JVM speed and **in the 80% Kover gate**; and a **thin framework half** (AndroidKeyStore import for device binding + Tink AEAD storage + `connectionPool.evictAll()` on rotation) tested instrumented. This restores the iOS test posture (iOS unit-tests `PKCS12Importer`/`CertificateSummary`/`MutualTLSChallengeResponder`) and avoids leaning on Robolectric for AndroidKeyStore.
|
||||
|
||||
**Pure-Kotlin (JVM-unit-testable, the 80%-coverage targets):** `:wire-protocol`, `:session-core`, `:api-client`, the logic half of `:host-registry`, **and the pure half of `:client-tls`**.
|
||||
**Android-framework-bound (instrumented + device QA):** `:terminal-view`, the framework half of `:client-tls`, the storage half of `:host-registry`, and `:app`.
|
||||
|
||||
### `:app` internal package layout (mirrors `App/WebTerm/`)
|
||||
|
||||
```
|
||||
android/app/src/main/java/wang/yaojia/webterm/
|
||||
├── di/ Hilt modules (OkHttp singleton, engine factory, stores, per-feature boundaries)
|
||||
├── designsystem/ Theme.kt, Tokens.kt, StatusBadge, TelemetryChip, Card…
|
||||
├── screens/ PairingScreen, SessionListScreen, TerminalScreen, ProjectsScreen,
|
||||
│ ProjectDetailScreen, DiffScreen, ClientCertScreen, TimelineSheet
|
||||
├── viewmodels/ SessionListViewModel, GateViewModel, ProjectsViewModel, ProjectDetailViewModel,
|
||||
│ DiffViewModel, PairingViewModel, ClientCertViewModel, TimelineViewModel
|
||||
├── wiring/ TerminalSessionController, EventBus (per-consumer channels), SessionActivityBridge,
|
||||
│ ThumbnailPipeline, ColdStartPolicy, AppEnvironment (composition root),
|
||||
│ RetainedSessionHolder (@ActivityRetainedScoped / nav-scoped)
|
||||
├── components/ KeyBar, QuickReply, QuickReplyStore, GateBanner, PlanGateSheet, ReconnectBanner,
|
||||
│ AwayDigestView, TelemetryChips, TerminalContextMenu, ContinueLastBanner
|
||||
├── push/ FcmService, DenyBroadcastReceiver, AllowTrampolineActivity, PushRegistrar, NotificationBuilder
|
||||
└── nav/ DeepLinkRouter, NavGraph
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. The server contract (single reference for the Android side)
|
||||
|
||||
The complete surface the client uses, confirmed against `src/server.ts`. **The server is unchanged for everything except one additive P1 touch-point (§4.5).**
|
||||
|
||||
### 4.1 WebSocket — `wss://<host>/term` (path from `WireConstants.wsPath`)
|
||||
|
||||
Single connection, JSON text frames. `attach` MUST be the first frame on every (re)connect. `Origin` header stamped from `HostEndpoint.originHeader`.
|
||||
|
||||
**Client → server**
|
||||
| type | shape | notes |
|
||||
|---|---|---|
|
||||
| `attach` | `{ "type":"attach", "sessionId": <uuid-v4-lowercase> \| null, "cwd"?: "/abs/path" }` | `sessionId` key **always present** (JSON `null` for new session — server rejects a missing key). `cwd` appended only when `sessionId` is null. |
|
||||
| `input` | `{ "type":"input", "data": <string> }` | raw keyboard bytes, verbatim, never filtered. |
|
||||
| `resize` | `{ "type":"resize", "cols": <int 1..1000>, "rows": <int 1..1000> }` | own message → `ioctl(TIOCSWINSZ)`→SIGWINCH. Validate range before send (server silently drops out-of-range). |
|
||||
| `approve` | `{ "type":"approve", "mode"?: "acceptEdits" \| "default" }` | `mode` is a **TOP-LEVEL** key. No key = plain approve. |
|
||||
| `reject` | `{ "type":"reject" }` | |
|
||||
|
||||
**Server → client** (every frame untrusted; undecodable → drop + count, never crash)
|
||||
| type | shape | notes |
|
||||
|---|---|---|
|
||||
| `attached` | `{ "type":"attached", "sessionId": <uuid> }` | **always adopt** the server-issued id. Must pass v4 regex AND `UUID` parse. |
|
||||
| `output` | `{ "type":"output", "data": <string> }` | opaque ANSI/UTF-8 → emulator verbatim. Ring-buffer replay arrives as one large frame right after attach (prefixed by `ESC[0m` — do NOT strip). |
|
||||
| `exit` | `{ "type":"exit", "code": <int>, "reason"?: <string> }` | terminal; `code == -1` = spawn-failure, non-retryable, `reason` required. |
|
||||
| `status` | `{ "type":"status", "status": <ClaudeStatus>, "detail"?, "pending"?: bool, "gate"?: "plan"\|"tool" }` | cockpit side-channel. `pending` absent → false; unknown/absent `gate` while pending → treat as tool. |
|
||||
| `telemetry` | `{ "type":"telemetry", "at": <ms>, ...optional metrics }` | `at` is the only required field (missing → drop whole frame). |
|
||||
|
||||
Client-side frame max message size raised to **16 MiB** (guard with a client-side cap) to receive ring-buffer replay; oversized → non-retryable `replayTooLarge` (§9 R6). The large replay append is **chunked and off-main** (§6.2).
|
||||
|
||||
### 4.2 Read-only HTTP (GET, **no** `Origin` header)
|
||||
|
||||
| Route | Returns |
|
||||
|---|---|
|
||||
| `GET /live-sessions` | `[LiveSessionInfo]` — `{ id, cols, rows, status, cwd?, title?, telemetry?, lastOutputAt? }` (list-lossy; unknown `status` → `.unknown`; `lastOutputAt` already serialized server-side → unread dots unblocked) |
|
||||
| `GET /live-sessions/:id/preview` | `SessionPreview { id, cols, rows, data }` — `data` ~24 KiB opaque ANSI (≤256 KiB cap client-side) |
|
||||
| `GET /live-sessions/:id/events` | `[TimelineEvent]` — away-digest + activity-timeline source (consumed by A28) |
|
||||
| `GET /config/ui` | `UiConfig { allowAutoMode }` |
|
||||
| `GET /projects` | `[ProjectInfo]` (list-lossy) |
|
||||
| `GET /projects/detail?path=` | `ProjectDetail { sessions, worktrees, claudeMd, … }` |
|
||||
| `GET /projects/diff?path=&staged=1\|0` | `DiffResult` (files→hunks→lines; **`staged` must be `'1'`/`'0'`**) |
|
||||
| `GET /prefs` | `UiPrefs` (opaque JSON object — **preserve unknown top-level keys**) |
|
||||
|
||||
### 4.3 Guarded HTTP (state-changing — MUST send `Origin == HostEndpoint.originHeader` byte-equal; foreign/missing → 403)
|
||||
|
||||
| Route | Body | Returns |
|
||||
|---|---|---|
|
||||
| `DELETE /live-sessions/:id` | — | 204 (404 = already gone = success) |
|
||||
| `POST /hook/decision` | `{ sessionId, decision: "allow"\|"deny", token }` | 204 (400 malformed, **403 stale/used/mismatched token**, 429 ≤10/min). Token single-use, from push payload only — never persist/log. |
|
||||
| `PUT /prefs` | full blob ≤64 KB | 200 echo (replaces whole blob → rewrite the unknown-key-preserving object) |
|
||||
|
||||
> `DELETE /live-sessions` (kill-all) is **NOT consumed** (iOS lacks it). Do not build it.
|
||||
|
||||
### 4.4 Endpoints the iOS client does NOT use (match iOS — do not consume unless expanding scope)
|
||||
|
||||
`GET /sessions`, `POST /hook`, `POST /hook/permission`, `POST /hook/status`, `POST /open-in-editor`, `POST /projects/worktree`, `DELETE /live-sessions` (kill-all). Push web-only: `GET /push/vapid-key`, `POST/DELETE /push/subscribe` (Android uses FCM, not VAPID).
|
||||
|
||||
### 4.5 The ONE additive server change Android requires (P1 push)
|
||||
|
||||
APNs routes (`POST/DELETE /push/apns-token`) do not transfer to FCM. Add, mirroring `src/push/apns.ts` behind the existing `combineNotifyServices(...)` seam (zero new event paths — `notify(session, cls, token?)` already carries needs-input/done):
|
||||
|
||||
- **NEW `src/push/fcm.ts`** — a `NotifyService` impl: OAuth2 bearer via **`google-auth-library`** (`GoogleAuth`/`JWT` with the service-account key + scope `https://www.googleapis.com/auth/firebase.messaging`; the lib handles token mint + ~1h refresh) → `POST fcm.googleapis.com/v1/projects/{PID}/messages:send` with `{message:{token, data:{sessionId,cls,token?}, android:{priority:"high"|"normal", ttl}}}`. **Data-only** (no `notification` block). Prune on `UNREGISTERED`/`INVALID_ARGUMENT`; the lib auto-refreshes on 401. **DECIDED (R7): use `google-auth-library`** (add as a server dep) rather than hand-rolling RS256 — less crypto to own/test than `apns.ts`'s hand-rolled JWT.
|
||||
- **NEW route `POST/DELETE /push/fcm-token`** — mirrors the apns-token route but with an **FCM-token validator that is intentionally loose** (non-empty, bounded max length, `base64url` charset plus `:` — FCM token format/length is undocumented and changes; a strict `{140-170}` regex risks rejecting valid tokens). Same `Origin`-guard + rate limit.
|
||||
- **NEW env group `FCM_*`** (project id + service-account key path), all-or-disabled like `loadApnsConfig`.
|
||||
|
||||
Payload minimization carried over verbatim: `data` holds only `sessionId`, `cls`, and (gate only) `token` — never cwd/command/terminal bytes.
|
||||
|
||||
---
|
||||
|
||||
## 5. Phased task plan (waves for multi-agent parallelism)
|
||||
|
||||
Same rules as `PLAN.md` §0: each task has a stable ID, an exclusive `Owns:` list, `Depends:` (interface, not implementation), and a `Verify:` step. `:wire-protocol` types **including `HostEndpoint`** are frozen by **A2** — other tasks only read them. **A15 freezes the `:app` wiring + DI contracts** before AW4 fans out. `PROGRESS_LOG.md` is orchestrator-only. Dispatch ~3–5 file-disjoint same-wave tasks concurrently; use `isolation: worktree` for concurrent Gradle/test runs.
|
||||
|
||||
Ordering follows the mandate: **protocol+transport → session core → terminal render → UI screens → cockpit/push/mTLS** — with two throwaway spikes (**S1** renderer, **S2** FCM) run in AW1 so both Critical risks are measured with schedule buffer remaining.
|
||||
|
||||
```
|
||||
AW0 foundations (serial, blocks all)
|
||||
A1 gradle scaffold → A2 :wire-protocol (FROZEN contract incl. HostEndpoint + PingableTermTransport) → A3 :test-support fakes
|
||||
│
|
||||
▼
|
||||
AW1 leaf modules + de-risking spikes (parallel — highest fan-out)
|
||||
A4 MessageCodec + Validation (:wire-protocol, TDD golden vectors)
|
||||
A5 :session-core timing/lifecycle reducers (ReconnectMachine, PingScheduler, SessionEvent)
|
||||
A6 :session-core cockpit reducers (GateTracker/GateState, AwayDigest, UnreadLedger, KeyByteMap, TitleSanitizer)
|
||||
A7 :transport-okhttp (TermTransport/PingableTermTransport WS + HttpTransport, server-cert trust config)
|
||||
A8 :api-client routes (12 REST, tolerant decode, Origin-iff-guarded, strict query, prefs unknown-key)
|
||||
A9 :api-client pairing (probe/PairingError/HostClassifier tiers)
|
||||
A10 :client-tls pure half (PKCS12 parse, X509KeyManager alias logic, CertificateSummary, warning tiers)
|
||||
A11 :client-tls framework half (AndroidKeyStore import, Tink AEAD, connectionPool.evictAll on rotation)
|
||||
A12 :host-registry (Host, HostStore iface + DataStore, LastSessionStore)
|
||||
A13 :app designsystem (theme tokens, primitives) + Hilt skeleton
|
||||
S1 RENDERER SPIKE (throwaway): Termux emulator, no local process, canned WS bytes → one rendered screen
|
||||
S2 FCM DELIVERY SPIKE (real device): data-only high-priority delivery under Doze / OEM battery managers
|
||||
│
|
||||
▼
|
||||
AW2 session engine + wiring freeze (convergence #1)
|
||||
A14 SessionEngine lifecycle (generation-safe cancel, attach-first, backoff, ping, gate reduce, digest) [Dep A4,A5,A6,A7]
|
||||
A15 FREEZE :app wiring + DI contracts (EventBus per-consumer channels, TerminalSessionController surface,
|
||||
AppEnvironment, RetainedSessionHolder scope, ColdStartPolicy seam, per-feature Hilt boundaries, confinement invariant) [Dep A14]
|
||||
│
|
||||
▼
|
||||
AW3 terminal render (the XL piece — S1 must be green; R2 already resolved: Apache-2.0 via JitPack)
|
||||
A16 :terminal-view — RemoteTerminalSession (Termux terminal-view via JitPack 0.118.0 — or vendor for pin; NO local process; off-main append, resize math) [Dep A15; consumes :wire-protocol only]
|
||||
A17 KeyBar overlay (IME-bypass) + hardware chords + DECCKM split [Dep A16, A6 KeyByteMap]
|
||||
A18 ThumbnailPipeline (off-screen emulator → Canvas cell painter, LRU, Semaphore(2), mTLS fetch) [Dep A16, A8, A11] (M–L)
|
||||
│
|
||||
▼
|
||||
AW4 UI screens (parallel — file-disjoint by screen; A15 wiring frozen)
|
||||
A19 Pairing flow (QR CameraX/MLKit + confirm-gate + warning tiers; NO cert import) [Dep A9, A12]
|
||||
A20 Session list + dashboard + host menu (poll STARTED-scoped, rows, swipe-kill per-session, thumbnails,
|
||||
multi-host switch + checkmark, host-menu: 配对新主机 / 设备证书) [Dep A8, A18]
|
||||
A21 Terminal screen wiring + reconnect/EXIT banner + new-session-in-cwd toolbar
|
||||
(TerminalSessionController, EventBus fan-out, privacy shade, RetainedSessionHolder, lifecycle rebuild) [Dep A14, A15, A16, A17]
|
||||
A22 Gate/cockpit surfaces (GateViewModel, banner + plan sheet + telemetry chips + away digest, two-line stale-guard, haptics) [Dep A14]
|
||||
A23 Projects + ProjectGrouping + detail + "open Claude here" + iPad projects grid/sheet detents [Dep A8]
|
||||
A24 Diff viewer (flattened lazy list, staged '1'/'0', lossy decode) [Dep A8]
|
||||
A25 Quick-reply chips + editable palette (DataStore) [Dep A6 KeyByteMap]
|
||||
A26 Adaptive large-screen layout (ListDetail/NavigationSuite, pointer context menu via pointerInput) [Dep A20, A21]
|
||||
A27 ClientCertScreen (device cert import/rotate/remove + summary; host-menu reachable) [Dep A9, A10, A11, A12]
|
||||
A28 Activity-timeline sheet (TimelineSheet + TimelineViewModel over /events, wired to away-digest expand) [Dep A8, A22]
|
||||
A29 Cold-start UX (ColdStartPolicy route + continue-last-session banner stack+sidebar + LastSessionStore lifecycle via SessionActivityBridge) [Dep A12, A14, A20]
|
||||
│
|
||||
▼
|
||||
AW5 cockpit / push / mTLS integration (parallel where disjoint — pull forward alongside AW2/AW3 where deps allow)
|
||||
A30 FCM client (FcmService data-only → NotificationBuilder; Deny→BroadcastReceiver goAsync;
|
||||
Allow→translucent trampoline Activity hosting BiometricPrompt→expedited POST) [Dep A8, A12]
|
||||
A31 PushRegistrar (POST /push/fcm-token per host, POST_NOTIFICATIONS, self-heal) [Dep A8, A12]
|
||||
A32 DeepLinkRouter + custom scheme + verified App Links (assetlinks) + push-tap routing [Dep A21]
|
||||
A33 SERVER: src/push/fcm.ts + POST/DELETE /push/fcm-token + FCM_* env [server-side, no client dep] ← routed to server module owner; startable day 1
|
||||
│
|
||||
▼
|
||||
AW6 integration + acceptance
|
||||
A34 :app instrumented E2E against real `npm start` (attach→attached→output, reconnect replay, kill, hook/decision, bad-Origin reject)
|
||||
A35 One macrobenchmark/Espresso happy path: pair → attach → type → approve
|
||||
A36 Coverage gate (Kover ≥80% on pure modules incl. :client-tls pure half) + README(android/) + PROGRESS_LOG finalize + device-QA checklist
|
||||
```
|
||||
|
||||
### Task detail (Owns / Verify highlights)
|
||||
|
||||
- **A1** `Owns: android/settings.gradle.kts, android/build.gradle.kts, android/gradle/libs.versions.toml, android/**/build.gradle.kts stubs, android/README.md`. `Verify:` `./gradlew help` + empty modules assemble.
|
||||
- **A2** `Owns: :wire-protocol/**` model + interfaces (sealed `ClientMessage`/`ServerMessage`, `TermTransport`, `HttpTransport`, `PingableTermTransport`, `WireConstants`, **`HostEndpoint`**, enums). **Freezes the shared contract incl. Origin derivation.** `Verify:` compiles; consumers can import.
|
||||
- **A4** `Owns: :wire-protocol MessageCodec.kt, Validation.kt + tests`. `Verify:` golden-vector encode byte-equality vs captured web-client frames; `HostEndpoint` origin table vs `src/http/origin.ts` (in A2's tests but re-asserted here).
|
||||
- **A5** `Owns: :session-core/{ReconnectMachine,PingScheduler,SessionEvent}.kt + tests`. `Verify:` backoff ladder 1→2→4→8→16→30; no-reset-on-foreground; ping 25s/2-miss under virtual time.
|
||||
- **A6** `Owns: :session-core/{GateTracker,GateState,AwayDigest,UnreadLedger,KeyByteMap,TitleSanitizer,SessionEvent-cockpit}.kt + tests`. `Verify:` two-line epoch guard + `canDecide`; digest once-per-reconnect, all-zero suppressed; `KeyByteMap == public/keybar.ts`; `TitleSanitizer` strips bidi/zero-width + 256-char cap.
|
||||
- **A7** `Owns: :transport-okhttp/**`. `Verify:` `Origin` header reaches server (integration); clean-finish vs error preserved via `channelFlow`; server-cert trust: default trust for tunnel/Tailscale, `ws://` allowlist for LAN.
|
||||
- **A10** `Owns: :client-tls pure half (Pkcs12Parse.kt, ClientKeyManagerLogic.kt, CertificateSummary.kt, HostClassifier.kt) + tests`. `Verify:` wrong-passphrase → `UnrecoverableKeyException`; alias selection; classifier tiers (loopback/tailscale/privateLAN/public, fail-safe unknown→public). **In the Kover gate.**
|
||||
- **A11** `Owns: :client-tls framework half (AndroidKeyStoreImporter.kt, TinkCertStore.kt, IdentityRepository.kt) + androidTest`. `Verify:` (instrumented) mid-run-imported cert presented on next handshake with no relaunch; `connectionPool.evictAll()` drops old-identity pooled/resumed connections.
|
||||
- **A14** `Owns: :session-core/SessionEngine.kt + SessionEngineTest.kt`. `Verify:` FakeTransport drives attach-first ordering, adopt-server-id, connect-now-on-foreground, close≠kill, oversized-replay terminal, gate-decision epoch drop.
|
||||
- **A15** `Owns: :app/wiring/{EventBus,TerminalSessionController(interface+skeleton),AppEnvironment,RetainedSessionHolder,ColdStartPolicy}.kt + di/*Module.kt boundaries`. `Verify:` per-consumer `Channel` fan-out (slow collector never drops a gate AND never stalls output); engine hosted in `@ActivityRetainedScoped`/nav-scoped holder; contract compiles for all AW4 consumers.
|
||||
- **A16** `Owns: :terminal-view/**`. `Verify:` cols×rows from font metrics; replay never drops bytes (pendingOutput flush, chunked off-main append); DECCKM arrows still `ESC O A` under app-cursor mode; rotation re-binds surviving emulator without blank/replay.
|
||||
- **A21** `Owns: :app screens/TerminalScreen.kt, components/ReconnectBanner.kt, wiring/TerminalSessionController(impl).kt`. `Verify:` banner precedence (exited/failed outrank connecting/reconnecting); exit `-1` spawn-failure copy; `replayTooLarge` no-spinner actionable copy + "new session"; toolbar "new session in cwd" → `attach(null, cwd)`.
|
||||
- **A27** `Owns: :app screens/ClientCertScreen.kt, viewmodels/ClientCertViewModel.kt`. `Verify:` SAF `.p12` import → AndroidKeyStore (import validates before persist so a bad passphrase can't clobber a prior identity); rotate/remove; summary (issuer-CN/expiry/expired-warning); host-menu reachability.
|
||||
- **A29** `Owns: :app wiring/SessionActivityBridge.kt, components/ContinueLastBanner.kt, nav/ColdStartPolicy-wiring`. `Verify:` no-host→pairing / host→sessions route; adopted→set / exited→clear `LastSessionStore`; banner in stack + sidebar.
|
||||
- **A30** `Owns: :app push/{FcmService,NotificationBuilder,DenyBroadcastReceiver,AllowTrampolineActivity}.kt`. `Verify:` Deny via `BroadcastReceiver` (`goAsync()` + expedited POST, no UI); Allow via translucent `excludeFromRecents` Activity hosting `BiometricPrompt` then POST; token never logged/persisted; single-use token → retried-after-success POST returns 403 (idempotency guarantee).
|
||||
- **S1** `Owns: throwaway sandbox module (deleted after)`. `Verify:` a Termux `TerminalEmulator` with the JNI process removed renders one canned screen from WS-shaped bytes; sizes the `TerminalSession` replacement surface. **Gate to AW3.**
|
||||
- **S2** `Owns: throwaway push sandbox + a test Firebase project`. `Verify:` measure data-only high-priority delivery latency/loss on ≥2 real handsets (incl. one Xiaomi/Huawei/Samsung) under Doze/force-stop. **Feeds the R1 decision.**
|
||||
|
||||
---
|
||||
|
||||
## 6. Terminal rendering deep-dive (the single hardest piece)
|
||||
|
||||
No JVM port of SwiftTerm exists, so we adopt **Termux `terminal-emulator` (VT parser + `TerminalBuffer` scrollback ring) + `terminal-view` (`TerminalView` + `TerminalRenderer`)**. Do NOT hand-roll an ANSI parser. **This is a source vendor, not a Maven dependency** — there is no consumable `com.termux:terminal-emulator` artifact, which is why R2 (license) and S1 (seam spike) gate AW3.
|
||||
|
||||
### 6.1 The wiring problem and the chosen shape (this is a fork, not a subclass)
|
||||
|
||||
Termux's `TerminalView.attachSession(TerminalSession)` couples the view to a `TerminalSession` whose **constructor forks a local process over JNI** and pumps its stdout into the emulator, and whose `write()`/`getEmulator()`/`initializeEmulator()` the view drives. We have no local process. So the real work is **forking/replacing `TerminalSession`** — remove the JNI subprocess, override `write()` to send over the WS, and call `initializeEmulator()` manually — **not merely subclassing the view** (platform review correction). S1 proves this seam is mechanically achievable before A16 commits 2–3 weeks.
|
||||
|
||||
```
|
||||
class RemoteTerminalSession(
|
||||
private val engineSend: (ClientMessage) -> Unit // ordered send pump → engine.send
|
||||
) {
|
||||
val emulator = TerminalEmulator(termOutput, cols=80, rows=24, transcriptRows=10_000)
|
||||
|
||||
// termOutput: TerminalOutput whose write(bytes,off,len) forwards to engineSend(Input)
|
||||
// — carries emulator-originated bytes: DA/DSR replies, mouse, bracketed-paste wrappers.
|
||||
|
||||
fun feedRemote(bytes: ByteArray) // remote output → emulator.append (off-main, chunked); post onScreenUpdated()
|
||||
fun writeInput(data: String) // typed/keybar bytes → engineSend(Input(data))
|
||||
fun updateSize(cols, rows) // emulator.resize(cols,rows); engineSend(Resize)
|
||||
}
|
||||
```
|
||||
|
||||
The Termux `TerminalView` is subclassed only to (a) expose an `onKeyCommand`-style outlet and (b) install the key-bar / pointer menu — glyph rendering, cursor, selection, IME, scroll stay stock, exactly as iOS uses stock SwiftTerm.
|
||||
|
||||
### 6.2 Inbound: output bytes → rendered cells (off-main append)
|
||||
|
||||
```
|
||||
SessionEngine.events (engine confinement dispatcher)
|
||||
→ SessionEvent.Output(String) decoded to ByteArray (UTF-8) in TerminalSessionController (A21)
|
||||
→ RemoteTerminalSession.feedRemote(bytes)
|
||||
→ append on a CONFINED single-thread dispatcher (mirrors Termux's background reader thread),
|
||||
CHUNKED so a multi-MB ring-replay never blocks a single append
|
||||
→ post terminalView.onScreenUpdated() to Dispatchers.Main.immediate (invalidate → TerminalRenderer.render())
|
||||
```
|
||||
|
||||
**Why off-main (platform review mustFix):** `TerminalEmulator`/`TerminalBuffer` is single-writer, read by the renderer during the UI-thread draw. A synchronous multi-MB `append` on `Main.immediate` blocks the UI thread → ANR. Termux appends on a background reader and only posts `onScreenUpdated` to the UI thread; we replicate that (single-writer discipline enforced against the UI-thread renderer read).
|
||||
|
||||
**pendingOutput invariant (must replicate exactly):** output that arrives before the `TerminalView` binds (ring-buffer replay can land first) is queued in an `ArrayDeque<ByteArray>` and flushed **in submission order** the instant the view binds. The `ESC[0m` soft-reset prefix on replay is passed through, never stripped.
|
||||
|
||||
### 6.3 Outbound: input → bytes → wire (one ordered pump)
|
||||
|
||||
Three sources funnel into a **single ordered send pump** (a `Channel<ClientMessage>` consumed by one coroutine) so two fast taps never race onto the wire:
|
||||
|
||||
1. **Typing (IME):** `TerminalView` → `RemoteTerminalSession.writeInput` (stock Termux `InputConnection` path, redirected from process-write to WS-send).
|
||||
2. **Key-bar taps:** Compose buttons → `KeyByteMap.bytes(key)` → `engine.send(Input(...))` **bypassing the IME entirely** (soft keyboard never pops), mirroring the web `ws.send` bypass.
|
||||
3. **Hardware chords:** `onKeyEvent` → Esc/Ctrl-letter/⇧Tab map via `KeyByteMap`; **arrows/Enter/Tab are left to Termux's `KeyHandler`** so `cursorKeysApplication` (DECCKM) still emits `ESC O A` for vim/htop. Hardcoding `ESC [ A` would break TUIs — do not.
|
||||
|
||||
### 6.4 Resize math (cols×rows)
|
||||
|
||||
Termux `TerminalRenderer` exposes cell metrics: `mFontWidth = ceil(paint.measureText("X"))`, `mFontLineSpacing = ceil(descent - ascent) + lineSpacingAdd`. On every layout / font-size change:
|
||||
|
||||
```
|
||||
cols = max(1, floor((viewWidth - 2*hPad) / mFontWidth))
|
||||
rows = max(1, floor((viewHeight - 2*vPad) / mFontLineSpacing))
|
||||
if (cols,rows) != lastSentDims && cols>0 && rows>0:
|
||||
emulator.resize(cols, rows) // local buffer reflow
|
||||
engine.send(Resize(cols, rows)) // → server SIGWINCH
|
||||
lastSentDims = (cols, rows)
|
||||
```
|
||||
|
||||
**Latest-writer-wins sizing (v0.4):** drop non-positive pre-layout dims; remember `lastSentDims`; on `Lifecycle.State.RESUMED` (pane-show/device-switch) and window-focus, re-send `lastSentDims` via `engine.notifyForegrounded(dims)` to reclaim full-screen. Attach/detach never resize. Feed **both** lifecycle-RESUMED and the view's size-changed callback into the same connect-now+resize path (missing either breaks device-switch reclaim). Font metrics differ from SwiftTerm, so QA resize parity against web/iOS on the same device sizes (R5).
|
||||
|
||||
### 6.5 Scrollback, selection, links, titles
|
||||
|
||||
- **Scrollback:** local scroll via `TerminalBuffer` `transcriptRows` (~10 000). Authoritative history is the server's ~2 MB ring replayed on attach — the local transcript is just for scroll-up while connected.
|
||||
- **Selection/copy:** Termux `TextSelectionCursorController` + Android `ActionMode` → `ClipboardManager`.
|
||||
- **Links:** URL detection → `Intent(ACTION_VIEW)` with an **http/https-only allowlist**. OSC 52 host-clipboard writes are declined.
|
||||
- **OSC 0/2 titles:** delegate → **`TitleSanitizer` (in `:session-core`, JVM-tested)** — strip bidi/zero-width, 256-char cap. Titles are attacker-influenced; render inert. Same sanitizer feeds the session-list/switcher.
|
||||
|
||||
### 6.6 Lifecycle & config-change survival (retained holder — load-bearing for A14/A16/A21)
|
||||
|
||||
**Decision (architecture + platform mustFix):** `SessionEngine` + `RemoteTerminalSession` (emulator + scrollback) live in a **config-surviving `RetainedSessionHolder`** (`@ActivityRetainedScoped` / nav-scoped `ViewModel`, in `viewModelScope`), **not** `lifecycleScope` (which cancels at `ON_DESTROY` on every rotation). Rules:
|
||||
|
||||
- Distinguish **config change** (rotation/fold/multi-window/dark-mode — Activity recreated, Compose tree disposed, `AndroidView` factory re-run) from **real backgrounding** using `isChangingConfigurations()` / `onCleared()`.
|
||||
- On **config change:** the holder survives; **re-bind the surviving emulator to a recreated `AndroidView`** — no detach, no ~2 MB replay round-trip, no scroll-position loss. (Prefer this over `android:configChanges`, which breaks resource re-resolution and posture changes.)
|
||||
- On **real background** (`ON_STOP` that is not a config change): `engine.close()` (clean detach; server PTY survives). On real foreground return after a background stop: rebuild the stack, bump `generation`, key the `AndroidView` by `generation` so a fresh emulator replays the ring and re-fires resize. The generation bump is reserved for **genuine background detach only**, never config change.
|
||||
- **Flow-collection scoping:** all UI-facing collectors use `collectAsStateWithLifecycle`/`repeatOnLifecycle(STARTED)`; the HTTP poll is a STARTED-scoped loop (matching iOS's foreground poll); the WS is gated to STARTED; the terminal output collector runs on `Main.immediate` but is cancelled with the **retained holder**, not the composition, so a recompose never drops a frame.
|
||||
|
||||
### 6.7 Preview thumbnails (cleaner than iOS, but more code — re-baselined M→M–L)
|
||||
|
||||
Instantiate an **off-screen `TerminalEmulator`** (no `TerminalView`), `append(previewBytes)`, then rasterize `TerminalBuffer` cells directly with a `Canvas` + monospace `Paint` — port `TerminalRenderer.render` onto a `Bitmap`-backed `Canvas`, drawing fg/bg per cell from each cell's `TextStyle`. This avoids iOS's off-screen-`UIView` snapshot and the `CADisplayLink` leak, but the manual per-cell painter is **more work than iOS's cheap `SwiftTerm.feed` off-screen path** — sized M–L, on the A16 critical path. Cache `Bitmap` in an `LruCache` keyed **`(sessionId, lastOutputAt)`** (unchanged `lastOutputAt` ⇒ never re-render); cap concurrency with `Semaphore(2)` (FIFO); dedup same-key via `Map<Key, Deferred<Bitmap>>` under a `Mutex`; any failure caches a placeholder under the same key. Fetch preview bytes over the **mTLS OkHttp client** (tunnel-host previews must pass nginx).
|
||||
|
||||
### 6.8 Fallback if the primary renderer fails
|
||||
|
||||
Two tiers, chosen by decision gate. **Note the blast radius:** the tier-2 WebView path also invalidates the §6.7 off-screen-emulator thumbnail approach, so it is a partial AW3 rebuild, not a drop-in swap.
|
||||
|
||||
1. **License-safe fallback (if Termux GPL/AGPL is rejected, §9 R2):** fork **jackpal `Android-Terminal-Emulator`** (Apache-2.0). Weaker true-color/OSC coverage — accept a fidelity QA pass. Same `RemoteTerminalSession` seam applies.
|
||||
2. **Fidelity fallback (if JVM emulation diverges from web/iOS on real Claude TUIs):** render **xterm.js offscreen in a `WebView`** — byte-identical to the web client by construction; feed `output` via `evaluateJavascript("term.write(...)")`, read input via a JS bridge. Heavier (WebView per session), against the native goal — **last resort**, decided after A16 QA against live Claude Code sessions (and would require re-doing §6.7 thumbnails).
|
||||
|
||||
### 6.9 Server-cert trust (closes the platform-review gap)
|
||||
|
||||
OkHttp requires an `(SSLSocketFactory, X509TrustManager)` pair. Decision:
|
||||
|
||||
- **Tunnel (`*.terminal.yaojia.wang`) + Tailscale MagicDNS:** real CA-signed (LE) certs → **default system trust**. No custom trust manager.
|
||||
- **Bare LAN:** use **`ws://` cleartext**, permitted only for private LAN/Tailscale CIDRs via `network_security_config` (global `usesCleartextTraffic=false`).
|
||||
- **Self-signed LAN `wss`:** DEFERRED — a documented `CertificatePinner`/custom `X509TrustManager` path, not built by default.
|
||||
|
||||
---
|
||||
|
||||
## 7. Testing strategy (mirror iOS package tests; 80% coverage target)
|
||||
|
||||
### Unit (JVM, no device) — the 80% target modules
|
||||
|
||||
JUnit5 + `kotlinx-coroutines-test` (virtual time) + Turbine + MockK. Measured by **Kover**; gate ≥80% on `:wire-protocol`, `:session-core`, `:api-client`, `:host-registry` (logic), **and `:client-tls` pure half**.
|
||||
|
||||
- **`:wire-protocol`** — `MessageCodec` **golden-vector** byte-equality vs captured web-client frames (explicit `sessionId:null`, top-level `approve.mode`, JS control-char escaping, lowercase UUIDs); tolerant decode (bad JSON/unknown type/missing-required → null; wrong-typed optional → absent; `telemetry.at` mandatory); `Validation` (v4 regex; resize 1..1000; absolute cwd); `HostEndpoint` origin/wsURL vs `src/http/origin.ts` (default-port omission, lowercase, IPv6 brackets).
|
||||
- **`:session-core`** — `ReconnectMachine` ladder + no-reset-on-foreground; `PingScheduler` 25s/2-miss (drives only `PingableTermTransport`); `GateTracker` rising-edge epoch + two-line stale-guard + `canDecide`; `AwayDigest` once-per-reconnect; `UnreadLedger` watermark/cap/tie-break; **`TitleSanitizer` bidi/zero-width strip + 256 cap**; `SessionEngine` (FakeTransport + FakeTimeSource): attach-first ordering, adopt-server-id, connect-now-on-foreground, close≠kill, oversized-replay terminal, gate-decision epoch drop.
|
||||
- **`:api-client`** — FakeHttpTransport: **Origin-iff-guarded** (3 guarded routes carry byte-equal Origin; RO GETs must not); tolerant list decode (drop-one-keep-rest, unknown status→unknown, non-array→invalidResponseBody); **strict RFC3986 query encoding**; UiPrefs unknown-key round-trip (Int-vs-Double preserved); loose FCM-token validator; pairing probe leaves no orphan; `PairingError`/`HostClassifier` tiers (fail-safe unknown→public).
|
||||
- **`:client-tls` pure half** — PKCS12 parse happy + wrong-passphrase (`UnrecoverableKeyException`) + corrupt (`EOFException`); `X509KeyManager` alias/`getPrivateKey` selection; `CertificateSummary` fields; classifier warning tiers.
|
||||
|
||||
### Instrumented (androidTest — device/emulator; Robolectric only where faithful)
|
||||
|
||||
- **`:terminal-view`** — resize→cols/rows for a known font size; keybar tap → exact bytes on a fake send pump; DECCKM arrow form; pendingOutput flush ordering; **off-main chunked append does not stall UI**; rotation re-binds surviving emulator (no blank); thumbnail rasterization non-null.
|
||||
- **`:client-tls` framework half (real AndroidKeyStore — NOT Robolectric)** — import happy + no-identity mapping; **re-reading `X509KeyManager` presents a mid-run-imported cert on the next handshake with no relaunch**; **`connectionPool.evictAll()` — pooled/resumed connections do not reuse the old identity**; import validates before persist (bad passphrase can't clobber prior identity).
|
||||
- **`:host-registry`** — DataStore read-modify-write list; secret split (cert never in plain store).
|
||||
- **`:app`** — FCM data-only → `NotificationBuilder` Allow/Deny actions; **Deny `BroadcastReceiver` POSTs `/hook/decision` (token never logged)**; **Allow trampoline Activity hosts a mock `BiometricPrompt` then POSTs**; DeepLinkRouter v4-whitelist; Compose UI tests for gate banner/plan sheet/**reconnect banner precedence**/session-list rows/**continue-last banner**.
|
||||
|
||||
### E2E / integration (mirror `ios/IntegrationTests`, ~10 tests)
|
||||
|
||||
Instrumented suite against a **real `npm start` Node server**: `attach→attached→output` timing; reconnect replays ring buffer (F5/F6); spawn-failure → `exit(-1)`; kill via `DELETE /live-sessions/:id`; `hook/decision` resolves a held gate; bad Origin rejected (F9). Plus **one macrobenchmark/Espresso happy-path**: pair → attach → type → approve.
|
||||
|
||||
### Deferred to real device (matches iOS)
|
||||
|
||||
Gesture/IME/CJK composition, camera-QR, haptics, lock-screen Allow/Deny, FCM end-to-end (real Firebase + device), OEM battery-manager + **force-stop** delivery — a written manual checklist in A36 (informed by the S2 spike), not automated.
|
||||
|
||||
---
|
||||
|
||||
## 8. Security checklist (non-negotiable)
|
||||
|
||||
- [ ] **Origin header parity** — one `HostEndpoint`-derived `Origin` (frozen in `:wire-protocol`), byte-equal to `new URL()`; stamped on the WS handshake and the **3 guarded HTTP routes** only. The single non-skippable CSWSH defense (TECH_DOC §7). Verified vs `src/http/origin.ts`.
|
||||
- [ ] **mTLS trust model (one key home)** — private key **imported non-exportable into `AndroidKeyStore`**; a **custom re-reading `X509KeyManager`** returns the AndroidKeyStore `PrivateKey`/chain per handshake; `KeyStore("PKCS12")` is import-parse-only (transient), never the runtime home; **NOT** system `KeyChain`. Client cert presented on both WS and REST via one shared `OkHttpClient`. On rotation, `connectionPool.evictAll()` so pooled/resumed connections drop the old identity.
|
||||
- [ ] **Cert storage** — private key non-exportable/device-bound; only the **cert chain + metadata** blob is stored, encrypted with **Tink AEAD + AndroidKeystore master key**; app-private, uninstall-wiped, never in backups/cloud. **No `.p12` blob or passphrase persisted.** Import validates before persisting so a bad passphrase can't clobber a prior identity.
|
||||
- [ ] **No system-wide credential leakage** — no ephemeral/disk HTTP cache (`OkHttpClient.cache(null)`) — preview/diff bodies can contain terminal secrets.
|
||||
- [ ] **Cleartext posture** — `usesCleartextTraffic=false` globally; a `network_security_config` allowlist permits `ws://` only for LAN/Tailscale CIDRs; `wss` (tunnel/Tailscale) uses default system trust; §5.4 warning tiers reproduced in-app (public-host blocking needs explicit acknowledge; tunnel host = cert-gated, warning softened, TLS failure re-mapped to "client cert invalid/revoked").
|
||||
- [ ] **Tunnel-host cert gate** — `runProbe` refuses to probe `*.terminal.yaojia.wang` unless a device cert is installed; both confirm and retry funnel through the one choke point (retry can't bypass).
|
||||
- [ ] **Push token discipline** — single-use `/hook/decision` capability token validated as v4 UUID, passed only to the POST, **never persisted, never logged, never in fallback copy**; payload minimized (`sessionId`/`cls`/`token` only).
|
||||
- [ ] **Notification-action trust split** — **Deny → `BroadcastReceiver`** (`goAsync()`, no UI, expedited POST). **Allow → a translucent, `excludeFromRecents` trampoline `Activity`** that hosts `BiometricPrompt` then POSTs — a `BroadcastReceiver`/`Service` cannot present `BiometricPrompt` and `goAsync()`'s ~10s budget would blow. (Corrects the draft's "no app open" claim for Allow.) All `PendingIntent`s `FLAG_IMMUTABLE` (API 31+). Deny is auth-free (fail-safe). The decision POST is **expedited** (not deferrable `WorkManager`); the single-use token makes a retried-after-success POST return 403 → idempotent retry-safety.
|
||||
- [ ] **App Links** — `assetlinks.json` over HTTPS, no redirect, correct **release** signing-cert SHA-256; deep-link fields UUID-whitelisted, invalid → ignore+count (never partially applied).
|
||||
- [ ] **Untrusted server strings** — titles/paths/branches/diff lines/telemetry/gate-detail rendered as **inert Compose `Text`**, no Markdown/`linkify`/`AnnotatedString` autolink; PR badge is a tappable link only when the URL parses `https`. OSC titles routed through `TitleSanitizer`.
|
||||
- [ ] **No secrets in code** — FCM service-account JSON **server-side only**; `google-services.json` (client config, not a secret) is fine; no API keys hardcoded; all config via env/DI.
|
||||
- [ ] **`POST_NOTIFICATIONS`** requested with rationale (API 33+); **`FLAG_SECURE` + privacy shade** so the recents snapshot never leaks terminal bytes. **No `NEARBY_WIFI_DEVICES`** — Android has no iOS-style local-network permission prompt; plain WS to a LAN IP needs no runtime permission (R12).
|
||||
|
||||
---
|
||||
|
||||
## 9. Risks & open questions (ranked)
|
||||
|
||||
| # | Risk / question | Severity | Mitigation / decision |
|
||||
|---|---|---|---|
|
||||
| **R1** | **FCM has no server-defined action buttons**; data-only high-priority is the only faithful Allow/Deny reproduction, and delivery is best-effort (Doze; OEM battery killers on Xiaomi/Huawei/Samsung; **a force-stopped/swiped-away app may get no FCM until next launch**; normal-priority "done" is Doze-batched). | **Accepted (was Critical)** | **DECIDED (user): accept best-effort background delivery** — documented as a known limitation; no background FGS (R9). Gate = `priority:"high"` data-only, notification built on-device; "done" = `normal`. S2 spike still runs to quantify real-device loss and inform user-facing copy ("delivery not guaranteed while backgrounded"). |
|
||||
| **R2** | **Termux `terminal-emulator`/`terminal-view` licensing.** ~~GPL, must vendor~~ **CORRECTED (independently verified 2026):** these two libraries are **Apache-2.0** (per Termux `LICENSE.md` — they descend from jackpal's Apache-2.0 emulator; only `termux-shared` + the app module are GPLv3) and are **consumable via JitPack** (`com.termux:termux-app:terminal-view:0.118.0`), so no copyleft on the app and vendoring is optional. | **Low (was Critical)** | **No longer a blocking legal decision.** Mitigation = **scope the dependency to `terminal-view`(+`terminal-emulator`) only; never pull `termux-shared`/app module** (GPLv3). Add `com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava` to dodge a Guava conflict. When vendoring for version-pinning, spot-check per-file Apache headers. jackpal fork is a needless fallback (Apache-2.0 but archived 2022, VT-100-only). Sources: Termux `LICENSE.md`, Termux-Libraries wiki. |
|
||||
| **R3** | **Byte-exact JSON encode parity** vs `JSON.stringify`. | High | Hand-rolled `StringBuilder` codec + golden-vector tests (A4). Non-negotiable gate. |
|
||||
| **R4** | **OkHttp binds `SSLSocketFactory` at build time** + pooled/resumed connections keep the old identity → breaks "no-relaunch" rotation. | High | Custom re-reading `X509KeyManager` over the AndroidKeyStore identity + `connectionPool.evictAll()` on rotation. Instrumented test proves next-handshake cert + no old-identity reuse (A11). |
|
||||
| **R5** | **cols×rows drift** — Android font metrics ≠ SwiftTerm. | High | Derive metrics from `TerminalRenderer` exactly; unit-test the formula; QA resize parity vs web/iOS. |
|
||||
| **R6** | **Oversized-replay classification** — OkHttp has no default max message size. | Medium | Client-side 16 MiB frame cap → non-retryable `replayTooLarge`; spike a real large-scrollback session. |
|
||||
| **R7** | **Server OAuth2 for FCM** — RS256 JWT + refresh vs the zero-new-dep rule. | Resolved | **DECIDED (user): use `google-auth-library`.** Add it as a server dependency (a deliberate exception to the zero-new-dep ethos) so A33 doesn't hand-roll RS256/JWKS. |
|
||||
| **R8** | **Key-bar above IME** — no `inputAccessoryView`. | Medium | Compose overlay via `WindowInsets.ime`/`imePadding()`; QA multiple IMEs; taps never route through `BasicTextField`. |
|
||||
| **R9** | **Background WS vs Android 12+ FGS-background-start block and the Android 14 `dataSync` / Android 15 `mediaProcessing` 6h-per-24h cap** (the draft's "6h FGS cap" was imprecise). No clean `foregroundServiceType` for "hold a WebSocket" (`dataSync` is capped; `specialUse` needs Play review; `connectedDevice` needs a companion). | Medium | **Decision: drop the background foreground-service entirely.** WS is foreground-only (STARTED-scoped); **FCM high-priority = background wake → foreground reconnect + replay**; server keeps the PTY alive. Avoids `specialUse` Play review and any `startForeground()` crash. |
|
||||
| **R10** | **EventBus fan-out** — a single `SharedFlow` with `onBufferOverflow=SUSPEND` back-pressures the engine pump (head-of-line-blocks OUTPUT for every consumer); DROP could lose a gate. | Medium | **Per-consumer buffered `Channel`s** (fan-out coroutine offers to each) — the true analogue of iOS's per-subscriber `AsyncStream`; a slow collector neither drops a gate nor stalls output. Tested in A15. |
|
||||
| **R11** | **`/prefs` unknown-key clobber**. | Medium | Store the raw `JsonObject`, rewrite only known keys; never PUT if `/prefs` never loaded; round-trip test (unknown key + Int-vs-Double). |
|
||||
| **R12** | **Error-taxonomy mismatch** — Android exceptions don't map 1:1 to NSURLError/POSIX; **no local-network permission prompt on Android** (iOS `localNetworkDenied`/`NEARBY_WIFI_DEVICES` logic is dead weight). | Low | Re-derive `PairingError.classify` from `ConnectException`/`SSLHandshakeException`/`UnknownHostException`; **drop `NEARBY_WIFI_DEVICES` and `localNetworkDenied`** (they gate Wi-Fi scanning/Aware, not sockets). |
|
||||
| **R13** | **Tablet detection fuzziness** — no clean iPad boolean. | Low | `currentWindowAdaptiveInfo().windowSizeClass`; pointer menu = `Modifier.pointerInput` secondary-click → `DropdownMenu`, gated on `Expanded` + `smallestScreenWidthDp>=600`. |
|
||||
|
||||
---
|
||||
|
||||
## 10. Effort estimate & suggested build order
|
||||
|
||||
Sizes carry over the iOS subsystem estimates, re-scaled for the port (S≈1–2d, M≈3–5d, L≈1–1.5wk, XL≈2–3wk, one engineer-equiv; parallelizable within a wave). Calendar re-baselined per risk review.
|
||||
|
||||
| Wave | Tasks | Effort | Notes |
|
||||
|---|---|---|---|
|
||||
| **AW0** foundations | A1–A3 | **S–M** (2–4d) | Serial; unblocks everything. `HostEndpoint` frozen here. |
|
||||
| **AW1** leaf modules + spikes | A4–A13 + S1 + S2 | **XL total, parallel** (~1.5wk wall) | Highest fan-out; 4–5 agents. A10+A11 (client-tls split) is the security-critical long pole. **S1/S2 de-risk the two Criticals now.** |
|
||||
| **AW2** engine + wiring freeze | A14 (L), A15 (M) | **L** (~1.5wk) | Actor→structured-concurrency lifecycle + retained holder is the correctness risk; **A15 must freeze the wiring/DI seam before AW4.** |
|
||||
| **AW3** terminal render | A16 (XL), A17 (M), A18 (M–L) | **XL** (~2.5–3wk) | The dominant uncertainty; **S1 green first** (R2 license already resolved — Apache-2.0/JitPack). A17/A18 parallel after A16's seam lands. |
|
||||
| **AW4** UI screens | A19–A29 | **XL total, highly parallel** (~2–2.5wk wall) | File-disjoint by screen → 4–5 agents. A20+A18 thumbnail, A23 grouping-parity, A21 banner/lifecycle are the risky ones. |
|
||||
| **AW5** push/mTLS/links | A30 (L), A31 (S), A32 (M), A33 server (M) | **L–XL, mostly parallel** (~1.5wk) | A33 (server FCM) startable day 1; A30/A31 pull forward alongside AW2/AW3 (depend only on AW1). |
|
||||
| **AW6** integration/acceptance | A34–A36 | **M** (~1wk) | E2E vs real Node server + coverage gate + device-QA checklist. |
|
||||
|
||||
**Total:** ≈ **11–14 engineer-weeks** solo; ≈ **7–10 calendar weeks** with 3–5 parallel agents per wave. The serial spine AW0 → A14/A15 → A16 → A21 → AW6 floors wall-clock at ~7 weeks even with infinite agents — the A14→A16 chain cannot be parallel-compressed, so the draft's "5–7 weeks" low end is not achievable.
|
||||
|
||||
### Suggested build order (critical path bolded)
|
||||
|
||||
1. **AW0 A1→A2→A3** (serial). Simultaneously kick off **A33** (server FCM — independent) and stand up **S1** and **S2** sandboxes.
|
||||
2. **AW1** in parallel: **A4** (codec golden vectors) and **A7** (transport) are on the critical path; A5, A6, A8, A9, A10, A11, A12, A13 fill the batch. **Run S1 (renderer seam) and S2 (FCM delivery) here.**
|
||||
3. **All three open questions are now DECIDED** — R2 (Termux = Apache-2.0/JitPack), R7 (use `google-auth-library`), R1 (accept best-effort FCM delivery). Nothing blocks AW3 or AW5. S2 still runs to quantify FCM loss and drive user-facing "not guaranteed while backgrounded" copy.
|
||||
4. **AW2 A14** (engine) then **A15** (wiring/DI freeze) — everything terminal/UI waits on the `SessionEvent` contract and the frozen wiring seam.
|
||||
5. **AW3 A16** (renderer) — the XL spike; get it green against a live Claude Code session (decide R5 cols×rows fidelity) before A17/A18. Give it explicit schedule buffer. (No R2 license fallback needed — Termux terminal libs are Apache-2.0.)
|
||||
6. **AW4** — fan out the screens: land **A21** (terminal wiring + reconnect banner) + **A22** (gate) first (full engine→UI path), then discovery/projects/diff/quick-reply/timeline/cold-start/cert, then A26 large-screen last. **Start A19/A23/A24/A25 during AW3** (they depend only on AW1 modules) to reclaim wall-clock lost to the serial A14→A16 spine.
|
||||
7. **AW5** — pull A30/A31 forward alongside AW3; A32 deep links after A21; needs A33 on a test Firebase project for end-to-end.
|
||||
8. **AW6** — integration, coverage gate, README, deferred device-QA checklist.
|
||||
|
||||
---
|
||||
|
||||
**Key file references (authoritative context):** `docs/TECH_DOC.md` §4/§5.2/§7, `docs/ARCHITECTURE.md` §1/§3.2/§8, `ios/README.md` + `ios/Packages/*`, and the confirmed server route surface in `src/server.ts` (`src/http/*`, `src/push/*`). The frozen wire contract lives in `src/types.ts` / `src/protocol.ts`, mirrored by the Android `:wire-protocol` module (A2).
|
||||
|
||||
---
|
||||
|
||||
## 11. Review resolutions
|
||||
|
||||
### Architecture review (7.5) — what changed
|
||||
- **mustFix (Allow ≠ BroadcastReceiver):** Allow now routes through a translucent `excludeFromRecents` trampoline `Activity` hosting `BiometricPrompt`; Deny stays a `BroadcastReceiver`. §5 A30, §8, §1 P1 updated.
|
||||
- **mustFix (engine ownership / config-change survival):** committed to a `RetainedSessionHolder` (`viewModelScope`/`@ActivityRetainedScoped`), distinguishing config change from real background via `isChangingConfigurations()`/`onCleared()`, re-binding the surviving emulator on rotation; generation-bump reserved for genuine background. §6.6 rewritten; A14/A15/A16/A21 gated on it.
|
||||
- **Adopted:** `TitleSanitizer` → `:session-core` (A6); `:client-tls` split into pure + framework halves (A10/A11, pure half in the Kover gate); `:terminal-view` boundary pinned to `:wire-protocol` only (decode in A21); **per-consumer `Channel` EventBus** (R10, A15); expedited decision POST not `WorkManager` (A30, idempotency via single-use token); confinement contract written as invariant #4; "1:1 mirror" reframed; Flow scoping (`repeatOnLifecycle(STARTED)`) specified (§6.6).
|
||||
|
||||
### Feature-parity review (7.5) — gaps closed
|
||||
- **Reconnect/EXIT banner** → A21 (with precedence + spawn-failure/`replayTooLarge` copy + "new session" action).
|
||||
- **Activity-timeline sheet** → A28 (wired to away-digest expand).
|
||||
- **Device-cert management screen** → A27 (split out of pairing; host-menu reachable).
|
||||
- **New-session-in-cwd** (phone toolbar + exit-banner) → A21.
|
||||
- **Continue-last-session cold-start** (`ColdStartPolicy` + banner + `LastSessionStore` lifecycle) → A29.
|
||||
- **Adopted:** `TitleSanitizer` named in A6; `DELETE /live-sessions` kill-all marked out-of-parity (§1 DEFERRED, §4.3/§4.4); host-menu contents enumerated (A20); iPad Projects grid assigned (A23); `lastOutputAt` noted as already server-serialized (A20 unblocked).
|
||||
|
||||
### Kotlin/Compose platform review (7) — corrections applied
|
||||
- **mustFix:** Allow-in-receiver contradiction resolved (as above); **single mTLS key home** chosen (AndroidKeyStore-imported + custom X509KeyManager; PKCS12 KeyStore is import-parse-only; no persisted `.p12`); **Termux is a source fork/vendor** (S1 spike + R2), not a subclass; **FGS dropped** — FCM-wake→foreground reconnect (R9), "6h cap" corrected to `dataSync`/`mediaProcessing` 6h-per-24h.
|
||||
- **Adopted:** off-main chunked `emulator.append` (§6.2); `connectionPool.evictAll()` on rotation (§8, A11); server-cert trust specified (§6.9); per-consumer channels (R10); `NEARBY_WIFI_DEVICES`/`localNetworkDenied` removed (R12); `ContextMenuArea` → `pointerInput` `DropdownMenu` (R13, A26); `EncryptedFile` inconsistency removed (Tink only, §2); loose FCM-token validator (§4.5); force-stop FCM caveat (R1).
|
||||
|
||||
### Risk & sequencing review (7) — sequencing fixes
|
||||
- **mustFix:** `HostEndpoint`/Origin moved to the AW0 freeze (A2); an explicit **wiring+DI freeze (A15)** inserted at the top of the AW2→AW4 boundary; a **throwaway renderer spike (S1)** added to AW1 before AW3 commits.
|
||||
- **Adopted:** **S2** FCM delivery spike in AW1; A30/A31 pulled forward; A5/A7 split into finer tasks (A5/A6 and A8/A9); A18 thumbnail re-baselined M→M–L; calendar re-baselined to ~7–10 weeks; A19/A23/A24/A25 started during AW3.
|
||||
|
||||
### Post-review correction (independent verification)
|
||||
- **R2 downgraded Critical→Low.** A dedicated web-verification pass (after the two explorer agents for the terminal stack failed their structured-output step) established that Termux's `terminal-view` + `terminal-emulator` are **Apache-2.0** (not GPL) and **available on JitPack** (not vendor-only). The plan's worst-case "choose GPL / fork / open-source" framing was wrong; the renderer decision is de-risked and AW3 is no longer gated on a license call. §2, §9 R2, §5, §10, and open-question #1 updated accordingly. Only guardrail: keep the dependency scoped to those two libs (never `termux-shared`/app module, which are GPLv3).
|
||||
|
||||
### Decisions locked (no open questions remain)
|
||||
1. ~~**R2 (BLOCKS A16 / AW3):** Termux is GPL and must be vendored.~~ **RESOLVED (verified 2026).** `terminal-view`/`terminal-emulator` are **Apache-2.0** on **JitPack**; no copyleft as long as the dependency is scoped to those two libs (not `termux-shared`/app). AW3/S1 not gated on a license call. Residual technical-only choice: JitPack-consume vs vendor-pin → default JitPack `0.118.0`.
|
||||
2. **R1 (push reliability): ✅ ACCEPTED (user).** Best-effort FCM background delivery is accepted as a documented limitation (no background FGS). S2 spike still runs to quantify loss and drive user-facing "delivery not guaranteed while backgrounded" copy.
|
||||
3. **R7 (FCM OAuth2): ✅ DECIDED (user) — use `google-auth-library`.** Added as a server dependency (deliberate exception to zero-new-dep); A33 does not hand-roll RS256.
|
||||
341
docs/PLAN_NATIVE_TUNNEL.md
Normal file
341
docs/PLAN_NATIVE_TUNNEL.md
Normal file
@@ -0,0 +1,341 @@
|
||||
# Native mTLS Reverse-Tunnel — Master Development Plan
|
||||
|
||||
> **Destination:** `docs/PLAN_NATIVE_TUNNEL.md`
|
||||
> **Status:** planning (synthesized from three reviewed track-plans: VPS, Server, Clients)
|
||||
> **Deployed baseline:** VPS `8.138.1.192` (Ubuntu 24.04, nginx 1.24, `:443` shared via `ssl_preread` stream — existing `*.term.yaojia.wang`→relay:8443 and `default`→xray:10443 preserved verbatim). Wildcard DNS `*.terminal.yaojia.wang → 8.138.1.192` is confirmed resolving.
|
||||
|
||||
## 1. Overview
|
||||
|
||||
We are building a **multi-tenant reverse tunnel** so native iOS / Android / desktop clients can reach a **base web-terminal server** running on the user's *local* machine(s) from the public internet, relayed through the user's VPS. The base app speaks its own WS protocol (`attach`/`attached`, `/live-sessions`) — **not** the E2E relay — so the already-deployed browser relay cannot serve these clients; this is a parallel, independent ingress.
|
||||
|
||||
The tool is **frp**. Each local host runs `frpc`, which dials the VPS on `:443` and exposes its loopback base app (`127.0.0.1:3000`) as `<name>.terminal.yaojia.wang`. On the VPS everything multiplexes onto the single open port `:443`, SNI-routed by the existing nginx `stream` (`ssl_preread`, TLS passthrough), coexisting with the relay and xray-Reality.
|
||||
|
||||
**The base app has no login/auth of its own.** Therefore **mTLS client-certificate authentication is the one and only security gate**, and it must be airtight. Two SNI routes are added:
|
||||
|
||||
- **`frp.terminal.yaojia.wang` → `frps` control `:7000`** (TLS passthrough). Guarded by frps **control-channel mTLS** (a per-host *frp-client CA*) **plus** a token — a leaked token alone must not let an attacker register a subdomain.
|
||||
- **`*.terminal.yaojia.wang` → nginx `:8470`** — a loopback TLS-terminating server that presents a publicly-trusted **LE wildcard cert**, enforces **`ssl_verify_client on`** against a **device CA** (with a CRL), then reverse-proxies (WS-upgrade) to the frps vhost `:7080` → `frpc` → local base app.
|
||||
|
||||
```
|
||||
Public internet (only :443 open in Aliyun SG)
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────────┐
|
||||
│ nginx stream :443 ssl_preread │ (TLS passthrough, no term)
|
||||
│ map $ssl_preread_server_name → │
|
||||
└───────────────────────────────────┘
|
||||
┌───────────────┬───────────────────┬────────────────┬─────────────┐
|
||||
│ frp.terminal │ *.terminal │ *.term │ default │
|
||||
│ .yaojia.wang │ .yaojia.wang │ .yaojia.wang │ (fallthru) │
|
||||
▼ ▼ ▼ ▼
|
||||
127.0.0.1:7000 127.0.0.1:8470 127.0.0.1:8443 127.0.0.1:10443
|
||||
┌──────────┐ ┌───────────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ frps │ │ nginx :8470 │ │ E2E relay│ │ xray │ ← EXISTING,
|
||||
│ control │ │ TLS-term + │ │ (browser)│ │ Reality │ preserved
|
||||
│ mTLS+tok │ │ mTLS device-CA│ └──────────┘ └──────────┘ verbatim
|
||||
│ :7000 │ │ + CRL │
|
||||
└────┬─────┘ │ proxy→ :7080 │
|
||||
control │ └──────┬────────┘
|
||||
channel │ │ vhost HTTP (WS upgrade)
|
||||
│ ▼
|
||||
│ ┌──────────────┐
|
||||
└──────────►│ frps vhost │ routes by Host → subdomain
|
||||
│ :7080 │
|
||||
└──────┬───────┘
|
||||
│ frp tunnel (frpc dials OUT :443, frp-TLS)
|
||||
▼
|
||||
══════════════ user's LOCAL machine ══════════════
|
||||
┌──────────────┐
|
||||
│ frpc │ subdomain=<name>, localPort=3000
|
||||
└──────┬───────┘
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ base app │ BIND_HOST=127.0.0.1 :3000 (loopback ONLY)
|
||||
│ ALLOWED_ORIGINS = https://<name>.terminal.yaojia.wang
|
||||
└──────────────┘
|
||||
▲
|
||||
native client ────────┘ iOS / Android / desktop present a DEVICE CERT
|
||||
https://<name>.terminal.yaojia.wang (mTLS) → speak base WS /term + /live-sessions
|
||||
```
|
||||
|
||||
**Trust model (declare in writing before shipping — this changes what "airtight" means):**
|
||||
|
||||
- **Model A — single-owner fleet** (matches the GOAL: "the user's LOCAL machine(s)"). Every device cert and host belongs to one operator. A single shared device-CA = "the closed set of my devices"; cross-host reach between your own machines is not a violation. **This is the v1 target.**
|
||||
- **Model B — true multi-tenant** (distrusting third parties enroll). A single shared device-CA gives **zero cross-tenant isolation** (any valid cert reaches any subdomain). Model B is **out of scope for v1** and must not ship on the shared-CA design; §5-R3 records the mitigation path.
|
||||
|
||||
---
|
||||
|
||||
## 2. The three tracks
|
||||
|
||||
### Track V — VPS (`8.138.1.192`)
|
||||
|
||||
Coexistence rule for every phase: never retype the existing `map` lines; merge additively; deploy only via `nginx -t && systemctl reload nginx`; re-run the coexistence oracle after each phase.
|
||||
|
||||
**V0 — Preflight & snapshot (~20 min)**
|
||||
- [ ] `cp /etc/nginx/stream-relay.conf{,.bak.$(date +%s)}`; `nginx -T > /root/nginx-dump.pre.txt`; `ss -ltnp | sort > /root/ports.pre.txt`.
|
||||
- [ ] Capture the **exact** current `map` body (`hostnames; *.term.yaojia.wang→8443; default→10443`) and `nginx -v` (expect 1.24 → no standalone `http2` directive is legal).
|
||||
- [ ] `dig +short {foo,frp}.terminal.yaojia.wang` → `8.138.1.192`.
|
||||
- [ ] Capture "before" oracle: relay 200 + xray real-MS cert (`s_client`/`curl` probes).
|
||||
- **Verify:** all reads succeed, `.bak` exists. **Risk:** none.
|
||||
|
||||
**V1 — frps: control `:7000` (mTLS) + vhost `:7080`, dedicated user, systemd (~1h)**
|
||||
- [ ] Install `frps` ≥ v0.52 (`disableCustomTLSFirstByte` exists ≥ v0.50). Mainland fetch may be blocked → `scp` from laptop.
|
||||
- [ ] Dedicated unprivileged `frp` user (loopback ports need no root) + systemd hardening (`NoNewPrivileges`, `ProtectSystem=strict`, `ReadWritePaths=/var/log/frp`).
|
||||
- [ ] `/etc/relay/frp/frps.toml` (0600, `frp:frp`):
|
||||
```toml
|
||||
bindAddr = "127.0.0.1"
|
||||
bindPort = 7000
|
||||
vhostHTTPPort = 7080
|
||||
auth.method = "token"
|
||||
auth.token = "<FRP_TOKEN>" # openssl rand -hex 32 → /etc/relay/secrets.env
|
||||
subDomainHost = "terminal.yaojia.wang"
|
||||
transport.tls.force = true # control-channel mTLS — token is NOT the sole gate
|
||||
transport.tls.certFile = "/etc/relay/frp-tls/frps-ctrl.cert.pem"
|
||||
transport.tls.keyFile = "/etc/relay/frp-tls/frps-ctrl.key.pem"
|
||||
transport.tls.trustedCaFile = "/etc/relay/frp-client-ca/frp-client-ca.cert.pem"
|
||||
log.to = "/var/log/frp/frps.log"
|
||||
log.level = "info"
|
||||
```
|
||||
- **Verify:** both ports loopback-bound; `journalctl -u frps` clean; `curl -s -H 'Host: probe.terminal.yaojia.wang' http://127.0.0.1:7080/` → frp 404 "no proxy". **Risk:** binary fetch (scp); token/cert never committed.
|
||||
|
||||
**V1b — Registration-authorization decision (~30 min–1h) — closes the subdomain-takeover hole**
|
||||
- [ ] **Model A (v1):** control-channel mTLS from V1 is the accepted gate. Issue **frp-client certs per host** (revoke one host by rotating the `frp-client-ca` trust without touching device certs). Document: the frp-client cert + token is a trusted secret whose leak compromises all hosts.
|
||||
- [ ] **Model B (deferred):** add an frps `[[httpPlugins]]` NewProxy authz delegate (reuse `term-relay/frp-scaffold/plugin-hook.ts`) mapping client-cert CN → allowed subdomain, rejecting mismatches.
|
||||
- **Verify:** an `frpc` with a valid token but **no** frp-client cert is refused at the control handshake; (Model B) host-A's cert claiming `subdomain=hostB` is refused.
|
||||
|
||||
**V2 — Device client-CA + issuance + revocation (~2h)**
|
||||
- [ ] `deploy/scripts/gen-device-ca.sh`: EC **P-256** self-signed CA (`CA:TRUE`, `keyCertSign,cRLSign`, 3650d) → `/etc/relay/device-ca/` (0700), idempotent, separate trust root from agent-CA/web-PKI. Parametrize to also scaffold `frp-client-ca` for V1.
|
||||
- [ ] `deploy/scripts/issue-device-cert.sh`: P-256 leaf, `EKU=clientAuth`, 825d; emits `.p12` (`openssl pkcs12 -export -legacy` for iOS/Android) **and** `.pem`/`.key.pem`/`.fullchain.pem` (desktop/curl). Appends CN/serial to `issued.log`.
|
||||
- [ ] `deploy/scripts/revoke-device.sh`: `openssl ca` revoke → regenerate `/etc/relay/device-ca/crl.pem` → `nginx -s reload`. **Initialize an empty CRL now** so V4 can reference it.
|
||||
- **Verify:** `openssl verify -CAfile device-ca.cert.pem test-device.pem` → OK; p12 lists the leaf; revoke → CRL lists the serial. **Risk:** p12 import quirks (mitigate `-legacy`); doc "P-256, never Ed25519".
|
||||
|
||||
**V3 — Wildcard server cert via LE DNS-01 (~1h) — LE is mainline (hard iOS prerequisite)**
|
||||
- [ ] `acme.sh --dns dns_ali -d '*.terminal.yaojia.wang' -d terminal.yaojia.wang` → install `/etc/relay/frp-tls/fullchain.pem` + `privkey.pem` (0600), `--reloadcmd 'nginx -s reload'`. Needs `Ali_Key`/`Ali_Secret`. Separate cert from `*.term` (different parent label — do not try to cover both).
|
||||
- **Verify:** SAN shows `*.terminal.yaojia.wang`; `curl https://<name>.terminal.yaojia.wang` from a stock machine validates with **no `-k`**. **Risk:** Aliyun DNS API creds/propagation — if unavailable, iOS bring-up stalls (self-signed fails on iOS).
|
||||
|
||||
**V4 — nginx `:8470` = TLS-term + mTLS + WS proxy (~1h)**
|
||||
- [ ] `/etc/nginx/conf.d/frp-mtls.conf`:
|
||||
```nginx
|
||||
map $http_upgrade $connection_upgrade { default upgrade; '' close; }
|
||||
|
||||
server {
|
||||
listen 127.0.0.1:8470 ssl; # NO `http2 off;` — invalid on nginx 1.24
|
||||
server_name ~^.+\.terminal\.yaojia\.wang$;
|
||||
|
||||
ssl_certificate /etc/relay/frp-tls/fullchain.pem;
|
||||
ssl_certificate_key /etc/relay/frp-tls/privkey.pem;
|
||||
|
||||
# --- the data-path security gate ---
|
||||
ssl_verify_client on; # ON, never `optional`
|
||||
ssl_client_certificate /etc/relay/device-ca/device-ca.cert.pem;
|
||||
ssl_verify_depth 1;
|
||||
ssl_crl /etc/relay/device-ca/crl.pem; # revocation from day one
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:7080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Origin https://$host; # needed for Android/curl; redundant-harmless for iOS
|
||||
proxy_set_header X-Client-Cert-CN $ssl_client_s_dn;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s; # prevents idle-WS proxy kill (default 60s)
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
}
|
||||
```
|
||||
- **Verify (loopback, before touching the stream), with `test-device`:** no-cert → TLS handshake failure (must fail); `--cert/--key` → frp 404 (reaches frps); revoked cert (added to CRL) → handshake failure.
|
||||
|
||||
**V5 — stream SNI additions (~30 min)**
|
||||
- [ ] Merge into the **captured** `map` body (do not retype the existing two lines):
|
||||
```nginx
|
||||
frp.terminal.yaojia.wang 127.0.0.1:7000; # exact wins over wildcard
|
||||
*.terminal.yaojia.wang 127.0.0.1:8470;
|
||||
# existing *.term.yaojia.wang → 8443 and default → 10443 preserved verbatim
|
||||
```
|
||||
- [ ] `nginx -t && systemctl reload nginx`.
|
||||
- **Verify:** 4-way `s_client -servername` matrix (frp control bytes / `*.terminal` LE cert / relay cert / xray real-MS cert). **Risk:** map typo → matrix + `.bak` + reload gate.
|
||||
|
||||
**V6 — End-to-end + acceptance (~1.5h)** — test `frpc` → local base app; full acceptance matrix (see M1). **Verify:** items 1–7 in Milestone M1.
|
||||
|
||||
**V7 — Per-host runbook + committable template (~45 min)**
|
||||
- [ ] `deploy/frp/frpc.example.toml` (no secrets) + short runbook: `BIND_HOST=127.0.0.1`, exact `ALLOWED_ORIGINS`, secure P12/cert delivery (scp not email), `revoke-device.sh` procedure, the frp-client trust-boundary note.
|
||||
|
||||
**Track-V effort: ~8–10h.** New files touch nothing existing (all loopback listeners + two additive `map` lines).
|
||||
|
||||
---
|
||||
|
||||
### Track S — Server / host packaging (base app — **zero `src/` changes**)
|
||||
|
||||
> Headline (verified): a working tunnel needs **zero base-app code changes** — env-extensible `ALLOWED_ORIGINS`, no `Host` reliance, PTY⊥WS decoupling. But "working" ≠ "safe": the tunnel MUST NOT be switched on until **S-GATE** is green.
|
||||
|
||||
**S-GATE — Preconditions (BLOCKING; before `frpc` starts on ANY host)**
|
||||
- [ ] **Declare the trust model** (Model A / Model B) in the deploy doc — one line. v1 = Model A.
|
||||
- [ ] **Prove mTLS is enforced / cert-free is rejected** (owned by Track V): `curl -sI https://<name>.terminal.yaojia.wang/live-sessions` **with no cert** → TLS failure. If it returns `200`, **STOP — the shell is world-open.**
|
||||
- [ ] **Prove a client can present a cert** (owned by Track C): `curl --cert dev.crt --key dev.key -sI …/live-sessions` → `200`.
|
||||
- **Effort:** 0.5h + cross-track wait. **Risk if skipped:** unauthenticated public root shell.
|
||||
|
||||
**S0 — Base-app config per host (no code)**
|
||||
- [ ] Run the **compiled** app: `npm ci && npm run build` → `node dist/server.js` (`build:web` only if this host also serves the browser cockpit).
|
||||
| Var | Value | Note |
|
||||
|---|---|---|
|
||||
| `PORT` | `3000` | frpc dials `127.0.0.1:3000`; desktop-embedded may drift off 3000 — pin it (S2). |
|
||||
| `BIND_HOST` | `127.0.0.1` | **Mandatory.** Default is `0.0.0.0` (`src/config.ts:37`) → leaving it default exposes an **unauth'd shell on the LAN**, bypassing mTLS entirely. |
|
||||
| `ALLOWED_ORIGINS` | `https://<name>.terminal.yaojia.wang` | Bare host. `:443` also matches (URL normalizes it away on both sides; iOS omits it) — **not** a failure mode. A *non-default* port would mismatch. |
|
||||
| `SHELL_PATH` | `/bin/zsh` · `/bin/bash` · `powershell.exe` | per-OS |
|
||||
| `IDLE_TTL` | ≥ `86400` (raise for multi-day walk-away) | reaps only after detached **and** silent past TTL |
|
||||
| `USE_TMUX` | `1` on *nix, `0`/unset on Windows | cross-restart PTY survival |
|
||||
- **Origin note:** the client must send *some* `Origin` matching `ALLOWED_ORIGINS` or it 401s — a **functional** contract, not an authz boundary (a native client can send any Origin). Already satisfied: `HostEndpoint.originHeader` and the server's `isOriginAllowed` both normalize via WHATWG URL.
|
||||
- **Verify:** `lsof -iTCP:3000 -sTCP:LISTEN` shows `127.0.0.1:3000` (not `*`); Origin-gate curl pair (101 good / 401 bogus). **Effort:** 0.5h/host.
|
||||
|
||||
**S1 — frpc on the host**
|
||||
- [ ] `frpc.toml` (see M1 for the full block): `type=http`, unique `subdomain=<name>`, `transport.tls.serverName=frp.terminal.yaojia.wang`, `disableCustomTLSFirstByte=true`, frp-client cert/key (V1 control mTLS), `loginFailExit=false`, token `chmod 600`.
|
||||
- [ ] Keep a **name registry** (frps rejects dup subdomains; while a host's frpc is offline anyone with the token could claim its subdomain — closed in Model A by the frp-client cert).
|
||||
- **Verify:** frpc log `start proxy success`; S-GATE curl-with-cert → 200; kill frpc → app + live PTY survive → restart → client reattaches with replay. **Effort:** 1–2h first host, ~15 min after.
|
||||
|
||||
**S2 — Durable service packaging (the real work; ~1.5–2.5 days)**
|
||||
- [ ] Port the *shape* of `agent/src/service/{launchd,systemd,install}.ts`, but note the gaps: the **launchd writer has no `<EnvironmentVariables>`** and the **systemd writer has no `EnvironmentFile`** — extend both to inject S0 env. `agent/src/service/originConfig.ts` writes the **wrong zone** (`.term.` not `.terminal.`) — parameterize the zone or write fresh.
|
||||
- [ ] Two supervised user-scoped units per host (app + frpc), never root (keep the `RootRefusedError` pattern). Linux: `loginctl enable-linger`. Windows: WinSW/nssm, `USE_TMUX=0`.
|
||||
- [ ] **Desktop-embedded hosts:** `pickFreePort` can drift off 3000 → **pin the port**; inject `ALLOWED_ORIGINS` (today `buildServerEnv` only relays ambient env). The Electron app must be **always-running** for the embedded server to exist → for walk-away hosts, prefer the standalone `node dist/server.js` service over the GUI.
|
||||
- **Verify:** reboot → both units up, tunnel serves within ~30s; `kill -9` each → supervisor restarts. **Effort:** 1.5–2.5 days for env-injecting cross-OS templates + install script + 80%-coverage tests; ~15 min/host after.
|
||||
|
||||
**S3 — Survival & health validation (~1–2h)**
|
||||
- Verified guarantees hold: PTY⊥WS (`src/server.ts:841` close→`detachWs`, never kill), idle reaper reaps only detached+silent (`:686`), client backoff-reconnect with `sessionId` replay.
|
||||
- [ ] **Idle-WS timeout (was missing):** the server sends **no** WS pings. Fixed cross-track by V4's `proxy_read_timeout 3600s` **and** client periodic pings (iOS `URLSessionWebSocketTask.sendPing`).
|
||||
- **Verify matrix:** (a) kill frpc mid-session → PTY alive, reattach on restart; (b) `IDLE_TTL=60` → detached+silent reaped, active not; (c) **idle 5 min, tunnel up → WS not dropped**; (d) reboot VPS → frpc re-establishes, client resumes.
|
||||
|
||||
**S4 — mTLS scoping (Model A: accept; Model B: conditional-CRITICAL)**
|
||||
- [ ] Multi-tenant cert→host authorization (CRITICAL in B, N/A in A): per-tenant client-CA selected by `$host`/SNI, or `map $host → $expected_ou` reject on mismatch.
|
||||
- Notes for the record: `/hook*` is tunnel-reachable (`isLoopback()` sees frpc's `127.0.0.1`) and `/live-sessions/:id/preview` (`:323`) **leaks scrollback** to any cert-holder with no Origin guard — sets Model B's blast radius (a foreign valid cert reads your terminals, not just opens new ones). Accept in Model A.
|
||||
|
||||
**Track-S effort: ~2.5–3.5 days** (S2 tooling + S3 validation) + a blocking wait on V and C tracks for the S-GATE proof. Zero `src/` changes.
|
||||
|
||||
---
|
||||
|
||||
### Track C — Native clients (present the device cert)
|
||||
|
||||
**Client preconditions (gate on these; mostly infra):** P-A `BIND_HOST=127.0.0.1` on every host; P-B frps `:7080`/`:7000` loopback-only & absent from the Aliyun SG (`nmap … -p 7000,7080` closed); P-C LE wildcard live (self-signed fails iOS ATS + Chromium + Android default TLS); P-D mTLS `on` not `optional`, trust anchor = device-CA only; P-E scope decision (A/B).
|
||||
|
||||
#### C-iOS — do first (client exists; ~5–7 dev-days)
|
||||
|
||||
**iOS-1 · `ClientTLS` leaf SPM package** (`ios/Packages/ClientTLS/`, imports `Security`/`Foundation`)
|
||||
- [ ] `ClientIdentity.swift` — `@unchecked Sendable` wrapper over `SecIdentity` + issuer chain.
|
||||
- [ ] `PKCS12Importer.swift` — `SecPKCS12Import`; map `errSecAuthFailed`→`.wrongPassphrase`, `errSecDecode`→`.corruptFile`, `errSecPkg`→`.unsupported`.
|
||||
- [ ] `KeychainClientIdentityStore.swift` — persist raw `.p12` + passphrase under `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`, re-import at launch.
|
||||
- [ ] `MutualTLSChallengeResponder.swift` — pure `resolve(challenge, identity:)`: `ClientCertificate`+identity → `.useCredential(URLCredential(identity:…, persistence:.forSession))`; `ClientCertificate`+no identity → `.cancelAuthenticationChallenge` (clean, classifiable error); `ServerTrust` → `.performDefaultHandling` (LE validated by system).
|
||||
- **Verify:** `swift test --package-path Packages/ClientTLS` — responder truth table (3 challenge types × identity present/absent) + import of a CI-generated fixture `.p12` incl. wrong-passphrase.
|
||||
|
||||
**iOS-2 · Wire responder into both transports**
|
||||
- [ ] `URLSessionTermTransport`/`WSConnection`: add `init(identity:)`; implement **`urlSession(_:task:didReceive:completionHandler:)`** (task-level receives connection-level challenges) delegating to the responder; keep `didCompleteWithError` intact. *(Note: the current transport delegate has no challenge handler at all — this is net-new.)*
|
||||
- [ ] `URLSessionHTTPTransport`: build the session with an `NSObject` `ClientTLSSessionDelegate` implementing the session-level `urlSession(_:didReceive:completionHandler:)`. Apply to both `production()` and `SessionThumbnail.liveTransport` (`SessionThumbnail.swift:242`).
|
||||
- [ ] `AppEnvironment.production()`: load identity from the store, inject into both transports + probe closure.
|
||||
- **Verify:** `xcodebuild test`; `FakeChallenge` unit test asserts each transport returns the credential iff an identity is set.
|
||||
|
||||
**iOS-3 · Install UX + pairing guard + reworded warning**
|
||||
- [ ] `ClientCertScreen.swift` — `.fileImporter([.pkcs12])` + secure passphrase field → import → store → show issuer CN + **expiry** + "replace certificate" (rotation). Settings entry "Device certificate."
|
||||
- [ ] `PairingViewModel`: for a `.terminal.yaojia.wang` host, (a) block probe if no identity installed ("先安装本设备证书"); (b) **replace the `publicHostBlocking` copy** for tunnel hosts (the current "anyone who can reach the port gets a shell" is false under mTLS and deters the intended flow). Keep the blocking tier for genuinely public non-tunnel hosts.
|
||||
- [ ] Add a `.clientCertRejected` classification (nginx-rejected mTLS surfaces as `NSURLErrorSecureConnectionFailed`/reset — currently mis-classified `.tlsFailure` "server cert invalid"). Copy: "本设备证书无效或已吊销,请重新导入。" *(`PairingError.swift` already maps `NSURLErrorClientCertificateRequired/Rejected` — primed.)*
|
||||
- **Install UX:** deliver `device.p12` (AirDrop/Files) → WebTerm → Settings → Device certificate → Import + passphrase (in-app, app-sandboxed; not a config profile).
|
||||
- **Verify:** XCUITest import fixture → pair `https://<name>.terminal.yaojia.wang` → probe → attach. Manual on-device against the real VPS; confirm nginx logs `$ssl_client_verify=SUCCESS`.
|
||||
|
||||
#### C-Desktop — do second (Electron; ~6–9 dev-days) — reverses `DESKTOP_PLAN.md §0` (record the deviation)
|
||||
|
||||
**D-1 · Remote-host mode**
|
||||
- [ ] `desktop/src/remote-hosts.ts` — `RemoteHost{id,name,url}`, persisted via `settings-store.ts` (extend `DesktopPrefs`).
|
||||
- [ ] `window.ts`/`main.ts`: allow a `BrowserWindow` to load `https://<name>.terminal.yaojia.wang/`; **relax the `will-navigate` block to exactly the selected remote origin** (keep it locked to that one origin; keep the embedded local server running — this box is also its own tunnel source). Tray host-picker: "This machine (local)" ↔ each remote host.
|
||||
- **Verify:** `npm run typecheck`; switch to a remote host, confirm page + `wss://` load; confirm the origin lock still blocks elsewhere.
|
||||
|
||||
**D-2 · mTLS via OS store (the only viable path)**
|
||||
- [ ] `main.ts`: `app.on('select-client-certificate', (event, wc, url, list, cb) => { event.preventDefault(); const cert = pickByIssuer(list, DEVICE_CA_CN); cb(cert) })`. **`event.preventDefault()` is mandatory** (else Electron auto-picks `list[0]`). Empty/no-match → clear "device certificate not installed" dialog, never silent `cb()`.
|
||||
- **Drop** the "app reads `.p12`" idea — infeasible: Chromium sources client certs **only** from the OS store; `setCertificateVerifyProc` is server-side only. The `.p12` must live in the login keychain / Personal store. Document the macOS private-key ACL "Always Allow" one-time prompt.
|
||||
- **Install UX:** macOS double-click `.p12` → login keychain; Windows → Personal store; app auto-selects by issuer.
|
||||
- **Verify:** end-to-end connect; nginx logs a verified cert; empty keychain → dialog, not a hang.
|
||||
|
||||
**D-3 · Local `frpc` supervision (coordinate with Track V/S)**
|
||||
- [ ] Optionally spawn/monitor `frpc` from `main.ts` and set the embedded server's `ALLOWED_ORIGINS=https://<name>.terminal.yaojia.wang` (additive via `config.ts:214`). Embedded server already defaults `BIND_HOST=127.0.0.1` (`server-config.ts:17`) — satisfies P-A **unless `lanSharing` is enabled** (document: re-opens the unauth'd LAN path).
|
||||
- **Verify:** desktop-launched frpc registers; host reachable from an off-LAN client.
|
||||
|
||||
#### C-Android — do last (no `android/` exists; WebView MVP ~2.5–3.5 wk)
|
||||
|
||||
Pick **WebView shell** (mirrors the desktop pattern; xterm.js in `public/` is the terminal; avoids the Termux GPLv3 entanglement of full-native).
|
||||
- [ ] **C-1 Shell:** Kotlin, single Activity, hardened `WebView` (JS on, file access off, one allowed origin). Verify: loads a base app.
|
||||
- [ ] **C-2 mTLS:** `WebViewClient.onReceivedClientCertRequest` → `req.proceed(privateKey, chain)` from `KeyStore.getInstance("PKCS12")`; import via SAF (`ACTION_OPEN_DOCUMENT`) + passphrase; `.p12` app-private, key wrapped by Android Keystore. Verify: connect to `<name>.terminal.yaojia.wang`; nginx logs verified cert; wrong/absent → clean error UI.
|
||||
- [ ] **C-3 Host mgmt:** multi-host list (DataStore/Room), add/switch/remove.
|
||||
- [ ] **C-4 Notifications + deep links:** reuse the ntfy bridge (matches existing infra) rather than FCM; `webterminal://` intent filter.
|
||||
- Defer full-native (Compose + terminal-view + OkHttp `X509KeyManager`) — 4–8 wk, pulls in GPLv3.
|
||||
|
||||
**Track-C effort: iOS 5–7 d · Desktop 6–9 d · Android 2.5–3.5 wk**, gated on preconditions.
|
||||
|
||||
---
|
||||
|
||||
## 3. Cross-track dependencies & build order
|
||||
|
||||
**Dependency graph**
|
||||
- `Track V (V0→V6)` proves the server-side gate → **unblocks S-GATE #2** and the entire tunnel-enable step.
|
||||
- `Track C iOS-1/2` (first client cert support) → **unblocks S-GATE #3**.
|
||||
- `Track S S-GATE` gates `S1` (frpc on any host) on **both** V's cert-rejection proof and C's cert-presentation proof.
|
||||
- `V3 (LE wildcard)` is a **hard prerequisite** for all three clients (P-C) — do it early.
|
||||
- `V4 proxy_read_timeout` + client pings jointly fix idle-WS kill (S3).
|
||||
- Desktop `D-3` and Android depend on the per-host runbook `V7` + service tooling `S2`.
|
||||
|
||||
**Concrete build ORDER**
|
||||
1. **V0 → V5** (VPS: frps + device-CA + LE + nginx mTLS + SNI routes). Fully self-verifiable with `curl --cert` / `websocat`.
|
||||
2. **V6** end-to-end with a throwaway `frpc` + a spare base app on the VPS/laptop → **Milestone M1** (mTLS proven with `curl`, no client yet).
|
||||
3. In parallel once M1 is green: **C-iOS (iOS-1 → iOS-3)** and **S0/S1** on the first real local host.
|
||||
4. **First real end-to-end from iOS** → **Milestone M2**.
|
||||
5. **S2** durable service tooling → **S-GATE** repeatable per host → onboard a 2nd/3rd host → **Milestone M3**.
|
||||
6. **C-Android** WebView MVP → **Milestone M4**.
|
||||
7. **C-Desktop** remote mode + OS-store mTLS → **Milestone M5**.
|
||||
|
||||
---
|
||||
|
||||
## 4. Milestones
|
||||
|
||||
- **M1 — VPS tunnel up + one local host + mTLS proven with ONE device (curl) end-to-end.** Acceptance matrix on `t1.terminal.yaojia.wang` (base app `BIND_HOST=127.0.0.1 ALLOWED_ORIGINS=https://t1.terminal.yaojia.wang PORT=3000`; `frpc.toml`):
|
||||
```toml
|
||||
serverAddr="8.138.1.192"; serverPort=443; auth.token="<FRP_TOKEN>"
|
||||
transport.tls.enable=true
|
||||
transport.tls.serverName="frp.terminal.yaojia.wang"
|
||||
transport.tls.disableCustomTLSFirstByte=true # CRITICAL for ssl_preread
|
||||
transport.tls.certFile="<host-frp-client.pem>" # frps control mTLS
|
||||
transport.tls.keyFile="<host-frp-client.key.pem>"
|
||||
transport.tls.trustedCaFile="/etc/.../frps-ctrl-ca.pem"
|
||||
[[proxies]]
|
||||
name="t1"; type="http"; localIP="127.0.0.1"; localPort=3000; subdomain="t1"
|
||||
```
|
||||
1. no device cert → TLS handshake failure. 2. `--cert test-device.pem --key …` → `200` JSON from `/live-sessions`. 3. WS `attach`→`attached` on `wss://t1.terminal.yaojia.wang/term`. 4. **H1 negative:** a 2nd frpc with the token but no/other frp-client cert claiming `t1` → refused at control. 5. revoked device cert → rejected (CRL). 6. first byte to `:443` is `0x16` (`tcpdump`, proves `disableCustomTLSFirstByte`). 7. 4-zone SNI + V0 coexistence oracle green; `journalctl -u xray -u nginx` clean.
|
||||
- **M2 — iOS mTLS end-to-end.** Real iPhone with an imported `device.p12` pairs `https://t1.terminal.yaojia.wang`, probes `/live-sessions`, attaches `/term`, and drives a live shell; nginx logs `$ssl_client_verify=SUCCESS`; idle-5-min WS stays up.
|
||||
- **M3 — Multi-host.** Two+ real local hosts (`h1`, `h2`) each running the durable service (S2) + frpc; each reachable at its own subdomain; onboarding a new host = runbook only (no VPS change); revoke one device removes its access fleet-wide.
|
||||
- **M4 — Android WebView MVP.** Android device imports `.p12`, connects to a host over mTLS, manages ≥2 hosts, receives an ntfy push → deep-links into a session.
|
||||
- **M5 — Desktop remote mode.** Electron app switches to a remote host, selects the device cert from the OS keychain, connects over `wss://` + mTLS; origin lock still blocks foreign navigation.
|
||||
|
||||
---
|
||||
|
||||
## 5. Risk register
|
||||
|
||||
| # | Risk | Sev | Mitigation |
|
||||
|---|---|---|---|
|
||||
| **R1** | **mTLS client-cert support is net-new on all three clients** (iOS transport has no `didReceive challenge`; Android/desktop from-scratch). Until it ships, no client can reach a tunneled host. **Critical-path blocker.** | **CRITICAL** | Do iOS first (smallest, exists) to prove the whole path; VPS side is fully verifiable now with `curl --cert`/`websocat` independent of clients. LE wildcard (V3) de-scopes clients to only the client-cert branch. |
|
||||
| **R2** | **Base-app public exposure — mTLS is the ONLY gate and is bypassable.** `BIND_HOST` default `0.0.0.0` serves an **unauth'd shell on the LAN**; frps `:7080`/`:7000` off-loopback or a single `ssl_verify_client optional`/misconfig = every session world-open; `/live-sessions/:id/preview` leaks scrollback to any cert-holder. | **CRITICAL** | Mandatory `BIND_HOST=127.0.0.1` (S0, P-A); frps loopback + absent from Aliyun SG (P-B, verify `nmap`); `ssl_verify_client on` never `optional` (P-D); **S-GATE blocks all tunnel-enable** until cert-free rejection is proven; CRL from day one (V2/V4). |
|
||||
| **R3** | **frps registration = subdomain takeover / active MITM.** Shared token isn't bound to a subdomain → any token-holder registers any subdomain and MITMs a valid-cert client. | HIGH | Control-channel mTLS (V1, per-host frp-client CA) so a leaked token alone is insufficient; NewProxy authz plugin pins cert→subdomain for Model B (V1b). |
|
||||
| **R4** | **No cross-tenant isolation under one device-CA** (Model B). | HIGH (B only) | Declare **Model A** for v1; Model B needs per-tenant CA / `$host`→OU map + per-host frp token. Do not ship B on shared-CA. |
|
||||
| **R5** | **Self-signed server cert is a hard iOS blocker** (URLSession has no tap-to-accept). | HIGH | LE DNS-01 wildcard is mainline (V3) — system-trusted on all three platforms. |
|
||||
| **R6** | **Idle-WS proxy kill** (~60s nginx default, no app-level pings) → reconnect churn. | HIGH | V4 `proxy_read_timeout/proxy_send_timeout 3600s` + client periodic WS pings; explicit idle-5-min test (S3, M2). |
|
||||
| **R7** | **`http2 off;` invalid on nginx 1.24** → `nginx -t` fails, reload aborted. | HIGH (deploy) | Omit the directive (HTTP/2 off by default); pin at V0 via `nginx -v`. |
|
||||
| **R8** | **No revocation** → one lost device = fleet access until full CA rotation. | HIGH | `ssl_crl` + `revoke-device.sh` from day one (V2); client re-import/rotate path (iOS-3, D-2, C-2). |
|
||||
| **R9** | **Desktop-embedded weak substrate** — dynamic port drift off 3000; GUI must stay running. | MED | Pin the port + inject `ALLOWED_ORIGINS`; prefer standalone `node dist/server.js` service for walk-away hosts. |
|
||||
| **R10** | **Env-injection missing** in `launchd`/`systemd` writers; `originConfig.ts` uses wrong zone. | MED | Treat S2 as real work (~1.5–2.5 d), not copy-paste; parameterize the zone. |
|
||||
| **R11** | **Cert-provisioning ops burden** grows with device count. | MED | Small enrollment helper wrapping `issue-device-cert.sh`; secure `.p12` delivery (scp/AirDrop, not email). |
|
||||
| **R12** | **LE DNS-01 depends on Aliyun API creds/propagation.** | MED | Provision `Ali_Key`/`Ali_Secret` early; if unavailable, iOS bring-up stalls — sequence V3 before M2. |
|
||||
|
||||
---
|
||||
|
||||
## 6. MVP fast-path — shortest route to ONE device securely reaching ONE host
|
||||
|
||||
Goal: an iPhone opens a shell on your laptop through the VPS, with mTLS as the only gate, in the fewest steps. **Model A. Defer** the NewProxy authz plugin, multi-host tooling, Android, desktop, and durable service packaging.
|
||||
|
||||
1. **VPS `V0`→`V5`** but minimal: install frps with **control mTLS + token** (V1), `gen-device-ca.sh` + `issue-device-cert.sh` + empty CRL (V2), **LE wildcard** (V3, non-negotiable for iOS), nginx `:8470` mTLS (V4), two SNI `map` lines (V5). *(~5–6h.)*
|
||||
2. **`V6`/M1 with curl:** run one throwaway base app + `frpc name=t1` on the laptop (`BIND_HOST=127.0.0.1`, `ALLOWED_ORIGINS=https://t1.terminal.yaojia.wang`). Prove: no-cert → reject; `curl --cert` → `200`; `websocat` `attach`→`attached`. **→ M1.**
|
||||
3. **iOS client cert only:** build `ClientTLS` (iOS-1) + wire the two transports (iOS-2) + a minimal import screen (iOS-3, skip the reworded-warning polish for now). Import `device.p12`, pair `https://t1.terminal.yaojia.wang`, attach. **→ M2.**
|
||||
|
||||
**Fast-path total: ~1.5–2 days VPS + ~4–5 dev-days iOS.** Everything else (authz plugin, S2 durable services, multi-host, Android, desktop remote mode) layers on afterward without reworking this core.
|
||||
172
docs/PLAN_RELAY_PHASE1.md
Normal file
172
docs/PLAN_RELAY_PHASE1.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# PLAN_RELAY_PHASE1 — deploy the native rendezvous-relay to a single VPS (staging, durable)
|
||||
|
||||
> **Decision (2026-07-06):** Build **Phase 1 "1b — full durable staging"** on cloud VPS `8.138.1.192`
|
||||
> (Alibaba Cloud, mainland). Real **Postgres + Redis via Docker Compose on the VPS**; real **TLS on
|
||||
> `:443` via Let's Encrypt** against an **already ICP-filed domain**; agent runs on the operator's own
|
||||
> machine and dials OUT. This plan is the file-level execution spec derived from a full code audit of
|
||||
> the 7 relay packages. It supersedes the high-level phasing in [DEPLOY_RELAY.md](./DEPLOY_RELAY.md) §4
|
||||
> for the concrete build; that doc still holds for the *why* and the security runbook (§7).
|
||||
>
|
||||
> **Scope OUT (→ Phase 2):** real KMS custody of the CA (dev in-process signer is accepted for
|
||||
> staging), F6 recoverable-replay transport (`loadReplay` stays fail-closed — not on the terminal path),
|
||||
> WebAuthn/passkey step-up (staging uses `NO_STEPUP_POLICY` — single operator), wildcard multi-tenant
|
||||
> subdomains, metering/alerting. Single tenant, single host, single relay node.
|
||||
|
||||
---
|
||||
|
||||
## 0. Target (what "done" means)
|
||||
|
||||
From the operator's laptop (agent dialing out) and any browser:
|
||||
|
||||
1. `https://<sub>.<BASE_DOMAIN>` serves the relay-web bundle (same origin as the WSS endpoint).
|
||||
2. Operator logs in, picks the host, and gets a live shell **spliced by the real relay-node** — the
|
||||
relay only sees ciphertext (INV2); E2E is browser↔agent.
|
||||
3. Agent enrolled once via a pairing code → holds a SPIFFE mTLS cert → dials `wss://…:AGENT_PORT`.
|
||||
4. **Restart-safe:** bouncing the relay/control-plane process does NOT lose the host registration or
|
||||
kill the operator's PTY (state is in Postgres/Redis; INV7).
|
||||
5. Revoking the host tears the tunnel down within the INV12 budget (Redis `relay:revocations`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Code audit summary — what exists vs. what must be built
|
||||
|
||||
Security-critical *logic* is written and injectable across all 7 packages. Phase 1 = build the
|
||||
integration + infra layer they were designed to receive. Grounded gaps:
|
||||
|
||||
| Area | State (file evidence) | Task |
|
||||
|---|---|---|
|
||||
| P3 Postgres adapter | **absent** — `store/pg.ts` does not exist; only `db/pool.ts` (`createPgPool`/`createQuery`) + full `db/migrations/0001_init.sql`. `memory.ts` is the semantics reference (INV8 versioning, CAS). | **A1** |
|
||||
| P3 migration runner | none | **A1** |
|
||||
| P3 server entrypoint | **absent** — no `.listen()`, no `start`; only `buildControlPlane(env, overrides)` factory (`main.ts:60`). | **A2** |
|
||||
| P3 Redis revocation bus | `createRedisRevocationBus(RedisPublisher)` exists (`routing/bus.ts:42`) but no `ioredis` client instantiated; `main.ts:93` leaves `bus` inert. | **A2** |
|
||||
| P3 F2 capability verifier | throwing stub `refuseAllVerifier` (`main.ts:44`). Must inject relay-auth `verifyCapabilityToken`. **Impedance:** CP's `CapabilityVerifier.verify` is **sync** (`api/authz.ts:24`), relay-auth's is **async** → seam must go async. | **A3** |
|
||||
| relay EnforceDeps over shared store | relay-run uses its own in-RAM fakes (`relay-run/src/wiring/memory-stores.ts`), a **separate world** from the CP. Must implement `HostRegistryPort`/`SessionRegistryPort`/`RevocationStore`/`TokenBucketStore`/`AuditSink` over the **same** Postgres/Redis. | **B1** |
|
||||
| relay `MtlsVerifier` | stub returns seeded host for any cert (`relay-world.ts:149`). Must use relay-auth `verifyAgentCert(leaf, caChain, now, hosts)` against the shared host registry + pinned agent CA. | **B2** |
|
||||
| relay `RouteResolver` | one-entry in-RAM map (`data-plane.ts:110`). Must resolve subdomain→hostId from `hosts.getBySubdomain`. | **B3** |
|
||||
| relay revocation subscriber | not wired. Subscribe `relay:revocations` → `killsScope` → `closeStream`. | **B4** |
|
||||
| relay-run Phase-1 entry | `main.ts` hardcodes `127.0.0.1:8443`, `baseDomain='term.localhost'`, self-signed certs, dials no agent, `NO_STEP_UP`. | **B5** |
|
||||
| agent build | `noEmit:true`, no `build` script, `dist/cli.js` never produced. | **C1** |
|
||||
| agent runnable entry | `cli.ts` exports `parseArgs`/`runCli` but has **no `main()`/argv bootstrap, no `CliDeps` factory, no concrete `runTunnel`** (only assembled in `test/acceptance/cafeDemo.test.ts`). | **C2** |
|
||||
| relay-web static serve | `build.mjs` emits `public/build/`; **no HTTP server** ships. Must serve `public/` same-origin as WSS. | **D1** |
|
||||
| Infra | no Dockerfiles/compose/systemd anywhere. | **E** |
|
||||
|
||||
---
|
||||
|
||||
## 2. Build order (waves by dependency)
|
||||
|
||||
```
|
||||
A (P3 durable + serving) ──▶ B (relay data-plane on shared store) ──▶ E (infra + wire-up)
|
||||
└──▶ C (agent buildable + runnable) ──────────▶
|
||||
└──▶ D (relay-web served) ────────────────────▶
|
||||
```
|
||||
|
||||
A is the foundation (both planes read the same store). B/C/D can proceed once A's store contract is
|
||||
green. E stands up infra and does the end-to-end enroll→dial→click-through.
|
||||
|
||||
### Wave A — control-plane durable + serving
|
||||
- **A1 · PG store adapter + migration runner.** `Owns: control-plane/src/store/pg.ts`,
|
||||
`control-plane/src/db/migrate.ts`, `control-plane/test/store/pg.test.ts`.
|
||||
`createPgStores(query: QueryFn): Stores` implementing all 9 ports (`store/ports.ts`) with **byte-for-byte
|
||||
semantics parity with `memory.ts`**: INV8 status-version+pointer swaps in one transaction, `casRedeem`
|
||||
single-winner, `registerFailure` increment, `reserve` single-winner on the `subdomain UNIQUE`
|
||||
constraint, TTL on routes (store `expires_at`, filter on read — Postgres has no key TTL). Map
|
||||
snake_case columns ↔ camelCase records. Migration runner applies `db/migrations/*.sql` idempotently.
|
||||
**Verify:** TDD against a real Postgres (Testcontainers or a Docker `postgres:16` on
|
||||
`127.0.0.1:5432`); run the SAME behavioral suite the memory store passes.
|
||||
- **A2 · P3 server entrypoint + Redis wiring.** `Owns: control-plane/src/server.ts`,
|
||||
`control-plane/src/boot/redis.ts`, `package.json` (`start` script).
|
||||
`loadEnv(process.env)` → `createPgPool(PG_URL)`+`createQuery` → `createPgStores` → migrate →
|
||||
`ioredis` client → `createRedisRevocationBus(redis)` → `buildControlPlane(env, {stores, bus,
|
||||
verifier, caChainDer})` → `app.listen({host:'0.0.0.0', port})`. Graceful SIGTERM. **Verify:** boots
|
||||
against Docker PG+Redis; `POST /accounts` then `POST /accounts/:id/pairing-codes` round-trips.
|
||||
- **A3 · F2 capability verifier (async).** `Owns: control-plane/src/api/authz.ts` (make
|
||||
`CapabilityVerifier.verify` return `Promise`, await at call sites), `control-plane/src/boot/verifier.ts`,
|
||||
`control-plane/src/main.ts` (default + `overrides.verifier` plumb + call `loadVerifyKeyFromEnv`).
|
||||
Real verifier delegates to relay-auth `verifyCapabilityToken(raw, expectedAud, now)`; configure the
|
||||
verify key from `CAPABILITY_SIGN_PUBKEY_B64` at boot. **Verify:** a token signed by the matching key
|
||||
verifies; a foreign/expired token 401s; unblocks programmatic account/pairing seeding.
|
||||
|
||||
### Wave B — relay data-plane on the shared store (P1 via relay-run)
|
||||
- **B1 · shared-store EnforceDeps.** `Owns: relay-run/src/wiring/stores-pg.ts`,
|
||||
`relay-run/test/stores-pg.test.ts`. Implement relay-auth's `HostRegistryPort`, `SessionRegistryPort`,
|
||||
`RevocationStore`, `TokenBucketStore` (Redis token bucket), `AuditSink` (append to `audit_log`) over
|
||||
the SAME PG/Redis as P3. Replace `memory-stores.ts` in the Phase-1 path.
|
||||
- **B2 · registry-backed `MtlsVerifier`.** `Owns: relay-run/src/wiring/mtls-verifier.ts`. Wrap
|
||||
relay-auth `verifyAgentCert(leafPem, caChainPem, now, hosts)`; pin the agent CA bundle; return
|
||||
`{hostId, accountId}` only for enrolled+unrevoked hosts (INV4/INV14).
|
||||
- **B3 · store-backed `RouteResolver`.** `Owns: relay-run/src/wiring/route-resolver.ts`.
|
||||
`resolve(subdomain)` → `hosts.getBySubdomain` → hostId (or null).
|
||||
- **B4 · revocation subscriber.** `Owns: relay-run/src/wiring/revocation-subscriber.ts` (or reuse
|
||||
`term-relay/data-plane/revocation-subscriber.ts`). ioredis SUBSCRIBE `relay:revocations` → parse
|
||||
`KillSignal` → `killsScope(signal, hostAccountId, hostId)` → `node.closeStream(...)`.
|
||||
- **B5 · relay-run Phase-1 entry.** `Owns: relay-run/src/main-phase1.ts`, `relay-run/package.json`
|
||||
(`start:phase1`). Env-driven (no hardcoding, CLAUDE §Config): bind `0.0.0.0`; `BASE_DOMAIN` +
|
||||
computed `allowedOrigins` (port-less on :443, exact-match at `onUpgrade.ts:102`); real LE cert/key
|
||||
paths for browser WSS; **separate** private agent-CA bundle for mTLS; `loadVerifyKeyFromEnv` at
|
||||
startup; compose B1–B4; keep `NO_STEPUP_POLICY` (staging). Leave the Phase-0 `main.ts` untouched for dev.
|
||||
|
||||
### Wave C — agent buildable + runnable (P2)
|
||||
- **C1 · agent build.** `Owns: agent/tsconfig.build.json`, `agent/package.json` (`build`). Emit
|
||||
`dist/cli.js` (esbuild bundle or `tsc` emit; keep `src` tsconfig `noEmit` for typecheck).
|
||||
- **C2 · CLI bootstrap + `runTunnel`.** `Owns: agent/src/main.ts` (shebang + argv → `runCli`),
|
||||
`agent/src/transport/runTunnel.ts`, `agent/src/cli/deps.ts` (`CliDeps` factory). Assemble
|
||||
`dialRelay`→`holdTunnel`→`createStreamRouter`→`dialLoopback` + heartbeat/backoff, porting the proven
|
||||
wiring from `test/acceptance/cafeDemo.test.ts`. **Verify:** `web-terminal-agent pair <CODE>` then
|
||||
`web-terminal-agent run` enrolls + dials against a local Phase-1 relay.
|
||||
|
||||
### Wave D — serve relay-web (P6)
|
||||
- **D1 · same-origin static server.** `Owns: relay-run/src/servers/static-web.ts` (fold into the
|
||||
browser HTTPS server so the bundle is served from the SAME origin/cert as the WSS, keeping
|
||||
Origin/CSP aligned). Serve `relay-web/public/` (built). Add `npm --prefix relay-web run build` to the
|
||||
deploy step. `loadReplay` stays fail-closed (Phase 2).
|
||||
|
||||
### Wave E — infra + integration on 8.138.1.192
|
||||
- **E1 · Docker Compose** `Owns: deploy/docker-compose.yml`, `deploy/.env.example`. `postgres:16` +
|
||||
`redis:7`, volumes, bound to `127.0.0.1` (not public). Health checks.
|
||||
- **E2 · DNS + TLS.** A-record `<sub>.<BASE_DOMAIN>` → `8.138.1.192`; Let's Encrypt cert
|
||||
(HTTP-01 on :80 or DNS-01) for that subdomain → `TLS_CERT_PATH`/`TLS_KEY_PATH`.
|
||||
- **E3 · Enrollment CA.** Generate the private agent CA (staging may reuse the CP dev signer's CA) →
|
||||
`CA_INTERMEDIATE_CERT_PATH`, `NODE_MTLS_TRUST_BUNDLE_PATH`, `AGENT_CA_CERT_PATH`. **Never** LE for mTLS.
|
||||
- **E4 · Env + systemd + security group.** `deploy/control-plane.env`, `deploy/relay.env`,
|
||||
`deploy/*.service`; open inbound `:443` (browser) + `AGENT_PORT` (agent mTLS) in the Aliyun security
|
||||
group; keep CP admin + PG + Redis loopback-only.
|
||||
- **E5 · End-to-end.** `POST /accounts` → `POST /accounts/:id/pairing-codes` → agent `pair`+`run` on
|
||||
the laptop → browser opens `https://<sub>.<BASE_DOMAIN>` → click through to the shell. Confirm
|
||||
restart-safety + revocation teardown.
|
||||
|
||||
---
|
||||
|
||||
## 3. Environment / config reference (Phase 1, this VPS)
|
||||
|
||||
**control-plane** (`control-plane/src/env.ts`, all required unless defaulted):
|
||||
`PG_URL`, `REDIS_URL`, `CAPABILITY_SIGN_PUBKEY_B64` (32-byte Ed25519, base64), `CA_INTERMEDIATE_KMS_KEY_REF`
|
||||
(dev signer accepts any ref), `CA_INTERMEDIATE_CERT_PATH`, `NODE_MTLS_TRUST_BUNDLE_PATH`, `BASE_DOMAIN`;
|
||||
defaulted `HEARTBEAT_TTL_SEC`=15, `PAIRING_TTL_SEC`=600, `PAIRING_MAX_REDEEM_ATTEMPTS`=5.
|
||||
|
||||
**relay-run Phase 1** (`term-relay/data-plane/config.ts` + new): `BASE_DOMAIN`, `BIND_HOST`=0.0.0.0,
|
||||
`BIND_PORT`=443, `TLS_CERT_PATH`, `TLS_KEY_PATH`, `AGENT_BIND_PORT`, `AGENT_CA_CERT_PATH`,
|
||||
`AGENT_CA_CHAIN_PATH`, `RELAY_NODE_ID`, `RELAY_AUTH_VERIFY_PUBKEY` (= `CAPABILITY_SIGN_PUBKEY_B64`,
|
||||
base64url), `RELAY_TRUST_DOMAIN`, `PG_URL`, `REDIS_URL`.
|
||||
|
||||
**agent** (`agent/src/config/agentConfig.ts`): `RELAY_URL` (`wss://…:AGENT_PORT`), `ENROLL_URL`
|
||||
(`https://<cp-host>/enroll`), `HOST_ID`, `SUBDOMAIN`, `LOCAL_TARGET_URL` (`ws://127.0.0.1:3000` — the
|
||||
base app), `STATE_DIR` (`~/.web-terminal-agent`).
|
||||
|
||||
> **Key agreement (linchpin):** the P5 capability-signing keypair — CP signs, relay verifies. CP env
|
||||
> `CAPABILITY_SIGN_PUBKEY_B64` and relay env `RELAY_AUTH_VERIFY_PUBKEY` MUST be the SAME public key.
|
||||
> The **agent enrollment CA** (mTLS) and the **browser LE cert** are two independent trust chains.
|
||||
|
||||
---
|
||||
|
||||
## 4. Invariants to preserve (do not regress)
|
||||
INV2 opaque splice (relay sees only ciphertext) · INV3 accountId only from authenticated material ·
|
||||
INV7 PTY≠WS, relay nodes stateless/restart-safe · INV8 immutable versioned status · INV10 zero-payload
|
||||
audit · INV12 revocation teardown budget · INV14 registry-gated mTLS. Origin/CSWSH exact-match on every
|
||||
browser upgrade. Config via env only — no hardcoded hosts/ports/secrets.
|
||||
|
||||
---
|
||||
|
||||
## 5. Progress
|
||||
Tracked in [PROGRESS_LOG.md](./PROGRESS_LOG.md) under a `RELAY-PHASE1` heading. Task IDs: `A1–A3`,
|
||||
`B1–B5`, `C1–C2`, `D1`, `E1–E5`. Orchestrator appends one entry per task on completion (status, files,
|
||||
verification command+result, deviations, next).
|
||||
@@ -24,6 +24,50 @@
|
||||
|
||||
> 新会话读到的第一块。保持准确,只描述"此刻"。
|
||||
|
||||
### 🚧 RELAY-PHASE1 — 把原生 rendezvous-relay 部署到单台 VPS(8.138.1.192,阿里云;2026-07-06)
|
||||
- **目标**: DEPLOY_RELAY §4 的 **Phase 1「1b 全量持久化 staging」**。VPS 上 Docker 自建 Postgres+Redis;已备案域名走 443 + Let's Encrypt;agent 跑在操作者本机向外拨号。完整文件级方案见 [PLAN_RELAY_PHASE1.md](./PLAN_RELAY_PHASE1.md)。
|
||||
- **进度**: **Wave A/B/C/D/E 代码全部完成并通过 Verify** —— 一个后台 Workflow(12 agents,0 error)跑完 A2、B1–B5、B6、C、D1、E。Verify 门槛:**四个包 tsc 全干净、可构建包全 build 成功、314/314 测试通过**(control-plane 103 / relay-run 74 / agent 137 + relay-web 118)。对抗式安全评审:**所有硬不变量 PASS**(INV2 opaque splice、Origin/CSWSH 精确匹配、mTLS INV14 registry 门控、token-mint 密码学、撤销 INV12、jti 单次、参数化 SQL)。
|
||||
- **各任务**: A2 P3 server 入口+Redis 总线 `[x]`;B1 共享 store EnforceDeps `[x]`;B2 registry-backed MtlsVerifier `[x]`(异步阻抗由 B5 的 `bridgeAsyncMtls` per-DER 预取桥无锁解决);B3 store RouteResolver `[x]`;B4 撤销订阅 `[x]`;B5 `main-phase1.ts` 组合入口 + staging `/auth/mint` 签发 `[x]`;C agent `dist/cli.js` 构建+`runTunnel` 运行入口 `[x]`;D1 同源托管 relay-web `[x]`;E systemd+脚本+RUNBOOK `[x]`;**B6 relay-web 登录/DPoP `[~] PARTIAL`**。
|
||||
- **B7 收尾 `[x]` DONE**: ① 功能阻塞已闭环 — `browser-server.ts` 现从 `term.dpop.<b64u>` 子协议读 DPoP proof 喂 `UpgradeRequest.dpop`(头优先、否则回退子协议),真实浏览器可通过 DPoP 门。② F1 `/auth/mint` 加 Redis 按 IP 限流(HMAC 盐哈希 key,burst5/refill0.2,早于口令比对,耗尽 429);F2 `activeSessionCount` 接真实同租户在线 WS 计数;F5 错误日志只记 `e.message(+.code)` 防 DSN 泄漏。relay-run `npx vitest run` → **10 files / 92 passed**、tsc 干净。**F3/F4(撤销后 agent 空转重连、slowloris)为 LOW → Phase 2 backlog。**
|
||||
- **⚠️ 端到端仅可在 VPS 验证**:浏览器登录→token→DPoP→upgrade→agent 拼接,本地只到类型/单元层。E2–E5 远端执行见 `deploy/RUNBOOK.md`(需 VPS 访问权)。
|
||||
- **远端(需 VPS 执行,见 `deploy/RUNBOOK.md`)**: DNS、Let's Encrypt 签证、私有 enrollment CA + capability keypair 生成、阿里云安全组放行 443/AGENT_PORT、systemd 起两个服务、端到端 enroll→dial→浏览器点进 shell。
|
||||
- **未做/推迟到 Phase 2**: 真 KMS、F6 replay、WebAuthn step-up(staging 用 `NO_STEPUP_POLICY`)、通配多租户、F3/F4。
|
||||
- **未做/推迟到 Phase 2**: 真 KMS(dev signer 暂用)、F6 replay(`loadReplay` fail-closed)、WebAuthn step-up(staging 用 `NO_STEPUP_POLICY`)、通配多租户。
|
||||
|
||||
#### [x] A1 — Postgres store 适配器(P3)+ 迁移运行器(2026-07-06)
|
||||
- **交付**: `createPgStores(query): Stores` 把 9 个 store 端口映射为参数化 SQL(零字符串拼接);`runMigrations(query)` 按文件名顺序跑 `db/migrations/*.sql`(幂等)。
|
||||
- **文件(全在 Owns 内)**: `control-plane/src/store/pg.ts`、`src/db/migrate.ts`、`db/migrations/0002_routes.sql`(新增 — 0001 无 routes 表)、`test/store/pg.test.ts`。
|
||||
- **语义对齐 memory.ts**: INV8 accounts/hosts `swapStatus` 用单条 **writable CTE**(UPDATE 指针 + INSERT 版本行)原子完成,无显式事务;重复插入捕获 pg `23505`→`throw duplicate`;`casRedeem` 单赢家 `UPDATE...WHERE redeemed_at IS NULL RETURNING`;RouteStore 存 `expires_at`,`expires_at<=now()` 视为不存在(fail-closed INV7)+ 惰性 DELETE。
|
||||
- **验证(实测)**: `npx vitest run test/store/pg.test.ts` → **23/23 通过**(自起 `postgres:16` 一次性容器 :5433,afterAll 自动 `docker rm -f`);`pg.ts` 覆盖 **96.72% stmts / 100% funcs**。
|
||||
- **偏差**: ① `0002_routes.sql` 新增;② SubdomainStore 无独立预留表,按 `hosts.subdomain UNIQUE` 归属裁决;③ migrate 按 `;` 切分逐条执行(参数化路径拒绝多语句)。**阻塞**: 无。
|
||||
|
||||
#### [x] A3 — 真能力验证器(relay-auth verifyCapabilityToken)+ 同步→异步 seam(2026-07-06)
|
||||
- **交付**: 抛异常的 `refuseAllVerifier` stub 换成委托 P5 异步 `verifyCapabilityToken` 的真验证器;`await` 贯通整个 authorizer 路径。默认仍 fail-closed(异步化后 reject→401)。
|
||||
- **文件**: `src/api/authz.ts`(`CapabilityVerifier.verify`→`Promise`)、`src/api/provision.ts`(5 个路由 call site 改 `await principal(req)`)、`src/boot/verifier.ts`(新;`configureCapabilityVerifyKey` 把 `env.capabilitySignPubkey` 32 字节导入 WebCrypto verify key 后调 relay-auth `configureVerifyKey`,桥接 `CAPABILITY_SIGN_PUBKEY_B64` vs `RELAY_AUTH_VERIFY_PUBKEY` 命名差)、`src/main.ts`、`package.json`(加 `relay-auth: file:../relay-auth`)、`test/verifier.test.ts`(新)+ 更新 api 测试 stub 为异步。
|
||||
- **验证(实测)**: `npx tsc --noEmit` 全项目干净;`npx vitest run` **15 files / 101 tests pass**。
|
||||
- **偏差**: 手建 `control-plane/node_modules/relay-auth` 符号链接以配合新 file: 依赖(`npm install` 会重建)。**阻塞**: 无。
|
||||
|
||||
### ✅ NATIVE-TUNNEL — 原生 mTLS 反向隧道:四轨代码全部落地(2026-07-07,分支 `feat/relay-phase1`,commits 5337281/e38e6d1/bb09495/d0c249c)
|
||||
- **背景**: 原生 iOS/Android/桌面客户端讲 base-app 协议(非 E2E relay),需经用户 VPS 反向隧道(frp)回到本机 `127.0.0.1:3000`。**base app 无鉴权 → mTLS 客户端证书是唯一闸门**。总计划见 [PLAN_NATIVE_TUNNEL.md](./PLAN_NATIVE_TUNNEL.md)(Model A 单主人机队;LE 泛证书;frps 控制通道 mTLS+token;CRL 第一天上;`BIND_HOST=127.0.0.1` 强制)。
|
||||
- **执行**: 两个后台 Workflow —— (1) build→review 四轨并行(8 agents,0 error,860k tok);(2) 修复两个 HIGH(2 agents,0 error,308k tok)。四轨文件互不相交(`ios/`·`desktop/`·`agent/`·`deploy/`),**零 `src/` 改动**(计划承诺兑现)。
|
||||
- **各轨(全部已验证)**:
|
||||
- **C-iOS `[x]`**: 新 `ios/Packages/ClientTLS`(SecIdentity 包装、PKCS12 导入含类型化错误、keychain 存储 `AfterFirstUnlockThisDeviceOnly`、纯 `MutualTLSChallengeResponder` 真值表、跨平台 X.509 DER 摘要)。两个 transport + SessionThumbnail 改**惰性 `@Sendable ()->ClientIdentity?` provider**(WS 每连接、HTTP 每证书挑战解析 → 装证书后免重启生效)。`ClientCertScreen` 经 `hostMenu` 的「设备证书」入口可达;PairingViewModel 对 `*.terminal.yaojia.wang` 无证书时挡探测、mTLS 拒绝重映射为 clientCertRejected。**验证**: ClientTLS 14/14、SessionCore 93/93、xcodegen+xcodebuild BUILD SUCCEEDED。
|
||||
- **C-Desktop `[x]`**(D-1/D-2): `remote-hosts.ts` + DesktopPrefs(不可变、向后兼容)+ 托盘选择器;`will-navigate` 动态锁定选中远端 origin、null 失败关闭(无开放重定向);`select-client-certificate` handler `event.preventDefault()` 优先、按 issuer CN 选证、缺证弹框不静默。**验证**: `npm run typecheck` 干净。
|
||||
- **S2 host-packaging `[x]`**: launchd 写 `<EnvironmentVariables>`、systemd 写 `EnvironmentFile`、originConfig zone 参数化(默认 `term` 保留);`InstallOptions` 从 CLI install → createCliDeps → installService seam → writers 全程贯通,生成单元携带 `BIND_HOST`(隧道机默认 `127.0.0.1`)/`ALLOWED_ORIGINS`/`PORT`/`SHELL_PATH`/`IDLE_TTL`/`USE_TMUX`;systemd 拒绝控制字符。**验证**: agent typecheck+build 干净、vitest **166/166**(154+12 新)。
|
||||
- **V2/V7 VPS 脚本(可提交件)`[x]`**: `gen-device-ca.sh`(EC P-256,`--kind frp-client`)、`issue-device-cert.sh`(P-256 leaf,EKU=clientAuth only,.p12 `-legacy`+.pem,防 CSR 注入 serverAuth/CA:TRUE)、`revoke-device.sh`(CRL 强制)、`frp/{frps,frpc}` 模板(`disableCustomTLSFirstByte=true`)、`nginx/frp-mtls.conf`(:8470 TLS 终止 + `ssl_verify_client on` + `ssl_crl`,WS 升级代理 →:7080)、`nginx/stream-sni-additions.md`(两条加性 SNI map,合并式共存)。**验证**: tmp 目录跑通 gen→issue→`openssl verify`→revoke→CRL(OpenSSL 3.0.18);无密钥/证书误入库。
|
||||
- **对抗式评审结论**: Desktop/VPS 直接 PASS;iOS/S2 各命中 1 个 HIGH(ClientCertScreen 无入口 / 环境注入未接到 install CLI)—— 均属跨 lane 收尾接线,已由第二个 Workflow 补齐并复验。
|
||||
- **实机 VPS 部署 V0→V6 完成,M1 达成 `[x]`(2026-07-07,8.138.1.192)**: frps 0.61.1(systemd `frps.service`,控制 :7000 + vhost :7080,控制通道 mTLS+token);P-256 device CA + frp-client CA(openssl ca db + CRL);nginx `:8470`(`ssl_verify_client on` + device-CA + `ssl_crl` → WS 代理 :7080);stream map **加性合并** `frp.terminal→7000`/`*.terminal→8470`(保留 `*.term→8443`/`writer→8444`/`default→10443`,`.bak` 已存)。**M1 全绿**:无证书→400 拒;设备证书→200 `/live-sessions` JSON(全链路 :443→SNI→:8470 mTLS→frps→frpc→base app);frpc 无 frp-client 证书→控制层拒(`error: EOF`);吊销设备证书→400、另一证书仍 200(CRL,`nginx -s reload` 后即时);4-zone SNI oracle 共存(xray/writer 未动)。base app **零改动**。详见 memory `native-tunnel-m1-live`。
|
||||
- **LE 泛证书已上线 `[x]`(2026-07-07)**: VPS→LE 被墙(curl err 28),改在**本机 Mac** 跑 `acme.sh` **手动 DNS-01**(用户不交 `Ali_Key`/`Ali_Secret`,手动加两条 `_acme-challenge.terminal` TXT,`dig` 验证),本机签出 **Let's Encrypt 泛证书 `*.terminal.yaojia.wang`(ECC,exp 2026-10-05)**,`scp` 到 VPS `/etc/relay/frp-tls/`,`systemctl restart nginx`(graceful reload 有 worker 竞态,restart 才稳)。**无 `-k` 公网信任验证全绿**:无证书→400、设备证书→200 `/live-sessions` JSON;共存 oracle 不变。**续期注意**:手动模式不自动续,2026-10-05 前需重跑本机手动流程(或补 Ali 凭证走 dns_ali 自动续)。
|
||||
- **M2 现在只剩设备侧**: ① 把 `feat/relay-phase1` 的 iOS ClientTLS 版本 build 装到 iPhone;② 设置→设备证书 导入 `.p12`(测试证书 `/root/deploy-native/out-dev/test-device.p12`,口令 `testpass123`);③ 配对 `https://t1.terminal.yaojia.wang`。t1 现指向 VPS 自身的临时 base app;真机 iPhone→笔记本 用例需把笔记本按 `deploy/frp/README.md` 上线为一台 host。
|
||||
- **旁注(非本次改动)**: E2E 浏览器 relay `:8443` 在部署前已 DOWN(只有 :8444 writer 在),故 `terminal.term` 无证书;`*.term→8443` 路由从未被动过。
|
||||
|
||||
### ✅ 修复:底部快捷键栏遮挡终端内容(iOS 安全区,2026-07-06,main)
|
||||
- **现象(用户截图,iPhone)**: 底部快捷键栏(Esc/Esc²/⇧Tab…)压住终端最后一行(Claude Code "bypass permissions" 提示行只露上半)。
|
||||
- **根因(`public/`)**: `index.html` 设了 `viewport-fit=cover`(布局铺满物理屏、延伸进刘海/Home 指示条),但 `style.css` **全程没有任何 `env(safe-area-inset-*)` 补偿**。于是 `#keybar`(`fixed; bottom:0; height:--keybar-h`)整条压进 Home 指示条区,而 `#term` 只预留了裸 `--keybar-h`,末行与键栏相撞。教科书级 cover-无-safe-area 缺陷。
|
||||
- **修复(纯 CSS,零 JS)**: `:root` 新增 `--safe-t/--safe-b = env(safe-area-inset-top/bottom, 0px)`,穿过所有贴边固定元素:`#tabbar` 顶部让出刘海(border-box + `padding-top:--safe-t`)、`#term` inset 改 `calc(--tabbar-h+--safe-t) … calc(--keybar-h+--safe-b)`、`#keybar` 高度 `calc(--keybar-h+--safe-b)` + `padding-bottom:--safe-b`(芯片留在顶部 --keybar-h,Home 条上方)、`#approvalbar` 与 `body.home-open #term` 底部、`#searchbox`/`#settingspanel` 顶部同步。inset=0 时全部塌回原值 → 桌面/无安全区平台**零回归**。
|
||||
- **验证(headless,390×844,注入 `--safe-t:59px --safe-b:34px` 模拟 iPhone)**: 开一个真实会话后量测 `term_bottom==keybar_top==764`(gap=0,无重叠)、`tabbar_bottom==99`(=40+59,让出刘海);截图确认芯片位于 Home 指示条上方、终端不再被遮。`npm run build:web` 通过。
|
||||
- **待办**: 未提交;未在真机 iPhone 上复验(仅 headless 模拟安全区)。
|
||||
|
||||
### ✅ 修复:项目面板把父文件夹当项目 & 会话点亮所有祖先项目(2026-07-06,分支 `feat/ios-client`)
|
||||
- **现象(用户截图)**: 只在 `web-terminal` 跑了一个会话,但 "Active now" 同时显示 `web-terminal`/`Documents`/`yiukai` 三张卡,且父文件夹本身被列为项目。
|
||||
- **根因(`src/http/projects.ts`)**: ① `belongsTo` 纯前缀匹配 → 会话按 cwd 归属到**每一个**祖先项目;② 历史合并(`mergeHistory`)把曾经跑过会话的 cwd(如 `~`、`~/Documents`)原样列为项目。
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import APIClient
|
||||
import ClientTLS
|
||||
import Foundation
|
||||
import os
|
||||
import SwiftUI
|
||||
@@ -225,12 +226,19 @@ final class SessionThumbnailPipeline {
|
||||
cache = SessionThumbnailCache(maxEntries: maxCacheEntries)
|
||||
}
|
||||
|
||||
/// 生产装配:共享一个 ephemeral URLSession(preview 字节可能含屏上密钥,
|
||||
/// 内存缓存 only——同 T-iOS-19 对 RO GET 的裁定)。
|
||||
/// 生产装配:ephemeral URLSession(preview 字节可能含屏上密钥,内存缓存
|
||||
/// only——同 T-iOS-19 对 RO GET 的裁定),并携带 C-iOS-2 的设备证书身份,
|
||||
/// 这样对隧道主机的预览取数也能通过 mTLS(否则缩略图会被 nginx 拒握手)。
|
||||
/// 身份经 provider 每次握手时按需从 keychain 载入(MEDIUM 无需重启修复:
|
||||
/// 中途导入的证书下次取数即生效;缺证=nil,对本地主机无副作用)。
|
||||
static func live() -> SessionThumbnailPipeline {
|
||||
SessionThumbnailPipeline(
|
||||
let identityStore = KeychainClientIdentityStore()
|
||||
let transport = URLSessionHTTPTransport(identityProvider: {
|
||||
identityStore.loadedIdentityOrNil()
|
||||
})
|
||||
return SessionThumbnailPipeline(
|
||||
loader: { request in
|
||||
try await APIClient(endpoint: request.endpoint, http: liveTransport)
|
||||
try await APIClient(endpoint: request.endpoint, http: transport)
|
||||
.preview(id: request.sessionId)
|
||||
},
|
||||
renderer: { data, cols, rows in
|
||||
@@ -239,8 +247,6 @@ final class SessionThumbnailPipeline {
|
||||
)
|
||||
}
|
||||
|
||||
private static let liveTransport = URLSessionHTTPTransport()
|
||||
|
||||
/// 取(或渲染)一张缩略图。永不抛错:任何失败显式降级为 `.placeholder`
|
||||
/// (占位图就是缩略图的错误 UI;细节进内部日志,绝不静默无痕)。
|
||||
func thumbnail(for request: SessionThumbnailRequest) async -> SessionThumbnailImage {
|
||||
|
||||
243
ios/App/WebTerm/Screens/ClientCertScreen.swift
Normal file
243
ios/App/WebTerm/Screens/ClientCertScreen.swift
Normal file
@@ -0,0 +1,243 @@
|
||||
import ClientTLS
|
||||
import Foundation
|
||||
import Observation
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// C-iOS-3 · Device-certificate install / rotation screen.
|
||||
///
|
||||
/// Flow: `.fileImporter([.pkcs12])` → secure passphrase → import (validates via
|
||||
/// `SecPKCS12Import`) → persist in the keychain → show issuer CN + expiry, with
|
||||
/// replace/rotate + remove. This is the in-app, app-sandboxed install path (a
|
||||
/// `.p12` delivered via AirDrop/Files), NOT a configuration profile.
|
||||
///
|
||||
/// INTEGRATION NOTE: presenting this screen needs a one-line nav/Settings entry
|
||||
/// ("Device certificate") in the app chrome (RootView / PairingScreen), which
|
||||
/// live outside this task's ownership. The screen is fully self-contained so
|
||||
/// that wiring is a single `NavigationLink { ClientCertScreen() }`.
|
||||
struct ClientCertScreen: View {
|
||||
@State private var model: ClientCertViewModel
|
||||
|
||||
init(model: ClientCertViewModel = ClientCertViewModel()) {
|
||||
_model = State(initialValue: model)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
installedSection
|
||||
importSection
|
||||
if model.summary != nil {
|
||||
removeSection
|
||||
}
|
||||
}
|
||||
.navigationTitle(ClientCertCopy.title)
|
||||
.fileImporter(
|
||||
isPresented: $model.isPickingFile,
|
||||
allowedContentTypes: [.pkcs12],
|
||||
allowsMultipleSelection: false
|
||||
) { result in
|
||||
model.handleFileSelection(result)
|
||||
}
|
||||
.task { model.refresh() }
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
@ViewBuilder private var installedSection: some View {
|
||||
Section(ClientCertCopy.installedHeader) {
|
||||
if let summary = model.summary {
|
||||
LabeledContent(ClientCertCopy.subject, value: summary.subjectCommonName ?? "—")
|
||||
LabeledContent(ClientCertCopy.issuer, value: summary.issuerCommonName ?? "—")
|
||||
LabeledContent(ClientCertCopy.expiry, value: model.expiryText(summary))
|
||||
if summary.isExpired() {
|
||||
Label(ClientCertCopy.expiredWarning, systemImage: "exclamationmark.triangle")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
} else {
|
||||
Text(ClientCertCopy.noneInstalled).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var importSection: some View {
|
||||
Section {
|
||||
if let name = model.pendingFileName {
|
||||
LabeledContent(ClientCertCopy.selectedFile, value: name)
|
||||
SecureField(ClientCertCopy.passphrase, text: $model.passphrase)
|
||||
.textContentType(.password)
|
||||
.autocorrectionDisabled()
|
||||
Button(ClientCertCopy.importAction) { model.importPending() }
|
||||
.disabled(!model.canImport)
|
||||
Button(ClientCertCopy.cancelAction, role: .cancel) { model.clearPending() }
|
||||
} else {
|
||||
Button {
|
||||
model.beginPicking()
|
||||
} label: {
|
||||
Label(
|
||||
model.summary == nil
|
||||
? ClientCertCopy.chooseFile
|
||||
: ClientCertCopy.replaceFile,
|
||||
systemImage: "doc.badge.plus"
|
||||
)
|
||||
}
|
||||
}
|
||||
if let error = model.errorMessage {
|
||||
Text(error).foregroundStyle(.red).font(.footnote)
|
||||
}
|
||||
} header: {
|
||||
Text(model.summary == nil ? ClientCertCopy.importHeader : ClientCertCopy.rotateHeader)
|
||||
} footer: {
|
||||
Text(ClientCertCopy.importFooter)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var removeSection: some View {
|
||||
Section {
|
||||
Button(ClientCertCopy.removeAction, role: .destructive) { model.remove() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// State machine for the install screen. Errors are mapped to actionable copy
|
||||
/// and never swallowed; a failed import leaves any prior identity intact.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ClientCertViewModel {
|
||||
private(set) var summary: ClientCertificateSummary?
|
||||
var isPickingFile = false
|
||||
var passphrase = ""
|
||||
private(set) var pendingData: Data?
|
||||
private(set) var pendingFileName: String?
|
||||
private(set) var errorMessage: String?
|
||||
|
||||
@ObservationIgnored private let store: any ClientIdentityStore
|
||||
|
||||
init(store: any ClientIdentityStore = KeychainClientIdentityStore()) {
|
||||
self.store = store
|
||||
}
|
||||
|
||||
var canImport: Bool { pendingData != nil && !passphrase.isEmpty }
|
||||
|
||||
func refresh() {
|
||||
do {
|
||||
summary = try store.loadSummary()
|
||||
} catch {
|
||||
summary = nil
|
||||
errorMessage = ClientCertCopy.loadFailed
|
||||
}
|
||||
}
|
||||
|
||||
func beginPicking() {
|
||||
errorMessage = nil
|
||||
isPickingFile = true
|
||||
}
|
||||
|
||||
func handleFileSelection(_ result: Result<[URL], Error>) {
|
||||
switch result {
|
||||
case .failure:
|
||||
errorMessage = ClientCertCopy.fileReadFailed
|
||||
case .success(let urls):
|
||||
guard let url = urls.first else { return }
|
||||
readFile(at: url)
|
||||
}
|
||||
}
|
||||
|
||||
private func readFile(at url: URL) {
|
||||
let didAccess = url.startAccessingSecurityScopedResource()
|
||||
defer { if didAccess { url.stopAccessingSecurityScopedResource() } }
|
||||
do {
|
||||
pendingData = try Data(contentsOf: url)
|
||||
pendingFileName = url.lastPathComponent
|
||||
passphrase = ""
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
pendingData = nil
|
||||
pendingFileName = nil
|
||||
errorMessage = ClientCertCopy.fileReadFailed
|
||||
}
|
||||
}
|
||||
|
||||
func importPending() {
|
||||
guard let data = pendingData else { return }
|
||||
do {
|
||||
try store.save(p12Data: data, passphrase: passphrase)
|
||||
clearPending()
|
||||
refresh()
|
||||
} catch {
|
||||
errorMessage = Self.copy(for: error)
|
||||
}
|
||||
}
|
||||
|
||||
func clearPending() {
|
||||
pendingData = nil
|
||||
pendingFileName = nil
|
||||
passphrase = ""
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
func remove() {
|
||||
do {
|
||||
try store.remove()
|
||||
summary = nil
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = ClientCertCopy.removeFailed
|
||||
}
|
||||
}
|
||||
|
||||
func expiryText(_ summary: ClientCertificateSummary) -> String {
|
||||
guard let notAfter = summary.notAfter else { return "—" }
|
||||
return notAfter.formatted(date: .abbreviated, time: .omitted)
|
||||
}
|
||||
|
||||
/// Map an import/storage error to install-UX copy (never swallowed).
|
||||
private static func copy(for error: any Error) -> String {
|
||||
switch error {
|
||||
case PKCS12ImportError.wrongPassphrase:
|
||||
return ClientCertCopy.errWrongPassphrase
|
||||
case PKCS12ImportError.corruptFile:
|
||||
return ClientCertCopy.errCorruptFile
|
||||
case PKCS12ImportError.unsupported:
|
||||
return ClientCertCopy.errUnsupported
|
||||
case PKCS12ImportError.noIdentity:
|
||||
return ClientCertCopy.errNoIdentity
|
||||
case ClientIdentityStoreError.keychain, ClientIdentityStoreError.corruptStoredBlob:
|
||||
return ClientCertCopy.errKeychain
|
||||
default:
|
||||
return ClientCertCopy.errUnknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// User-facing copy (Chinese, matching the rest of the app).
|
||||
enum ClientCertCopy {
|
||||
static let title = "设备证书"
|
||||
static let installedHeader = "已安装证书"
|
||||
static let subject = "设备 (CN)"
|
||||
static let issuer = "签发 CA"
|
||||
static let expiry = "有效期至"
|
||||
static let expiredWarning = "证书已过期,请重新导入。"
|
||||
static let noneInstalled = "尚未安装设备证书。"
|
||||
|
||||
static let importHeader = "导入证书"
|
||||
static let rotateHeader = "替换证书"
|
||||
static let chooseFile = "选择 .p12 文件"
|
||||
static let replaceFile = "选择新的 .p12 文件"
|
||||
static let selectedFile = "已选文件"
|
||||
static let passphrase = "证书密码"
|
||||
static let importAction = "导入"
|
||||
static let cancelAction = "取消"
|
||||
static let removeAction = "移除证书"
|
||||
static let importFooter =
|
||||
"证书用于连接你自己的隧道主机 (mTLS)。通过 AirDrop / 文件 把 .p12 传到本机后在此导入;证书与密码只保存在本设备钥匙串。"
|
||||
|
||||
static let errWrongPassphrase = "密码错误,请重试。"
|
||||
static let errCorruptFile = "文件无法识别,请确认是有效的 .p12 证书。"
|
||||
static let errUnsupported = "证书格式不受支持(请用 openssl pkcs12 -export -legacy 生成)。"
|
||||
static let errNoIdentity = "该 .p12 不含身份(缺少私钥),无法用于客户端认证。"
|
||||
static let errKeychain = "保存到钥匙串失败,请重试。"
|
||||
static let errUnknown = "导入失败,请重试。"
|
||||
static let loadFailed = "读取已安装证书失败。"
|
||||
static let fileReadFailed = "无法读取所选文件。"
|
||||
static let removeFailed = "移除证书失败,请重试。"
|
||||
}
|
||||
@@ -23,6 +23,12 @@ struct SessionListScreen: View {
|
||||
var onOpen: (SessionListViewModel.OpenRequest) -> Void = { _ in }
|
||||
/// Host-switch header hook: "添加主机" entry (pairing sheet, T-iOS-15).
|
||||
var onAddHost: () -> Void = {}
|
||||
/// C-iOS-3 (HIGH reachability fix) · "设备证书" entry — presents
|
||||
/// `ClientCertScreen` (import / rotate the mTLS device cert). The device
|
||||
/// cert is a global concern, but the toolbar host-menu is the app's only
|
||||
/// settings-like surface and is present in every empty state, so this is the
|
||||
/// nav idiom that makes the orphaned screen reachable.
|
||||
var onDeviceCert: () -> Void = {}
|
||||
/// T-iOS-28 (additive slot) · shared thumbnail pipeline: one cache + one
|
||||
/// render-concurrency gate across ALL rows (scrolling must never spawn
|
||||
/// unbounded offscreen terminals). `@State` keeps it stable across body
|
||||
@@ -213,6 +219,12 @@ struct SessionListScreen: View {
|
||||
} label: {
|
||||
Label(ScreenCopy.addHost, systemImage: "plus")
|
||||
}
|
||||
Button {
|
||||
onDeviceCert()
|
||||
} label: {
|
||||
Label(ScreenCopy.deviceCert, systemImage: "lock.shield")
|
||||
}
|
||||
.accessibilityIdentifier("sessions.deviceCert")
|
||||
} label: {
|
||||
Label(
|
||||
viewModel.activeHost?.name ?? ScreenCopy.hostMenuFallback,
|
||||
@@ -342,6 +354,7 @@ private enum ScreenCopy {
|
||||
static let newSession = "新建会话"
|
||||
static let kill = "结束"
|
||||
static let addHost = "配对新主机"
|
||||
static let deviceCert = "设备证书"
|
||||
static let hostMenuFallback = "主机"
|
||||
static let notPairedTitle = "还没有配对的主机"
|
||||
static let notPairedHint = "先配对你电脑上的 web-terminal(扫码或手输地址),会话会出现在这里。"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import APIClient
|
||||
import ClientTLS
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import Observation
|
||||
@@ -116,10 +117,21 @@ final class PairingViewModel {
|
||||
|
||||
@ObservationIgnored private let store: any HostStore
|
||||
@ObservationIgnored private let probe: Probe
|
||||
/// C-iOS-3 · Whether a device client certificate is installed. Used to gate
|
||||
/// the probe for tunnel hosts (mTLS-only). Injected so tests control it;
|
||||
/// production reads the keychain. Defaulted so existing call sites compile.
|
||||
@ObservationIgnored private let isDeviceCertInstalled: @Sendable () -> Bool
|
||||
|
||||
init(store: any HostStore, probe: @escaping Probe) {
|
||||
init(
|
||||
store: any HostStore,
|
||||
probe: @escaping Probe,
|
||||
isDeviceCertInstalled: @escaping @Sendable () -> Bool = {
|
||||
KeychainClientIdentityStore().hasInstalledIdentity()
|
||||
}
|
||||
) {
|
||||
self.store = store
|
||||
self.probe = probe
|
||||
self.isDeviceCertInstalled = isDeviceCertInstalled
|
||||
}
|
||||
|
||||
// MARK: - Input boundaries (untrusted, validated via HostEndpoint)
|
||||
@@ -205,10 +217,20 @@ final class PairingViewModel {
|
||||
|
||||
private func runProbe(for pending: PendingHost) async {
|
||||
needsPublicRiskAcknowledgement = false
|
||||
// C-iOS-3 · Tunnel hosts are mTLS-only: refuse to probe (which would
|
||||
// fail at the TLS handshake) until a device certificate is installed.
|
||||
// This is the single choke point both confirmConnect and retry funnel
|
||||
// through, so the gate can't be bypassed via retry().
|
||||
if Self.isTunnelHost(pending.endpoint), !isDeviceCertInstalled() {
|
||||
phase = .failed(pending, FailureDisplay(
|
||||
message: PairingCopy.deviceCertRequired, action: .retry
|
||||
))
|
||||
return
|
||||
}
|
||||
phase = .probing(pending)
|
||||
switch await probe(pending.endpoint) {
|
||||
case .failure(let error):
|
||||
phase = .failed(pending, Self.display(for: error))
|
||||
phase = .failed(pending, Self.display(for: error, endpoint: pending.endpoint))
|
||||
case .success(let endpoint):
|
||||
await storePairedHost(endpoint: endpoint, pending: pending)
|
||||
}
|
||||
@@ -239,6 +261,19 @@ final class PairingViewModel {
|
||||
|
||||
// MARK: - PairingError → copy + action (task RED list, one case each)
|
||||
|
||||
/// C-iOS-3 · Host-aware display. nginx rejects an invalid/absent/revoked
|
||||
/// client cert at the TLS layer; URLSession surfaces that as
|
||||
/// secureConnectionFailed / connection-reset → `PairingError.classify` maps
|
||||
/// it to `.tlsFailure` ("server cert invalid"), which is the WRONG diagnosis
|
||||
/// for an mTLS tunnel host. Re-map that one case to the device-cert copy;
|
||||
/// everything else falls through to the per-case mapping.
|
||||
static func display(for error: PairingError, endpoint: HostEndpoint) -> FailureDisplay {
|
||||
if case .tlsFailure = error, isTunnelHost(endpoint) {
|
||||
return FailureDisplay(message: PairingCopy.clientCertRejected, action: .retry)
|
||||
}
|
||||
return display(for: error)
|
||||
}
|
||||
|
||||
static func display(for error: PairingError) -> FailureDisplay {
|
||||
switch error {
|
||||
case .localNetworkDenied:
|
||||
@@ -270,6 +305,14 @@ final class PairingViewModel {
|
||||
/// block regardless of scheme (§5.4 table: https is included in the
|
||||
/// public-host confirm warning); otherwise https clears every notice.
|
||||
static func warning(for endpoint: HostEndpoint) -> SecurityWarning {
|
||||
// C-iOS-3 · A *.terminal.yaojia.wang tunnel host is gated by the device
|
||||
// client certificate (mTLS), so the "anyone who can reach the port gets
|
||||
// a shell" blocking warning is FALSE here and would only deter the
|
||||
// intended flow. The real gate is the cert-install check in runProbe.
|
||||
// Genuinely public NON-tunnel hosts still hit .publicHostBlocking below.
|
||||
if isTunnelHost(endpoint) {
|
||||
return .none
|
||||
}
|
||||
let hostClass = classifyHost(endpoint.baseURL.host ?? "")
|
||||
if hostClass == .publicHost {
|
||||
return .publicHostBlocking
|
||||
@@ -353,8 +396,17 @@ final class PairingViewModel {
|
||||
return octets
|
||||
}
|
||||
|
||||
/// C-iOS-3 · A native mTLS reverse-tunnel host (`<name>.terminal.yaojia.wang`).
|
||||
/// These reach a loopback base app through the VPS and are protected ONLY by
|
||||
/// the device client certificate — hence the softened warning + the
|
||||
/// cert-install gate + the client-cert-rejected re-classification.
|
||||
static func isTunnelHost(_ endpoint: HostEndpoint) -> Bool {
|
||||
(endpoint.baseURL.host ?? "").lowercased().hasSuffix(tunnelZoneSuffix)
|
||||
}
|
||||
|
||||
// MARK: - Named constants (no magic values, plan §4)
|
||||
|
||||
private static let tunnelZoneSuffix = ".terminal.yaojia.wang"
|
||||
private static let schemeSeparator = "://"
|
||||
private static let defaultManualScheme = "http://"
|
||||
private static let httpsScheme = "https"
|
||||
@@ -385,6 +437,13 @@ enum PairingCopy {
|
||||
"TLS 连接失败:证书无效或不受信任。"
|
||||
static let timeout =
|
||||
"连接超时。请确认主机在线、与手机在同一网络后重试。"
|
||||
/// C-iOS-3 · Tunnel host reached without a device certificate installed.
|
||||
static let deviceCertRequired =
|
||||
"请先安装本设备证书:到 设置 →「设备证书」导入 .p12 后,再连接该隧道主机。"
|
||||
/// C-iOS-3 · nginx rejected the presented client certificate (invalid /
|
||||
/// revoked). Surfaced in place of the mis-classified "server cert invalid".
|
||||
static let clientCertRejected =
|
||||
"本设备证书无效或已吊销,请重新导入。"
|
||||
|
||||
static func hostUnreachable(_ underlying: String) -> String {
|
||||
"无法连接主机:\(underlying)"
|
||||
|
||||
@@ -51,6 +51,9 @@ struct AdaptiveRootView: View {
|
||||
) {
|
||||
projectsSheet
|
||||
}
|
||||
.sheet(isPresented: $coordinator.isDeviceCertPresented) {
|
||||
deviceCertSheet
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Layout branch (the SOLE size-class consumer)
|
||||
@@ -107,4 +110,20 @@ struct AdaptiveRootView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Device certificate sheet (C-iOS-3, HIGH reachability fix)
|
||||
|
||||
/// 自带 NavigationStack(`ClientCertScreen` 用 `.navigationTitle`)+ 右上「完成」
|
||||
/// 关闭按钮,保证导入 .p12 后能明确返回。`ClientCertScreen` 自持
|
||||
/// `ClientCertViewModel`(keychain store),此处零依赖注入。
|
||||
@ViewBuilder private var deviceCertSheet: some View {
|
||||
NavigationStack {
|
||||
ClientCertScreen()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button(RootCopy.done) { coordinator.isDeviceCertPresented = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,11 @@ final class AppCoordinator {
|
||||
/// prefs 每次进入都重新拉取。
|
||||
private(set) var projectsViewModel: ProjectsViewModel?
|
||||
var isProjectsPresented = false
|
||||
/// C-iOS-3 (HIGH reachability fix) · "设备证书" sheet (import / rotate the
|
||||
/// mTLS device cert via `ClientCertScreen`). No VM state — the screen owns
|
||||
/// its own `ClientCertViewModel` over the keychain store; dismissal needs no
|
||||
/// refresh because the transports resolve the identity lazily per connection.
|
||||
var isDeviceCertPresented = false
|
||||
|
||||
let sessionList: SessionListViewModel
|
||||
@ObservationIgnored let environment: AppEnvironment
|
||||
@@ -108,6 +113,13 @@ final class AppCoordinator {
|
||||
projectsViewModel = nil
|
||||
}
|
||||
|
||||
// MARK: - Device certificate (C-iOS-3)
|
||||
|
||||
/// Toolbar host-menu 入口:呈现设备证书导入/轮换 sheet。
|
||||
func presentDeviceCert() {
|
||||
isDeviceCertPresented = true
|
||||
}
|
||||
|
||||
/// "在此仓库开新会话":关 sheet → fresh spawn(`attach(null, cwd)`)+
|
||||
/// attach 后注入 `claude\r`(帧序由 engine 的 attach-first 队列保证)。
|
||||
func openProject(_ request: ProjectOpenRequest) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import APIClient
|
||||
import ClientTLS
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import SessionCore
|
||||
@@ -37,8 +38,20 @@ struct AppEnvironment: Sendable {
|
||||
var unreadStore: any UnreadWatermarkStore = UserDefaultsUnreadWatermarkStore()
|
||||
|
||||
static func production() -> AppEnvironment {
|
||||
let http = URLSessionHTTPTransport()
|
||||
let termTransport = URLSessionTermTransport()
|
||||
// C-iOS-2 (MEDIUM no-relaunch fix) · Resolve the installed device client
|
||||
// identity LAZILY from the keychain on each connect/challenge, injected
|
||||
// into BOTH transports + the probe as a provider. This way a certificate
|
||||
// imported from the "设备证书" screen takes effect on the NEXT connection
|
||||
// without relaunching — a snapshot captured here would stay stale. A
|
||||
// missing cert is the normal pre-install state (→ nil); genuine faults
|
||||
// are logged, not fatal (`loadedIdentityOrNil`). mTLS challenges only
|
||||
// fire for tunnel hosts, so a `nil` result is inert for local hosts.
|
||||
let identityStore = KeychainClientIdentityStore()
|
||||
let identityProvider: @Sendable () -> ClientIdentity? = {
|
||||
identityStore.loadedIdentityOrNil()
|
||||
}
|
||||
let http = URLSessionHTTPTransport(identityProvider: identityProvider)
|
||||
let termTransport = URLSessionTermTransport(identityProvider: identityProvider)
|
||||
return AppEnvironment(
|
||||
hostStore: KeychainHostStore(),
|
||||
lastSessionStore: UserDefaultsLastSessionStore(),
|
||||
|
||||
@@ -74,7 +74,8 @@ struct StackRootView: View {
|
||||
SessionListScreen(
|
||||
viewModel: coordinator.sessionList,
|
||||
onOpen: { coordinator.open($0) },
|
||||
onAddHost: { coordinator.presentAddHost() }
|
||||
onAddHost: { coordinator.presentAddHost() },
|
||||
onDeviceCert: { coordinator.presentDeviceCert() }
|
||||
)
|
||||
.safeAreaInset(edge: .bottom) { continueLastBanner }
|
||||
// 横幅出现/消失走 DS 动效(reduceMotion 时塌成瞬切,无位移)。
|
||||
@@ -178,4 +179,5 @@ struct ProjectsToolbarItem: ToolbarContent {
|
||||
enum RootCopy {
|
||||
static let continueLast = "继续上次会话"
|
||||
static let projects = "项目"
|
||||
static let done = "完成"
|
||||
}
|
||||
|
||||
@@ -39,7 +39,8 @@ struct SplitRootView: View {
|
||||
// 分栏只是又一个触发面:映射到同一 selectSidebarItem 路由。
|
||||
coordinator.selectSidebarItem(sidebarItem(for: request))
|
||||
},
|
||||
onAddHost: { coordinator.presentAddHost() }
|
||||
onAddHost: { coordinator.presentAddHost() },
|
||||
onDeviceCert: { coordinator.presentDeviceCert() }
|
||||
)
|
||||
.safeAreaInset(edge: .bottom) { continueLastBanner }
|
||||
.animation(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ClientTLS
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
|
||||
@@ -9,14 +10,39 @@ import WireProtocol
|
||||
/// bypass that single audited point (review CRITICAL).
|
||||
struct URLSessionHTTPTransport: HTTPTransport {
|
||||
private let session: URLSession
|
||||
/// Strong reference to the mTLS delegate. URLSession retains its delegate
|
||||
/// until invalidated, but this ephemeral session is never explicitly
|
||||
/// invalidated, so holding it here documents the ownership and keeps the
|
||||
/// transport a self-contained value.
|
||||
private let tlsDelegate: LazyClientTLSSessionDelegate
|
||||
|
||||
/// Default is EPHEMERAL, not `.shared` (T-iOS-19 finding): RO GET bodies
|
||||
/// include `/live-sessions/:id/preview` — raw terminal ring-buffer bytes
|
||||
/// that may contain printed secrets — and `.shared`'s default URLCache
|
||||
/// writes responses to disk. Ephemeral keeps them memory-only, matching
|
||||
/// the WS transport and the privacy-shade posture.
|
||||
init(session: URLSession = URLSession(configuration: .ephemeral)) {
|
||||
self.session = session
|
||||
/// Fixed-identity convenience (snapshot callers / tests): wraps a constant
|
||||
/// provider, so behaviour is identical to capturing the identity directly.
|
||||
init(identity: ClientIdentity? = nil) {
|
||||
self.init(identityProvider: { identity })
|
||||
}
|
||||
|
||||
/// C-iOS-2 (MEDIUM no-relaunch fix) · The session's mTLS delegate resolves
|
||||
/// the device identity LAZILY, per client-certificate challenge, from
|
||||
/// `identityProvider`. A `ClientCertificate` challenge from a tunneled host
|
||||
/// is thus answered with whatever cert is installed AT CHALLENGE TIME — so a
|
||||
/// certificate imported mid-run is presented on the NEXT TLS handshake
|
||||
/// without an app relaunch (a snapshot captured here would stay stale). The
|
||||
/// session itself stays a single long-lived value (no per-request churn).
|
||||
/// Local http/https hosts never issue that challenge, so the provider is
|
||||
/// never even consulted for them (and a `nil` result is inert regardless).
|
||||
///
|
||||
/// EPHEMERAL, not `.shared` (T-iOS-19 finding): RO GET bodies include
|
||||
/// `/live-sessions/:id/preview` — raw terminal ring-buffer bytes that may
|
||||
/// contain printed secrets — and `.shared`'s default URLCache writes
|
||||
/// responses to disk. Ephemeral keeps them memory-only, matching the WS
|
||||
/// transport and the privacy-shade posture.
|
||||
init(identityProvider: @escaping @Sendable () -> ClientIdentity?) {
|
||||
let delegate = LazyClientTLSSessionDelegate(identityProvider: identityProvider)
|
||||
self.tlsDelegate = delegate
|
||||
self.session = URLSession(
|
||||
configuration: .ephemeral, delegate: delegate, delegateQueue: nil
|
||||
)
|
||||
}
|
||||
|
||||
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
||||
@@ -29,3 +55,40 @@ struct URLSessionHTTPTransport: HTTPTransport {
|
||||
return (data, httpResponse)
|
||||
}
|
||||
}
|
||||
|
||||
/// C-iOS-2 (MEDIUM no-relaunch fix) · Session-level mTLS delegate that resolves
|
||||
/// the device identity FRESH per client-certificate challenge — the App-layer
|
||||
/// twin of ClientTLS's fixed-identity `ClientTLSSessionDelegate` (which captures
|
||||
/// the identity once). The provider is only consulted for a `ClientCertificate`
|
||||
/// challenge, so a `ServerTrust` challenge never triggers a keychain read; the
|
||||
/// pure decision itself is delegated to the shared `MutualTLSChallengeResponder`
|
||||
/// (single truth table, unit-tested in ClientTLS).
|
||||
///
|
||||
/// `@unchecked Sendable`: URLSession retains its delegate and invokes it from
|
||||
/// arbitrary queues; every stored field is an immutable `let` over a `@Sendable`
|
||||
/// value (the provider is `@Sendable`, the responder is stateless).
|
||||
private final class LazyClientTLSSessionDelegate:
|
||||
NSObject, URLSessionDelegate, @unchecked Sendable {
|
||||
private let identityProvider: @Sendable () -> ClientIdentity?
|
||||
private let responder = MutualTLSChallengeResponder()
|
||||
|
||||
init(identityProvider: @escaping @Sendable () -> ClientIdentity?) {
|
||||
self.identityProvider = identityProvider
|
||||
super.init()
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
// Only a client-certificate challenge needs the identity; resolving it
|
||||
// for a server-trust challenge would do a needless keychain read/import
|
||||
// on every HTTPS handshake.
|
||||
let isClientCert = challenge.protectionSpace.authenticationMethod
|
||||
== NSURLAuthenticationMethodClientCertificate
|
||||
let identity = isClientCert ? identityProvider() : nil
|
||||
let resolution = responder.resolve(challenge, identity: identity)
|
||||
completionHandler(resolution.disposition, resolution.credential)
|
||||
}
|
||||
}
|
||||
|
||||
22
ios/Packages/ClientTLS/Package.swift
Normal file
22
ios/Packages/ClientTLS/Package.swift
Normal file
@@ -0,0 +1,22 @@
|
||||
// swift-tools-version: 6.0
|
||||
// C-iOS-1 · Device client-certificate (mutual-TLS) leaf package.
|
||||
//
|
||||
// Standalone, no local-package dependencies — imports ONLY Security/Foundation.
|
||||
// It is a downward leaf: SessionCore and the App target may depend on it, it
|
||||
// depends on nothing in this workspace. This keeps the pure, unit-testable
|
||||
// mTLS logic (identity wrapper, PKCS#12 import, challenge responder) isolated
|
||||
// from the WS/HTTP transport plumbing that consumes it.
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "ClientTLS",
|
||||
platforms: [.iOS(.v17), .macOS(.v14)],
|
||||
products: [
|
||||
.library(name: "ClientTLS", targets: ["ClientTLS"]),
|
||||
],
|
||||
targets: [
|
||||
.target(name: "ClientTLS"),
|
||||
.testTarget(name: "ClientTLSTests", dependencies: ["ClientTLS"]),
|
||||
],
|
||||
swiftLanguageModes: [.v6]
|
||||
)
|
||||
@@ -0,0 +1,188 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// C-iOS-3 · Display summary of a device certificate, shown on the install /
|
||||
/// rotation screen so the user can confirm *which* cert is active and *when*
|
||||
/// it expires before relying on it.
|
||||
public struct ClientCertificateSummary: Equatable, Sendable {
|
||||
/// Subject common name — the device/leaf CN (e.g. `t1-iphone`).
|
||||
public let subjectCommonName: String?
|
||||
/// Issuer common name — the device-CA CN (e.g. `webterm-device-ca`).
|
||||
public let issuerCommonName: String?
|
||||
/// Not-after date; `nil` if it could not be parsed.
|
||||
public let notAfter: Date?
|
||||
|
||||
public init(subjectCommonName: String?, issuerCommonName: String?, notAfter: Date?) {
|
||||
self.subjectCommonName = subjectCommonName
|
||||
self.issuerCommonName = issuerCommonName
|
||||
self.notAfter = notAfter
|
||||
}
|
||||
|
||||
/// Expired relative to `now` (defaults to the current instant). Unknown
|
||||
/// expiry is treated as NOT expired (fail-open for display only — the TLS
|
||||
/// stack, not this label, is the real gate).
|
||||
public func isExpired(asOf now: Date = Date()) -> Bool {
|
||||
guard let notAfter else { return false }
|
||||
return notAfter < now
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the display fields off a `SecCertificate`.
|
||||
///
|
||||
/// Implemented as a minimal X.509 DER walk over `SecCertificateCopyData` rather
|
||||
/// than `SecCertificateCopyValues`, because that API and its `kSecOID*` /
|
||||
/// `kSecPropertyKey*` constants are **macOS-only** — unavailable on iOS, which
|
||||
/// is the real deployment target. The DER walk is identical on both platforms
|
||||
/// and unit-testable against the fixture. Any parse miss degrades to `nil`
|
||||
/// fields (the summary is display-only; the TLS stack is the gate).
|
||||
enum CertificateInspector {
|
||||
static func summary(of certificate: SecCertificate) -> ClientCertificateSummary {
|
||||
let der = [UInt8](SecCertificateCopyData(certificate) as Data)
|
||||
let fields = X509Fields.parse(der: der)
|
||||
return ClientCertificateSummary(
|
||||
subjectCommonName: fields?.subjectCommonName,
|
||||
issuerCommonName: fields?.issuerCommonName,
|
||||
notAfter: fields?.notAfter
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Minimal ASN.1 DER reader
|
||||
|
||||
/// One tag-length-value element, as byte ranges into the source buffer.
|
||||
private struct DERElement {
|
||||
let tag: UInt8
|
||||
let valueStart: Int
|
||||
let valueEnd: Int
|
||||
}
|
||||
|
||||
private enum DER {
|
||||
/// DER tags used here.
|
||||
static let sequence: UInt8 = 0x30
|
||||
static let set: UInt8 = 0x31
|
||||
static let oid: UInt8 = 0x06
|
||||
static let contextTag0: UInt8 = 0xA0 // [0] EXPLICIT (X.509 version)
|
||||
static let utcTime: UInt8 = 0x17
|
||||
static let generalizedTime: UInt8 = 0x18
|
||||
|
||||
/// Read a single TLV at `start`. Returns the element and the index just
|
||||
/// past it, or `nil` on any malformed length/overrun (defensive: the cert
|
||||
/// bytes come from the system but are still parsed as untrusted input).
|
||||
static func read(_ bytes: [UInt8], at start: Int) -> DERElement? {
|
||||
guard start >= 0, start + 1 < bytes.count else { return nil }
|
||||
let tag = bytes[start]
|
||||
var index = start + 1
|
||||
let firstLengthByte = bytes[index]
|
||||
index += 1
|
||||
var length = 0
|
||||
if firstLengthByte & 0x80 == 0 {
|
||||
length = Int(firstLengthByte)
|
||||
} else {
|
||||
let byteCount = Int(firstLengthByte & 0x7F)
|
||||
guard byteCount > 0, byteCount <= 4, index + byteCount <= bytes.count else {
|
||||
return nil
|
||||
}
|
||||
for _ in 0..<byteCount {
|
||||
length = (length << 8) | Int(bytes[index])
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
let valueEnd = index + length
|
||||
guard length >= 0, valueEnd <= bytes.count else { return nil }
|
||||
return DERElement(tag: tag, valueStart: index, valueEnd: valueEnd)
|
||||
}
|
||||
|
||||
/// Split a constructed value `[start, end)` into its immediate children.
|
||||
static func children(_ bytes: [UInt8], from start: Int, to end: Int) -> [DERElement] {
|
||||
var elements: [DERElement] = []
|
||||
var index = start
|
||||
while index < end, let element = read(bytes, at: index) {
|
||||
elements.append(element)
|
||||
index = element.valueEnd
|
||||
}
|
||||
return elements
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - X.509 field extraction
|
||||
|
||||
private struct X509Fields {
|
||||
let subjectCommonName: String?
|
||||
let issuerCommonName: String?
|
||||
let notAfter: Date?
|
||||
|
||||
/// CommonName attribute OID `2.5.4.3` → DER content bytes `55 04 03`.
|
||||
private static let commonNameOID: [UInt8] = [0x55, 0x04, 0x03]
|
||||
|
||||
/// Certificate ::= SEQUENCE { tbsCertificate, signatureAlgorithm, signature }
|
||||
/// tbsCertificate ::= SEQUENCE { [0] version?, serialNumber, signature,
|
||||
/// issuer Name, validity, subject Name, subjectPublicKeyInfo, ... }
|
||||
static func parse(der bytes: [UInt8]) -> X509Fields? {
|
||||
guard let certificate = DER.read(bytes, at: 0), certificate.tag == DER.sequence else {
|
||||
return nil
|
||||
}
|
||||
let certChildren = DER.children(
|
||||
bytes, from: certificate.valueStart, to: certificate.valueEnd
|
||||
)
|
||||
guard let tbs = certChildren.first, tbs.tag == DER.sequence else { return nil }
|
||||
|
||||
var tbsChildren = DER.children(bytes, from: tbs.valueStart, to: tbs.valueEnd)
|
||||
// Optional EXPLICIT [0] version — drop it so the fixed fields align.
|
||||
if let first = tbsChildren.first, first.tag == DER.contextTag0 {
|
||||
tbsChildren.removeFirst()
|
||||
}
|
||||
// Fixed order after (optional) version: serialNumber, signature,
|
||||
// issuer, validity, subject, ...
|
||||
guard tbsChildren.count >= 5 else { return nil }
|
||||
let issuer = tbsChildren[2]
|
||||
let validity = tbsChildren[3]
|
||||
let subject = tbsChildren[4]
|
||||
|
||||
return X509Fields(
|
||||
subjectCommonName: commonName(bytes, in: subject),
|
||||
issuerCommonName: commonName(bytes, in: issuer),
|
||||
notAfter: notAfterDate(bytes, in: validity)
|
||||
)
|
||||
}
|
||||
|
||||
/// Name ::= SEQUENCE OF RDN(SET) OF AttributeTypeAndValue(SEQ{OID, value}).
|
||||
/// Returns the first CN value found.
|
||||
private static func commonName(_ bytes: [UInt8], in name: DERElement) -> String? {
|
||||
for rdn in DER.children(bytes, from: name.valueStart, to: name.valueEnd)
|
||||
where rdn.tag == DER.set {
|
||||
for atv in DER.children(bytes, from: rdn.valueStart, to: rdn.valueEnd)
|
||||
where atv.tag == DER.sequence {
|
||||
let parts = DER.children(bytes, from: atv.valueStart, to: atv.valueEnd)
|
||||
guard parts.count >= 2, parts[0].tag == DER.oid else { continue }
|
||||
let oid = Array(bytes[parts[0].valueStart..<parts[0].valueEnd])
|
||||
if oid == commonNameOID {
|
||||
let value = Array(bytes[parts[1].valueStart..<parts[1].valueEnd])
|
||||
// PrintableString / UTF8String both decode as UTF-8.
|
||||
return String(bytes: value, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Validity ::= SEQUENCE { notBefore Time, notAfter Time } — the 2nd child.
|
||||
private static func notAfterDate(_ bytes: [UInt8], in validity: DERElement) -> Date? {
|
||||
let times = DER.children(bytes, from: validity.valueStart, to: validity.valueEnd)
|
||||
guard times.count >= 2 else { return nil }
|
||||
let notAfter = times[1]
|
||||
let value = Array(bytes[notAfter.valueStart..<notAfter.valueEnd])
|
||||
guard let text = String(bytes: value, encoding: .ascii) else { return nil }
|
||||
return parseTime(text, tag: notAfter.tag)
|
||||
}
|
||||
|
||||
private static func parseTime(_ text: String, tag: UInt8) -> Date? {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = TimeZone(identifier: "UTC")
|
||||
// UTCTime: YYMMDDHHMMSSZ (used for years < 2050); GeneralizedTime:
|
||||
// YYYYMMDDHHMMSSZ. The 2-digit-year window covers the relevant range
|
||||
// (device certs live in the 2020s-2040s).
|
||||
formatter.dateFormat = tag == DER.utcTime ? "yyMMddHHmmss'Z'" : "yyyyMMddHHmmss'Z'"
|
||||
return formatter.date(from: text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// C-iOS-1 · An imported device client identity: the `SecIdentity` (leaf
|
||||
/// certificate + its private key) plus the issuer chain that accompanies it in
|
||||
/// the TLS handshake.
|
||||
///
|
||||
/// `@unchecked Sendable`: `SecIdentity` / `SecCertificate` are CoreFoundation
|
||||
/// handles that are immutable once imported and thread-safe to read; this value
|
||||
/// only ever holds finished imports and never mutates them. Marking it lets the
|
||||
/// identity flow into the `@unchecked Sendable` WS connection and the URLSession
|
||||
/// delegates that answer client-certificate challenges.
|
||||
public struct ClientIdentity: @unchecked Sendable {
|
||||
/// The leaf certificate + private key used to authenticate to the server.
|
||||
public let secIdentity: SecIdentity
|
||||
/// Issuer certificates to present alongside the leaf (the CA chain, leaf
|
||||
/// excluded). May be empty when the trust anchor is already pinned server
|
||||
/// side (nginx `ssl_client_certificate` = the device-CA) — the leaf alone
|
||||
/// then verifies at `ssl_verify_depth 1`.
|
||||
public let issuerCertificates: [SecCertificate]
|
||||
|
||||
public init(secIdentity: SecIdentity, issuerCertificates: [SecCertificate] = []) {
|
||||
self.secIdentity = secIdentity
|
||||
self.issuerCertificates = issuerCertificates
|
||||
}
|
||||
|
||||
/// The `URLCredential` handed back to a `ClientCertificate` auth challenge.
|
||||
///
|
||||
/// `.forSession` (not `.permanent`) per plan §C-iOS-1: the identity already
|
||||
/// lives in the app's keychain item — persisting the credential in the
|
||||
/// shared URL credential store would be a second, unmanaged copy.
|
||||
public func urlCredential(
|
||||
persistence: URLCredential.Persistence = .forSession
|
||||
) -> URLCredential {
|
||||
URLCredential(
|
||||
identity: secIdentity,
|
||||
certificates: issuerCertificates.isEmpty ? nil : issuerCertificates,
|
||||
persistence: persistence
|
||||
)
|
||||
}
|
||||
|
||||
/// The leaf `SecCertificate` backing this identity (for display / summary).
|
||||
public func leafCertificate() -> SecCertificate? {
|
||||
var certificate: SecCertificate?
|
||||
let status = SecIdentityCopyCertificate(secIdentity, &certificate)
|
||||
return status == errSecSuccess ? certificate : nil
|
||||
}
|
||||
|
||||
/// Human-readable summary of the leaf certificate (subject CN, issuer CN,
|
||||
/// expiry) for the install/rotation UI. `nil` only if the leaf can't be
|
||||
/// read (should never happen for a valid import).
|
||||
public func summary() -> ClientCertificateSummary? {
|
||||
leafCertificate().map(CertificateInspector.summary(of:))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import Foundation
|
||||
|
||||
/// C-iOS-2 · Reusable `NSObject` URLSession delegate that answers
|
||||
/// **session-level** auth challenges by delegating to `MutualTLSChallengeResponder`.
|
||||
///
|
||||
/// This is the seam for the HTTP transport (`session.data(for:)` surfaces
|
||||
/// challenges through `urlSession(_:didReceive:completionHandler:)`). The WS
|
||||
/// transport can't reuse it — its connection object is already the session's
|
||||
/// delegate and needs the **task-level** callback — so that one implements the
|
||||
/// task-level method itself against the same responder.
|
||||
///
|
||||
/// `@unchecked Sendable`: URLSession retains its delegate and invokes it from
|
||||
/// arbitrary queues; every stored field is an immutable `let` over thread-safe
|
||||
/// values (`ClientIdentity` is `@unchecked Sendable`, the responder is stateless).
|
||||
public final class ClientTLSSessionDelegate: NSObject, URLSessionDelegate, @unchecked Sendable {
|
||||
private let identity: ClientIdentity?
|
||||
private let responder: MutualTLSChallengeResponder
|
||||
|
||||
public init(
|
||||
identity: ClientIdentity?,
|
||||
responder: MutualTLSChallengeResponder = MutualTLSChallengeResponder()
|
||||
) {
|
||||
self.identity = identity
|
||||
self.responder = responder
|
||||
super.init()
|
||||
}
|
||||
|
||||
public func urlSession(
|
||||
_ session: URLSession,
|
||||
didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
let resolution = responder.resolve(challenge, identity: identity)
|
||||
completionHandler(resolution.disposition, resolution.credential)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import Foundation
|
||||
import os
|
||||
import Security
|
||||
|
||||
/// Persists the device identity so it survives relaunch. The raw `.p12` bytes
|
||||
/// **and** its passphrase are stored together (the passphrase is required to
|
||||
/// re-import via `SecPKCS12Import` at every launch), then re-imported on load.
|
||||
public protocol ClientIdentityStore: Sendable {
|
||||
/// Validate (`SecPKCS12Import`) then persist the `.p12` + passphrase.
|
||||
/// Throws `PKCS12ImportError` on a bad passphrase / corrupt file (nothing is
|
||||
/// persisted in that case) and `ClientIdentityStoreError` on a storage fault.
|
||||
func save(p12Data: Data, passphrase: String) throws
|
||||
/// Re-import and return the stored identity; `nil` if none is installed.
|
||||
func loadIdentity() throws -> ClientIdentity?
|
||||
/// Display summary of the stored certificate; `nil` if none is installed.
|
||||
func loadSummary() throws -> ClientCertificateSummary?
|
||||
/// Delete the stored identity (rotation / removal). Idempotent.
|
||||
func remove() throws
|
||||
/// Cheap existence check for gating (does NOT re-import).
|
||||
func hasInstalledIdentity() -> Bool
|
||||
}
|
||||
|
||||
public enum ClientIdentityStoreError: Error, Equatable, Sendable {
|
||||
/// A Keychain `SecItem*` call failed with this `OSStatus`.
|
||||
case keychain(OSStatus)
|
||||
/// The stored blob was present but could not be decoded.
|
||||
case corruptStoredBlob
|
||||
}
|
||||
|
||||
public extension ClientIdentityStore {
|
||||
/// Convenience for composition roots: load the identity, logging and
|
||||
/// swallowing errors into `nil`. A missing cert is the normal pre-install
|
||||
/// state; a genuine fault must not crash launch, but is logged (never
|
||||
/// silently dropped).
|
||||
func loadedIdentityOrNil() -> ClientIdentity? {
|
||||
do {
|
||||
return try loadIdentity()
|
||||
} catch {
|
||||
ClientTLSLog.identity.error(
|
||||
"loadIdentity failed: \(String(describing: error), privacy: .public)"
|
||||
)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The stored payload — `.p12` bytes plus the passphrase needed to re-import.
|
||||
private struct StoredP12Blob: Codable {
|
||||
let p12: Data
|
||||
let passphrase: String
|
||||
}
|
||||
|
||||
/// Keychain-backed store: one `kSecClassGenericPassword` item holding the
|
||||
/// JSON-encoded `StoredP12Blob` in `kSecValueData`, protected with
|
||||
/// `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` (available after the first
|
||||
/// unlock post-boot, never migrates off this device).
|
||||
public struct KeychainClientIdentityStore: ClientIdentityStore {
|
||||
public static let defaultService = "com.yaojia.webterm.clienttls"
|
||||
public static let defaultAccount = "device-identity"
|
||||
|
||||
private let service: String
|
||||
private let account: String
|
||||
|
||||
public init(
|
||||
service: String = defaultService, account: String = defaultAccount
|
||||
) {
|
||||
self.service = service
|
||||
self.account = account
|
||||
}
|
||||
|
||||
public func save(p12Data: Data, passphrase: String) throws {
|
||||
// Validate BEFORE persisting — a wrong passphrase / corrupt file must
|
||||
// surface to the install UI and leave any prior identity untouched.
|
||||
_ = try PKCS12Importer.importIdentity(data: p12Data, passphrase: passphrase)
|
||||
let blob = try encode(StoredP12Blob(p12: p12Data, passphrase: passphrase))
|
||||
try writeItem(blob)
|
||||
}
|
||||
|
||||
public func loadIdentity() throws -> ClientIdentity? {
|
||||
guard let blob = try readBlob() else { return nil }
|
||||
return try PKCS12Importer.importIdentity(
|
||||
data: blob.p12, passphrase: blob.passphrase
|
||||
)
|
||||
}
|
||||
|
||||
public func loadSummary() throws -> ClientCertificateSummary? {
|
||||
try loadIdentity()?.summary()
|
||||
}
|
||||
|
||||
public func remove() throws {
|
||||
let status = SecItemDelete(baseQuery() as CFDictionary)
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||
throw ClientIdentityStoreError.keychain(status)
|
||||
}
|
||||
}
|
||||
|
||||
public func hasInstalledIdentity() -> Bool {
|
||||
var query = baseQuery()
|
||||
query[kSecReturnData as String] = false
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess
|
||||
}
|
||||
|
||||
// MARK: - Keychain plumbing
|
||||
|
||||
private func baseQuery() -> [String: Any] {
|
||||
[
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
}
|
||||
|
||||
private func writeItem(_ data: Data) throws {
|
||||
// Delete-then-add keeps the item's protection class deterministic
|
||||
// (SecItemUpdate can't change kSecAttrAccessible in place).
|
||||
let deleteStatus = SecItemDelete(baseQuery() as CFDictionary)
|
||||
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
|
||||
throw ClientIdentityStoreError.keychain(deleteStatus)
|
||||
}
|
||||
var attributes = baseQuery()
|
||||
attributes[kSecValueData as String] = data
|
||||
attributes[kSecAttrAccessible as String] =
|
||||
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
let addStatus = SecItemAdd(attributes as CFDictionary, nil)
|
||||
guard addStatus == errSecSuccess else {
|
||||
throw ClientIdentityStoreError.keychain(addStatus)
|
||||
}
|
||||
}
|
||||
|
||||
private func readBlob() throws -> StoredP12Blob? {
|
||||
var query = baseQuery()
|
||||
query[kSecReturnData as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
if status == errSecItemNotFound { return nil }
|
||||
guard status == errSecSuccess, let data = result as? Data else {
|
||||
throw ClientIdentityStoreError.keychain(status)
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder().decode(StoredP12Blob.self, from: data)
|
||||
} catch {
|
||||
throw ClientIdentityStoreError.corruptStoredBlob
|
||||
}
|
||||
}
|
||||
|
||||
private func encode(_ blob: StoredP12Blob) throws -> Data {
|
||||
do {
|
||||
return try JSONEncoder().encode(blob)
|
||||
} catch {
|
||||
throw ClientIdentityStoreError.corruptStoredBlob
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory store for previews and unit tests: same import/summary code path as
|
||||
/// the keychain store (so the roundtrip is exercised) without any keychain
|
||||
/// entitlement. `@unchecked Sendable` — mutable blob guarded by a lock.
|
||||
public final class InMemoryClientIdentityStore: ClientIdentityStore, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var blob: StoredP12BlobBox?
|
||||
|
||||
/// Boxed so the private `StoredP12Blob` type stays file-private above; this
|
||||
/// mirror keeps the two bytes+passphrase without exposing the Codable type.
|
||||
private struct StoredP12BlobBox {
|
||||
let p12: Data
|
||||
let passphrase: String
|
||||
}
|
||||
|
||||
public init() {}
|
||||
|
||||
public func save(p12Data: Data, passphrase: String) throws {
|
||||
_ = try PKCS12Importer.importIdentity(data: p12Data, passphrase: passphrase)
|
||||
lock.withLock { blob = StoredP12BlobBox(p12: p12Data, passphrase: passphrase) }
|
||||
}
|
||||
|
||||
public func loadIdentity() throws -> ClientIdentity? {
|
||||
guard let stored = lock.withLock({ blob }) else { return nil }
|
||||
return try PKCS12Importer.importIdentity(
|
||||
data: stored.p12, passphrase: stored.passphrase
|
||||
)
|
||||
}
|
||||
|
||||
public func loadSummary() throws -> ClientCertificateSummary? {
|
||||
try loadIdentity()?.summary()
|
||||
}
|
||||
|
||||
public func remove() throws {
|
||||
lock.withLock { blob = nil }
|
||||
}
|
||||
|
||||
public func hasInstalledIdentity() -> Bool {
|
||||
lock.withLock { blob != nil }
|
||||
}
|
||||
}
|
||||
|
||||
enum ClientTLSLog {
|
||||
static let identity = Logger(subsystem: "com.yaojia.webterm", category: "client-tls")
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import Foundation
|
||||
|
||||
/// C-iOS-1 · The pure, synchronous decision at the heart of mutual TLS.
|
||||
///
|
||||
/// It maps a URLSession auth challenge (+ the optionally-installed device
|
||||
/// identity) to a disposition and credential. Deliberately free of any
|
||||
/// URLSession / socket state so the full truth table is unit-testable without a
|
||||
/// live connection — the transports (WS task delegate, HTTP session delegate)
|
||||
/// are thin adapters that call `resolve` and forward its result to the
|
||||
/// completion handler.
|
||||
///
|
||||
/// Truth table (plan §C-iOS-1):
|
||||
/// a. `ClientCertificate` + identity present → `.useCredential` with the
|
||||
/// identity's `URLCredential`.
|
||||
/// b. `ClientCertificate` + no identity → `.cancelAuthenticationChallenge`
|
||||
/// (a clean, classifiable failure — never a silent no-cert continue).
|
||||
/// c. `ServerTrust` → `.performDefaultHandling`
|
||||
/// (the LE wildcard is validated by the system trust store).
|
||||
/// d. anything else → `.performDefaultHandling`.
|
||||
public struct MutualTLSChallengeResponder: Sendable {
|
||||
/// The responder's decision. Not `Sendable` (it may carry a `URLCredential`,
|
||||
/// which isn't) — it is produced and consumed synchronously on the delegate
|
||||
/// queue, never sent across isolation domains.
|
||||
public struct Resolution {
|
||||
public let disposition: URLSession.AuthChallengeDisposition
|
||||
public let credential: URLCredential?
|
||||
|
||||
public init(
|
||||
disposition: URLSession.AuthChallengeDisposition,
|
||||
credential: URLCredential? = nil
|
||||
) {
|
||||
self.disposition = disposition
|
||||
self.credential = credential
|
||||
}
|
||||
}
|
||||
|
||||
public init() {}
|
||||
|
||||
/// Resolve a full `URLAuthenticationChallenge` by dispatching on its
|
||||
/// authentication method.
|
||||
public func resolve(
|
||||
_ challenge: URLAuthenticationChallenge, identity: ClientIdentity?
|
||||
) -> Resolution {
|
||||
resolve(
|
||||
authenticationMethod: challenge.protectionSpace.authenticationMethod,
|
||||
identity: identity
|
||||
)
|
||||
}
|
||||
|
||||
/// Method-keyed core (challenge-free) — the directly-tested pure function.
|
||||
public func resolve(
|
||||
authenticationMethod: String, identity: ClientIdentity?
|
||||
) -> Resolution {
|
||||
switch authenticationMethod {
|
||||
case NSURLAuthenticationMethodClientCertificate:
|
||||
guard let identity else {
|
||||
// (b) No installed identity: cancel so the failure surfaces as a
|
||||
// classifiable client-cert error rather than a bare TLS reset.
|
||||
return Resolution(disposition: .cancelAuthenticationChallenge)
|
||||
}
|
||||
// (a) Present the device certificate for this session.
|
||||
return Resolution(
|
||||
disposition: .useCredential, credential: identity.urlCredential()
|
||||
)
|
||||
case NSURLAuthenticationMethodServerTrust:
|
||||
// (c) System-trusted LE wildcard — default evaluation.
|
||||
return Resolution(disposition: .performDefaultHandling)
|
||||
default:
|
||||
// (d) Basic/Digest/NTLM/etc. — not used by this app; default.
|
||||
return Resolution(disposition: .performDefaultHandling)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Failure modes of a PKCS#12 import, each mapped to actionable install-UX copy
|
||||
/// (C-iOS-3). The three plan-named `OSStatus` mappings are honored:
|
||||
/// `errSecAuthFailed → .wrongPassphrase`, `errSecDecode → .corruptFile`, and any
|
||||
/// other non-success status (unsupported/unknown format — the plan's "errSecPkg"
|
||||
/// bucket) → `.unsupported`.
|
||||
public enum PKCS12ImportError: Error, Equatable, Sendable {
|
||||
/// Wrong passphrase (`errSecAuthFailed`).
|
||||
case wrongPassphrase
|
||||
/// Not a decodable PKCS#12 blob (`errSecDecode`) — truncated / not a `.p12`.
|
||||
case corruptFile
|
||||
/// Decoded, but the format/algorithms aren't importable on this OS
|
||||
/// (any other non-success `OSStatus`). Retains the raw status for logs.
|
||||
case unsupported(OSStatus)
|
||||
/// Import succeeded but carried no `SecIdentity` (e.g. a certs-only `.p12`).
|
||||
case noIdentity
|
||||
|
||||
public static func == (lhs: PKCS12ImportError, rhs: PKCS12ImportError) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.wrongPassphrase, .wrongPassphrase),
|
||||
(.corruptFile, .corruptFile),
|
||||
(.noIdentity, .noIdentity):
|
||||
return true
|
||||
case let (.unsupported(a), .unsupported(b)):
|
||||
return a == b
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Imports a `.p12` into a `ClientIdentity` via `SecPKCS12Import`.
|
||||
///
|
||||
/// Memory-only: `SecPKCS12Import` returns the identity/chain as CoreFoundation
|
||||
/// handles without writing to the keychain, so callers control persistence
|
||||
/// (see `KeychainClientIdentityStore`).
|
||||
public enum PKCS12Importer {
|
||||
public static func importIdentity(
|
||||
data: Data, passphrase: String
|
||||
) throws -> ClientIdentity {
|
||||
let options = [kSecImportExportPassphrase as String: passphrase] as CFDictionary
|
||||
var rawItems: CFArray?
|
||||
let status = SecPKCS12Import(data as CFData, options, &rawItems)
|
||||
|
||||
switch status {
|
||||
case errSecSuccess:
|
||||
break
|
||||
case errSecAuthFailed:
|
||||
throw PKCS12ImportError.wrongPassphrase
|
||||
case errSecDecode:
|
||||
throw PKCS12ImportError.corruptFile
|
||||
default:
|
||||
// Covers unsupported formats/algorithms and any other failure —
|
||||
// the plan's `errSecPkg → .unsupported` mapping (that symbol does
|
||||
// not exist in the SDK; the status is preserved for diagnostics).
|
||||
throw PKCS12ImportError.unsupported(status)
|
||||
}
|
||||
|
||||
guard let items = rawItems as? [[String: Any]], let first = items.first,
|
||||
let identityValue = first[kSecImportItemIdentity as String]
|
||||
else {
|
||||
throw PKCS12ImportError.noIdentity
|
||||
}
|
||||
// Force-cast is safe: `kSecImportItemIdentity` is always a SecIdentity.
|
||||
let secIdentity = identityValue as! SecIdentity
|
||||
let chain = (first[kSecImportItemCertChain as String] as? [SecCertificate]) ?? []
|
||||
return ClientIdentity(
|
||||
secIdentity: secIdentity,
|
||||
issuerCertificates: issuerChain(from: chain, identity: secIdentity)
|
||||
)
|
||||
}
|
||||
|
||||
/// The chain from `SecPKCS12Import` includes the leaf at index 0; the
|
||||
/// `URLCredential` must carry only the *issuer* certs (Apple: do not repeat
|
||||
/// the identity's own cert). Drops whichever chain entry DER-matches the
|
||||
/// identity's leaf.
|
||||
private static func issuerChain(
|
||||
from chain: [SecCertificate], identity: SecIdentity
|
||||
) -> [SecCertificate] {
|
||||
var leaf: SecCertificate?
|
||||
SecIdentityCopyCertificate(identity, &leaf)
|
||||
guard let leafData = leaf.map({ SecCertificateCopyData($0) as Data }) else {
|
||||
return chain
|
||||
}
|
||||
return chain.filter { SecCertificateCopyData($0) as Data != leafData }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import ClientTLS
|
||||
|
||||
// C-iOS-1 · Store roundtrip via the InMemory store (same import/summary code
|
||||
// path as the keychain store, minus the entitlement — the keychain variant is
|
||||
// covered by on-device/simulator tests, mirroring KeychainHostStoreLiveTests).
|
||||
|
||||
@Test("save → loadIdentity roundtrips and hasInstalledIdentity flips")
|
||||
func storeRoundtrip() throws {
|
||||
// Arrange
|
||||
let store = InMemoryClientIdentityStore()
|
||||
#expect(store.hasInstalledIdentity() == false)
|
||||
#expect(try store.loadIdentity() == nil)
|
||||
|
||||
// Act
|
||||
try store.save(
|
||||
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
||||
)
|
||||
|
||||
// Assert
|
||||
#expect(store.hasInstalledIdentity() == true)
|
||||
let identity = try #require(try store.loadIdentity())
|
||||
#expect(identity.summary()?.subjectCommonName == ClientTLSFixtures.leafCommonName)
|
||||
let summary = try #require(try store.loadSummary())
|
||||
#expect(summary.issuerCommonName == ClientTLSFixtures.issuerCommonName)
|
||||
}
|
||||
|
||||
@Test("save validates the passphrase and leaves prior state untouched on failure")
|
||||
func storeSaveValidatesBeforePersisting() {
|
||||
// Arrange
|
||||
let store = InMemoryClientIdentityStore()
|
||||
|
||||
// Act / Assert — a wrong passphrase must surface, not persist.
|
||||
#expect(throws: PKCS12ImportError.wrongPassphrase) {
|
||||
try store.save(p12Data: ClientTLSFixtures.deviceP12Data, passphrase: "wrong")
|
||||
}
|
||||
#expect(store.hasInstalledIdentity() == false)
|
||||
}
|
||||
|
||||
@Test("remove clears the stored identity")
|
||||
func storeRemove() throws {
|
||||
// Arrange
|
||||
let store = InMemoryClientIdentityStore()
|
||||
try store.save(
|
||||
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
||||
)
|
||||
#expect(store.hasInstalledIdentity() == true)
|
||||
|
||||
// Act
|
||||
try store.remove()
|
||||
|
||||
// Assert
|
||||
#expect(store.hasInstalledIdentity() == false)
|
||||
#expect(try store.loadIdentity() == nil)
|
||||
}
|
||||
|
||||
@Test("loadedIdentityOrNil returns nil (not a throw) when nothing is installed")
|
||||
func loadedIdentityOrNilEmpty() {
|
||||
let store = InMemoryClientIdentityStore()
|
||||
#expect(store.loadedIdentityOrNil() == nil)
|
||||
}
|
||||
33
ios/Packages/ClientTLS/Tests/ClientTLSTests/Fixtures.swift
Normal file
33
ios/Packages/ClientTLS/Tests/ClientTLSTests/Fixtures.swift
Normal file
@@ -0,0 +1,33 @@
|
||||
import Foundation
|
||||
|
||||
/// Embedded test fixture: a real PKCS#12 generated at authoring time with
|
||||
/// OpenSSL (EC P-256 self-signed device-CA → EC P-256 leaf, `EKU=clientAuth`,
|
||||
/// exported `-legacy` for iOS/Android import parity — mirrors
|
||||
/// `deploy/scripts/gen-device-ca.sh` / `issue-device-cert.sh`).
|
||||
///
|
||||
/// Embedded as base64 rather than an SPM resource so the MUST-PASS package test
|
||||
/// runs headless with no `Bundle.module` resource wiring. Regenerate with:
|
||||
/// openssl ecparam -name prime256v1 -genkey -noout -out ca.key.pem
|
||||
/// openssl req -x509 -new -key ca.key.pem -sha256 -days 3650 \
|
||||
/// -subj "/CN=webterm-device-ca-fixture" \
|
||||
/// -addext "basicConstraints=critical,CA:TRUE" \
|
||||
/// -addext "keyUsage=critical,keyCertSign,cRLSign" -out ca.cert.pem
|
||||
/// openssl ecparam -name prime256v1 -genkey -noout -out leaf.key.pem
|
||||
/// openssl req -new -key leaf.key.pem -subj "/CN=device-fixture" -out leaf.csr.pem
|
||||
/// openssl x509 -req -in leaf.csr.pem -CA ca.cert.pem -CAkey ca.key.pem \
|
||||
/// -CAcreateserial -days 825 -sha256 -extfile leaf.ext -out leaf.cert.pem
|
||||
/// openssl pkcs12 -export -legacy -inkey leaf.key.pem -in leaf.cert.pem \
|
||||
/// -certfile ca.cert.pem -passout pass:clienttls-test-pass \
|
||||
/// -name device-fixture -out device.p12 ; base64 device.p12
|
||||
enum ClientTLSFixtures {
|
||||
static let passphrase = "clienttls-test-pass"
|
||||
static let leafCommonName = "device-fixture"
|
||||
static let issuerCommonName = "webterm-device-ca-fixture"
|
||||
|
||||
static var deviceP12Data: Data {
|
||||
Data(base64Encoded: deviceP12Base64)!
|
||||
}
|
||||
|
||||
// swiftlint:disable:next line_length
|
||||
static let deviceP12Base64 = "MIIF8wIBAzCCBbkGCSqGSIb3DQEHAaCCBaoEggWmMIIFojCCBGcGCSqGSIb3DQEHBqCCBFgwggRUAgEAMIIETQYJKoZIhvcNAQcBMBwGCiqGSIb3DQEMAQYwDgQIKBUqQZQxk0wCAggAgIIEIBIkpttDOWO6edQjmr9V3ASfsuc0m+Wdl8//Yrt5FPgz5HOhYZKhOUXhBLWyGXPnTx8fBrC1Yk4YmXwQisHT/4PsfvwIAgF9PBFo3UNwsQLATWHrplUF1rTO9uB5Luju306Ox9QYB+VP64BOFxdixtvhjZF8LemfJ6bV/9DWmXOk5UoGdd0c5/6WeYUTfr1aFG/TJo+GOHa5CD5fW5Mr4SejYKNcvvT2Fki5kCKAQRbhJb8W7+RzcmYoF6ECzCNeSnDNenpZm2xowpdGP/6d3U7MbrXj4bmAtQYljAnA391atw/bm2T9d8Q6CPx5QD+WqMNdKN6tYV9OvDB6XB+VPimVMd0GCNYaIKazHj0ohNYqvEYDjSaYgbBvLHJyJRqnumyjwMz4o26NoTGm1m/U28pUGUJL3njYhuyxKilat85oXT3YO4x16bCeF7fmuGVkakeZjrxvxAKap8rD5yPaDd533Va9+xg1byIj7h01Q5tiOXO3sqkEYB1XI9/n78Et5eZItMYEH53v4uE9NRWZmetA7TQ5uRon4EK1ajKIJaQi/6lGdCAzLf5tysZ7A8vfJPJeBRFI2a9QYQzcyL8sZWEfAhJszRo59g6LWZn87DXL6EFEP/UvV0tDy6kPz/OzUmDGMrWNbeCX/5zI/xaFaf+2OpiXkJYwMUybi9JkTmCfNeOLdpPjHI0lJ47iG2cmiiMNYWJ5hXKqmdxigQt5W/5WShjD4iYh6PcjzdD6IBD5UT8d3fTKWa4cvJs2bnILjElXOWo+s8o5NGAL359+QbCQfiWYha1P4SdSNhX98SXy0K6Dla0+Ny+miEVIf5N0jXvNlsJnnjjGgK+9gPGlU9mEbpF+4EqV2XgGp+Tg8ZGO489kK2S37gJTKMUhU+haMhAF0eoj5FSMN30LcXnHTjMjsps4hpeGeUu8gb0dm96+vvmAAibt9wnCULgMFKIEpP10IY5l4D8puLTi+smvf0MMvvrGRC3+aAq+jfemQEmJZgfhlhtr1O5DdUoTvcWazmNdNScwoJ68M/D/45pplBUglECub/Ib34HKNGmEMX5+tbDEHxe589A2Jwxvq1meycCnj3IoL/lXSprB7jWt3ZInURTJItaS5GSw9D4vLboe4OISAHZftdgGJea2rXJYPZk6N5L0EbLfftEh+zzOTb3Zr3MsIiCdxpWLshaDfiijRgVYsAd0rsx0LG+8+Icb33KvN3MK/ol8PjavM+T7F2qUIiKyaA5eZ9B5CAkm7YGvrE1TBYHeEpsgvaGnRyl3PcPYSCoOQ5ckZ67briJlf6OpZM+5P6u/MP/BWhQa8Ksox2SO4aZEtDzceWAHI4ccJSbD8h/jpoLZbSm2Z+CqNUmqr1atZnZgpgArKdcnxErljkCOfkp+k6nRkhg5RkbOCjCCATMGCSqGSIb3DQEHAaCCASQEggEgMIIBHDCCARgGCyqGSIb3DQEMCgECoIG0MIGxMBwGCiqGSIb3DQEMAQMwDgQIybmfLdxQ2ZUCAggABIGQQm1iiwDNnPOMO1rBboRmVPjVzV8OLeEVLNz2qhOhSR7nEBMSw289dZzdgbY/PZh2GrO0duJSHIzjBj7W9ShHVB6zdl0kZx1JRh5aJie3eEZ76l9zVOZadp2T0wCd7HD7c4rOYJFjuTwPVB4/GWzlA3r2HVGiyuQR8N8Z855M97/KrUCByR6HG1Nl8e1+EYyrMVIwIwYJKoZIhvcNAQkVMRYEFBZc8x6cgn9EB/NImL993PHArfe6MCsGCSqGSIb3DQEJFDEeHhwAZABlAHYAaQBjAGUALQBmAGkAeAB0AHUAcgBlMDEwITAJBgUrDgMCGgUABBQGw7bWopAP93FLjXc4LIiReHWd5AQISUwQzLqUrPECAggA"
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import ClientTLS
|
||||
|
||||
// C-iOS-1 · The responder truth table: 3 challenge types × identity present /
|
||||
// absent. Exercised through the pure method-keyed core AND through a real
|
||||
// `URLAuthenticationChallenge` (proving the method is extracted correctly),
|
||||
// with no live socket.
|
||||
|
||||
private let responder = MutualTLSChallengeResponder()
|
||||
|
||||
private func makeIdentity() throws -> ClientIdentity {
|
||||
try PKCS12Importer.importIdentity(
|
||||
data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - ClientCertificate
|
||||
|
||||
@Test("ClientCertificate + identity → .useCredential with a credential")
|
||||
func clientCertWithIdentityUsesCredential() throws {
|
||||
// Arrange
|
||||
let identity = try makeIdentity()
|
||||
|
||||
// Act
|
||||
let resolution = responder.resolve(
|
||||
authenticationMethod: NSURLAuthenticationMethodClientCertificate, identity: identity
|
||||
)
|
||||
|
||||
// Assert
|
||||
#expect(resolution.disposition == .useCredential)
|
||||
#expect(resolution.credential != nil)
|
||||
#expect(resolution.credential?.identity != nil)
|
||||
}
|
||||
|
||||
@Test("ClientCertificate + no identity → .cancelAuthenticationChallenge, no credential")
|
||||
func clientCertWithoutIdentityCancels() {
|
||||
// Act
|
||||
let resolution = responder.resolve(
|
||||
authenticationMethod: NSURLAuthenticationMethodClientCertificate, identity: nil
|
||||
)
|
||||
|
||||
// Assert
|
||||
#expect(resolution.disposition == .cancelAuthenticationChallenge)
|
||||
#expect(resolution.credential == nil)
|
||||
}
|
||||
|
||||
// MARK: - ServerTrust
|
||||
|
||||
@Test("ServerTrust → .performDefaultHandling regardless of identity")
|
||||
func serverTrustDefaultHandling() throws {
|
||||
let identity = try makeIdentity()
|
||||
for id in [identity, nil] as [ClientIdentity?] {
|
||||
let resolution = responder.resolve(
|
||||
authenticationMethod: NSURLAuthenticationMethodServerTrust, identity: id
|
||||
)
|
||||
#expect(resolution.disposition == .performDefaultHandling)
|
||||
#expect(resolution.credential == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Other method (e.g. Basic)
|
||||
|
||||
@Test("an unrelated method → .performDefaultHandling regardless of identity")
|
||||
func otherMethodDefaultHandling() throws {
|
||||
let identity = try makeIdentity()
|
||||
for id in [identity, nil] as [ClientIdentity?] {
|
||||
let resolution = responder.resolve(
|
||||
authenticationMethod: NSURLAuthenticationMethodHTTPBasic, identity: id
|
||||
)
|
||||
#expect(resolution.disposition == .performDefaultHandling)
|
||||
#expect(resolution.credential == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Full URLAuthenticationChallenge extraction
|
||||
|
||||
@Test("resolve(challenge:) extracts the method from the protection space")
|
||||
func resolveFromRealChallenge() throws {
|
||||
// Arrange — a real challenge for the ClientCertificate method.
|
||||
let identity = try makeIdentity()
|
||||
let challenge = makeChallenge(method: NSURLAuthenticationMethodClientCertificate)
|
||||
|
||||
// Act
|
||||
let withIdentity = responder.resolve(challenge, identity: identity)
|
||||
let withoutIdentity = responder.resolve(challenge, identity: nil)
|
||||
|
||||
// Assert
|
||||
#expect(withIdentity.disposition == .useCredential)
|
||||
#expect(withIdentity.credential != nil)
|
||||
#expect(withoutIdentity.disposition == .cancelAuthenticationChallenge)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Minimal sender so `URLAuthenticationChallenge` can be constructed in-process
|
||||
/// (its designated init requires a non-optional sender). The responder never
|
||||
/// calls back into the sender — it only reads `protectionSpace`.
|
||||
private final class NoopChallengeSender: NSObject, URLAuthenticationChallengeSender {
|
||||
func use(_ credential: URLCredential, for challenge: URLAuthenticationChallenge) {}
|
||||
func continueWithoutCredential(for challenge: URLAuthenticationChallenge) {}
|
||||
func cancel(_ challenge: URLAuthenticationChallenge) {}
|
||||
}
|
||||
|
||||
private func makeChallenge(method: String) -> URLAuthenticationChallenge {
|
||||
let space = URLProtectionSpace(
|
||||
host: "t1.terminal.yaojia.wang", port: 443, protocol: "https",
|
||||
realm: nil, authenticationMethod: method
|
||||
)
|
||||
return URLAuthenticationChallenge(
|
||||
protectionSpace: space, proposedCredential: nil, previousFailureCount: 0,
|
||||
failureResponse: nil, error: nil, sender: NoopChallengeSender()
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import Testing
|
||||
@testable import ClientTLS
|
||||
|
||||
// C-iOS-1 · PKCS#12 import against a real OpenSSL-generated fixture `.p12`,
|
||||
// including the wrong-passphrase and corrupt-file error mappings.
|
||||
|
||||
@Test("import with the correct passphrase yields an identity whose leaf CN matches")
|
||||
func importSucceedsAndExposesLeaf() throws {
|
||||
// Arrange
|
||||
let data = ClientTLSFixtures.deviceP12Data
|
||||
|
||||
// Act
|
||||
let identity = try PKCS12Importer.importIdentity(
|
||||
data: data, passphrase: ClientTLSFixtures.passphrase
|
||||
)
|
||||
|
||||
// Assert
|
||||
let summary = try #require(identity.summary())
|
||||
#expect(summary.subjectCommonName == ClientTLSFixtures.leafCommonName)
|
||||
#expect(summary.issuerCommonName == ClientTLSFixtures.issuerCommonName)
|
||||
// 825-day leaf minted at authoring time → not yet expired.
|
||||
#expect(summary.isExpired() == false)
|
||||
}
|
||||
|
||||
@Test("import drops the leaf from the issuer chain (credential must not repeat it)")
|
||||
func importIssuerChainExcludesLeaf() throws {
|
||||
// Arrange / Act
|
||||
let identity = try PKCS12Importer.importIdentity(
|
||||
data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
||||
)
|
||||
|
||||
// Assert — fixture chain is [leaf, CA]; issuerCertificates keeps only the CA.
|
||||
let leaf = try #require(identity.leafCertificate())
|
||||
let leafData = SecCertificateCopyData(leaf) as Data
|
||||
#expect(identity.issuerCertificates.count == 1)
|
||||
for issuer in identity.issuerCertificates {
|
||||
#expect((SecCertificateCopyData(issuer) as Data) != leafData)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("wrong passphrase maps errSecAuthFailed → .wrongPassphrase")
|
||||
func importWrongPassphrase() {
|
||||
// Arrange / Act / Assert
|
||||
#expect(throws: PKCS12ImportError.wrongPassphrase) {
|
||||
_ = try PKCS12Importer.importIdentity(
|
||||
data: ClientTLSFixtures.deviceP12Data, passphrase: "not-the-passphrase"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("garbage bytes map errSecDecode → .corruptFile")
|
||||
func importCorruptFile() {
|
||||
// Arrange
|
||||
let garbage = Data([0x00, 0x01, 0x02, 0x03, 0x99, 0xAB, 0xCD, 0xEF])
|
||||
|
||||
// Act / Assert
|
||||
#expect(throws: PKCS12ImportError.corruptFile) {
|
||||
_ = try PKCS12Importer.importIdentity(data: garbage, passphrase: "x")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("empty data is treated as a corrupt file, not a crash")
|
||||
func importEmptyData() {
|
||||
#expect(throws: PKCS12ImportError.corruptFile) {
|
||||
_ = try PKCS12Importer.importIdentity(data: Data(), passphrase: "x")
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
// swift-tools-version: 6.0
|
||||
// T-iOS-1 scaffold shell. SessionEngine / ReconnectMachine / PingScheduler /
|
||||
// GateState / AwayDigest / URLSessionTermTransport land in W1–W2 tasks.
|
||||
// Dependency direction is strictly downward: SessionCore → WireProtocol only.
|
||||
// Dependency direction is strictly downward: SessionCore → WireProtocol, plus
|
||||
// ClientTLS (C-iOS-2) for the device client-cert mTLS responder the WS transport
|
||||
// answers connection-level challenges with. Both are downward leaves.
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
@@ -12,12 +14,16 @@ let package = Package(
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../WireProtocol"),
|
||||
.package(path: "../ClientTLS"),
|
||||
.package(path: "../TestSupport"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "SessionCore",
|
||||
dependencies: [.product(name: "WireProtocol", package: "WireProtocol")]
|
||||
dependencies: [
|
||||
.product(name: "WireProtocol", package: "WireProtocol"),
|
||||
.product(name: "ClientTLS", package: "ClientTLS"),
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "SessionCoreTests",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ClientTLS
|
||||
import Darwin
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
@@ -76,16 +77,45 @@ protocol ConnectionPinger: Sendable {
|
||||
public struct URLSessionTermTransport: TermTransport {
|
||||
/// `URLSessionWebSocketTask.maximumMessageSize` applied to every task.
|
||||
private let maxMessageBytes: Int
|
||||
/// C-iOS-2 (MEDIUM no-relaunch fix) · Resolve the optional device client
|
||||
/// identity LAZILY, once per `connect`, so a certificate imported mid-run
|
||||
/// takes effect on the NEXT connection without an app relaunch (a snapshot
|
||||
/// captured at composition would stay stale). When the resolved identity is
|
||||
/// present, the connection answers a `ClientCertificate` challenge (mTLS to a
|
||||
/// tunneled host) with its credential; when `nil`, the challenge is cancelled
|
||||
/// cleanly. Local (non-mTLS) hosts never issue the challenge, so a `nil`
|
||||
/// result is inert there.
|
||||
private let identityProvider: @Sendable () -> ClientIdentity?
|
||||
|
||||
public init() {
|
||||
self.init(maxMessageBytes: Tunables.maxWSMessageBytes)
|
||||
/// Fixed-identity convenience (snapshot callers / tests): wraps a constant
|
||||
/// provider, so behaviour is identical to capturing the identity directly.
|
||||
public init(identity: ClientIdentity? = nil) {
|
||||
self.init(identityProvider: { identity })
|
||||
}
|
||||
|
||||
/// C-iOS-2 (MEDIUM no-relaunch fix) · per-connect identity resolution: the
|
||||
/// provider is re-consulted on every `connect`, so a nil→installed
|
||||
/// transition is picked up without relaunch.
|
||||
public init(identityProvider: @escaping @Sendable () -> ClientIdentity?) {
|
||||
self.init(
|
||||
maxMessageBytes: Tunables.maxWSMessageBytes, identityProvider: identityProvider
|
||||
)
|
||||
}
|
||||
|
||||
/// Internal TEST seam: a shrunken cap makes the oversize→EMSGSIZE path
|
||||
/// deterministic without 16 MiB fixtures. Production code paths always go
|
||||
/// through `init()` and the frozen `Tunables.maxWSMessageBytes`.
|
||||
init(maxMessageBytes: Int) {
|
||||
/// through `init()` / `init(identityProvider:)` and the frozen
|
||||
/// `Tunables.maxWSMessageBytes`.
|
||||
init(maxMessageBytes: Int, identity: ClientIdentity? = nil) {
|
||||
self.init(maxMessageBytes: maxMessageBytes, identityProvider: { identity })
|
||||
}
|
||||
|
||||
init(
|
||||
maxMessageBytes: Int,
|
||||
identityProvider: @escaping @Sendable () -> ClientIdentity?
|
||||
) {
|
||||
self.maxMessageBytes = maxMessageBytes
|
||||
self.identityProvider = identityProvider
|
||||
}
|
||||
|
||||
public func connect(to endpoint: HostEndpoint) async throws -> TransportConnection {
|
||||
@@ -93,9 +123,13 @@ public struct URLSessionTermTransport: TermTransport {
|
||||
}
|
||||
|
||||
/// Internal: concrete-typed connect (tests assert task configuration;
|
||||
/// `connectPingable` builds on it).
|
||||
/// `connectPingable` builds on it). The identity is resolved HERE, per
|
||||
/// connect, from `identityProvider` (no-relaunch pickup).
|
||||
func openConnection(to endpoint: HostEndpoint) async throws -> WSConnection {
|
||||
try await WSConnection.open(endpoint: endpoint, maxMessageBytes: maxMessageBytes)
|
||||
try await WSConnection.open(
|
||||
endpoint: endpoint, maxMessageBytes: maxMessageBytes,
|
||||
identity: identityProvider()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +169,13 @@ final class WSConnection: NSObject, @unchecked Sendable {
|
||||
private let frames: AsyncThrowingStream<String, any Error>
|
||||
private let framesContinuation: AsyncThrowingStream<String, any Error>.Continuation
|
||||
|
||||
/// C-iOS-2 mTLS. Set once in `configure` BEFORE `task.resume()`, so the
|
||||
/// connection-level challenge (which can only arrive after resume) always
|
||||
/// reads a stable value without locking — same set-once discipline as
|
||||
/// `task`/`session`.
|
||||
private var identity: ClientIdentity?
|
||||
private let challengeResponder = MutualTLSChallengeResponder()
|
||||
|
||||
private override init() {
|
||||
(frames, framesContinuation) = AsyncThrowingStream<String, any Error>.makeStream()
|
||||
super.init()
|
||||
@@ -143,9 +184,13 @@ final class WSConnection: NSObject, @unchecked Sendable {
|
||||
/// Connect: build session+task, resume, await the delegate-driven
|
||||
/// handshake (didOpen / didCompleteWithError — no receive-error guessing),
|
||||
/// then start the re-arming receive loop.
|
||||
static func open(endpoint: HostEndpoint, maxMessageBytes: Int) async throws -> WSConnection {
|
||||
static func open(
|
||||
endpoint: HostEndpoint, maxMessageBytes: Int, identity: ClientIdentity? = nil
|
||||
) async throws -> WSConnection {
|
||||
let connection = WSConnection()
|
||||
connection.configure(endpoint: endpoint, maxMessageBytes: maxMessageBytes)
|
||||
connection.configure(
|
||||
endpoint: endpoint, maxMessageBytes: maxMessageBytes, identity: identity
|
||||
)
|
||||
try await connection.performHandshake()
|
||||
connection.startReceiveLoop()
|
||||
return connection
|
||||
@@ -163,7 +208,12 @@ final class WSConnection: NSObject, @unchecked Sendable {
|
||||
|
||||
// MARK: Setup
|
||||
|
||||
private func configure(endpoint: HostEndpoint, maxMessageBytes: Int) {
|
||||
private func configure(
|
||||
endpoint: HostEndpoint, maxMessageBytes: Int, identity: ClientIdentity?
|
||||
) {
|
||||
// Set BEFORE building the task/resume: the mTLS challenge can only fire
|
||||
// after `task.resume()`, so this write happens-before any read of it.
|
||||
self.identity = identity
|
||||
// Origin: SINGLE source of truth = endpoint.originHeader (plan §5.1).
|
||||
var request = URLRequest(url: endpoint.wsURL)
|
||||
request.setValue(endpoint.originHeader, forHTTPHeaderField: "Origin")
|
||||
@@ -311,6 +361,22 @@ extension WSConnection: URLSessionWebSocketDelegate {
|
||||
takeHandshakeContinuation()?.resume()
|
||||
}
|
||||
|
||||
/// C-iOS-2 · Connection-level auth challenge (server trust + client cert)
|
||||
/// arrives task-level for a WS task. Delegate the decision to the pure
|
||||
/// `MutualTLSChallengeResponder`: present the device identity for an mTLS
|
||||
/// tunnel host, default-handle the LE server trust, cancel cleanly if a
|
||||
/// client cert is demanded but none is installed. `identity` is set-once
|
||||
/// before `resume()` (see `configure`), so this read needs no lock.
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
task: URLSessionTask,
|
||||
didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
let resolution = challengeResponder.resolve(challenge, identity: identity)
|
||||
completionHandler(resolution.disposition, resolution.credential)
|
||||
}
|
||||
|
||||
/// Server close frame processed → CLEAN close: the stream FINISHES
|
||||
/// (distinguishable from the transport-error THROW path).
|
||||
func urlSession(
|
||||
|
||||
@@ -243,6 +243,29 @@ struct URLSessionTermTransportTests {
|
||||
await connection.close()
|
||||
}
|
||||
|
||||
@Test("identity provider is resolved PER CONNECT (no-relaunch pickup, not captured once)")
|
||||
func identityProviderResolvedPerConnect() async throws {
|
||||
// A cert imported mid-run must take effect on the NEXT connection. The
|
||||
// transport therefore re-consults its provider on every `connect` rather
|
||||
// than capturing a snapshot — proven here by a provider whose invocation
|
||||
// count grows once per connect (nil identity: ScriptedWSServer is plain
|
||||
// ws, so the connection succeeds regardless).
|
||||
let (server, endpoint) = try await Self.startServer()
|
||||
defer { server.stop() }
|
||||
let calls = CallCounter()
|
||||
let transport = URLSessionTermTransport(identityProvider: {
|
||||
calls.increment()
|
||||
return nil
|
||||
})
|
||||
|
||||
let first = try await transport.connect(to: endpoint)
|
||||
await first.close()
|
||||
let second = try await transport.connect(to: endpoint)
|
||||
await second.close()
|
||||
|
||||
#expect(calls.value == 2)
|
||||
}
|
||||
|
||||
@Test("connect to a dead port throws (handshake failure path, no hang)")
|
||||
func connectToDeadPortThrows() async throws {
|
||||
let server = ScriptedWSServer()
|
||||
@@ -254,5 +277,14 @@ struct URLSessionTermTransportTests {
|
||||
_ = try await URLSessionTermTransport().connect(to: endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe invocation counter for the `@Sendable` identity provider
|
||||
/// (URLSession may consult it off the test's isolation domain).
|
||||
private final class CallCounter: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var count = 0
|
||||
func increment() { lock.withLock { count += 1 } }
|
||||
var value: Int { lock.withLock { count } }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -31,6 +31,8 @@ packages:
|
||||
path: Packages/HostRegistry
|
||||
APIClient:
|
||||
path: Packages/APIClient
|
||||
ClientTLS:
|
||||
path: Packages/ClientTLS # C-iOS · device client-cert (mTLS) leaf package
|
||||
TestSupport:
|
||||
path: Packages/TestSupport # test doubles — WebTermTests only, never the app target
|
||||
# SwiftTerm is the ONLY third-party dependency, attached to the App target
|
||||
@@ -50,6 +52,7 @@ targets:
|
||||
- package: SessionCore
|
||||
- package: HostRegistry
|
||||
- package: APIClient
|
||||
- package: ClientTLS
|
||||
- package: SwiftTerm
|
||||
settings:
|
||||
base:
|
||||
@@ -119,6 +122,7 @@ targets:
|
||||
- package: SessionCore
|
||||
- package: HostRegistry
|
||||
- package: APIClient
|
||||
- package: ClientTLS
|
||||
- package: TestSupport
|
||||
settings:
|
||||
base:
|
||||
|
||||
@@ -25,6 +25,13 @@
|
||||
--tabbar-h: 40px;
|
||||
--keybar-h: 46px;
|
||||
|
||||
/* iOS notch / home-indicator clearance. index.html sets viewport-fit=cover,
|
||||
* so the layout spans the physical screen edges; without these the top bar
|
||||
* hides under the status bar and the bottom key-bar covers terminal output.
|
||||
* Falls back to 0px on platforms without safe areas. */
|
||||
--safe-t: env(safe-area-inset-top, 0px);
|
||||
--safe-b: env(safe-area-inset-bottom, 0px);
|
||||
|
||||
--ui-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
@@ -56,7 +63,9 @@ body {
|
||||
#tabbar {
|
||||
position: fixed;
|
||||
inset: 0 0 auto 0;
|
||||
height: var(--tabbar-h);
|
||||
box-sizing: border-box;
|
||||
height: calc(var(--tabbar-h) + var(--safe-t));
|
||||
padding-top: var(--safe-t);
|
||||
background: linear-gradient(180deg, #181a22, var(--surface-1));
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
@@ -285,7 +294,7 @@ body {
|
||||
/* ── Terminal area ───────────────────────────────────────────────── */
|
||||
#term {
|
||||
position: absolute;
|
||||
inset: var(--tabbar-h) 0 var(--keybar-h) 0;
|
||||
inset: calc(var(--tabbar-h) + var(--safe-t)) 0 calc(var(--keybar-h) + var(--safe-b)) 0;
|
||||
background: var(--bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -301,13 +310,14 @@ body {
|
||||
#keybar {
|
||||
position: fixed;
|
||||
inset: auto 0 0 0;
|
||||
height: var(--keybar-h);
|
||||
box-sizing: border-box;
|
||||
height: calc(var(--keybar-h) + var(--safe-b));
|
||||
background: var(--surface-1);
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 0 6px;
|
||||
padding: 0 6px var(--safe-b);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
z-index: 1000;
|
||||
@@ -382,7 +392,7 @@ body {
|
||||
|
||||
#searchbox {
|
||||
position: fixed;
|
||||
top: calc(var(--tabbar-h) + 8px);
|
||||
top: calc(var(--tabbar-h) + var(--safe-t) + 8px);
|
||||
right: 10px;
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
@@ -480,7 +490,7 @@ body {
|
||||
/* Settings panel */
|
||||
#settingspanel {
|
||||
position: fixed;
|
||||
top: calc(var(--tabbar-h) + 8px);
|
||||
top: calc(var(--tabbar-h) + var(--safe-t) + 8px);
|
||||
right: 10px;
|
||||
z-index: 1100;
|
||||
background: var(--surface-2);
|
||||
@@ -679,7 +689,7 @@ body {
|
||||
/* Approval banner (H3) */
|
||||
#approvalbar {
|
||||
position: fixed;
|
||||
inset: auto 0 var(--keybar-h) 0;
|
||||
inset: auto 0 calc(var(--keybar-h) + var(--safe-b)) 0;
|
||||
z-index: 1050;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1547,9 +1557,10 @@ body {
|
||||
body.home-open #keybar {
|
||||
display: none;
|
||||
}
|
||||
/* Home chooser hides the key-bar, so reclaim its reserved space at the bottom. */
|
||||
/* Home chooser hides the key-bar, so reclaim its reserved space at the bottom
|
||||
* (keeping the home-indicator clearance so launcher content stays reachable). */
|
||||
body.home-open #term {
|
||||
bottom: 0;
|
||||
bottom: var(--safe-b);
|
||||
}
|
||||
|
||||
/* Desktop: float the Sessions/Projects toggle top-right, on the SAME row as the
|
||||
|
||||
@@ -9,8 +9,24 @@
|
||||
},
|
||||
"scripts": {
|
||||
"start": "tsx src/main.ts",
|
||||
"start:phase1": "tsx src/main-phase1.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"control-plane": "file:../control-plane",
|
||||
"relay-auth": "file:../relay-auth",
|
||||
"relay-contracts": "file:../relay-contracts",
|
||||
"relay-e2e": "file:../relay-e2e",
|
||||
"term-relay": "file:../term-relay",
|
||||
"relay-web": "file:../relay-web",
|
||||
"agent": "file:../agent",
|
||||
"ws": "^8.18.0",
|
||||
"pg": "^8.12.0",
|
||||
"ioredis": "^5.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.19.0"
|
||||
}
|
||||
}
|
||||
|
||||
56
relay-run/scripts/mint-manage-token.ts
Normal file
56
relay-run/scripts/mint-manage-token.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* RELAY-PHASE1 · staging admin-token minter (run from relay-run/ so relay-auth resolves).
|
||||
*
|
||||
* cd relay-run && npx tsx scripts/mint-manage-token.ts --aud <BASE_DOMAIN> [--sub <accountId>]
|
||||
*
|
||||
* Mints a §4.3 `manage` capability token signed by the P5 PRIVATE key, for the deny-by-default
|
||||
* control-plane admin API (POST /accounts, /accounts/:id/pairing-codes). `aud` MUST equal the CP's
|
||||
* BASE_DOMAIN (its `expectedAud`). The admin API verifies signature+aud+rights and derives accountId
|
||||
* ONLY from the token (INV3) — it does NOT require a live DPoP proof — but the issuer still stamps a
|
||||
* well-formed `cnf.jkt` (from a throwaway ephemeral key) because issueCapabilityToken mandates it.
|
||||
* TTL is clamped to 60 s, so mint immediately before each admin call.
|
||||
*
|
||||
* Prints ONLY the raw token to stdout; the signing key material is never printed.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { issueCapabilityToken } from 'relay-auth'
|
||||
import { generateEd25519KeyPair, exportEd25519PublicRaw } from 'relay-auth/src/crypto/ed25519.js'
|
||||
import { jwkThumbprint } from 'relay-auth/src/crypto/thumbprint.js'
|
||||
|
||||
function arg(name: string, fallback?: string): string | undefined {
|
||||
const i = process.argv.indexOf(`--${name}`)
|
||||
return i >= 0 && i + 1 < process.argv.length ? process.argv[i + 1] : fallback
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const keyPath = arg('key', '/etc/relay/capability/capability-sign.key.pem') as string
|
||||
const aud = arg('aud')
|
||||
const sub = arg('sub', 'bootstrap') as string
|
||||
const host = arg('host', '_manage_') as string
|
||||
const rights = (arg('rights', 'manage') as string).split(',').map((r) => r.trim())
|
||||
if (aud === undefined || aud.length === 0) {
|
||||
process.stderr.write('FATAL: --aud <BASE_DOMAIN> is required\n')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const pem = readFileSync(keyPath, 'utf8')
|
||||
const der = Buffer.from(pem.replace(/-----[^-]+-----/g, '').replace(/\s+/g, ''), 'base64')
|
||||
const signingKey = await globalThis.crypto.subtle.importKey('pkcs8', der, { name: 'Ed25519' }, false, ['sign'])
|
||||
|
||||
const eph = await generateEd25519KeyPair()
|
||||
const cnfJkt = await jwkThumbprint(await exportEd25519PublicRaw(eph.publicKey))
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
|
||||
const token = await issueCapabilityToken(
|
||||
// Runtime only reads principal.accountId; a minimal shape is intentional for this staging tool.
|
||||
{ principal: { accountId: sub } as never, aud, host, rights: rights as never, ttlSeconds: 60, cnfJkt },
|
||||
signingKey,
|
||||
now,
|
||||
)
|
||||
process.stdout.write(token)
|
||||
}
|
||||
|
||||
main().catch((e: unknown) => {
|
||||
process.stderr.write(`mint failed: ${e instanceof Error ? e.message : String(e)}\n`)
|
||||
process.exit(1)
|
||||
})
|
||||
303
relay-run/src/main-phase1.ts
Normal file
303
relay-run/src/main-phase1.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Phase 1 PRODUCTION entrypoint — `npm run start:phase1`. Composes the SHARED-STORE data plane
|
||||
* (B1–B4) behind a publicly-bound TLS listener, serves the relay-web bundle same-origin as the
|
||||
* browser WSS (D1), and hosts a STAGING operator token-mint (`POST /auth/mint`, B5/auth-mint.ts).
|
||||
*
|
||||
* Unlike Phase-0 `main.ts` (in-RAM fakes + self-signed dev CA), everything here is real and
|
||||
* env-configured — one world of truth over the SAME Postgres + Redis as the control-plane (INV7,
|
||||
* restart-safe): the host registry that gates mTLS (INV14) and route resolution, the Redis
|
||||
* revocation bus that tears live tunnels down (INV12), and the shared P5 verify key (INV9).
|
||||
*
|
||||
* browser ──WSS(:BIND_PORT)──▶ relay-node ──opaque splice(INV2)──▶ agent tunnel ◀──mTLS(:AGENT_BIND_PORT)── agent
|
||||
* │ P5 onUpgrade: Origin/CSWSH + capability verify + DPoP │ registry-gated verifyAgentCert
|
||||
* └ same-origin: static bundle (D1) + POST /auth/mint (B5) └ Redis relay:revocations → teardown
|
||||
*
|
||||
* All configuration is from ENV (no hardcoded hosts/ports/secrets). Phase-0 `main.ts` is UNTOUCHED.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
import { loadVerifyKeyFromEnv, KeyConfigError } from 'relay-auth/src/config/keys.js'
|
||||
import { createPgPool, createQuery } from 'control-plane/src/db/pool.js'
|
||||
import { createPgStores } from 'control-plane/src/store/pg.js'
|
||||
import { createRedisClient } from 'control-plane/src/boot/redis.js'
|
||||
import type {
|
||||
MtlsVerifier,
|
||||
TlsServerFactory,
|
||||
} from 'term-relay/data-plane/agent-listener.js'
|
||||
|
||||
import { createRelayEnforceDeps } from './wiring/stores-pg.js'
|
||||
import { createMtlsVerifier, type AsyncMtlsVerifier } from './wiring/mtls-verifier.js'
|
||||
import { createStoreRouteResolver } from './wiring/route-resolver.js'
|
||||
import { startRevocationSubscriber, type RevocableNode } from './wiring/revocation-subscriber.js'
|
||||
import { createAuthorizer } from './wiring/authorizer.js'
|
||||
import { buildDataPlane, makeDataPlaneConfig } from './wiring/data-plane.js'
|
||||
import { makeAgentTlsServerFactory } from './servers/agent-tls.js'
|
||||
import { startBrowserServer } from './servers/browser-server.js'
|
||||
import { createAuthMintRoute, loadSigningKeyFromEnv } from './servers/auth-mint.js'
|
||||
|
||||
const DEFAULT_BIND_HOST = '0.0.0.0'
|
||||
const DEFAULT_BIND_PORT = 443
|
||||
const HERE = dirname(fileURLToPath(import.meta.url)) // <repo>/relay-run/src
|
||||
const DEFAULT_WEB_ROOT = join(HERE, '..', '..', 'relay-web', 'public')
|
||||
|
||||
// ── env helpers (fail-fast on misconfiguration) ─────────────────────────────────────────────────
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const v = process.env[name]
|
||||
if (v === undefined || v.length === 0) {
|
||||
throw new KeyConfigError(`required env ${name} is not set`)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
function requirePort(name: string): number {
|
||||
const raw = requireEnv(name)
|
||||
const n = Number(raw)
|
||||
if (!Number.isInteger(n) || n < 1 || n > 65535) {
|
||||
throw new KeyConfigError(`env ${name} must be an integer port 1–65535 (got ${JSON.stringify(raw)})`)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
function intEnv(name: string, fallback: number): number {
|
||||
const raw = process.env[name]
|
||||
if (raw === undefined || raw.length === 0) return fallback
|
||||
const n = Number(raw)
|
||||
if (!Number.isInteger(n) || n < 1 || n > 65535) {
|
||||
throw new KeyConfigError(`env ${name} must be an integer port 1–65535 (got ${JSON.stringify(raw)})`)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
/**
|
||||
* F5/INV9 · render an error for logs WITHOUT leaking the raw object. A raw PG/Redis error can carry
|
||||
* the connection DSN (with password) in its properties, so we log only `.message` (+ `.code` when
|
||||
* present, e.g. 'ECONNREFUSED') and never the object itself.
|
||||
*/
|
||||
function errText(e: unknown): string {
|
||||
if (e instanceof Error) {
|
||||
const code = (e as { code?: unknown }).code
|
||||
return code === undefined ? e.message : `${e.message} (code=${String(code)})`
|
||||
}
|
||||
return String(e)
|
||||
}
|
||||
|
||||
// ── async mTLS → sync-slot bridge ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface MtlsBridge {
|
||||
/** Sync `MtlsVerifier` for the data plane; reads the pre-computed verdict for this connection. */
|
||||
readonly sync: MtlsVerifier
|
||||
/** Wrap the real TLS factory so each peer is registry-verified (async) BEFORE `attach` runs. */
|
||||
wrap(base: TlsServerFactory): TlsServerFactory
|
||||
}
|
||||
|
||||
/**
|
||||
* term-relay's `MtlsVerifier.verifyPeer` is SYNC, but a registry-backed verifier (B2) is inherently
|
||||
* async (Postgres lookup) — the ASYNC IMPEDANCE flagged in mtls-verifier.ts. We bridge it WITHOUT
|
||||
* editing term-relay (outside our lane) by doing the async verify in the TLS `onPeer` hook and
|
||||
* caching the verdict keyed by the peer's DER, which the sync `verifyPeer` (called synchronously by
|
||||
* `attach`, immediately after `onPeer` fires) then reads. The set→onPeer→get sequence runs
|
||||
* synchronously inside one `.then` callback, so a single-slot cache per DER is race-free. Fail-closed
|
||||
* throughout: a rejected/failed verify caches `null`, so `attach` closes the peer with 4401 (INV14).
|
||||
*/
|
||||
function bridgeAsyncMtls(
|
||||
asyncMtls: AsyncMtlsVerifier,
|
||||
onError: (e: unknown) => void,
|
||||
): MtlsBridge {
|
||||
const pending = new Map<string, { hostId: string; accountId: string } | null>()
|
||||
const keyOf = (der: Uint8Array): string => Buffer.from(der).toString('base64')
|
||||
|
||||
const sync: MtlsVerifier = {
|
||||
verifyPeer(peerCert) {
|
||||
const k = keyOf(peerCert)
|
||||
const verdict = pending.get(k) ?? null
|
||||
pending.delete(k) // one-shot: consumed by the attach() that triggered this onPeer
|
||||
return verdict
|
||||
},
|
||||
}
|
||||
|
||||
const wrap = (base: TlsServerFactory): TlsServerFactory => (opts, onPeer) =>
|
||||
base(opts, (ws, der) => {
|
||||
asyncMtls
|
||||
.verifyPeer(der)
|
||||
.then((verdict) => {
|
||||
pending.set(keyOf(der), verdict)
|
||||
onPeer(ws, der) // sync attach() → sync.verifyPeer(der) reads + consumes the verdict
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
onError(e)
|
||||
pending.set(keyOf(der), null) // fail-closed → attach() closes 4401
|
||||
onPeer(ws, der)
|
||||
})
|
||||
})
|
||||
|
||||
return { sync, wrap }
|
||||
}
|
||||
|
||||
// ── boot ────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const now = (): number => Math.floor(Date.now() / 1000)
|
||||
|
||||
// Config (fail-fast; secrets are read but never logged — INV9).
|
||||
const bindHost = process.env.BIND_HOST || DEFAULT_BIND_HOST
|
||||
const bindPort = intEnv('BIND_PORT', DEFAULT_BIND_PORT)
|
||||
const agentBindPort = requirePort('AGENT_BIND_PORT')
|
||||
const tlsCertPath = requireEnv('TLS_CERT_PATH')
|
||||
const tlsKeyPath = requireEnv('TLS_KEY_PATH')
|
||||
const agentServerCertPath = requireEnv('AGENT_SERVER_CERT_PATH')
|
||||
const agentServerKeyPath = requireEnv('AGENT_SERVER_KEY_PATH')
|
||||
const agentCaCertPath = requireEnv('AGENT_CA_CERT_PATH')
|
||||
const agentCaChainPath = requireEnv('AGENT_CA_CHAIN_PATH')
|
||||
const baseDomain = requireEnv('BASE_DOMAIN')
|
||||
const relayNodeId = requireEnv('RELAY_NODE_ID')
|
||||
const trustDomain = requireEnv('RELAY_TRUST_DOMAIN')
|
||||
const allowedOrigins = requireEnv('ALLOWED_ORIGINS')
|
||||
.split(',')
|
||||
.map((o) => o.trim())
|
||||
.filter((o) => o.length > 0)
|
||||
if (allowedOrigins.length === 0) {
|
||||
throw new KeyConfigError('ALLOWED_ORIGINS must contain at least one origin (CSWSH exact-match)')
|
||||
}
|
||||
const pgUrl = requireEnv('PG_URL')
|
||||
const redisUrl = requireEnv('REDIS_URL')
|
||||
const webRoot = process.env.WEB_ROOT || DEFAULT_WEB_ROOT
|
||||
|
||||
// Shared P5 verify key (RELAY_AUTH_VERIFY_PUBKEY) — configured process-wide, never logged (INV9).
|
||||
await loadVerifyKeyFromEnv()
|
||||
|
||||
// Shared stores: SAME Postgres + Redis as the control-plane (INV7).
|
||||
const pool = createPgPool(pgUrl)
|
||||
const query = createQuery(pool)
|
||||
const stores = createPgStores(query)
|
||||
const redis = createRedisClient(redisUrl)
|
||||
const redisSubscriber = createRedisClient(redisUrl) // dedicated subscriber-mode connection
|
||||
|
||||
const deps = createRelayEnforceDeps({ query, redis })
|
||||
const resolver = createStoreRouteResolver({ hosts: stores.hosts })
|
||||
|
||||
const asyncMtls = createMtlsVerifier({
|
||||
caChainPem: readFileSync(agentCaChainPath, 'utf8'),
|
||||
hosts: deps.hosts,
|
||||
now,
|
||||
onError: (e) => console.error('[mtls-verify]', errText(e)),
|
||||
})
|
||||
const mtlsBridge = bridgeAsyncMtls(asyncMtls, (e) => console.error('[mtls-bridge]', errText(e)))
|
||||
|
||||
const authorizer = createAuthorizer({ deps, allowedOrigins, now })
|
||||
|
||||
const config = makeDataPlaneConfig({ baseDomain, bindHost, bindPort, agentBindPort, relayNodeId })
|
||||
|
||||
const agentTlsFactory = makeAgentTlsServerFactory({
|
||||
serverCertPath: agentServerCertPath,
|
||||
serverKeyPath: agentServerKeyPath,
|
||||
bindHost,
|
||||
bindPort: agentBindPort,
|
||||
onListening: () => console.log(`[agent-mtls] listening wss://${bindHost}:${agentBindPort}`),
|
||||
onError: (e) => console.error('[agent-mtls]', errText(e)),
|
||||
})
|
||||
|
||||
const dp = buildDataPlane({
|
||||
config,
|
||||
authorizer,
|
||||
resolver,
|
||||
mtls: mtlsBridge.sync,
|
||||
now,
|
||||
// Agent-TLS trust store for validating the agent's CLIENT leaf (requestCert+rejectUnauthorized).
|
||||
// MUST be the FULL chain (intermediate + self-signed root): Node/OpenSSL rejects a leaf whose only
|
||||
// anchor is a non-self-signed intermediate (no PARTIAL_CHAIN flag). Intermediate-only ⇒ every agent
|
||||
// is reset at the TLS layer before attach() runs.
|
||||
caBundle: [readFileSync(agentCaChainPath)],
|
||||
onError: (e) => console.error('[data-plane]', errText(e)),
|
||||
tlsServerFactory: mtlsBridge.wrap(agentTlsFactory),
|
||||
})
|
||||
|
||||
// STAGING operator token-mint (B5). Enabled only when BOTH the password gate and the signing key
|
||||
// are configured; otherwise the endpoint stays off (fail-closed) and static-only mode serves.
|
||||
const operatorPassword = process.env.OPERATOR_PASSWORD ?? ''
|
||||
const signPrivRaw = process.env.CAPABILITY_SIGN_PRIVKEY ?? ''
|
||||
let onRequest: ReturnType<typeof createAuthMintRoute> | undefined
|
||||
let mintEnabled = false
|
||||
if (operatorPassword.length > 0 && signPrivRaw.length > 0) {
|
||||
const signingKey = await loadSigningKeyFromEnv(signPrivRaw)
|
||||
// F1: the public :443 mint MUST be rate-limited (brute-force of OPERATOR_PASSWORD → full shell).
|
||||
// Reuse the shared Redis token bucket keyed on a salted hash of the client IP (raw IP never stored).
|
||||
const mintRateSalt = process.env.MINT_RATE_SALT || 'relay-run-mint-rate-salt'
|
||||
onRequest = createAuthMintRoute({
|
||||
signingKey,
|
||||
hosts: stores.hosts,
|
||||
operatorPassword,
|
||||
now,
|
||||
rateLimit: { buckets: deps.buckets, salt: mintRateSalt },
|
||||
onError: (e) => console.error('[auth-mint]', errText(e)),
|
||||
})
|
||||
mintEnabled = true
|
||||
} else {
|
||||
console.warn(
|
||||
'[auth-mint] STAGING mint disabled — set OPERATOR_PASSWORD and CAPABILITY_SIGN_PRIVKEY to enable POST /auth/mint',
|
||||
)
|
||||
}
|
||||
|
||||
const browserServer = startBrowserServer({
|
||||
certPath: tlsCertPath,
|
||||
keyPath: tlsKeyPath,
|
||||
bindHost,
|
||||
bindPort,
|
||||
node: dp.node,
|
||||
landingHtml: '<!doctype html><title>relay</title>', // unused when staticRoot is set
|
||||
staticRoot: webRoot,
|
||||
...(onRequest ? { onRequest } : {}),
|
||||
onListening: () => console.log(`[browser-wss] listening https://${bindHost}:${bindPort}`),
|
||||
onError: (e) => console.error('[browser-wss]', errText(e)),
|
||||
})
|
||||
|
||||
// INV12: a Redis relay:revocations kill-signal tears matching live tunnel(s) down on this node.
|
||||
const revocableNode: RevocableNode = {
|
||||
activeTunnels: () =>
|
||||
[...dp.listener.tunnels().values()].map((t) => ({ hostId: t.hostId, accountId: t.accountId })),
|
||||
closeStream: (hostId) => dp.node.closeTunnel(hostId),
|
||||
}
|
||||
const revsub = startRevocationSubscriber({
|
||||
redisSubscriber,
|
||||
node: revocableNode,
|
||||
// INV10: log counts + scope KIND only — never signal.reason / terminal payload.
|
||||
onApplied: (signal, hostsAffected) =>
|
||||
console.log(`[revocation] applied scope=${signal.scope.kind} hostsAffected=${hostsAffected}`),
|
||||
onDropped: () => console.warn('[revocation] dropped malformed kill-signal'),
|
||||
onError: (e) => console.error('[revocation]', errText(e)),
|
||||
})
|
||||
|
||||
console.log('\n=== relay-run Phase 1 READY ===')
|
||||
console.log(`Base domain : ${baseDomain} trustDomain: ${trustDomain} node: ${relayNodeId}`)
|
||||
console.log(`Browser WSS : https://${bindHost}:${bindPort} (static root: ${webRoot})`)
|
||||
console.log(`Agent mTLS : wss://${bindHost}:${agentBindPort}`)
|
||||
console.log(`Allowed origins : ${allowedOrigins.join(', ')}`)
|
||||
console.log(`Operator mint : ${mintEnabled ? 'ENABLED (STAGING /auth/mint)' : 'disabled'}`)
|
||||
console.log('Ctrl-C to stop.\n')
|
||||
|
||||
let shuttingDown = false
|
||||
const shutdown = async (): Promise<void> => {
|
||||
if (shuttingDown) return
|
||||
shuttingDown = true
|
||||
console.log('\nshutting down…')
|
||||
try {
|
||||
revsub.close()
|
||||
browserServer.close()
|
||||
dp.listener.close()
|
||||
await Promise.allSettled([redis.quit(), redisSubscriber.quit(), pool.end()])
|
||||
} catch (e) {
|
||||
console.error('[shutdown]', errText(e))
|
||||
} finally {
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
process.on('SIGINT', () => void shutdown())
|
||||
process.on('SIGTERM', () => void shutdown())
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('fatal:', errText(e))
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -37,6 +37,11 @@ export function makeAgentTlsServerFactory(opts: AgentTlsOptions): TlsServerFacto
|
||||
const der = peer && peer.raw ? new Uint8Array(peer.raw) : new Uint8Array()
|
||||
onPeer(wsToWebSocketLike(ws), der)
|
||||
})
|
||||
wss.on('error', (e) => opts.onError?.(e))
|
||||
// Surface TLS-layer client failures (e.g. a client leaf that doesn't chain to `ca`). Node
|
||||
// otherwise SWALLOWS 'tlsClientError' — the peer just sees an abrupt reset with no server signal,
|
||||
// which is exactly what masked the intermediate-only-CA bug.
|
||||
server.on('tlsClientError', (e) => opts.onError?.(e))
|
||||
server.on('error', (e) => opts.onError?.(e))
|
||||
server.listen(opts.bindPort, opts.bindHost, () => opts.onListening?.())
|
||||
return {
|
||||
|
||||
314
relay-run/src/servers/auth-mint.ts
Normal file
314
relay-run/src/servers/auth-mint.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* B5 · STAGING-ONLY operator token-mint (`POST /auth/mint`).
|
||||
*
|
||||
* ┌─ STAGING NOTICE ────────────────────────────────────────────────────────────────────────────┐
|
||||
* │ The ONLY gate here is a shared `OPERATOR_PASSWORD` (constant-time compared). This is a │
|
||||
* │ deliberate Phase-1 staging shortcut so an operator can mint a browser capability token without │
|
||||
* │ the full human-auth stack. Phase 2 REPLACES this password gate with WebAuthn (P5 T5–T8). │
|
||||
* └────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
*
|
||||
* Flow: the operator's browser generates its OWN DPoP keypair, computes the base64url SHA-256 JWK
|
||||
* thumbprint `jkt`, and POSTs `{ password, jkt, subdomain }`. On a correct password we resolve the
|
||||
* subdomain to its host row in the CP `hosts` store and mint a short-lived (<=60 s) §4.3 capability
|
||||
* token BOUND to that `jkt` (`cnf.jkt`, RFC 7800 proof-of-possession) via the REAL P5 issue path.
|
||||
*
|
||||
* SECURITY:
|
||||
* - INV3: `sub`(accountId)/`host`(hostId)/`aud`(subdomain) come SOLELY from the authenticated CP
|
||||
* store row — the request body only NAMES a subdomain and the client's own DPoP thumbprint; it
|
||||
* never supplies an accountId/hostId.
|
||||
* - INV9: the signing private key and the minted token are NEVER logged. `onError` receives only
|
||||
* the thrown error (callers must not log request bodies / tokens either).
|
||||
* - Deny-by-default: unknown/revoked subdomain, bad password, malformed body → 4xx, no token.
|
||||
* - Input is validated at the boundary (Zod) BEFORE any store/crypto work; body size is capped.
|
||||
*/
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import { createHash, createHmac, timingSafeEqual } from 'node:crypto'
|
||||
import { issueCapabilityToken, type AuthenticatedPrincipal, type TokenBucketStore } from 'relay-auth'
|
||||
import type { CapabilityRight } from 'relay-contracts'
|
||||
import type { HostStore } from 'control-plane/src/store/ports.js'
|
||||
|
||||
/** Route this handler owns. */
|
||||
const MINT_PATH = '/auth/mint' as const
|
||||
/** Minted tokens are connect-scoped and short-lived (P5 clamps issue to [30, 60] s). */
|
||||
const MINT_TTL_SEC = 60
|
||||
/** Reject an oversized request body before buffering it (DoS guard). */
|
||||
const MAX_BODY_BYTES = 4096
|
||||
/** Least-privilege: an operator connecting to their terminal needs only `attach`. */
|
||||
const MINT_RIGHTS: readonly CapabilityRight[] = ['attach']
|
||||
/** A base64url SHA-256 JWK thumbprint is exactly 43 chars of [A-Za-z0-9_-]. */
|
||||
const JKT_RE = /^[A-Za-z0-9_-]{43}$/
|
||||
/**
|
||||
* F1 · public-mint brute-force throttle (per remote IP). Strict & low: a full bucket allows a short
|
||||
* burst, then admits ~1 attempt every 5 s — enough for an operator's retries, useless for guessing
|
||||
* OPERATOR_PASSWORD. Tuned so a fresh IP gets `MINT_RATE_BURST` immediate tries.
|
||||
*/
|
||||
export const MINT_RATE_BURST = 5
|
||||
export const MINT_RATE_REFILL_PER_SEC = 0.2
|
||||
/** A single DNS label (tenant subdomain): lowercase alnum + hyphens, 1–63 chars, no leading/trailing '-'. */
|
||||
const SUBDOMAIN_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/
|
||||
|
||||
/** The single CP `hosts` capability this route needs (interface segregation). */
|
||||
export type SubdomainHostLookup = Pick<HostStore, 'getBySubdomain'>
|
||||
|
||||
/**
|
||||
* F1 · per-remote-address throttle for the public `/auth/mint`. Reuses the shared (Redis) token
|
||||
* bucket. Keyed on a SALTED HASH of the client IP — the raw IP is never used as a key. Omit to
|
||||
* disable the throttle (unit tests / trusted-network deployments); production ALWAYS supplies it.
|
||||
*/
|
||||
export interface MintRateLimit {
|
||||
/** Token-bucket store (Redis-backed in prod; the SAME store the relay's pre-auth limiter uses). */
|
||||
readonly buckets: TokenBucketStore
|
||||
/** Salt for hashing the client IP into the bucket key — prevents storing/inferring the raw IP. */
|
||||
readonly salt: string
|
||||
/** Bucket capacity (max immediate burst). Default {@link MINT_RATE_BURST}. */
|
||||
readonly burst?: number
|
||||
/** Refill tokens/second. Default {@link MINT_RATE_REFILL_PER_SEC}. */
|
||||
readonly refillPerSec?: number
|
||||
}
|
||||
|
||||
export interface AuthMintDeps {
|
||||
/** P5 capability signing PRIVATE key (Ed25519 CryptoKey with ['sign']). NEVER logged. */
|
||||
readonly signingKey: CryptoKey
|
||||
/** CP hosts store — the ONLY source of accountId/hostId (INV3). */
|
||||
readonly hosts: SubdomainHostLookup
|
||||
/** Shared staging operator password (constant-time compared). Phase 2 → WebAuthn. */
|
||||
readonly operatorPassword: string
|
||||
/** Epoch-SECONDS clock (token iat/exp). */
|
||||
readonly now: () => number
|
||||
/** F1 per-IP brute-force throttle. When set, exhaustion returns 429 BEFORE the password compare. */
|
||||
readonly rateLimit?: MintRateLimit
|
||||
/** Observability seam — receives thrown errors only (never bodies/tokens/keys). */
|
||||
readonly onError?: (e: unknown) => void
|
||||
}
|
||||
|
||||
interface MintRequest {
|
||||
readonly password: string
|
||||
readonly jkt: string
|
||||
readonly subdomain: string
|
||||
}
|
||||
|
||||
/** Validate the untrusted JSON body at the boundary. Returns the typed request or `null` to reject. */
|
||||
function parseMintRequest(json: unknown): MintRequest | null {
|
||||
if (typeof json !== 'object' || json === null) return null
|
||||
const { password, jkt, subdomain } = json as Record<string, unknown>
|
||||
if (typeof password !== 'string' || password.length === 0) return null
|
||||
if (typeof jkt !== 'string' || !JKT_RE.test(jkt)) return null
|
||||
if (typeof subdomain !== 'string' || !SUBDOMAIN_RE.test(subdomain)) return null
|
||||
return { password, jkt, subdomain }
|
||||
}
|
||||
|
||||
/** Length-independent constant-time string equality (compares fixed-size SHA-256 digests). */
|
||||
function safeEqual(a: string, b: string): boolean {
|
||||
const ha = createHash('sha256').update(a).digest()
|
||||
const hb = createHash('sha256').update(b).digest()
|
||||
return timingSafeEqual(ha, hb)
|
||||
}
|
||||
|
||||
/** Salted HMAC-SHA256 of the client IP → an OPAQUE, non-reversible bucket key (never the raw IP). */
|
||||
function mintBucketKey(salt: string, remoteAddr: string): string {
|
||||
return 'mint:' + createHmac('sha256', salt).update(remoteAddr).digest('hex').slice(0, 32)
|
||||
}
|
||||
|
||||
/**
|
||||
* F1 · consume one token from the per-IP mint bucket. Returns true when the request may proceed,
|
||||
* false when the IP is throttled. A thrown store error is NOT swallowed here — it propagates to
|
||||
* handleMint's outer catch, which denies (500, no token) and reports via `onError` (deny-by-default).
|
||||
*/
|
||||
async function allowMintAttempt(rl: MintRateLimit, remoteAddr: string, now: number): Promise<boolean> {
|
||||
const key = mintBucketKey(rl.salt, remoteAddr)
|
||||
const burst = rl.burst ?? MINT_RATE_BURST
|
||||
const refillPerSec = rl.refillPerSec ?? MINT_RATE_REFILL_PER_SEC
|
||||
return rl.buckets.take(key, refillPerSec, burst, now)
|
||||
}
|
||||
|
||||
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
||||
const payload = JSON.stringify(body)
|
||||
res.writeHead(status, {
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
'cache-control': 'no-store',
|
||||
})
|
||||
res.end(payload)
|
||||
}
|
||||
|
||||
/** Path portion of a URL (query/hash stripped) — the route matches on path only. */
|
||||
function pathOf(url: string): string {
|
||||
return url.split('?', 1)[0].split('#', 1)[0]
|
||||
}
|
||||
|
||||
/** Buffer the request body up to `maxBytes`; reject (and destroy the stream) if it exceeds the cap. */
|
||||
function readBody(req: IncomingMessage, maxBytes: number): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
let size = 0
|
||||
const chunks: Buffer[] = []
|
||||
req.on('data', (chunk: Buffer) => {
|
||||
size += chunk.length
|
||||
if (size > maxBytes) {
|
||||
req.destroy()
|
||||
reject(new Error('request body too large'))
|
||||
return
|
||||
}
|
||||
chunks.push(chunk)
|
||||
})
|
||||
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
||||
req.on('error', (e) => reject(e))
|
||||
})
|
||||
}
|
||||
|
||||
function operatorPrincipal(accountId: string, authAt: number): AuthenticatedPrincipal {
|
||||
// Minimal authenticated principal — issueCapabilityToken reads only `.accountId` (INV3).
|
||||
return {
|
||||
kind: 'human',
|
||||
accountId,
|
||||
principalId: `operator:${accountId}`,
|
||||
amr: ['passkey'],
|
||||
authAt,
|
||||
stepUpAt: null,
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMint(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
deps: AuthMintDeps,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// F1: per-IP throttle BEFORE any body buffering / password compare — blunts a brute-force of
|
||||
// OPERATOR_PASSWORD on the public :443 mint. Keyed on the SALTED IP hash (raw IP never stored).
|
||||
if (deps.rateLimit !== undefined) {
|
||||
const remoteAddr = req.socket?.remoteAddress ?? ''
|
||||
if (!(await allowMintAttempt(deps.rateLimit, remoteAddr, deps.now()))) {
|
||||
sendJson(res, 429, { error: 'rate_limited' })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let raw: string
|
||||
try {
|
||||
raw = await readBody(req, MAX_BODY_BYTES)
|
||||
} catch {
|
||||
sendJson(res, 413, { error: 'payload_too_large' })
|
||||
return
|
||||
}
|
||||
|
||||
let json: unknown
|
||||
try {
|
||||
json = JSON.parse(raw)
|
||||
} catch {
|
||||
sendJson(res, 400, { error: 'invalid_json' })
|
||||
return
|
||||
}
|
||||
|
||||
const parsed = parseMintRequest(json)
|
||||
if (parsed === null) {
|
||||
sendJson(res, 400, { error: 'invalid_request' })
|
||||
return
|
||||
}
|
||||
const { password, jkt, subdomain } = parsed
|
||||
|
||||
// STAGING gate. Constant-time to avoid a password-length/prefix timing oracle.
|
||||
if (!safeEqual(password, deps.operatorPassword)) {
|
||||
sendJson(res, 401, { error: 'unauthorized' })
|
||||
return
|
||||
}
|
||||
|
||||
const host = await deps.hosts.getBySubdomain(subdomain)
|
||||
if (host === null) {
|
||||
sendJson(res, 404, { error: 'unknown_subdomain' })
|
||||
return
|
||||
}
|
||||
if (host.status === 'revoked') {
|
||||
sendJson(res, 403, { error: 'host_revoked' })
|
||||
return
|
||||
}
|
||||
|
||||
const nowSec = deps.now()
|
||||
// INV3: accountId/hostId/subdomain are the store row's, NEVER the request body's.
|
||||
const token = await issueCapabilityToken(
|
||||
{
|
||||
principal: operatorPrincipal(host.accountId, nowSec),
|
||||
aud: host.subdomain,
|
||||
host: host.hostId,
|
||||
rights: MINT_RIGHTS,
|
||||
ttlSeconds: MINT_TTL_SEC,
|
||||
cnfJkt: jkt,
|
||||
},
|
||||
deps.signingKey,
|
||||
nowSec,
|
||||
)
|
||||
// INV9: the token is a bearer secret — return it, NEVER log it.
|
||||
sendJson(res, 200, { token })
|
||||
} catch (e: unknown) {
|
||||
deps.onError?.(e)
|
||||
if (!res.headersSent) sendJson(res, 500, { error: 'internal_error' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `POST /auth/mint` pre-router for `startBrowserServer`'s `onRequest` hook.
|
||||
*
|
||||
* Returns `true` when it CLAIMS the request (its path is `/auth/mint`) so the caller must not also
|
||||
* write a response — the actual mint completes asynchronously. Returns `false` for any other path so
|
||||
* the request falls through to the static/landing handler.
|
||||
*/
|
||||
export function createAuthMintRoute(
|
||||
deps: AuthMintDeps,
|
||||
): (req: IncomingMessage, res: ServerResponse) => boolean {
|
||||
return (req, res) => {
|
||||
if (pathOf(req.url ?? '/') !== MINT_PATH) return false // not our route → fall through to static
|
||||
if (req.method !== 'POST') {
|
||||
sendJson(res, 405, { error: 'method_not_allowed' })
|
||||
return true
|
||||
}
|
||||
void handleMint(req, res, deps)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// ── P5 capability signing-key loader ──────────────────────────────────────────────────────────────
|
||||
|
||||
const PEM_BODY_RE = /-----BEGIN [^-]+-----|-----END [^-]+-----/g
|
||||
|
||||
/** Coerce to an ArrayBuffer-backed view (WebCrypto's importKey wants `Uint8Array<ArrayBuffer>`). */
|
||||
function toArrayBufferView(u: Uint8Array): Uint8Array<ArrayBuffer> {
|
||||
const out = new Uint8Array(u.byteLength)
|
||||
out.set(u)
|
||||
return out
|
||||
}
|
||||
|
||||
/** Decode standard-or-URL base64 (padding optional) to bytes. */
|
||||
function base64AnyToBytes(s: string): Uint8Array {
|
||||
const std = s.replace(/-/g, '+').replace(/_/g, '/')
|
||||
return new Uint8Array(Buffer.from(std, 'base64'))
|
||||
}
|
||||
|
||||
/** Strip PEM armor + whitespace and base64-decode the body to DER bytes. */
|
||||
function pemBodyToDer(pem: string): Uint8Array {
|
||||
const b64 = pem.replace(PEM_BODY_RE, '').replace(/\s+/g, '')
|
||||
return base64AnyToBytes(b64)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the P5 capability SIGNING key (Ed25519 private) from the `CAPABILITY_SIGN_PRIVKEY` env value.
|
||||
* Accepts either a PKCS#8 PEM (the `gen-capability-key.sh` output — contains `-----BEGIN`) or a
|
||||
* base64/base64url encoding of the PKCS#8 DER. Imported non-extractable with only `['sign']` usage.
|
||||
*
|
||||
* INV9: the raw key material is never logged; this throws a generic error on a malformed value.
|
||||
*/
|
||||
export async function loadSigningKeyFromEnv(raw: string): Promise<CryptoKey> {
|
||||
const trimmed = raw.trim()
|
||||
if (trimmed.length === 0) throw new Error('CAPABILITY_SIGN_PRIVKEY is empty')
|
||||
const der = trimmed.includes('BEGIN') ? pemBodyToDer(trimmed) : base64AnyToBytes(trimmed)
|
||||
if (der.length === 0) throw new Error('CAPABILITY_SIGN_PRIVKEY did not decode to any key bytes')
|
||||
try {
|
||||
return await globalThis.crypto.subtle.importKey(
|
||||
'pkcs8',
|
||||
toArrayBufferView(der),
|
||||
{ name: 'Ed25519' },
|
||||
false,
|
||||
['sign'],
|
||||
)
|
||||
} catch {
|
||||
// Never surface the underlying material in the error.
|
||||
throw new Error('CAPABILITY_SIGN_PRIVKEY is not a valid PKCS#8 Ed25519 private key')
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import { APP_SUBPROTOCOL } from 'relay-contracts'
|
||||
import type { UpgradeRequest } from 'term-relay/data-plane/upgrade.js'
|
||||
import type { RelayNode } from 'term-relay/data-plane/relay-node.js'
|
||||
import { wsToWebSocketLike } from '../wiring/socket-pipe.js'
|
||||
import { serveStatic } from './static-web.js'
|
||||
import { extractDpopProofFromSubprotocols } from './dpop-subprotocol.js'
|
||||
|
||||
export interface BrowserServerOptions {
|
||||
readonly certPath: string
|
||||
@@ -20,6 +22,20 @@ export interface BrowserServerOptions {
|
||||
readonly bindPort: number
|
||||
readonly node: RelayNode
|
||||
readonly landingHtml: string
|
||||
/**
|
||||
* When set, the HTTP handler serves the built relay-web bundle from this directory (D1), SAME
|
||||
* ORIGIN as the WSS (so Origin/CSP stay aligned). When unset, `landingHtml` is served — the
|
||||
* Phase-0 dev fallback. The WS upgrade is unaffected either way (it rides the `upgrade` event).
|
||||
*/
|
||||
readonly staticRoot?: string
|
||||
/**
|
||||
* Optional pre-router (B5): consulted BEFORE `staticRoot`/`landingHtml` on every non-upgrade HTTP
|
||||
* request. Return `true` to claim the request (the hook owns the response — it may finish it
|
||||
* asynchronously); return `false` to fall through to the static/landing behavior below. Default
|
||||
* (undefined) preserves D1's behavior exactly. WS upgrades never reach this hook (they ride the
|
||||
* `upgrade` event), so same-origin `POST /auth/mint` can coexist with the WSS.
|
||||
*/
|
||||
readonly onRequest?: (req: IncomingMessage, res: ServerResponse) => boolean
|
||||
readonly onListening?: () => void
|
||||
readonly onError?: (e: unknown) => void
|
||||
}
|
||||
@@ -35,12 +51,26 @@ function parseCookies(header: string | undefined): Record<string, string> {
|
||||
return out
|
||||
}
|
||||
|
||||
function buildUpgradeRequest(req: IncomingMessage): UpgradeRequest {
|
||||
/**
|
||||
* Build the P1 `UpgradeRequest` from the raw upgrade request.
|
||||
*
|
||||
* DPoP transport (B7): the `dpop` REQUEST HEADER is read first (a proxy or native client MAY set it),
|
||||
* but a browser's native WebSocket API cannot set headers, so relay-web offers the proof as an extra
|
||||
* `term.dpop.<b64u>` subprotocol entry. The header WINS when both are present; otherwise the proof
|
||||
* falls back to the subprotocol (fail-closed → null when absent/malformed). htu/htm are NOT set here:
|
||||
* the authorizer re-derives them from the request's own resolved authority (`expectedAud`), never from
|
||||
* client-supplied claims — so both transports feed the SAME downstream DPoP binding.
|
||||
*
|
||||
* `activeSessionCount` is supplied by the caller (the live per-tenant connection count, F2).
|
||||
*/
|
||||
export function buildUpgradeRequest(req: IncomingMessage, activeSessionCount: number): UpgradeRequest {
|
||||
const proto = req.headers['sec-websocket-protocol']
|
||||
const subprotocols = (typeof proto === 'string' ? proto.split(',') : [])
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length > 0)
|
||||
const dpopHeader = req.headers['dpop']
|
||||
const headerProof = typeof dpopHeader === 'string' && dpopHeader.length > 0 ? dpopHeader : null
|
||||
const proof = headerProof ?? extractDpopProofFromSubprotocols(subprotocols)
|
||||
return {
|
||||
host: req.headers.host ?? '',
|
||||
origin: typeof req.headers.origin === 'string' ? req.headers.origin : undefined,
|
||||
@@ -48,15 +78,32 @@ function buildUpgradeRequest(req: IncomingMessage): UpgradeRequest {
|
||||
subprotocols,
|
||||
cookies: parseCookies(req.headers.cookie),
|
||||
remoteAddr: req.socket.remoteAddress ?? '',
|
||||
dpop: { proof: typeof dpopHeader === 'string' ? dpopHeader : null, publicKeyThumbprint: null },
|
||||
activeSessionCount: 0,
|
||||
dpop: { proof, publicKeyThumbprint: null },
|
||||
activeSessionCount,
|
||||
}
|
||||
}
|
||||
|
||||
export function startBrowserServer(opts: BrowserServerOptions): Server {
|
||||
const server = createServer(
|
||||
{ cert: readFileSync(opts.certPath), key: readFileSync(opts.keyPath) },
|
||||
(_req: IncomingMessage, res: ServerResponse) => {
|
||||
(req: IncomingMessage, res: ServerResponse) => {
|
||||
// Pre-router (B5): a claimed request is fully owned by the hook (e.g. POST /auth/mint) and
|
||||
// must NOT fall through to static/landing (which would double-write the response).
|
||||
if (opts.onRequest !== undefined && opts.onRequest(req, res)) return
|
||||
// Static-bundle mode (Phase 1): serve relay-web from `staticRoot`, SAME-ORIGIN as the WSS.
|
||||
// Non-upgrade HTTP requests only — WS upgrades never reach this handler (see `upgrade` event).
|
||||
if (opts.staticRoot !== undefined) {
|
||||
const file = serveStatic(opts.staticRoot, req.url ?? '/')
|
||||
if (file) {
|
||||
res.writeHead(file.status, file.headers)
|
||||
res.end(file.body)
|
||||
} else {
|
||||
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
|
||||
res.end('Not Found')
|
||||
}
|
||||
return
|
||||
}
|
||||
// Phase-0 fallback: a single landing page.
|
||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
||||
res.end(opts.landingHtml)
|
||||
},
|
||||
@@ -66,9 +113,24 @@ export function startBrowserServer(opts: BrowserServerOptions): Server {
|
||||
handleProtocols: (protocols: Set<string>) =>
|
||||
protocols.has(APP_SUBPROTOCOL) ? APP_SUBPROTOCOL : false,
|
||||
})
|
||||
// F2: best-effort concurrent-session count per tenant. The relay-node keeps its browser↔agent
|
||||
// splice index PRIVATE (no public per-host stream count), so we approximate the account's "active
|
||||
// sessions" with the number of browser WS connections currently open to the SAME tenant origin
|
||||
// (single-tenant staging ⇒ subdomain↔host/account is 1:1). We pass the count of the OTHER live
|
||||
// connections (this one excluded) so P5's `checkConcurrentSessions` sees only prior sessions. This
|
||||
// over-counts unauthorized/failing connects until they close, which fails SAFE (toward the cap).
|
||||
const liveByTenant = new Map<string, number>()
|
||||
wss.on('connection', (ws: WsWebSocket, req: IncomingMessage) => {
|
||||
const tenantKey = req.headers.host ?? ''
|
||||
const priorCount = liveByTenant.get(tenantKey) ?? 0
|
||||
liveByTenant.set(tenantKey, priorCount + 1)
|
||||
ws.once('close', () => {
|
||||
const next = (liveByTenant.get(tenantKey) ?? 1) - 1
|
||||
if (next <= 0) liveByTenant.delete(tenantKey)
|
||||
else liveByTenant.set(tenantKey, next)
|
||||
})
|
||||
opts.node
|
||||
.handleBrowserUpgrade(buildUpgradeRequest(req), wsToWebSocketLike(ws))
|
||||
.handleBrowserUpgrade(buildUpgradeRequest(req, priorCount), wsToWebSocketLike(ws))
|
||||
.catch((e) => opts.onError?.(e))
|
||||
})
|
||||
server.on('error', (e) => opts.onError?.(e))
|
||||
|
||||
36
relay-run/src/servers/dpop-subprotocol.ts
Normal file
36
relay-run/src/servers/dpop-subprotocol.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* B7 · Decode the browser's DPoP proof carried as an EXTRA WS subprotocol entry.
|
||||
*
|
||||
* A browser's native WebSocket API cannot set request headers, so relay-web offers the §4.3 DPoP
|
||||
* proof-of-possession JWS as an additional `Sec-WebSocket-Protocol` entry
|
||||
* `term.dpop.<base64url(proofJws)>`
|
||||
* — mirroring how the capability token rides `term.token.<b64u>` (relay-contracts subprotocol.ts).
|
||||
* This is the relay-side inverse of relay-web `encodeDpopSubprotocol`; the wire format is decoded
|
||||
* byte-for-byte with the SAME isomorphic base64url helper both sides share (relay-contracts), so
|
||||
* issuance and verification can never drift.
|
||||
*
|
||||
* FAIL-CLOSED (INV15): an absent entry OR malformed base64url / non-UTF-8 payload → `null`, so the
|
||||
* downstream DPoP gate denies through the audited path instead of ever seeing a half-decoded proof.
|
||||
*/
|
||||
import { decodeBase64UrlString } from 'relay-contracts'
|
||||
|
||||
/** Subprotocol entry prefix carrying the DPoP proof (mirrors relay-web `DPOP_SUBPROTOCOL_PREFIX`). */
|
||||
export const DPOP_SUBPROTOCOL_PREFIX = 'term.dpop.' as const
|
||||
|
||||
/**
|
||||
* Extract the DPoP proof JWS from a parsed subprotocol list: find the first `term.dpop.` entry,
|
||||
* strip the prefix, and base64url-decode the remainder. Returns `null` when the entry is absent,
|
||||
* empty, or does not decode (deny-by-default).
|
||||
*/
|
||||
export function extractDpopProofFromSubprotocols(values: readonly string[]): string | null {
|
||||
const entry = values.find((v) => v.startsWith(DPOP_SUBPROTOCOL_PREFIX))
|
||||
if (entry === undefined) return null
|
||||
const encoded = entry.slice(DPOP_SUBPROTOCOL_PREFIX.length)
|
||||
if (encoded.length === 0) return null
|
||||
try {
|
||||
const decoded = decodeBase64UrlString(encoded)
|
||||
return decoded.length > 0 ? decoded : null
|
||||
} catch {
|
||||
return null // fail-closed: malformed base64url or invalid UTF-8
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user