Compare commits
54 Commits
feat/tunne
...
7db7be456c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7db7be456c | ||
|
|
1137090626 | ||
|
|
553a00c32f | ||
|
|
d92caedaee | ||
|
|
2a602d5289 | ||
|
|
befe677759 | ||
|
|
7064a39bf1 | ||
|
|
0970c623eb | ||
|
|
5509c81eee | ||
|
|
f3f4d8baa6 | ||
|
|
b1bc50ccd1 | ||
|
|
1dbed54581 | ||
|
|
675de771c7 | ||
|
|
7c1d43376d | ||
|
|
0b35dc043f | ||
|
|
c98f5e6a1f | ||
|
|
1e398c7561 | ||
|
|
55d177e9ee | ||
|
|
9f7f5c0c54 | ||
|
|
6e04eb0661 | ||
|
|
af630143de | ||
|
|
10688b0dd1 | ||
|
|
5e427dcf98 | ||
|
|
07bcbf0c08 | ||
|
|
9a5909f672 | ||
|
|
fff011bb7f | ||
|
|
232ef22535 | ||
|
|
ca9eaa8f1f | ||
|
|
bc31de85dd | ||
|
|
469037cb94 | ||
|
|
9683a16f4f | ||
|
|
c81821b890 | ||
|
|
6541246fc9 | ||
|
|
a7eba2d43b | ||
|
|
19f241d7a3 | ||
|
|
552f35c690 | ||
|
|
1dd12b035a | ||
|
|
7551f8a4b2 | ||
|
|
b119c31019 | ||
|
|
3076843e9c | ||
|
|
e062065cd3 | ||
|
|
debf47d99e | ||
|
|
3e49e36806 | ||
|
|
09134e5001 | ||
|
|
8be2b06564 | ||
|
|
e7bfbe951d | ||
|
|
f8f82dce21 | ||
|
|
c6d819f85f | ||
|
|
afc22989d6 | ||
|
|
733c8a8318 | ||
|
|
007e598802 | ||
|
|
cd97114f87 | ||
|
|
5475b661ae | ||
|
|
06814ba276 |
5
.claude/settings.json
Normal file
5
.claude/settings.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"worktree": {
|
||||||
|
"baseRef": "head"
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -3,6 +3,13 @@ node_modules/
|
|||||||
|
|
||||||
# build output
|
# build output
|
||||||
dist/
|
dist/
|
||||||
|
# EXCEPTION: `agent/src/dist/` holds SOURCE (the packaging config for the distributable binary),
|
||||||
|
# not build output. The blanket `dist/` above swallowed it, so it was never committed — a fresh
|
||||||
|
# clone was missing it and could neither typecheck `agent/src/index.ts` nor import it from the
|
||||||
|
# committed `agent/test/buildBinary.test.ts`. The directory must be re-included FIRST: git does not
|
||||||
|
# descend into an excluded directory, so un-ignoring only the file inside it would not work.
|
||||||
|
!agent/src/dist/
|
||||||
|
!agent/src/dist/**
|
||||||
public/build/
|
public/build/
|
||||||
desktop/build/
|
desktop/build/
|
||||||
desktop/dist-app/
|
desktop/dist-app/
|
||||||
@@ -10,6 +17,9 @@ desktop/dist-app/
|
|||||||
# local Claude Code settings (not shared)
|
# local Claude Code settings (not shared)
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
|
||||||
|
# per-session git worktrees (EnterWorktree) — live on disk, never committed
|
||||||
|
.claude/worktrees/
|
||||||
|
|
||||||
# logs / OS cruft
|
# logs / OS cruft
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|||||||
22
CLAUDE.md
22
CLAUDE.md
@@ -16,6 +16,26 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
**Language decision: TypeScript (`.ts`), not `.js`** — ARCHITECTURE §0 records this divergence from TECH_DOC's original `.js` filenames. Wherever the two docs conflict, ARCHITECTURE wins on *how* (it was cross-validated and corrected); TECH_DOC wins on *why/scope*.
|
**Language decision: TypeScript (`.ts`), not `.js`** — ARCHITECTURE §0 records this divergence from TECH_DOC's original `.js` filenames. Wherever the two docs conflict, ARCHITECTURE wins on *how* (it was cross-validated and corrected); TECH_DOC wins on *why/scope*.
|
||||||
|
|
||||||
|
## Session Workflow: One Worktree per Session (MANDATORY)
|
||||||
|
|
||||||
|
**Every session that changes files works in its own git worktree, and merges back to `develop` when the work is done.** Don't develop directly on `develop` in the main checkout (the one exception is a change to this workflow itself — the rule can't bootstrap inside its own worktree).
|
||||||
|
|
||||||
|
1. **Start of session** — before touching any file, call the `EnterWorktree` tool with a task-descriptive name (e.g. `EnterWorktree({name: "fix-cjk-locale"})`). It creates `.claude/worktrees/<name>/` on branch **`worktree-<name>`** (the tool prefixes it — the name you pass is *not* the branch name) and moves the session's cwd into it. Do all work there.
|
||||||
|
- Base ref is `head` (configured in `.claude/settings.json` → `worktree.baseRef`), so the worktree branches from the **current `develop` HEAD**, not `origin/main`. This matters: `develop` runs ~75 commits ahead of `origin/main`, so the default `fresh` base ref would silently produce a badly stale worktree. `develop` is the working trunk; `main` is the release branch.
|
||||||
|
- It branches from the last **commit**, so uncommitted edits sitting in the main checkout do **not** carry over. Commit or stash them first if the task needs them.
|
||||||
|
- Read-only sessions (answering a question, inspecting a remote host) don't need a worktree — only create one when files will change.
|
||||||
|
2. **During the session** — commit inside the worktree as normal (conventional-commit format, see the global git-workflow rule). Tests/`tsc` run against the worktree copy, so concurrent sessions never collide on the working tree; each holds a `locked` worktree of its own, so never `git worktree remove` a directory this session didn't create.
|
||||||
|
3. **End of session — merge back.** Commit everything in the worktree first, then, **in this order**:
|
||||||
|
```bash
|
||||||
|
# 1. ExitWorktree({action: "keep"}) → cwd returns to the main checkout, branch survives
|
||||||
|
git merge --no-ff worktree-<name> # 2. from the main checkout, on develop
|
||||||
|
git worktree remove .claude/worktrees/<name> && git branch -d worktree-<name> # 3. clean up
|
||||||
|
```
|
||||||
|
**Order matters:** `ExitWorktree({action: "remove"})` deletes the branch along with the directory, so calling it before the merge throws the work away. (It does refuse when commits aren't yet on `develop` — a safety net, not a plan.) `keep` is also the right call whenever the work is unfinished and the session should be resumable.
|
||||||
|
4. **Do not merge to `main`** as part of this flow — `main` is promoted from `develop` separately.
|
||||||
|
|
||||||
|
Subagents dispatched with `isolation: worktree` (PLAN §4) get their own throwaway worktrees on top of this — that is a separate, nested mechanism and does not replace the session-level worktree.
|
||||||
|
|
||||||
## Development Workflow: Plan & Progress Log (MANDATORY)
|
## Development Workflow: Plan & Progress Log (MANDATORY)
|
||||||
|
|
||||||
Work proceeds against a **phased plan** and is tracked in a **progress log that acts as cross-session memory**. A new Claude instance must be able to read the log and know exactly where things stand. Follow these rules:
|
Work proceeds against a **phased plan** and is tracked in a **progress log that acts as cross-session memory**. A new Claude instance must be able to read the log and know exactly where things stand. Follow these rules:
|
||||||
@@ -65,6 +85,8 @@ npm test # unit tests (vitest, all modules)
|
|||||||
|
|
||||||
Config is via env vars only (no hardcoding): `PORT`, `SHELL_PATH`, `BIND_HOST`, `IDLE_TTL`, `SCROLLBACK_BYTES`, `MAX_PAYLOAD_BYTES`, `USE_TMUX` (1/0/auto), `ALLOWED_ORIGINS`. Note `allowedOrigins` is derived from the host's network-interface IPs (not from `BIND_HOST` — `0.0.0.0` is never a valid Origin); see ARCHITECTURE §3.1.
|
Config is via env vars only (no hardcoding): `PORT`, `SHELL_PATH`, `BIND_HOST`, `IDLE_TTL`, `SCROLLBACK_BYTES`, `MAX_PAYLOAD_BYTES`, `USE_TMUX` (1/0/auto), `ALLOWED_ORIGINS`. Note `allowedOrigins` is derived from the host's network-interface IPs (not from `BIND_HOST` — `0.0.0.0` is never a valid Origin); see ARCHITECTURE §3.1.
|
||||||
|
|
||||||
|
`WEBTERM_TOKEN` (w5-access-token, optional) — a shared access token that gates the WS handshake (alongside, not replacing, the Origin check) and every remote HTTP route. **Unset ⇒ auth disabled**, so LAN zero-config is preserved exactly as before; only when set does the gate activate. When set it must be 16–512 URL/cookie-safe chars (`[A-Za-z0-9._~+/=-]`) or the server refuses to start. Deliver it once via `GET /?token=<t>` (or `POST /auth`), which sets an `HttpOnly; SameSite=Strict; Secure-when-https` cookie the browser auto-sends thereafter; loopback hook ingest (`/hook*`) is exempt so the smart-features side-channel keeps working. **Honest tradeoff:** it is a bar-raiser, **not** a TLS/Tailscale substitute — on bare `ws://` the token travels in cleartext and is replayable by a LAN sniffer; it only meaningfully hardens the relay/tunnel (TLS-terminated) path. Never port-forward the raw port to the internet. See `src/http/auth.ts` and `docs/plans/w5-access-token.md`.
|
||||||
|
|
||||||
## Architecture (the parts that span files)
|
## Architecture (the parts that span files)
|
||||||
|
|
||||||
The server is a **byte-shuttle, not a terminal**. It does not parse ANSI/terminal semantics — xterm.js (browser) interprets escape sequences and renders; node-pty (server) provides the pseudo-terminal so the shell believes it has a real TTY. This separation is the central simplification — keep it. Don't add terminal-semantic parsing on the server.
|
The server is a **byte-shuttle, not a terminal**. It does not parse ANSI/terminal semantics — xterm.js (browser) interprets escape sequences and renders; node-pty (server) provides the pseudo-terminal so the shell believes it has a real TTY. This separation is the central simplification — keep it. Don't add terminal-semantic parsing on the server.
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -24,6 +24,14 @@ Sessions survive disconnects: the shell (and whatever's running in it) keeps goi
|
|||||||
- **Sessions ↔ Projects toggle** — a segmented control on the home screen flips between the running-sessions view and the projects view.
|
- **Sessions ↔ Projects toggle** — a segmented control on the home screen flips between the running-sessions view and the projects view.
|
||||||
- **⌂ Home overlay** — a Home button in the tab bar overlays the chooser on top of the current terminal so you can start another session/project without closing your tabs.
|
- **⌂ Home overlay** — a Home button in the tab bar overlays the chooser on top of the current terminal so you can start another session/project without closing your tabs.
|
||||||
|
|
||||||
|
### Split-grid watch board (v0.8, desktop)
|
||||||
|
On a large screen (≥ 1024px) the terminal area can split into a grid so several **live, interactive** sessions show at once — built for watching multiple Claude Code runs in parallel. Desktop-only (a 2×2 of terminals is unusable on a phone); the server and wire protocol are untouched, and single-pane mode is unchanged.
|
||||||
|
- **Layouts** — a toolbar toggle cycles **single / 1×2 / 1×3 / 2×2 / 2×3**. The board shows the first N tabs (drag-reorder the tab bar, or drag a tab straight onto a quadrant, to choose which); a dashed **+ New session** tile fills any empty slot. The choice persists.
|
||||||
|
- **Click-to-focus** — the quadrant you click wears a focus ring and owns the keyboard, mobile key-bar, voice, and the approval bar; **Ctrl+`** cycles focus (⇧ reverses). Only the focused pane takes keyboard focus, so panes don't fight over it.
|
||||||
|
- **Inline approve per quadrant** — a background quadrant waiting on a tool permission glows amber and shows its own **✓ / ✗** buttons, so you can clear approvals across several sessions without switching; its OS notification is suppressed while it's on screen.
|
||||||
|
- **Maximize (⛶) / monitor (👁) per quadrant** — ⛶ expands one quadrant to fill the grid (the others stay live behind it); 👁 flips a quadrant to a **read-only preview** (polled screen snapshots — no WebSocket attach, no resize) so watching a session in a small quadrant never shrinks it for another device using it full-screen.
|
||||||
|
- **Resizable splitters + saved presets** — drag the gutters between panes to re-balance column/row sizes (persisted per layout), and save a layout + its split as a named preset to re-apply in one click.
|
||||||
|
|
||||||
### Claude Code cockpit
|
### Claude Code cockpit
|
||||||
- **Live per-tab status** — Claude Code hooks POST to the server (loopback side-channel); each tab badge shows **working / waiting-for-approval / idle / stuck** in real time. Install once with `npm run setup-hooks`.
|
- **Live per-tab status** — Claude Code hooks POST to the server (loopback side-channel); each tab badge shows **working / waiting-for-approval / idle / stuck** in real time. Install once with `npm run setup-hooks`.
|
||||||
- **Remote approve / reject** — when Claude asks for tool permission, the request is *held* server-side and an **Approve / Reject** bar appears on every attached device — resolve it with a tap, no typing. Works across multiple devices (closing one mirror doesn't cancel the prompt for the others).
|
- **Remote approve / reject** — when Claude asks for tool permission, the request is *held* server-side and an **Approve / Reject** bar appears on every attached device — resolve it with a tap, no typing. Works across multiple devices (closing one mirror doesn't cancel the prompt for the others).
|
||||||
@@ -113,7 +121,7 @@ This wires Claude Code's hooks → **live per-tab status**, the **statusLine gau
|
|||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
```bash
|
```bash
|
||||||
npm test # vitest, all modules (~1470 tests, 80% coverage gate)
|
npm test # vitest, all modules (~1600 tests, 80% coverage gate)
|
||||||
npm run typecheck # tsc (backend + frontend)
|
npm run typecheck # tsc (backend + frontend)
|
||||||
npm run build # compile backend to dist/
|
npm run build # compile backend to dist/
|
||||||
```
|
```
|
||||||
@@ -200,6 +208,6 @@ The server is a **byte-shuttle, not a terminal**: `node-pty` gives the shell a r
|
|||||||
|
|
||||||
The other central design point: **PTY lifecycle ≠ WebSocket lifecycle.** A WS close *detaches* a client (the PTY keeps running for other devices and for reconnect); only an idle timeout (or explicit kill / server shutdown) ends a session.
|
The other central design point: **PTY lifecycle ≠ WebSocket lifecycle.** A WS close *detaches* a client (the PTY keeps running for other devices and for reconnect); only an idle timeout (or explicit kill / server shutdown) ends a session.
|
||||||
|
|
||||||
Tested with **vitest** (~470 tests, 80% coverage gate across backend + the logic-bearing frontend modules), plus real-PTY integration tests that auto-skip where `posix_spawn` is unavailable (sandboxes) and run everywhere else.
|
Tested with **vitest** (~1600 tests, 80% coverage gate across backend + the logic-bearing frontend modules), plus real-PTY integration tests that auto-skip where `posix_spawn` is unavailable (sandboxes) and run everywhere else.
|
||||||
|
|
||||||
Design and rationale: [`docs/TECH_DOC.md`](docs/TECH_DOC.md) (the *why*) and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) (the *how*). Feature PRDs: [`docs/FEATURE_PROJECT_MANAGER.md`](docs/FEATURE_PROJECT_MANAGER.md) (v0.6) and [`docs/FEATURE_WALKAWAY_WORKBENCH.md`](docs/FEATURE_WALKAWAY_WORKBENCH.md) (v0.7).
|
Design and rationale: [`docs/TECH_DOC.md`](docs/TECH_DOC.md) (the *why*) and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) (the *how*). Feature PRDs: [`docs/FEATURE_PROJECT_MANAGER.md`](docs/FEATURE_PROJECT_MANAGER.md) (v0.6) and [`docs/FEATURE_WALKAWAY_WORKBENCH.md`](docs/FEATURE_WALKAWAY_WORKBENCH.md) (v0.7).
|
||||||
|
|||||||
273
agent/src/certs/nativeRenew.ts
Normal file
273
agent/src/certs/nativeRenew.ts
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
/**
|
||||||
|
* Native-tunnel cert auto-renew wiring — TASK A5 (PLAN_ZERO_TOUCH_ROLLOUT).
|
||||||
|
*
|
||||||
|
* The native run-loop (`superviseNative`) used to only MONITOR the frp-client leaf's freshness; the
|
||||||
|
* leaf therefore expired at ~24h and the tunnel dropped until a manual re-pair. This module closes
|
||||||
|
* that gap by driving `createCertRotator`/`renewCert` (crypto is REUSED, never reimplemented):
|
||||||
|
*
|
||||||
|
* - `createMtlsFetch` — the injected `fetchImpl` the rotator hands to `renewCert`. It POSTs /renew
|
||||||
|
* over mTLS presenting the CURRENT keystore leaf (re-read on every call, so the first renewal
|
||||||
|
* after a rotation already authenticates with the freshly issued leaf). mTLS IS the auth — no
|
||||||
|
* token, `rejectUnauthorized` always true (INV4/INV14). The private key stays in-process.
|
||||||
|
* - `wireAutoRenew` — routes the rotator callbacks: rotated → restart frpc onto the new leaf and
|
||||||
|
* log (non-secret); revoked (403) → tear the tunnel down (INV12); error → log + let the rotator
|
||||||
|
* retry with backoff. A failed renewal NEVER crashes the supervisor.
|
||||||
|
* - `startNativeAutoRenew` — the builder `superviseNative` calls: loads the identity, builds the
|
||||||
|
* mTLS fetch + rotator at ~2/3-TTL, and starts it. Returns null (auto-renew disabled) if the host
|
||||||
|
* is not enrolled (no identity), rather than throwing into the run-loop.
|
||||||
|
*/
|
||||||
|
import { request as httpsRequest } from 'node:https'
|
||||||
|
import type { AgentConfig } from '../config/agentConfig.js'
|
||||||
|
import { resolveHostIdentity } from '../config/hostRecord.js'
|
||||||
|
import type { Keystore } from '../keys/keystore.js'
|
||||||
|
import type { Logger } from '../log/logger.js'
|
||||||
|
import type { TimerLike } from '../transport/seams.js'
|
||||||
|
import { createBackoff } from '../transport/backoff.js'
|
||||||
|
import { buildTlsOptions, type CertParser, type TlsClientOptions } from '../transport/dial.js'
|
||||||
|
import { DEFAULT_CERT_RENEW_WINDOW_MS } from '../health/probe.js'
|
||||||
|
import {
|
||||||
|
createCertRotator,
|
||||||
|
type CertExpiredBeyondGraceError,
|
||||||
|
type CertRotator,
|
||||||
|
} from './rotation.js'
|
||||||
|
|
||||||
|
/** Non-secret message from an unknown thrown value (never serializes cert/key material). */
|
||||||
|
function errorMessage(err: unknown): string {
|
||||||
|
return err instanceof Error ? err.message : String(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Socket-idle timeout for a /renew request. A stalled or overloaded control-plane (or a NAT that
|
||||||
|
* silently drops the connection after the TLS handshake) must NOT leave the renewal Promise pending
|
||||||
|
* forever — that would starve the rotator's backoff-retry loop and let the leaf silently expire. On
|
||||||
|
* timeout the request is destroyed and the rejection surfaces through the rotator's onError→backoff.
|
||||||
|
*/
|
||||||
|
export const RENEW_REQUEST_TIMEOUT_MS = 15_000
|
||||||
|
/**
|
||||||
|
* Hard cap on the buffered /renew response body. The reply is a small `{cert,caChain}` JSON; anything
|
||||||
|
* beyond a few KB is malformed or hostile, so we destroy the stream and reject rather than buffer it.
|
||||||
|
*/
|
||||||
|
export const MAX_RENEW_RESPONSE_BYTES = 64 * 1024
|
||||||
|
|
||||||
|
// --- mTLS fetch --------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** A single mTLS request the fetch shim delegates to (injectable so the shim is offline-testable). */
|
||||||
|
export interface MtlsRequestInit {
|
||||||
|
readonly method: string
|
||||||
|
readonly headers: Record<string, string>
|
||||||
|
readonly body?: string
|
||||||
|
}
|
||||||
|
export interface MtlsResponse {
|
||||||
|
readonly status: number
|
||||||
|
readonly body: string
|
||||||
|
}
|
||||||
|
export type MtlsRequest = (
|
||||||
|
url: string,
|
||||||
|
tls: TlsClientOptions,
|
||||||
|
init: MtlsRequestInit,
|
||||||
|
) => Promise<MtlsResponse>
|
||||||
|
|
||||||
|
/** Default mTLS transport: a `node:https` POST presenting the client cert/key + pinned CA. */
|
||||||
|
const defaultMtlsRequest: MtlsRequest = (url, tls, init) =>
|
||||||
|
new Promise<MtlsResponse>((resolve, reject) => {
|
||||||
|
const req = httpsRequest(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
method: init.method,
|
||||||
|
headers: init.headers,
|
||||||
|
cert: tls.cert,
|
||||||
|
key: tls.key,
|
||||||
|
// ca omitted ⇒ verify the server against the system roots (LE-fronted CP). Present only when a
|
||||||
|
// private CA is pinned (not for /renew).
|
||||||
|
...(tls.ca !== undefined ? { ca: tls.ca } : {}),
|
||||||
|
rejectUnauthorized: tls.rejectUnauthorized, // always true (anti-MITM, INV14)
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
let total = 0
|
||||||
|
res.on('data', (c: Buffer) => {
|
||||||
|
total += c.length
|
||||||
|
if (total > MAX_RENEW_RESPONSE_BYTES) {
|
||||||
|
res.destroy() // MEDIUM: refuse an unbounded body — a renew reply is a few-KB JSON
|
||||||
|
reject(new Error(`renew response body exceeded ${MAX_RENEW_RESPONSE_BYTES} byte cap`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chunks.push(c)
|
||||||
|
})
|
||||||
|
res.on('end', () =>
|
||||||
|
resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') }),
|
||||||
|
)
|
||||||
|
res.on('error', reject) // a mid-stream socket error must reject, not hang
|
||||||
|
},
|
||||||
|
)
|
||||||
|
// HIGH: bound the request so a peer that accepts the connection but never replies rejects (and the
|
||||||
|
// rotator re-enters backoff) instead of pending forever — destroy(err) emits 'error' → reject below.
|
||||||
|
req.setTimeout(RENEW_REQUEST_TIMEOUT_MS, () => {
|
||||||
|
req.destroy(new Error(`renew request timed out after ${RENEW_REQUEST_TIMEOUT_MS}ms`))
|
||||||
|
})
|
||||||
|
req.on('error', reject)
|
||||||
|
if (init.body !== undefined) req.write(init.body)
|
||||||
|
req.end()
|
||||||
|
})
|
||||||
|
|
||||||
|
function toHeaderRecord(headers: RequestInit['headers']): Record<string, string> {
|
||||||
|
if (!headers) return {}
|
||||||
|
if (headers instanceof Headers) {
|
||||||
|
const out: Record<string, string> = {}
|
||||||
|
headers.forEach((v, k) => {
|
||||||
|
out[k] = v
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if (Array.isArray(headers)) return Object.fromEntries(headers)
|
||||||
|
return { ...(headers as Record<string, string>) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the `fetch`-shaped shim `renewCert` uses. Each call re-reads the CURRENT keystore leaf via
|
||||||
|
* `buildTlsOptions` (which fail-fast throws NotEnrolled/CertExpired — the rotator then logs + retries
|
||||||
|
* with backoff, never crashing) and delegates to the mTLS transport, mapping the result to a real
|
||||||
|
* `Response` (so `res.ok`/`res.status`/`res.json()` behave exactly as `renewCert` expects).
|
||||||
|
*/
|
||||||
|
export function createMtlsFetch(
|
||||||
|
ks: Keystore,
|
||||||
|
opts: { request?: MtlsRequest; certParser?: CertParser } = {},
|
||||||
|
): typeof fetch {
|
||||||
|
const request = opts.request ?? defaultMtlsRequest
|
||||||
|
const shim = async (input: Parameters<typeof fetch>[0], init?: RequestInit): Promise<Response> => {
|
||||||
|
const url = typeof input === 'string' ? input : input.toString()
|
||||||
|
// Present the current frp-client leaf (client auth), but verify the /renew SERVER cert against the
|
||||||
|
// SYSTEM roots — its host (the LE-fronted control-plane) is publicly trusted; pinning the private
|
||||||
|
// enroll caChain here fails with "unable to get local issuer certificate". So drop `ca` (absent →
|
||||||
|
// node uses the default roots); rejectUnauthorized stays true.
|
||||||
|
// Deliberately still fail-closed on an EXPIRED leaf: nginx would refuse to forward it anyway, so
|
||||||
|
// a lapsed leaf is routed to the plain `/recover` endpoint by the rotator instead of through here.
|
||||||
|
const full = buildTlsOptions(ks, { ...(opts.certParser ? { certParser: opts.certParser } : {}) })
|
||||||
|
const tls: TlsClientOptions = { cert: full.cert, key: full.key, rejectUnauthorized: full.rejectUnauthorized }
|
||||||
|
const reqInit: MtlsRequestInit = {
|
||||||
|
method: init?.method ?? 'GET',
|
||||||
|
headers: toHeaderRecord(init?.headers),
|
||||||
|
...(typeof init?.body === 'string' ? { body: init.body } : {}),
|
||||||
|
}
|
||||||
|
const { status, body } = await request(url, tls, reqInit)
|
||||||
|
return new Response(body, { status })
|
||||||
|
}
|
||||||
|
return shim as typeof fetch
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- rotator wiring ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Non-secret identifiers logged alongside renew events (INV9). */
|
||||||
|
export interface AutoRenewLogIds {
|
||||||
|
readonly subdomain: string | null
|
||||||
|
readonly hostId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The two run-loop effects the rotator drives. */
|
||||||
|
export interface AutoRenewHooks {
|
||||||
|
/** Restart the supervised frpc so it re-reads the rotated cert (a leaf rotation only). */
|
||||||
|
restartChild(): void
|
||||||
|
/** Tear the tunnel down (host revoked ⇒ never reconnect, INV12). */
|
||||||
|
stop(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle for the wired auto-renew loop. */
|
||||||
|
export interface AutoRenewController {
|
||||||
|
stop(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire a rotator's callbacks to the run-loop and start it. Rotated → restart frpc; revoked → stop;
|
||||||
|
* error → log (non-secret) and let the rotator retry with backoff. Returns a controller that stops
|
||||||
|
* the rotator's scheduled timer.
|
||||||
|
*/
|
||||||
|
export function wireAutoRenew(
|
||||||
|
rotator: CertRotator,
|
||||||
|
hooks: AutoRenewHooks,
|
||||||
|
logger: Logger,
|
||||||
|
ids: AutoRenewLogIds,
|
||||||
|
): AutoRenewController {
|
||||||
|
const meta = { subdomain: ids.subdomain, hostId: ids.hostId }
|
||||||
|
rotator.onRotated(() => {
|
||||||
|
logger.log('info', 'frp-client cert rotated; restarting frpc onto the fresh leaf', meta)
|
||||||
|
hooks.restartChild()
|
||||||
|
})
|
||||||
|
rotator.onRevoked(() => {
|
||||||
|
logger.log('warn', 'frp-client cert renewal refused (host revoked); tearing down tunnel', meta)
|
||||||
|
hooks.stop()
|
||||||
|
})
|
||||||
|
rotator.onError((err) => {
|
||||||
|
logger.log('warn', 'frp-client cert renewal failed; will retry with backoff', {
|
||||||
|
...meta,
|
||||||
|
error: errorMessage(err),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
// Terminal: the grace window is spent, so every further attempt is guaranteed to fail. Say so once,
|
||||||
|
// at error level, naming the fix — and deliberately do NOT stop the supervisor: `pair` writes fresh
|
||||||
|
// cert files that the restart-on-exit frpc child picks up without a manual service restart.
|
||||||
|
rotator.onExhausted((err) => {
|
||||||
|
logger.log('error', 'frp-client cert expired beyond recovery grace — run `web-terminal-agent pair <CODE>` to re-pair this host', {
|
||||||
|
...meta,
|
||||||
|
expiredForMs: err.expiredForMs,
|
||||||
|
graceMs: err.graceMs,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
rotator.start()
|
||||||
|
return { stop: () => rotator.stop() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- builder -----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Injection seams for `startNativeAutoRenew` (all optional; unset ⇒ real transport/timers). */
|
||||||
|
export interface NativeAutoRenewOpts {
|
||||||
|
readonly mtlsRequest?: MtlsRequest
|
||||||
|
readonly certParser?: CertParser
|
||||||
|
/** Window in which an already-expired leaf may still be recovered via `/recover`. */
|
||||||
|
readonly expiredGraceMs?: number
|
||||||
|
/** Plain (NON-mTLS) fetch for the `/recover` call; unset ⇒ global fetch. */
|
||||||
|
readonly recoverFetchImpl?: typeof fetch
|
||||||
|
readonly timer?: TimerLike
|
||||||
|
readonly renewBeforeMs?: number
|
||||||
|
readonly retryBaseMs?: number
|
||||||
|
readonly now?: () => Date
|
||||||
|
readonly parseCert?: (pem: string) => Date
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build + start native cert auto-renew for `superviseNative`. Renews at ~2/3 of the leaf TTL
|
||||||
|
* (default `DEFAULT_CERT_RENEW_WINDOW_MS`, the same window the health probe alarms on). Returns null
|
||||||
|
* (auto-renew disabled, logged) when the host has no identity — an unenrolled run-loop must not throw.
|
||||||
|
*/
|
||||||
|
export function startNativeAutoRenew(
|
||||||
|
cfg: AgentConfig,
|
||||||
|
ks: Keystore,
|
||||||
|
hooks: AutoRenewHooks,
|
||||||
|
logger: Logger,
|
||||||
|
opts: NativeAutoRenewOpts = {},
|
||||||
|
): AutoRenewController | null {
|
||||||
|
const id = ks.loadIdentity()
|
||||||
|
if (id === null) {
|
||||||
|
logger.log('warn', 'no identity in keystore — cert auto-renew disabled', {})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const fetchImpl = createMtlsFetch(ks, {
|
||||||
|
...(opts.mtlsRequest ? { request: opts.mtlsRequest } : {}),
|
||||||
|
...(opts.certParser ? { certParser: opts.certParser } : {}),
|
||||||
|
})
|
||||||
|
const rotator = createCertRotator(cfg, id, ks, {
|
||||||
|
fetchImpl,
|
||||||
|
...(opts.expiredGraceMs !== undefined ? { expiredGraceMs: opts.expiredGraceMs } : {}),
|
||||||
|
...(opts.recoverFetchImpl ? { recoverFetchImpl: opts.recoverFetchImpl } : {}),
|
||||||
|
renewBeforeMs: opts.renewBeforeMs ?? DEFAULT_CERT_RENEW_WINDOW_MS,
|
||||||
|
...(opts.timer ? { timer: opts.timer } : {}),
|
||||||
|
...(opts.now ? { now: opts.now } : {}),
|
||||||
|
...(opts.parseCert ? { parseCert: opts.parseCert } : {}),
|
||||||
|
...(opts.retryBaseMs !== undefined
|
||||||
|
? { retryBackoff: createBackoff({ baseMs: opts.retryBaseMs, jitter: false }) }
|
||||||
|
: {}),
|
||||||
|
})
|
||||||
|
// Prefer the resolved identity (config > enrolment record > leaf SPIFFE SAN) so renewal warnings
|
||||||
|
// actually name the host — `cfg` alone is null on every install that predates the record.
|
||||||
|
const ids = resolveHostIdentity(cfg, () => ks.loadCert()?.certPem ?? null)
|
||||||
|
return wireAutoRenew(rotator, hooks, logger, ids)
|
||||||
|
}
|
||||||
33
agent/src/certs/pem.ts
Normal file
33
agent/src/certs/pem.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* PEM helpers shared by the native enroll (enroll/pair.ts) and renew (certs/rotation.ts) paths.
|
||||||
|
*
|
||||||
|
* The control-plane returns the frp-client leaf + CA chain as base64-encoded DER (cert as a string,
|
||||||
|
* caChain as a string[]); the keystore + frpc need PEM files. `derBase64ToPem` wraps a base64 DER body
|
||||||
|
* back into CERTIFICATE armor at 64 columns.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** base64(DER) → PEM (CERTIFICATE armor, 64-col wrapped). */
|
||||||
|
export function derBase64ToPem(derBase64: string, label = 'CERTIFICATE'): string {
|
||||||
|
const body = derBase64.replace(/\s+/g, '')
|
||||||
|
const lines = body.match(/.{1,64}/g) ?? []
|
||||||
|
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a control-plane cert response ({cert: base64 DER, caChain: base64 DER[]}) to PEM strings
|
||||||
|
* for the keystore. Throws if the shape is wrong. Shared by enroll + renew so both stay in lockstep.
|
||||||
|
*/
|
||||||
|
export function certResponseToPem(cert: unknown, caChain: unknown): { certPem: string; caChainPem: string } {
|
||||||
|
if (
|
||||||
|
typeof cert !== 'string' ||
|
||||||
|
!Array.isArray(caChain) ||
|
||||||
|
caChain.length === 0 ||
|
||||||
|
!caChain.every((c) => typeof c === 'string')
|
||||||
|
) {
|
||||||
|
throw new Error('cert response missing cert/caChain')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
certPem: derBase64ToPem(cert),
|
||||||
|
caChainPem: (caChain as string[]).map((c) => derBase64ToPem(c)).join(''),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,15 +13,48 @@ import type { AgentConfig } from '../config/agentConfig.js'
|
|||||||
import type { AgentIdentity } from '../keys/identity.js'
|
import type { AgentIdentity } from '../keys/identity.js'
|
||||||
import type { Keystore } from '../keys/keystore.js'
|
import type { Keystore } from '../keys/keystore.js'
|
||||||
import type { TimerLike } from '../transport/seams.js'
|
import type { TimerLike } from '../transport/seams.js'
|
||||||
|
import { createBackoff, type BackoffPolicy } from '../transport/backoff.js'
|
||||||
import { buildCsr } from '../enroll/csr.js'
|
import { buildCsr } from '../enroll/csr.js'
|
||||||
|
import { certResponseToPem } from './pem.js'
|
||||||
|
|
||||||
export const DEFAULT_RENEW_BEFORE_MS = 5 * 60_000 // renew 5 min before expiry
|
export const DEFAULT_RENEW_BEFORE_MS = 5 * 60_000 // renew 5 min before expiry
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long after `notAfter` a lapsed leaf may still be swapped for a fresh one (30 days).
|
||||||
|
*
|
||||||
|
* `/renew` is mTLS-authenticated by the very leaf it renews, so a lapsed leaf cannot renew itself —
|
||||||
|
* a deadlock that bricked a host for 8 days in production (the laptop slept through its renewal
|
||||||
|
* window, then the agent logged `client certificate has expired` 6380 times and never recovered).
|
||||||
|
* Inside this window the agent switches to the `/recover` endpoint instead; past it, only a re-pair
|
||||||
|
* can help and the rotator says so once and stops.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_EXPIRED_RENEW_GRACE_MS = 30 * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/** The leaf lapsed longer ago than the recovery grace allows ⇒ operator must re-pair this host. */
|
||||||
|
export class CertExpiredBeyondGraceError extends Error {
|
||||||
|
constructor(
|
||||||
|
/** How long ago the leaf expired (ms) — non-secret, safe to log. */
|
||||||
|
readonly expiredForMs: number,
|
||||||
|
/** The grace window that was exceeded (ms). */
|
||||||
|
readonly graceMs: number,
|
||||||
|
) {
|
||||||
|
super('client certificate expired beyond the recovery grace window; re-pair required')
|
||||||
|
this.name = 'CertExpiredBeyondGraceError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface CertRotator {
|
export interface CertRotator {
|
||||||
start(): void
|
start(): void
|
||||||
stop(): void
|
stop(): void
|
||||||
onRotated(cb: () => void): void
|
onRotated(cb: () => void): void
|
||||||
onRevoked(cb: () => void): void
|
onRevoked(cb: () => void): void
|
||||||
|
/** A renewal attempt failed (network/HTTP, NOT a 403 revoke). The rotator retries with backoff. */
|
||||||
|
onError(cb: (err: unknown) => void): void
|
||||||
|
/**
|
||||||
|
* TERMINAL: the leaf expired past the renewal grace window, so no future attempt can succeed. The
|
||||||
|
* rotator has stopped; recovery requires an operator re-pair.
|
||||||
|
*/
|
||||||
|
onExhausted(cb: (err: CertExpiredBeyondGraceError) => void): void
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RenewOutcome = 'rotated' | 'revoked'
|
export type RenewOutcome = 'rotated' | 'revoked'
|
||||||
@@ -31,6 +64,23 @@ export function renewalUrlFor(cfg: AgentConfig): string {
|
|||||||
return cfg.enrollUrl.replace(/\/enroll$/, '/renew')
|
return cfg.enrollUrl.replace(/\/enroll$/, '/renew')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recovery route for a leaf that has ALREADY EXPIRED — a sibling PATH on the same enroll host.
|
||||||
|
*
|
||||||
|
* It cannot be `/renew`, because nginx will not forward an expired client certificate at all: under
|
||||||
|
* `ssl_verify_client optional` it answers a bare `400 Bad Request`, and `optional_no_ca` does not
|
||||||
|
* help either — nginx only tolerates CHAIN errors there (`ngx_ssl_verify_error_optional` covers
|
||||||
|
* self-signed / unknown-issuer / unverifiable-leaf, NOT `X509_V_ERR_CERT_HAS_EXPIRED`). So recovery
|
||||||
|
* drops mTLS entirely: it is a plain HTTPS POST carrying the expired cert in the BODY. Nothing is
|
||||||
|
* lost by that — the accompanying CSR is self-signed by the same private key, and the control-plane
|
||||||
|
* signer already enforces CSR proof-of-possession plus `CSR key == registered key`, so possession is
|
||||||
|
* proven exactly as the TLS handshake used to prove it.
|
||||||
|
*/
|
||||||
|
export function recoveryUrlFor(cfg: AgentConfig): string {
|
||||||
|
if (cfg.recoverUrl != null && cfg.recoverUrl.length > 0) return cfg.recoverUrl
|
||||||
|
return cfg.enrollUrl.replace(/\/enroll$/, '/recover')
|
||||||
|
}
|
||||||
|
|
||||||
/** Ms until (validTo − renewBeforeMs), clamped to ≥ 0. */
|
/** Ms until (validTo − renewBeforeMs), clamped to ≥ 0. */
|
||||||
export function computeRenewDelayMs(
|
export function computeRenewDelayMs(
|
||||||
certPem: string,
|
certPem: string,
|
||||||
@@ -52,20 +102,49 @@ export async function renewCert(
|
|||||||
id: AgentIdentity,
|
id: AgentIdentity,
|
||||||
ks: Keystore,
|
ks: Keystore,
|
||||||
fetchImpl: typeof fetch,
|
fetchImpl: typeof fetch,
|
||||||
|
opts: { url?: string } = {},
|
||||||
): Promise<RenewOutcome> {
|
): Promise<RenewOutcome> {
|
||||||
const csr = buildCsr(id, cfg.subdomain ?? 'web-terminal-agent')
|
const csr = buildCsr(id, cfg.subdomain ?? 'web-terminal-agent')
|
||||||
const res = await fetchImpl(renewalUrlFor(cfg), {
|
const res = await fetchImpl(opts.url ?? renewalUrlFor(cfg), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ csr }),
|
body: JSON.stringify({ csr }),
|
||||||
})
|
})
|
||||||
if (res.status === 403) return 'revoked'
|
if (res.status === 403) return 'revoked'
|
||||||
if (!res.ok) throw new Error(`cert renewal failed: HTTP ${res.status}`)
|
if (!res.ok) throw new Error(`cert renewal failed: HTTP ${res.status}`)
|
||||||
const json = (await res.json()) as { cert?: string; caChain?: string }
|
// The control-plane returns cert=base64(DER) + caChain=base64(DER)[]; normalize to PEM for the
|
||||||
if (typeof json.cert !== 'string' || typeof json.caChain !== 'string') {
|
// keystore + frpc (same shape as native enroll).
|
||||||
throw new Error('cert renewal response missing cert/caChain')
|
const json = (await res.json()) as { cert?: unknown; caChain?: unknown }
|
||||||
}
|
const { certPem, caChainPem } = certResponseToPem(json.cert, json.caChain)
|
||||||
ks.saveCert(json.cert, json.caChain) // atomic whole-file install
|
ks.saveCert(certPem, caChainPem) // atomic whole-file install
|
||||||
|
return 'rotated'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One recovery round-trip for an EXPIRED leaf: plain HTTPS (no client cert — see `recoveryUrlFor`)
|
||||||
|
* POSTing the expired cert alongside a fresh CSR over the SAME key. Same outcome contract as
|
||||||
|
* `renewCert`: 'rotated' installs the new leaf, 403 ⇒ 'revoked', anything else throws to the retry.
|
||||||
|
*/
|
||||||
|
export async function recoverCert(
|
||||||
|
cfg: AgentConfig,
|
||||||
|
id: AgentIdentity,
|
||||||
|
ks: Keystore,
|
||||||
|
fetchImpl: typeof fetch,
|
||||||
|
url: string = recoveryUrlFor(cfg),
|
||||||
|
): Promise<RenewOutcome> {
|
||||||
|
const certs = ks.loadCert()
|
||||||
|
if (certs === null) throw new Error('cannot recover without the expired leaf on disk')
|
||||||
|
const csr = buildCsr(id, cfg.subdomain ?? 'web-terminal-agent')
|
||||||
|
const res = await fetchImpl(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ cert: certs.certPem, csr }),
|
||||||
|
})
|
||||||
|
if (res.status === 403) return 'revoked'
|
||||||
|
if (!res.ok) throw new Error(`cert recovery failed: HTTP ${res.status}`)
|
||||||
|
const json = (await res.json()) as { cert?: unknown; caChain?: unknown }
|
||||||
|
const { certPem, caChainPem } = certResponseToPem(json.cert, json.caChain)
|
||||||
|
ks.saveCert(certPem, caChainPem)
|
||||||
return 'rotated'
|
return 'rotated'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +158,12 @@ export function createCertRotator(
|
|||||||
fetchImpl?: typeof fetch
|
fetchImpl?: typeof fetch
|
||||||
now?: () => Date
|
now?: () => Date
|
||||||
parseCert?: (pem: string) => Date
|
parseCert?: (pem: string) => Date
|
||||||
|
/** Backoff policy for retrying a FAILED renewal (default 1s→30s). Reset after a success. */
|
||||||
|
retryBackoff?: BackoffPolicy
|
||||||
|
/** Plain (NON-mTLS) fetch used only for the expired-leaf `/recover` call. */
|
||||||
|
recoverFetchImpl?: typeof fetch
|
||||||
|
/** Window in which an expired leaf may still be recovered. 0 ⇒ no recovery at all. */
|
||||||
|
expiredGraceMs?: number
|
||||||
} = {},
|
} = {},
|
||||||
): CertRotator {
|
): CertRotator {
|
||||||
const renewBeforeMs = opts.renewBeforeMs ?? DEFAULT_RENEW_BEFORE_MS
|
const renewBeforeMs = opts.renewBeforeMs ?? DEFAULT_RENEW_BEFORE_MS
|
||||||
@@ -90,10 +175,15 @@ export function createCertRotator(
|
|||||||
clearInterval: (h) => clearInterval(h as ReturnType<typeof setInterval>),
|
clearInterval: (h) => clearInterval(h as ReturnType<typeof setInterval>),
|
||||||
}
|
}
|
||||||
const doFetch = opts.fetchImpl ?? fetch
|
const doFetch = opts.fetchImpl ?? fetch
|
||||||
|
const recoverFetch = opts.recoverFetchImpl ?? fetch
|
||||||
|
const expiredGraceMs = opts.expiredGraceMs ?? DEFAULT_EXPIRED_RENEW_GRACE_MS
|
||||||
const now = opts.now ?? (() => new Date())
|
const now = opts.now ?? (() => new Date())
|
||||||
|
const retryBackoff = opts.retryBackoff ?? createBackoff({ jitter: true })
|
||||||
let handle: unknown = null
|
let handle: unknown = null
|
||||||
let rotatedCb: (() => void) | null = null
|
let rotatedCb: (() => void) | null = null
|
||||||
let revokedCb: (() => void) | null = null
|
let revokedCb: (() => void) | null = null
|
||||||
|
let errorCb: ((err: unknown) => void) | null = null
|
||||||
|
let exhaustedCb: ((err: CertExpiredBeyondGraceError) => void) | null = null
|
||||||
|
|
||||||
function schedule(): void {
|
function schedule(): void {
|
||||||
const certs = ks.loadCert()
|
const certs = ks.loadCert()
|
||||||
@@ -102,19 +192,49 @@ export function createCertRotator(
|
|||||||
handle = timer.setTimeout(runRenewal, delay)
|
handle = timer.setTimeout(runRenewal, delay)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How this attempt must be made, derived from how stale the leaf on disk is:
|
||||||
|
* `normal` — still valid ⇒ ordinary mTLS `/renew`;
|
||||||
|
* `recover` — expired but inside the grace window ⇒ plain `/recover` with the cert in the body;
|
||||||
|
* `exhausted` — expired past the grace window ⇒ nothing can succeed; only an operator re-pair.
|
||||||
|
*/
|
||||||
|
function attemptPlan(): { mode: 'normal' | 'recover' | 'exhausted'; expiredForMs: number } {
|
||||||
|
const certs = ks.loadCert()
|
||||||
|
if (certs === null) return { mode: 'normal', expiredForMs: 0 }
|
||||||
|
const expiredForMs = now().getTime() - parseCert(certs.certPem).getTime()
|
||||||
|
if (expiredForMs <= 0) return { mode: 'normal', expiredForMs: 0 }
|
||||||
|
return { mode: expiredForMs > expiredGraceMs ? 'exhausted' : 'recover', expiredForMs }
|
||||||
|
}
|
||||||
|
|
||||||
function runRenewal(): void {
|
function runRenewal(): void {
|
||||||
void renewCert(cfg, id, ks, doFetch)
|
const plan = attemptPlan()
|
||||||
|
if (plan.mode === 'exhausted') {
|
||||||
|
// Terminal: report ONCE and arm nothing. The old code retried forever, which is how a single
|
||||||
|
// real failure turned into 6380 identical warnings that buried the signal.
|
||||||
|
handle = null
|
||||||
|
exhaustedCb?.(new CertExpiredBeyondGraceError(plan.expiredForMs, expiredGraceMs))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const attempt =
|
||||||
|
plan.mode === 'recover'
|
||||||
|
? recoverCert(cfg, id, ks, recoverFetch)
|
||||||
|
: renewCert(cfg, id, ks, doFetch)
|
||||||
|
void attempt
|
||||||
.then((outcome) => {
|
.then((outcome) => {
|
||||||
if (outcome === 'revoked') {
|
if (outcome === 'revoked') {
|
||||||
revokedCb?.()
|
revokedCb?.()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
retryBackoff.reset() // a healthy renewal clears the retry backoff for the next cycle
|
||||||
rotatedCb?.()
|
rotatedCb?.()
|
||||||
schedule()
|
schedule()
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((err: unknown) => {
|
||||||
// network error: retry after renewBeforeMs; the tunnel stays up meanwhile.
|
// Network/HTTP failure (never a 403 revoke): surface it (caller logs, no secret) and retry
|
||||||
handle = timer.setTimeout(runRenewal, renewBeforeMs)
|
// with backoff. The cert is still valid until expiry, so the tunnel stays up meanwhile — a
|
||||||
|
// failed renewal must NEVER tear the supervisor down.
|
||||||
|
errorCb?.(err)
|
||||||
|
handle = timer.setTimeout(runRenewal, retryBackoff.nextDelayMs())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,5 +252,11 @@ export function createCertRotator(
|
|||||||
onRevoked(cb): void {
|
onRevoked(cb): void {
|
||||||
revokedCb = cb
|
revokedCb = cb
|
||||||
},
|
},
|
||||||
|
onError(cb): void {
|
||||||
|
errorCb = cb
|
||||||
|
},
|
||||||
|
onExhausted(cb): void {
|
||||||
|
exhaustedCb = cb
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { dirname, join } from 'node:path'
|
|||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import type { CliDeps, NativeEnrollResult } from '../cli.js'
|
import type { CliDeps, NativeEnrollResult } from '../cli.js'
|
||||||
import type { AgentConfig } from '../config/agentConfig.js'
|
import type { AgentConfig } from '../config/agentConfig.js'
|
||||||
|
import { resolveHostIdentity, saveHostRecord } from '../config/hostRecord.js'
|
||||||
import { loadAgentConfig } from '../config/agentConfig.js'
|
import { loadAgentConfig } from '../config/agentConfig.js'
|
||||||
import { openKeystore } from '../keys/keystore.js'
|
import { openKeystore } from '../keys/keystore.js'
|
||||||
import { generateIdentity, generateP256Identity } from '../keys/identity.js'
|
import { generateIdentity, generateP256Identity } from '../keys/identity.js'
|
||||||
@@ -23,6 +24,7 @@ import { runTunnel } from '../transport/runTunnel.js'
|
|||||||
import { buildNativeFrpcToml } from '../transport/frpcToml.js'
|
import { buildNativeFrpcToml } from '../transport/frpcToml.js'
|
||||||
import { superviseFrpc } from '../transport/frpSupervise.js'
|
import { superviseFrpc } from '../transport/frpSupervise.js'
|
||||||
import { provisionFrpc } from '../provision/frpcBinary.js'
|
import { provisionFrpc } from '../provision/frpcBinary.js'
|
||||||
|
import { startNativeAutoRenew } from '../certs/nativeRenew.js'
|
||||||
import {
|
import {
|
||||||
probeLoopbackBaseApp,
|
probeLoopbackBaseApp,
|
||||||
renderHealthStatus,
|
renderHealthStatus,
|
||||||
@@ -98,7 +100,10 @@ async function enrollNative(
|
|||||||
id: AgentIdentity,
|
id: AgentIdentity,
|
||||||
ks: Keystore,
|
ks: Keystore,
|
||||||
): Promise<NativeEnrollResult> {
|
): Promise<NativeEnrollResult> {
|
||||||
const enroll = await redeemPairingCode(cfg.enrollUrl, code, id, ks)
|
const enroll = await redeemPairingCode(cfg.enrollUrl, code, id, ks, { allowMissingContentSecret: true })
|
||||||
|
// Write the identifiers down: the long-running `run` process has no other way to learn them, and
|
||||||
|
// without them every log line from the tunnel reads `{"subdomain":null,"hostId":null}`.
|
||||||
|
saveHostRecord(cfg.stateDir, { hostId: enroll.hostId, subdomain: enroll.subdomain })
|
||||||
return { hostId: enroll.hostId, subdomain: enroll.subdomain }
|
return { hostId: enroll.hostId, subdomain: enroll.subdomain }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,9 +161,12 @@ export function readFrpcLog(stateDir: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Native run-loop (B4/H4): supervise the pinned frpc child with restart-on-exit backoff while a
|
* Native run-loop (B4/H4 + A5): supervise the pinned frpc child with restart-on-exit backoff while a
|
||||||
* periodic health probe (frpc alive, base-app loopback reachable, proxy-started, cert-not-expiring)
|
* periodic health probe (frpc alive, base-app loopback reachable, proxy-started, cert-not-expiring)
|
||||||
* logs NON-SECRET status only (INV9). Resolves when the supervisor stops (SIGTERM/SIGINT).
|
* logs NON-SECRET status only (INV9), AND auto-renew the frp-client leaf at ~2/3 TTL so the tunnel
|
||||||
|
* never drops on cert expiry (A5): a successful renewal restarts frpc onto the fresh leaf, a 403
|
||||||
|
* revoke tears the tunnel down, and a failed renewal retries with backoff without crashing the
|
||||||
|
* supervisor. Resolves when the supervisor stops (SIGTERM/SIGINT).
|
||||||
*/
|
*/
|
||||||
function superviseNative(cfg: AgentConfig, ks: Keystore): Promise<number> {
|
function superviseNative(cfg: AgentConfig, ks: Keystore): Promise<number> {
|
||||||
const logger = createLogger('info')
|
const logger = createLogger('info')
|
||||||
@@ -180,16 +188,33 @@ function superviseNative(cfg: AgentConfig, ks: Keystore): Promise<number> {
|
|||||||
}),
|
}),
|
||||||
(report) => {
|
(report) => {
|
||||||
// INV9: only non-secret identifiers (subdomain/host id/expiry date) + boolean flags are logged.
|
// INV9: only non-secret identifiers (subdomain/host id/expiry date) + boolean flags are logged.
|
||||||
const ids = { subdomain: cfg.subdomain, hostId: cfg.hostId, certNotAfter: certNotAfter(ks) }
|
const host = resolveHostIdentity(cfg, () => ks.loadCert()?.certPem ?? null)
|
||||||
|
const ids = { ...host, certNotAfter: certNotAfter(ks) }
|
||||||
for (const line of renderHealthStatus(ids, report)) logger.log('info', line)
|
for (const line of renderHealthStatus(ids, report)) logger.log('info', line)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
// A5: silently renew the leaf before it expires. Restart frpc onto the fresh cert on rotation;
|
||||||
|
// stop the whole supervisor on a 403 revoke (INV12). Null ⇒ unenrolled (auto-renew disabled).
|
||||||
|
const autoRenew = startNativeAutoRenew(
|
||||||
|
cfg,
|
||||||
|
ks,
|
||||||
|
{
|
||||||
|
restartChild: () => handle.restartChild(),
|
||||||
|
stop: () => {
|
||||||
|
void handle.stop()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
logger,
|
||||||
|
)
|
||||||
const onSignal = (): void => {
|
const onSignal = (): void => {
|
||||||
void handle.stop()
|
void handle.stop()
|
||||||
}
|
}
|
||||||
process.once('SIGTERM', onSignal)
|
process.once('SIGTERM', onSignal)
|
||||||
process.once('SIGINT', onSignal)
|
process.once('SIGINT', onSignal)
|
||||||
return handle.done.finally(() => monitor.stop())
|
return handle.done.finally(() => {
|
||||||
|
monitor.stop()
|
||||||
|
autoRenew?.stop()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build the concrete CliDeps used by the real CLI entrypoint. */
|
/** Build the concrete CliDeps used by the real CLI entrypoint. */
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ export interface AgentConfig {
|
|||||||
readonly localTargetUrl: string
|
readonly localTargetUrl: string
|
||||||
readonly subdomain: string | null
|
readonly subdomain: string | null
|
||||||
readonly hostId: string | null
|
readonly hostId: string | null
|
||||||
|
/**
|
||||||
|
* Renewal endpoint used ONLY when the current leaf has already expired (the strict `/renew` vhost
|
||||||
|
* rejects an expired client cert before it reaches the control-plane). Optional: when unset it is
|
||||||
|
* derived from `enrollUrl` by swapping the `enroll.` label for `recover.` — see
|
||||||
|
* `certs/rotation.ts` `recoveryRenewalUrlFor`.
|
||||||
|
*/
|
||||||
|
readonly recoverUrl?: string | null | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,6 +66,11 @@ export const AgentConfigSchema = z
|
|||||||
.refine(isLoopbackWsUrl, 'localTargetUrl must be a ws:// loopback URL (anti-SSRF)'),
|
.refine(isLoopbackWsUrl, 'localTargetUrl must be a ws:// loopback URL (anti-SSRF)'),
|
||||||
subdomain: z.string().min(1).nullable(),
|
subdomain: z.string().min(1).nullable(),
|
||||||
hostId: z.string().min(1).nullable(),
|
hostId: z.string().min(1).nullable(),
|
||||||
|
recoverUrl: z
|
||||||
|
.string()
|
||||||
|
.refine((u) => hasScheme(u, 'https:'), 'recoverUrl must be an https:// URL')
|
||||||
|
.nullable()
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.readonly()
|
.readonly()
|
||||||
@@ -85,6 +97,7 @@ export function loadAgentConfig(
|
|||||||
localTargetUrl: argv.localTargetUrl ?? env.LOCAL_TARGET_URL ?? DEFAULT_LOCAL_TARGET,
|
localTargetUrl: argv.localTargetUrl ?? env.LOCAL_TARGET_URL ?? DEFAULT_LOCAL_TARGET,
|
||||||
subdomain: argv.subdomain ?? env.SUBDOMAIN ?? null,
|
subdomain: argv.subdomain ?? env.SUBDOMAIN ?? null,
|
||||||
hostId: argv.hostId ?? env.HOST_ID ?? null,
|
hostId: argv.hostId ?? env.HOST_ID ?? null,
|
||||||
|
recoverUrl: argv.recoverUrl ?? env.RECOVER_URL ?? null,
|
||||||
}
|
}
|
||||||
return AgentConfigSchema.parse(merged)
|
return AgentConfigSchema.parse(merged)
|
||||||
}
|
}
|
||||||
|
|||||||
97
agent/src/config/hostRecord.ts
Normal file
97
agent/src/config/hostRecord.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* Enrolment identifiers (`hostId` / `subdomain`) persisted next to the keystore.
|
||||||
|
*
|
||||||
|
* WHY: `pair --install` learns both from the control-plane's enroll response, but nothing ever wrote
|
||||||
|
* them down — so the long-running `run` process had `cfg.subdomain === null` and `cfg.hostId === null`
|
||||||
|
* and every log line came out as `{"subdomain":null,"hostId":null}`. When the tunnel broke in
|
||||||
|
* production, 6380 warnings named no host at all, which is exactly the moment you want them to.
|
||||||
|
*
|
||||||
|
* They are NOT secrets (the subdomain is a public DNS label), so this is a plain JSON file — kept in
|
||||||
|
* `stateDir` only because that is the one directory the agent already owns on every platform.
|
||||||
|
*
|
||||||
|
* Legacy installs enrolled before this existed have no record. Their leaf still carries the
|
||||||
|
* subdomain in its SPIFFE URI SAN, so `resolveHostIdentity` recovers it from there rather than
|
||||||
|
* forcing a re-pair just to get an identifier back into the logs.
|
||||||
|
*/
|
||||||
|
import { X509Certificate } from 'node:crypto'
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import type { AgentConfig } from './agentConfig.js'
|
||||||
|
|
||||||
|
const RECORD_FILE = 'host.json'
|
||||||
|
const DIR_MODE = 0o700
|
||||||
|
|
||||||
|
/** Non-secret identifiers assigned by the control-plane at enrolment. */
|
||||||
|
export interface HostRecord {
|
||||||
|
readonly hostId: string | null
|
||||||
|
readonly subdomain: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A non-empty string, or null — anything else on disk is treated as absent (never trusted). */
|
||||||
|
function stringOrNull(value: unknown): string | null {
|
||||||
|
return typeof value === 'string' && value.length > 0 ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist the enrolment identifiers into `stateDir`. Overwrites any previous record. */
|
||||||
|
export function saveHostRecord(stateDir: string, record: HostRecord): void {
|
||||||
|
if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true, mode: DIR_MODE })
|
||||||
|
writeFileSync(join(stateDir, RECORD_FILE), `${JSON.stringify(record, null, 2)}\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the persisted identifiers, or null if this host has none. A missing, unreadable, or
|
||||||
|
* malformed file is reported as "no record" — this feeds a logging path and must never throw into
|
||||||
|
* the run loop.
|
||||||
|
*/
|
||||||
|
export function loadHostRecord(stateDir: string): HostRecord | null {
|
||||||
|
const path = join(stateDir, RECORD_FILE)
|
||||||
|
if (!existsSync(path)) return null
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
|
||||||
|
if (typeof parsed !== 'object' || parsed === null) return null
|
||||||
|
const rec = parsed as Record<string, unknown>
|
||||||
|
return { hostId: stringOrNull(rec['hostId']), subdomain: stringOrNull(rec['subdomain']) }
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SPIFFE IDs issued for hosts end in `/host/<subdomain>` (see relay-auth `spiffeIdFor`). */
|
||||||
|
const SPIFFE_HOST_RE = /URI:(spiffe:\/\/[^\s,]*\/host\/([^\s,/]+))/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recover the subdomain from a leaf's SPIFFE URI SAN, or null if it carries none. Parse failures
|
||||||
|
* are null, never throws — a legacy install with a damaged cert must still start.
|
||||||
|
*/
|
||||||
|
export function subdomainFromCertPem(certPem: string): string | null {
|
||||||
|
try {
|
||||||
|
const san = new X509Certificate(certPem).subjectAltName ?? ''
|
||||||
|
const match = SPIFFE_HOST_RE.exec(san)
|
||||||
|
return match?.[2] ?? null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the identifiers to log for this host, most authoritative first:
|
||||||
|
* 1. explicit config (argv/env `SUBDOMAIN` / `HOST_ID`) — an operator override always wins;
|
||||||
|
* 2. the enrolment record written by `pair`;
|
||||||
|
* 3. the subdomain embedded in the stored leaf (legacy installs; yields no hostId).
|
||||||
|
*
|
||||||
|
* `readCertPem` is injected so this stays a pure decision over a supplied cert.
|
||||||
|
*/
|
||||||
|
export function resolveHostIdentity(
|
||||||
|
cfg: AgentConfig,
|
||||||
|
readCertPem: () => string | null,
|
||||||
|
): HostRecord {
|
||||||
|
const record = loadHostRecord(cfg.stateDir)
|
||||||
|
const subdomain =
|
||||||
|
cfg.subdomain ??
|
||||||
|
record?.subdomain ??
|
||||||
|
((): string | null => {
|
||||||
|
const pem = readCertPem()
|
||||||
|
return pem === null ? null : subdomainFromCertPem(pem)
|
||||||
|
})()
|
||||||
|
return { hostId: cfg.hostId ?? record?.hostId ?? null, subdomain }
|
||||||
|
}
|
||||||
45
agent/src/dist/buildBinary.ts
vendored
Normal file
45
agent/src/dist/buildBinary.ts
vendored
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Static-binary build spec — PLAN_RELAY_AGENT T16 (EXPLORE §6 distribution rank 2). Produces a
|
||||||
|
* `bun --compile` spec for a one-`curl | sh` install. `npx web-terminal-agent` stays the MVP path
|
||||||
|
* (rank 1). The bundle EXCLUDES dev/test deps and any terminal parser (INV11 re-check at the
|
||||||
|
* package boundary — the agent is a byte-shuttle, never an ANSI interpreter).
|
||||||
|
*/
|
||||||
|
export type BinaryTarget = 'darwin-arm64' | 'darwin-x64' | 'linux-x64' | 'linux-arm64'
|
||||||
|
|
||||||
|
export const BINARY_TARGETS: readonly BinaryTarget[] = [
|
||||||
|
'darwin-arm64',
|
||||||
|
'darwin-x64',
|
||||||
|
'linux-x64',
|
||||||
|
'linux-arm64',
|
||||||
|
]
|
||||||
|
|
||||||
|
export interface BuildSpec {
|
||||||
|
readonly tool: 'bun'
|
||||||
|
readonly entry: string
|
||||||
|
readonly target: BinaryTarget
|
||||||
|
readonly bunTarget: string // bun's --target triple
|
||||||
|
readonly outfile: string
|
||||||
|
readonly minify: true
|
||||||
|
/** Package-name substrings that must NOT appear in the bundle graph (INV11 tripwire). */
|
||||||
|
readonly forbiddenDeps: readonly string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUN_TRIPLE: Readonly<Record<BinaryTarget, string>> = {
|
||||||
|
'darwin-arm64': 'bun-darwin-arm64',
|
||||||
|
'darwin-x64': 'bun-darwin-x64',
|
||||||
|
'linux-x64': 'bun-linux-x64',
|
||||||
|
'linux-arm64': 'bun-linux-arm64',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the `bun --compile` spec for a target triple. Entry is the CLI. */
|
||||||
|
export function buildBinaryConfig(target: BinaryTarget): BuildSpec {
|
||||||
|
return {
|
||||||
|
tool: 'bun',
|
||||||
|
entry: 'src/cli.ts',
|
||||||
|
target,
|
||||||
|
bunTarget: BUN_TRIPLE[target],
|
||||||
|
outfile: `dist/web-terminal-agent-${target}`,
|
||||||
|
minify: true,
|
||||||
|
forbiddenDeps: ['xterm', 'ansi', 'vt100'],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import type { EnrollResult } from 'relay-contracts'
|
|||||||
import type { AgentIdentity } from '../keys/identity.js'
|
import type { AgentIdentity } from '../keys/identity.js'
|
||||||
import type { Keystore } from '../keys/keystore.js'
|
import type { Keystore } from '../keys/keystore.js'
|
||||||
import { buildCsr } from './csr.js'
|
import { buildCsr } from './csr.js'
|
||||||
|
import { derBase64ToPem } from '../certs/pem.js'
|
||||||
|
|
||||||
/** v0.8 shared-token gate vs v0.9+ per-host Ed25519. Default is `'ed25519'` from v0.9. */
|
/** v0.8 shared-token gate vs v0.9+ per-host Ed25519. Default is `'ed25519'` from v0.9. */
|
||||||
export type EnrollMode = 'token' | 'ed25519'
|
export type EnrollMode = 'token' | 'ed25519'
|
||||||
@@ -53,6 +54,12 @@ export interface RedeemOptions {
|
|||||||
readonly agentToken?: string
|
readonly agentToken?: string
|
||||||
readonly unwrapContentSecret?: UnwrapContentSecret
|
readonly unwrapContentSecret?: UnwrapContentSecret
|
||||||
readonly subject?: string
|
readonly subject?: string
|
||||||
|
/**
|
||||||
|
* Native frp-client enroll has NO E2E content secret — the control-plane returns
|
||||||
|
* `hostContentSecret: null`. When true, tolerate its absence (skip unwrap + storage); the plain
|
||||||
|
* frpc byte tunnel needs no content key. Defaults false so the legacy relay path still requires it.
|
||||||
|
*/
|
||||||
|
readonly allowMissingContentSecret?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EnrollResponseJson {
|
interface EnrollResponseJson {
|
||||||
@@ -63,11 +70,33 @@ interface EnrollResponseJson {
|
|||||||
hostContentSecret: string // base64url over the wire
|
hostContentSecret: string // base64url over the wire
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseEnrollResult(json: unknown): EnrollResult {
|
function parseEnrollResult(json: unknown, allowMissingContentSecret = false): EnrollResult {
|
||||||
const j = json as Partial<EnrollResponseJson>
|
const j = json as Partial<EnrollResponseJson>
|
||||||
if (typeof j.hostContentSecret !== 'string') {
|
if (typeof j.hostContentSecret !== 'string') {
|
||||||
|
if (!allowMissingContentSecret) {
|
||||||
throw new EnrollError('enroll response missing hostContentSecret')
|
throw new EnrollError('enroll response missing hostContentSecret')
|
||||||
}
|
}
|
||||||
|
// Native frp-client enroll: cert = base64(DER) string, caChain = base64(DER) string[], no content
|
||||||
|
// key. The keystore + frpc need PEM, so convert here. Empty secret sentinel is never stored.
|
||||||
|
const caChain: unknown = j.caChain
|
||||||
|
if (
|
||||||
|
typeof j.hostId !== 'string' ||
|
||||||
|
typeof j.subdomain !== 'string' ||
|
||||||
|
typeof j.cert !== 'string' ||
|
||||||
|
!Array.isArray(caChain) ||
|
||||||
|
caChain.length === 0 ||
|
||||||
|
!caChain.every((c) => typeof c === 'string')
|
||||||
|
) {
|
||||||
|
throw new EnrollError('enroll response missing required fields')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
hostId: j.hostId,
|
||||||
|
subdomain: j.subdomain,
|
||||||
|
cert: derBase64ToPem(j.cert),
|
||||||
|
caChain: (caChain as string[]).map((c) => derBase64ToPem(c)).join(''),
|
||||||
|
hostContentSecret: new Uint8Array(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
const candidate = {
|
const candidate = {
|
||||||
hostId: j.hostId,
|
hostId: j.hostId,
|
||||||
subdomain: j.subdomain,
|
subdomain: j.subdomain,
|
||||||
@@ -128,10 +157,13 @@ export async function redeemPairingCode(
|
|||||||
throw new EnrollError(`enroll response was not JSON: ${(err as Error).message}`)
|
throw new EnrollError(`enroll response was not JSON: ${(err as Error).message}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const enroll = parseEnrollResult(json)
|
const enroll = parseEnrollResult(json, opts.allowMissingContentSecret ?? false)
|
||||||
ks.saveCert(enroll.cert, enroll.caChain)
|
ks.saveCert(enroll.cert, enroll.caChain)
|
||||||
// FIX 3: unwrap in-process, persist ONLY the unwrapped secret (wrapped bytes never stored).
|
// FIX 3: unwrap in-process, persist ONLY the unwrapped secret (wrapped bytes never stored).
|
||||||
|
// Native frp-client enroll has no content secret (empty sentinel) → nothing to unwrap/store.
|
||||||
|
if (enroll.hostContentSecret.length > 0) {
|
||||||
const unwrapped = unwrap(enroll.hostContentSecret, id)
|
const unwrapped = unwrap(enroll.hostContentSecret, id)
|
||||||
ks.saveContentSecret(unwrapped)
|
ks.saveContentSecret(unwrapped)
|
||||||
|
}
|
||||||
return enroll
|
return enroll
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
* - FIX M-host-2service: base-app env (BIND_HOST/ALLOWED_ORIGINS/PORT/…) is routed to the
|
* - FIX M-host-2service: base-app env (BIND_HOST/ALLOWED_ORIGINS/PORT/…) is routed to the
|
||||||
* base-app unit ONLY; the agent unit (which supervises frpc) never carries it.
|
* base-app unit ONLY; the agent unit (which supervises frpc) never carries it.
|
||||||
*/
|
*/
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
import type { AgentConfig } from '../config/agentConfig.js'
|
import type { AgentConfig } from '../config/agentConfig.js'
|
||||||
import {
|
import {
|
||||||
agentLabel,
|
agentLabel,
|
||||||
@@ -202,13 +203,37 @@ export async function installService(
|
|||||||
// so nothing is ever written for a rejected install.
|
// so nothing is ever written for a rejected install.
|
||||||
const baseAppEnv = resolveBaseAppEnv(cfg, options)
|
const baseAppEnv = resolveBaseAppEnv(cfg, options)
|
||||||
const bin = deps.binPath()
|
const bin = deps.binPath()
|
||||||
const baseAppExec = options.baseAppExec ?? DEFAULT_BASE_APP_EXEC
|
const rawExec = options.baseAppExec ?? DEFAULT_BASE_APP_EXEC
|
||||||
|
// launchd/systemd start with a minimal PATH that excludes /usr/local/bin (where a nvm/brew `node`
|
||||||
|
// symlink usually lives), so a bare `node` program dies with EX_CONFIG(78). Use the absolute node
|
||||||
|
// (process.execPath) and export a PATH so the units — and the base app's tmux/node-pty/frpc
|
||||||
|
// subprocesses — resolve their tools.
|
||||||
|
const nodePath = process.execPath
|
||||||
|
const unitPath = `${dirname(nodePath)}:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin`
|
||||||
|
const baseAppExec = rawExec[0] === 'node' ? [nodePath, ...rawExec.slice(1)] : rawExec
|
||||||
|
const baseAppEnvWithPath = { ...baseAppEnv, PATH: unitPath }
|
||||||
|
// The agent unit runs `run`, which loadConfig()-validates ENROLL_URL(https)/RELAY_URL(wss) up front
|
||||||
|
// (fail-fast). Without these in the unit env the supervisor exits 1 on every launch — so inject the
|
||||||
|
// agent's own runtime config (NOT the base-app env) alongside PATH.
|
||||||
|
const agentEnv: Record<string, string> = {
|
||||||
|
PATH: unitPath,
|
||||||
|
ENROLL_URL: cfg.enrollUrl,
|
||||||
|
RELAY_URL: cfg.relayUrl,
|
||||||
|
STATE_DIR: cfg.stateDir,
|
||||||
|
LOCAL_TARGET_URL: cfg.localTargetUrl,
|
||||||
|
}
|
||||||
|
|
||||||
if (platform === 'launchd') {
|
if (platform === 'launchd') {
|
||||||
const baseAppPath = launchdPlistPath(deps.homedir(), baseAppLabel())
|
const baseAppPath = launchdPlistPath(deps.homedir(), baseAppLabel())
|
||||||
deps.writeFile(baseAppPath, buildLaunchdPlist(baseAppExec, baseAppEnv, baseAppLabel()))
|
deps.writeFile(
|
||||||
|
baseAppPath,
|
||||||
|
buildLaunchdPlist(baseAppExec, baseAppEnvWithPath, baseAppLabel(), join(cfg.stateDir, 'base-app.log')),
|
||||||
|
)
|
||||||
const agentPath = launchdPlistPath(deps.homedir(), agentLabel())
|
const agentPath = launchdPlistPath(deps.homedir(), agentLabel())
|
||||||
deps.writeFile(agentPath, buildLaunchdPlist([bin, 'run'], {}, agentLabel()))
|
deps.writeFile(
|
||||||
|
agentPath,
|
||||||
|
buildLaunchdPlist([nodePath, bin, 'run'], agentEnv, agentLabel(), join(cfg.stateDir, 'agent.log')),
|
||||||
|
)
|
||||||
for (const path of [baseAppPath, agentPath]) {
|
for (const path of [baseAppPath, agentPath]) {
|
||||||
const { cmd, args } = launchdLoadCommand(path)
|
const { cmd, args } = launchdLoadCommand(path)
|
||||||
await deps.runCommand(cmd, args)
|
await deps.runCommand(cmd, args)
|
||||||
@@ -217,8 +242,8 @@ export async function installService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const baseAppOptions: SystemdUnitOptions = options.envFile
|
const baseAppOptions: SystemdUnitOptions = options.envFile
|
||||||
? { env: baseAppEnv, envFile: options.envFile }
|
? { env: baseAppEnvWithPath, envFile: options.envFile }
|
||||||
: { env: baseAppEnv }
|
: { env: baseAppEnvWithPath }
|
||||||
const baseAppPath = systemdUnitPath(deps.homedir(), baseAppUnitName())
|
const baseAppPath = systemdUnitPath(deps.homedir(), baseAppUnitName())
|
||||||
deps.writeFile(
|
deps.writeFile(
|
||||||
baseAppPath,
|
baseAppPath,
|
||||||
@@ -227,7 +252,7 @@ export async function installService(
|
|||||||
const agentPath = systemdUnitPath(deps.homedir(), agentUnitName())
|
const agentPath = systemdUnitPath(deps.homedir(), agentUnitName())
|
||||||
deps.writeFile(
|
deps.writeFile(
|
||||||
agentPath,
|
agentPath,
|
||||||
buildSystemdUnit(`${bin} run`, deps.username(), {}, 'web-terminal host agent (frpc supervisor)'),
|
buildSystemdUnit(`${nodePath} ${bin} run`, deps.username(), { env: agentEnv }, 'web-terminal host agent (frpc supervisor)'),
|
||||||
)
|
)
|
||||||
for (const unit of [baseAppUnitName(), agentUnitName()]) {
|
for (const unit of [baseAppUnitName(), agentUnitName()]) {
|
||||||
const { cmd, args } = systemdEnableCommand(unit)
|
const { cmd, args } = systemdEnableCommand(unit)
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export function buildLaunchdPlist(
|
|||||||
programArguments: readonly string[],
|
programArguments: readonly string[],
|
||||||
env: ServiceEnv = {},
|
env: ServiceEnv = {},
|
||||||
label: string = AGENT_LABEL,
|
label: string = AGENT_LABEL,
|
||||||
|
logPath?: string,
|
||||||
): string {
|
): string {
|
||||||
return [
|
return [
|
||||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
@@ -91,6 +92,16 @@ export function buildLaunchdPlist(
|
|||||||
' <true/>',
|
' <true/>',
|
||||||
' <key>KeepAlive</key>',
|
' <key>KeepAlive</key>',
|
||||||
' <true/>',
|
' <true/>',
|
||||||
|
// launchd's default has no log sink (unlike systemd's journald), so a crashing unit is silent.
|
||||||
|
// Route stdout+stderr to a file so `pair --install` failures are diagnosable out of the box.
|
||||||
|
...(logPath !== undefined
|
||||||
|
? [
|
||||||
|
' <key>StandardOutPath</key>',
|
||||||
|
` <string>${escapeXml(logPath)}</string>`,
|
||||||
|
' <key>StandardErrorPath</key>',
|
||||||
|
` <string>${escapeXml(logPath)}</string>`,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
...environmentVariablesBlock(env),
|
...environmentVariablesBlock(env),
|
||||||
'</dict>',
|
'</dict>',
|
||||||
'</plist>',
|
'</plist>',
|
||||||
|
|||||||
@@ -25,7 +25,13 @@ export class CertExpiredError extends Error {
|
|||||||
export interface TlsClientOptions {
|
export interface TlsClientOptions {
|
||||||
readonly cert: string
|
readonly cert: string
|
||||||
readonly key: string
|
readonly key: string
|
||||||
readonly ca: string
|
/**
|
||||||
|
* CA(s) to verify the SERVER cert against. Set to the private tunnel CA for the relay dial; left
|
||||||
|
* undefined for a request whose server is publicly trusted (the LE-fronted control-plane /renew) so
|
||||||
|
* Node verifies against the system roots — pinning the private CA there fails with "unable to get
|
||||||
|
* local issuer certificate".
|
||||||
|
*/
|
||||||
|
readonly ca?: string
|
||||||
readonly rejectUnauthorized: true
|
readonly rejectUnauthorized: true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ export interface FrpSuperviseHandle {
|
|||||||
readonly done: Promise<number>
|
readonly done: Promise<number>
|
||||||
/** True while a frpc child is currently running (for the health probe). */
|
/** True while a frpc child is currently running (for the health probe). */
|
||||||
isChildAlive(): boolean
|
isChildAlive(): boolean
|
||||||
|
/**
|
||||||
|
* Kill the current child WITHOUT stopping the supervisor (A5): the restart-on-exit loop respawns a
|
||||||
|
* fresh frpc that re-reads the (now rotated) cert/key/CA files. A no-op once `stop()` was called —
|
||||||
|
* it must never resurrect a supervisor that is shutting down.
|
||||||
|
*/
|
||||||
|
restartChild(): void
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A frpc run lasting at least this long is "stable" ⇒ reset the restart backoff. */
|
/** A frpc run lasting at least this long is "stable" ⇒ reset the restart backoff. */
|
||||||
@@ -196,5 +202,8 @@ export function superviseFrpc(
|
|||||||
},
|
},
|
||||||
done,
|
done,
|
||||||
isChildAlive: () => child?.isAlive() ?? false,
|
isChildAlive: () => child?.isAlive() ?? false,
|
||||||
|
restartChild(): void {
|
||||||
|
if (!stopped) child?.kill()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,4 +145,44 @@ describe('superviseFrpc (B4/H4 — restart-on-exit backoff)', () => {
|
|||||||
await stopping
|
await stopping
|
||||||
expect(spawn).toHaveBeenCalledTimes(1)
|
expect(spawn).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('restartChild() kills the running child so the loop respawns onto the fresh cert files', async () => {
|
||||||
|
const flush = (): Promise<void> => new Promise((r) => setImmediate(r))
|
||||||
|
const children = [makeChild(), makeChild()]
|
||||||
|
let n = 0
|
||||||
|
const spawn: SpawnFrpc = () => children[n++]!.child
|
||||||
|
const handle = superviseFrpc('/frpc', '/toml', {
|
||||||
|
spawn,
|
||||||
|
sleep: async () => {},
|
||||||
|
logger: silentLogger,
|
||||||
|
now: () => 1_000_000, // fixed clock: run counts as unstable, but sleep is a no-op → respawn is immediate
|
||||||
|
})
|
||||||
|
await flush()
|
||||||
|
expect(handle.isChildAlive()).toBe(true) // child[0]
|
||||||
|
|
||||||
|
handle.restartChild() // A5: rotator calls this after a cert rotation to reload the new leaf
|
||||||
|
expect(children[0]!.killed).toBe(true)
|
||||||
|
|
||||||
|
await flush()
|
||||||
|
expect(n).toBe(2) // child[1] spawned — frpc now reads the rotated cert on disk
|
||||||
|
expect(handle.isChildAlive()).toBe(true)
|
||||||
|
await handle.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restartChild() after stop is a no-op (no spawn past shutdown)', async () => {
|
||||||
|
const first = makeChild()
|
||||||
|
let n = 0
|
||||||
|
const spawn: SpawnFrpc = vi.fn(() => (n++ === 0 ? first.child : makeChild().child))
|
||||||
|
const handle = superviseFrpc('/frpc', '/toml', {
|
||||||
|
spawn,
|
||||||
|
sleep: async () => {},
|
||||||
|
logger: silentLogger,
|
||||||
|
})
|
||||||
|
await Promise.resolve()
|
||||||
|
const stopping = handle.stop()
|
||||||
|
first.exit(0)
|
||||||
|
await stopping
|
||||||
|
handle.restartChild() // must not resurrect the supervisor
|
||||||
|
expect(spawn).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
126
agent/test/hostRecord.test.ts
Normal file
126
agent/test/hostRecord.test.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import type { AgentConfig } from '../src/config/agentConfig.js'
|
||||||
|
import {
|
||||||
|
loadHostRecord,
|
||||||
|
resolveHostIdentity,
|
||||||
|
saveHostRecord,
|
||||||
|
subdomainFromCertPem,
|
||||||
|
} from '../src/config/hostRecord.js'
|
||||||
|
|
||||||
|
const CFG: AgentConfig = {
|
||||||
|
relayUrl: 'wss://relay/agent',
|
||||||
|
enrollUrl: 'https://enroll.terminal.example.com/enroll',
|
||||||
|
stateDir: '/tmp/x',
|
||||||
|
localTargetUrl: 'ws://127.0.0.1:3000',
|
||||||
|
subdomain: null,
|
||||||
|
hostId: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
function tmpState(): string {
|
||||||
|
return mkdtempSync(join(tmpdir(), 'wta-hr-'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A real frp-client leaf as issued by the control-plane (SPIFFE URI SAN carries the subdomain). */
|
||||||
|
const LEAF_PEM = `-----BEGIN CERTIFICATE-----
|
||||||
|
MIIB1TCCAXygAwIBAgIUUj+CZ+6p29yI59VpyrekwSp9tAgwCgYIKoZIzj0EAwIw
|
||||||
|
EDEOMAwGA1UEAwwFaDdmZDgwHhcNMjYwNzI5MDgzNzIyWhcNMzYwNzI2MDgzNzIy
|
||||||
|
WjAQMQ4wDAYDVQQDDAVoN2ZkODBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABACg
|
||||||
|
xWQCQuxawnkkPZIgagEFtG0oBiuron4SSw3U1Q0FwCSH3BJep1MJtIuEQU3HfM4N
|
||||||
|
6Tk5kW4MWuIM8sNriiqjgbMwgbAwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMC
|
||||||
|
B4AwEwYDVR0lBAwwCgYIKwYBBQUHAwIwXAYDVR0RBFUwU4IaaDdmZDgudGVybWlu
|
||||||
|
YWwuZXhhbXBsZS5jb22GNXNwaWZmZTovL3JlbGF5LmV4YW1wbGUuY29tL2FjY291
|
||||||
|
bnQvYWNjLTEyMy9ob3N0L2g3ZmQ4MB0GA1UdDgQWBBSy/SJwjH/lm8TaY5Yk/TF+
|
||||||
|
wpg78TAKBggqhkjOPQQDAgNHADBEAiAjq1o5xpk+iF55uVfdyLP/a9OC09O0mN4P
|
||||||
|
YRk8x5MFaQIgYC3GTWqkwu0azrdffKl6jX0stbG+oM+0Cx2Cn7wy27c=
|
||||||
|
-----END CERTIFICATE-----`
|
||||||
|
|
||||||
|
describe('host record persistence', () => {
|
||||||
|
it('round-trips the enrolment identifiers through stateDir', () => {
|
||||||
|
const dir = tmpState()
|
||||||
|
saveHostRecord(dir, { hostId: 'h-1', subdomain: 'h7fd8' })
|
||||||
|
expect(loadHostRecord(dir)).toEqual({ hostId: 'h-1', subdomain: 'h7fd8' })
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when nothing was ever enrolled here', () => {
|
||||||
|
const dir = tmpState()
|
||||||
|
expect(loadHostRecord(dir)).toBeNull()
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats a corrupt record as absent rather than throwing (never crashes the run loop)', () => {
|
||||||
|
const dir = tmpState()
|
||||||
|
writeFileSync(join(dir, 'host.json'), '{not json')
|
||||||
|
expect(loadHostRecord(dir)).toBeNull()
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores a record whose fields are the wrong shape', () => {
|
||||||
|
const dir = tmpState()
|
||||||
|
writeFileSync(join(dir, 'host.json'), JSON.stringify({ hostId: 42, subdomain: [] }))
|
||||||
|
expect(loadHostRecord(dir)).toEqual({ hostId: null, subdomain: null })
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('subdomainFromCertPem', () => {
|
||||||
|
it('reads the subdomain out of the leaf SPIFFE URI SAN', () => {
|
||||||
|
expect(subdomainFromCertPem(LEAF_PEM)).toBe('h7fd8')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for a certificate with no SPIFFE SAN', () => {
|
||||||
|
expect(subdomainFromCertPem('-----BEGIN CERTIFICATE-----\nnope\n-----END CERTIFICATE-----')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for garbage instead of throwing', () => {
|
||||||
|
expect(subdomainFromCertPem('not a cert')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('resolveHostIdentity precedence', () => {
|
||||||
|
it('keeps explicit config (env/argv) over everything else', () => {
|
||||||
|
const dir = tmpState()
|
||||||
|
saveHostRecord(dir, { hostId: 'from-file', subdomain: 'from-file' })
|
||||||
|
const out = resolveHostIdentity(
|
||||||
|
{ ...CFG, stateDir: dir, subdomain: 'from-env', hostId: 'from-env' },
|
||||||
|
() => LEAF_PEM,
|
||||||
|
)
|
||||||
|
expect(out).toEqual({ hostId: 'from-env', subdomain: 'from-env' })
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the persisted enrolment record', () => {
|
||||||
|
const dir = tmpState()
|
||||||
|
saveHostRecord(dir, { hostId: 'h-1', subdomain: 'h7fd8' })
|
||||||
|
expect(resolveHostIdentity({ ...CFG, stateDir: dir }, () => null)).toEqual({
|
||||||
|
hostId: 'h-1',
|
||||||
|
subdomain: 'h7fd8',
|
||||||
|
})
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hosts enrolled before the record existed have no `host.json`. Their leaf still carries the
|
||||||
|
* subdomain, so they get an identifier in the logs without needing a re-pair.
|
||||||
|
*/
|
||||||
|
it('falls back to the leaf SPIFFE SAN when there is no record (legacy installs)', () => {
|
||||||
|
const dir = tmpState()
|
||||||
|
expect(resolveHostIdentity({ ...CFG, stateDir: dir }, () => LEAF_PEM)).toEqual({
|
||||||
|
hostId: null,
|
||||||
|
subdomain: 'h7fd8',
|
||||||
|
})
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('yields nulls when nothing is known (unenrolled host)', () => {
|
||||||
|
const dir = tmpState()
|
||||||
|
expect(resolveHostIdentity({ ...CFG, stateDir: dir }, () => null)).toEqual({
|
||||||
|
hostId: null,
|
||||||
|
subdomain: null,
|
||||||
|
})
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -81,8 +81,10 @@ describe('installService — two distinct units (FIX M-host-2service)', () => {
|
|||||||
expect(d.writes).toHaveLength(2)
|
expect(d.writes).toHaveLength(2)
|
||||||
const baseApp = unitWith(d, baseAppUnitName())
|
const baseApp = unitWith(d, baseAppUnitName())
|
||||||
const agent = unitWith(d, agentUnitName())
|
const agent = unitWith(d, agentUnitName())
|
||||||
// agent unit supervises frpc via `<bin> run`; base-app runs the node server (loopback)
|
// agent unit supervises frpc via `<node> <bin> run` (absolute node so a minimal service PATH
|
||||||
expect(agent).toContain('ExecStart=/usr/local/bin/web-terminal-agent run')
|
// that lacks /usr/local/bin can't fail with EX_CONFIG); base-app runs the node server (loopback)
|
||||||
|
expect(agent).toContain('/usr/local/bin/web-terminal-agent run')
|
||||||
|
expect(agent).toMatch(/ExecStart=\S*node\S* \/usr\/local\/bin\/web-terminal-agent run/)
|
||||||
expect(baseApp).toContain('ExecStart=')
|
expect(baseApp).toContain('ExecStart=')
|
||||||
expect(baseApp).toContain('server.js')
|
expect(baseApp).toContain('server.js')
|
||||||
expect(baseApp).not.toContain('web-terminal-agent run')
|
expect(baseApp).not.toContain('web-terminal-agent run')
|
||||||
|
|||||||
350
agent/test/nativeRenew.test.ts
Normal file
350
agent/test/nativeRenew.test.ts
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
/**
|
||||||
|
* A5 native cert auto-renew wiring — TDD.
|
||||||
|
*
|
||||||
|
* Covers the host-side glue that was the "one real host code gap": the native run-loop must actually
|
||||||
|
* RENEW the frp-client leaf (not merely monitor its freshness). Three units:
|
||||||
|
* - `createMtlsFetch` — the injected `fetchImpl` `renewCert` uses: it POSTs /renew over mTLS
|
||||||
|
* presenting the CURRENT keystore leaf (read fresh on every call, so a post-rotation renewal
|
||||||
|
* authenticates with the new leaf) and maps the transport response to a `Response`.
|
||||||
|
* - `wireAutoRenew` — routes the rotator's rotated→restartChild(+log), revoked→stop(+log),
|
||||||
|
* error→log(no secret) callbacks and starts it.
|
||||||
|
* - `startNativeAutoRenew` — the end-to-end builder `superviseNative` calls (identity + mTLS fetch
|
||||||
|
* + rotator), returning null (disabled) when unenrolled.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import type { AgentConfig } from '../src/config/agentConfig.js'
|
||||||
|
import { generateP256Identity } from '../src/keys/identity.js'
|
||||||
|
import { openKeystore } from '../src/keys/keystore.js'
|
||||||
|
import { createLogger } from '../src/log/logger.js'
|
||||||
|
import type { CertRotator } from '../src/certs/rotation.js'
|
||||||
|
import {
|
||||||
|
createMtlsFetch,
|
||||||
|
startNativeAutoRenew,
|
||||||
|
wireAutoRenew,
|
||||||
|
type MtlsRequest,
|
||||||
|
} from '../src/certs/nativeRenew.js'
|
||||||
|
import { FakeTimer } from './fixtures/fakes.js'
|
||||||
|
import { CertExpiredBeyondGraceError } from '../src/certs/rotation.js'
|
||||||
|
|
||||||
|
const CFG: AgentConfig = {
|
||||||
|
relayUrl: 'wss://relay/agent',
|
||||||
|
enrollUrl: 'https://cp.example.com/enroll',
|
||||||
|
stateDir: '/tmp/x',
|
||||||
|
localTargetUrl: 'ws://127.0.0.1:3000',
|
||||||
|
subdomain: 'host-42',
|
||||||
|
hostId: 'h-1',
|
||||||
|
}
|
||||||
|
|
||||||
|
function enrolledKs(): { dir: string; ks: ReturnType<typeof openKeystore> } {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'wta-nr-'))
|
||||||
|
const ks = openKeystore(dir)
|
||||||
|
ks.saveIdentity(generateP256Identity())
|
||||||
|
ks.saveCert('LEAFCERT', 'CACHAIN')
|
||||||
|
return { dir, ks }
|
||||||
|
}
|
||||||
|
|
||||||
|
const farFuture = (): { validTo: Date } => ({ validTo: new Date(Date.now() + 86_400_000) })
|
||||||
|
const flush = (): Promise<void> => new Promise((r) => setImmediate(r))
|
||||||
|
|
||||||
|
describe('createMtlsFetch (A5)', () => {
|
||||||
|
it('presents the current keystore cert/key/CA over mTLS and maps the transport response', async () => {
|
||||||
|
const { dir, ks } = enrolledKs()
|
||||||
|
const seen: Array<{ url: string; tls: Record<string, unknown>; init: Record<string, unknown> }> = []
|
||||||
|
const request: MtlsRequest = async (url, tls, init) => {
|
||||||
|
seen.push({ url, tls: tls as unknown as Record<string, unknown>, init: init as unknown as Record<string, unknown> })
|
||||||
|
return { status: 200, body: JSON.stringify({ cert: 'NEW', caChain: 'NEWCA' }) }
|
||||||
|
}
|
||||||
|
const f = createMtlsFetch(ks, { request, certParser: farFuture })
|
||||||
|
|
||||||
|
const res = await f('https://cp.example.com/renew', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: '{"csr":"x"}',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(res.ok).toBe(true)
|
||||||
|
expect(await res.json()).toEqual({ cert: 'NEW', caChain: 'NEWCA' })
|
||||||
|
expect(seen[0]!.url).toBe('https://cp.example.com/renew')
|
||||||
|
expect(seen[0]!.tls.cert).toBe('LEAFCERT')
|
||||||
|
// /renew verifies the LE-fronted control-plane against the SYSTEM roots, so no private CA is pinned
|
||||||
|
// (pinning the enroll caChain here fails with "unable to get local issuer certificate").
|
||||||
|
expect(seen[0]!.tls.ca).toBeUndefined()
|
||||||
|
expect(seen[0]!.tls.rejectUnauthorized).toBe(true)
|
||||||
|
expect(String(seen[0]!.tls.key)).toContain('PRIVATE KEY') // in-process PKCS#8 key, mTLS only
|
||||||
|
expect(seen[0]!.init.method).toBe('POST')
|
||||||
|
expect(seen[0]!.init.body).toBe('{"csr":"x"}')
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads the current cert on every call so a post-rotation renewal uses the new leaf', async () => {
|
||||||
|
const { dir, ks } = enrolledKs()
|
||||||
|
const certs: string[] = []
|
||||||
|
const request: MtlsRequest = async (_url, tls) => {
|
||||||
|
certs.push(tls.cert)
|
||||||
|
return { status: 200, body: '{}' }
|
||||||
|
}
|
||||||
|
const f = createMtlsFetch(ks, { request, certParser: farFuture })
|
||||||
|
|
||||||
|
await f('https://x/renew', { method: 'POST' })
|
||||||
|
ks.saveCert('ROTATEDLEAF', 'CACHAIN') // simulate a completed rotation
|
||||||
|
await f('https://x/renew', { method: 'POST' })
|
||||||
|
|
||||||
|
expect(certs).toEqual(['LEAFCERT', 'ROTATEDLEAF'])
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('propagates a not-enrolled keystore as a throw (renewCert then retries with backoff)', async () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'wta-nr-'))
|
||||||
|
const ks = openKeystore(dir)
|
||||||
|
const request: MtlsRequest = async () => ({ status: 200, body: '{}' })
|
||||||
|
const f = createMtlsFetch(ks, { request, certParser: farFuture })
|
||||||
|
await expect(f('https://x/renew', { method: 'POST' })).rejects.toThrow()
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('wireAutoRenew (A5)', () => {
|
||||||
|
function fakeRotator(): {
|
||||||
|
rotator: CertRotator
|
||||||
|
fire: {
|
||||||
|
rotated?: () => void
|
||||||
|
revoked?: () => void
|
||||||
|
error?: (e: unknown) => void
|
||||||
|
exhausted?: (e: CertExpiredBeyondGraceError) => void
|
||||||
|
}
|
||||||
|
start: ReturnType<typeof vi.fn>
|
||||||
|
stop: ReturnType<typeof vi.fn>
|
||||||
|
} {
|
||||||
|
const fire: {
|
||||||
|
rotated?: () => void
|
||||||
|
revoked?: () => void
|
||||||
|
error?: (e: unknown) => void
|
||||||
|
exhausted?: (e: CertExpiredBeyondGraceError) => void
|
||||||
|
} = {}
|
||||||
|
const start = vi.fn()
|
||||||
|
const stop = vi.fn()
|
||||||
|
const rotator: CertRotator = {
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
onRotated: (cb) => {
|
||||||
|
fire.rotated = cb
|
||||||
|
},
|
||||||
|
onRevoked: (cb) => {
|
||||||
|
fire.revoked = cb
|
||||||
|
},
|
||||||
|
onError: (cb) => {
|
||||||
|
fire.error = cb
|
||||||
|
},
|
||||||
|
onExhausted: (cb) => {
|
||||||
|
fire.exhausted = cb
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return { rotator, fire, start, stop }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('routes rotated→restartChild, revoked→stop, error→log; starts and stops the rotator', () => {
|
||||||
|
const { rotator, fire, start, stop } = fakeRotator()
|
||||||
|
const restartChild = vi.fn()
|
||||||
|
const stopSupervisor = vi.fn()
|
||||||
|
const lines: string[] = []
|
||||||
|
const logger = createLogger('info', (l) => lines.push(l))
|
||||||
|
|
||||||
|
const controller = wireAutoRenew(rotator, { restartChild, stop: stopSupervisor }, logger, {
|
||||||
|
subdomain: 'host-42',
|
||||||
|
hostId: 'h-1',
|
||||||
|
})
|
||||||
|
expect(start).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
fire.rotated!()
|
||||||
|
expect(restartChild).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
fire.revoked!()
|
||||||
|
expect(stopSupervisor).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
fire.error!(new Error('network down'))
|
||||||
|
|
||||||
|
controller.stop()
|
||||||
|
expect(stop).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
const joined = lines.join('\n')
|
||||||
|
expect(joined).toContain('host-42') // non-secret identifier is logged
|
||||||
|
expect(joined).not.toContain('LEAFCERT') // never a leaf/key/CSR
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('startNativeAutoRenew (A5 end-to-end)', () => {
|
||||||
|
it('a scheduled 200 renewal rotates the leaf on disk and restarts frpc with it', async () => {
|
||||||
|
const { dir, ks } = enrolledKs()
|
||||||
|
const timer = new FakeTimer()
|
||||||
|
const request: MtlsRequest = async () => ({
|
||||||
|
status: 200,
|
||||||
|
body: JSON.stringify({ cert: 'FRESHLEAF', caChain: ['CACHAIN'] }),
|
||||||
|
})
|
||||||
|
const restartChild = vi.fn()
|
||||||
|
const stop = vi.fn()
|
||||||
|
|
||||||
|
const controller = startNativeAutoRenew(
|
||||||
|
CFG,
|
||||||
|
ks,
|
||||||
|
{ restartChild, stop },
|
||||||
|
createLogger('error', () => {}),
|
||||||
|
{ mtlsRequest: request, certParser: farFuture, timer, renewBeforeMs: 1000, now: () => new Date(0), parseCert: () => new Date(2000) },
|
||||||
|
)!
|
||||||
|
expect(controller).not.toBeNull()
|
||||||
|
|
||||||
|
timer.advance(1000) // renewal fires at ~2/3 TTL
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
expect(ks.loadCert()!.certPem).toContain('FRESHLEAF') // atomic persist
|
||||||
|
expect(restartChild).toHaveBeenCalledTimes(1) // frpc restarted onto the fresh leaf
|
||||||
|
expect(stop).not.toHaveBeenCalled()
|
||||||
|
controller.stop()
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a scheduled 403 renewal tears the tunnel down (revoked) and never rotates', async () => {
|
||||||
|
const { dir, ks } = enrolledKs()
|
||||||
|
const timer = new FakeTimer()
|
||||||
|
const request: MtlsRequest = async () => ({ status: 403, body: '' })
|
||||||
|
const restartChild = vi.fn()
|
||||||
|
const stop = vi.fn()
|
||||||
|
|
||||||
|
const controller = startNativeAutoRenew(
|
||||||
|
CFG,
|
||||||
|
ks,
|
||||||
|
{ restartChild, stop },
|
||||||
|
createLogger('error', () => {}),
|
||||||
|
{ mtlsRequest: request, certParser: farFuture, timer, renewBeforeMs: 1000, now: () => new Date(0), parseCert: () => new Date(2000) },
|
||||||
|
)!
|
||||||
|
|
||||||
|
timer.advance(1000)
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
expect(stop).toHaveBeenCalledTimes(1)
|
||||||
|
expect(restartChild).not.toHaveBeenCalled()
|
||||||
|
expect(ks.loadCert()!.certPem).toBe('LEAFCERT') // untouched
|
||||||
|
controller.stop()
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a failing renewal is logged (no secret) and retried without crashing, then rotates', async () => {
|
||||||
|
const { dir, ks } = enrolledKs()
|
||||||
|
const timer = new FakeTimer()
|
||||||
|
let calls = 0
|
||||||
|
const request: MtlsRequest = async () => {
|
||||||
|
calls += 1
|
||||||
|
if (calls === 1) throw new Error('ECONNREFUSED')
|
||||||
|
return { status: 200, body: JSON.stringify({ cert: 'FRESHLEAF', caChain: ['CACHAIN'] }) }
|
||||||
|
}
|
||||||
|
const restartChild = vi.fn()
|
||||||
|
const stop = vi.fn()
|
||||||
|
const lines: string[] = []
|
||||||
|
|
||||||
|
const controller = startNativeAutoRenew(
|
||||||
|
CFG,
|
||||||
|
ks,
|
||||||
|
{ restartChild, stop },
|
||||||
|
createLogger('warn', (l) => lines.push(l)),
|
||||||
|
{
|
||||||
|
mtlsRequest: request,
|
||||||
|
certParser: farFuture,
|
||||||
|
timer,
|
||||||
|
renewBeforeMs: 1000,
|
||||||
|
retryBaseMs: 500,
|
||||||
|
now: () => new Date(0),
|
||||||
|
parseCert: () => new Date(2000),
|
||||||
|
},
|
||||||
|
)!
|
||||||
|
|
||||||
|
timer.advance(1000) // first attempt throws
|
||||||
|
await flush()
|
||||||
|
expect(restartChild).not.toHaveBeenCalled()
|
||||||
|
expect(stop).not.toHaveBeenCalled() // a failure NEVER tears down
|
||||||
|
expect(lines.join('\n')).toContain('retry')
|
||||||
|
|
||||||
|
timer.advance(500) // backoff retry fires and succeeds
|
||||||
|
await flush()
|
||||||
|
expect(ks.loadCert()!.certPem).toContain('FRESHLEAF')
|
||||||
|
expect(restartChild).toHaveBeenCalledTimes(1)
|
||||||
|
expect(lines.join('\n')).not.toContain('LEAFCERT')
|
||||||
|
controller.stop()
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null (auto-renew disabled) when the keystore has no identity', () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'wta-nr-'))
|
||||||
|
const ks = openKeystore(dir)
|
||||||
|
const lines: string[] = []
|
||||||
|
const controller = startNativeAutoRenew(
|
||||||
|
CFG,
|
||||||
|
ks,
|
||||||
|
{ restartChild: () => {}, stop: () => {} },
|
||||||
|
createLogger('warn', (l) => lines.push(l)),
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
expect(controller).toBeNull()
|
||||||
|
expect(lines.join('\n')).toContain('auto-renew disabled')
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mTLS renew transport stays STRICT about expiry: nginx refuses to forward an expired client
|
||||||
|
* cert at all, so an expired leaf must be routed to the plain `/recover` endpoint by the rotator
|
||||||
|
* rather than smuggled through this transport.
|
||||||
|
*/
|
||||||
|
describe('createMtlsFetch stays fail-closed on an expired leaf', () => {
|
||||||
|
it('refuses to present a lapsed leaf (recovery is the rotator\'s job, not this transport\'s)', async () => {
|
||||||
|
const { dir, ks } = enrolledKs()
|
||||||
|
let called = 0
|
||||||
|
const request: MtlsRequest = async () => {
|
||||||
|
called += 1
|
||||||
|
return { status: 201, body: '{}' }
|
||||||
|
}
|
||||||
|
const f = createMtlsFetch(ks, {
|
||||||
|
request,
|
||||||
|
certParser: () => ({ validTo: new Date(Date.now() - 86_400_000) }),
|
||||||
|
})
|
||||||
|
await expect(f('https://cp.example.com/renew', { method: 'POST' })).rejects.toThrow(
|
||||||
|
/expired/i,
|
||||||
|
)
|
||||||
|
expect(called).toBe(0)
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('wireAutoRenew exhausted routing', () => {
|
||||||
|
it('logs an actionable re-pair alarm and leaves the supervisor running', () => {
|
||||||
|
const fire: { exhausted?: (e: CertExpiredBeyondGraceError) => void } = {}
|
||||||
|
const rotator: CertRotator = {
|
||||||
|
start: vi.fn(),
|
||||||
|
stop: vi.fn(),
|
||||||
|
onRotated: () => {},
|
||||||
|
onRevoked: () => {},
|
||||||
|
onError: () => {},
|
||||||
|
onExhausted: (cb) => {
|
||||||
|
fire.exhausted = cb
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const restartChild = vi.fn()
|
||||||
|
const stop = vi.fn()
|
||||||
|
const lines: string[] = []
|
||||||
|
wireAutoRenew(rotator, { restartChild, stop }, createLogger('info', (l) => lines.push(l)), {
|
||||||
|
subdomain: 'h7fd8',
|
||||||
|
hostId: 'h-1',
|
||||||
|
})
|
||||||
|
fire.exhausted?.(new CertExpiredBeyondGraceError(40 * 86_400_000, 30 * 86_400_000))
|
||||||
|
|
||||||
|
const alarm = lines.find((l) => /re-pair/i.test(l))
|
||||||
|
expect(alarm).toBeDefined()
|
||||||
|
expect(alarm).toContain('h7fd8')
|
||||||
|
// The supervisor keeps running: a later `pair` writes fresh cert files that the restarting
|
||||||
|
// frpc child picks up. Tearing down here would make recovery need a manual restart too.
|
||||||
|
expect(stop).not.toHaveBeenCalled()
|
||||||
|
expect(restartChild).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
169
agent/test/nativeRenewTransport.test.ts
Normal file
169
agent/test/nativeRenewTransport.test.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* A5 default mTLS transport (`defaultMtlsRequest`) — the ONE seam the other nativeRenew tests inject
|
||||||
|
* past, so the real `node:https` transport had zero coverage. These tests mock `node:https` and drive
|
||||||
|
* the actual transport to prove:
|
||||||
|
* - the exact request options (rejectUnauthorized:true + client cert/key + pinned CA + method/body),
|
||||||
|
* - the HIGH fix: a request timeout is armed and a stalled peer REJECTS (never hangs forever),
|
||||||
|
* - the MEDIUM fix: an oversized response body is capped (destroyed + rejected, not buffered).
|
||||||
|
*/
|
||||||
|
import { EventEmitter } from 'node:events'
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const { requestMock } = vi.hoisted(() => ({ requestMock: vi.fn() }))
|
||||||
|
vi.mock('node:https', () => ({ request: requestMock, default: { request: requestMock } }))
|
||||||
|
|
||||||
|
import { generateP256Identity } from '../src/keys/identity.js'
|
||||||
|
import { openKeystore } from '../src/keys/keystore.js'
|
||||||
|
import {
|
||||||
|
createMtlsFetch,
|
||||||
|
RENEW_REQUEST_TIMEOUT_MS,
|
||||||
|
MAX_RENEW_RESPONSE_BYTES,
|
||||||
|
} from '../src/certs/nativeRenew.js'
|
||||||
|
|
||||||
|
const farFuture = (): { validTo: Date } => ({ validTo: new Date(Date.now() + 86_400_000) })
|
||||||
|
|
||||||
|
/** Fake `http.ClientRequest`: records setTimeout/write/end and emits 'error' on destroy(err). */
|
||||||
|
class FakeClientRequest extends EventEmitter {
|
||||||
|
readonly setTimeoutCalls: Array<{ ms: number; cb: () => void }> = []
|
||||||
|
readonly written: string[] = []
|
||||||
|
ended = false
|
||||||
|
destroyedWith: Error | undefined
|
||||||
|
setTimeout(ms: number, cb: () => void): this {
|
||||||
|
this.setTimeoutCalls.push({ ms, cb })
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
write(chunk: string): boolean {
|
||||||
|
this.written.push(chunk)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
end(): this {
|
||||||
|
this.ended = true
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
destroy(err?: Error): this {
|
||||||
|
this.destroyedWith = err
|
||||||
|
if (err) this.emit('error', err)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fake `http.IncomingMessage`: an EventEmitter with a statusCode and a real destroy(). */
|
||||||
|
class FakeIncomingMessage extends EventEmitter {
|
||||||
|
statusCode = 200
|
||||||
|
destroyed = false
|
||||||
|
destroy(): this {
|
||||||
|
this.destroyed = true
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReqCb = (res: FakeIncomingMessage) => void
|
||||||
|
let dirs: string[] = []
|
||||||
|
|
||||||
|
function enrolledKs(): ReturnType<typeof openKeystore> {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'wta-nrt-'))
|
||||||
|
dirs.push(dir)
|
||||||
|
const ks = openKeystore(dir)
|
||||||
|
ks.saveIdentity(generateP256Identity())
|
||||||
|
ks.saveCert('LEAFCERT', 'CACHAIN')
|
||||||
|
return ks
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
requestMock.mockReset()
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
for (const d of dirs) rmSync(d, { recursive: true, force: true })
|
||||||
|
dirs = []
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('defaultMtlsRequest transport (A5 — real node:https)', () => {
|
||||||
|
it('passes rejectUnauthorized:true + the client cert/key/CA + method/body, and arms the timeout', async () => {
|
||||||
|
const ks = enrolledKs()
|
||||||
|
let seenUrl = ''
|
||||||
|
let seenOpts: Record<string, unknown> = {}
|
||||||
|
let seenReq: FakeClientRequest | undefined
|
||||||
|
requestMock.mockImplementation((url: string, opts: Record<string, unknown>, cb: ReqCb) => {
|
||||||
|
seenUrl = url
|
||||||
|
seenOpts = opts
|
||||||
|
const req = new FakeClientRequest()
|
||||||
|
seenReq = req
|
||||||
|
queueMicrotask(() => {
|
||||||
|
const res = new FakeIncomingMessage()
|
||||||
|
res.statusCode = 200
|
||||||
|
cb(res)
|
||||||
|
res.emit('data', Buffer.from('{"cert":"NEW",'))
|
||||||
|
res.emit('data', Buffer.from('"caChain":"NEWCA"}'))
|
||||||
|
res.emit('end')
|
||||||
|
})
|
||||||
|
return req
|
||||||
|
})
|
||||||
|
|
||||||
|
const f = createMtlsFetch(ks, { certParser: farFuture }) // NO request seam → real defaultMtlsRequest
|
||||||
|
const res = await f('https://cp.example.com/renew', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: '{"csr":"x"}',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(await res.json()).toEqual({ cert: 'NEW', caChain: 'NEWCA' })
|
||||||
|
expect(seenUrl).toBe('https://cp.example.com/renew')
|
||||||
|
expect(seenOpts.rejectUnauthorized).toBe(true) // anti-MITM (INV14)
|
||||||
|
expect(seenOpts.method).toBe('POST')
|
||||||
|
expect(seenOpts.cert).toBe('LEAFCERT')
|
||||||
|
// ca omitted ⇒ verify the LE-fronted control-plane against the system roots (not the private CA).
|
||||||
|
expect(seenOpts.ca).toBeUndefined()
|
||||||
|
expect(String(seenOpts.key)).toContain('PRIVATE KEY') // in-process PKCS#8 key
|
||||||
|
// HIGH fix: a socket timeout is armed with the sane default so a stall can never hang forever.
|
||||||
|
expect(seenReq!.setTimeoutCalls).toHaveLength(1)
|
||||||
|
expect(seenReq!.setTimeoutCalls[0]!.ms).toBe(RENEW_REQUEST_TIMEOUT_MS)
|
||||||
|
expect(seenReq!.written).toEqual(['{"csr":"x"}'])
|
||||||
|
expect(seenReq!.ended).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('HIGH: a stalled peer (accepts TLS, never responds) REJECTS via the timeout, not hangs', async () => {
|
||||||
|
const ks = enrolledKs()
|
||||||
|
let seenReq: FakeClientRequest | undefined
|
||||||
|
requestMock.mockImplementation(() => {
|
||||||
|
const req = new FakeClientRequest()
|
||||||
|
seenReq = req
|
||||||
|
return req // never invokes the response callback — a stalled control-plane
|
||||||
|
})
|
||||||
|
|
||||||
|
const f = createMtlsFetch(ks, { certParser: farFuture })
|
||||||
|
const p = f('https://cp.example.com/renew', { method: 'POST', body: '{}' })
|
||||||
|
|
||||||
|
// Fire the armed socket-timeout callback (what node does when the socket idles past the limit).
|
||||||
|
expect(seenReq!.setTimeoutCalls).toHaveLength(1)
|
||||||
|
expect(seenReq!.setTimeoutCalls[0]!.ms).toBe(RENEW_REQUEST_TIMEOUT_MS)
|
||||||
|
seenReq!.setTimeoutCalls[0]!.cb()
|
||||||
|
|
||||||
|
await expect(p).rejects.toThrow(/timed out/i)
|
||||||
|
expect(seenReq!.destroyedWith).toBeInstanceOf(Error) // socket was torn down
|
||||||
|
})
|
||||||
|
|
||||||
|
it('MEDIUM: an oversized response body is capped — res destroyed + promise rejected', async () => {
|
||||||
|
const ks = enrolledKs()
|
||||||
|
let seenRes: FakeIncomingMessage | undefined
|
||||||
|
requestMock.mockImplementation((_url: string, _opts: unknown, cb: ReqCb) => {
|
||||||
|
const req = new FakeClientRequest()
|
||||||
|
queueMicrotask(() => {
|
||||||
|
const res = new FakeIncomingMessage()
|
||||||
|
res.statusCode = 200
|
||||||
|
seenRes = res
|
||||||
|
cb(res)
|
||||||
|
res.emit('data', Buffer.alloc(MAX_RENEW_RESPONSE_BYTES + 1)) // one byte over the cap
|
||||||
|
// deliberately NO 'end' — a capped stream must reject on its own, not wait for end
|
||||||
|
})
|
||||||
|
return req
|
||||||
|
})
|
||||||
|
|
||||||
|
const f = createMtlsFetch(ks, { certParser: farFuture })
|
||||||
|
await expect(f('https://cp.example.com/renew', { method: 'POST' })).rejects.toThrow(/cap|exceed/i)
|
||||||
|
expect(seenRes!.destroyed).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -8,9 +8,13 @@ import { openKeystore } from '../src/keys/keystore.js'
|
|||||||
import {
|
import {
|
||||||
computeRenewDelayMs,
|
computeRenewDelayMs,
|
||||||
createCertRotator,
|
createCertRotator,
|
||||||
|
recoverCert,
|
||||||
|
recoveryUrlFor,
|
||||||
renewCert,
|
renewCert,
|
||||||
renewalUrlFor,
|
renewalUrlFor,
|
||||||
} from '../src/certs/rotation.js'
|
} from '../src/certs/rotation.js'
|
||||||
|
import { CertExpiredBeyondGraceError } from '../src/certs/rotation.js'
|
||||||
|
import { createBackoff } from '../src/transport/backoff.js'
|
||||||
import { FakeTimer } from './fixtures/fakes.js'
|
import { FakeTimer } from './fixtures/fakes.js'
|
||||||
|
|
||||||
const CFG: AgentConfig = {
|
const CFG: AgentConfig = {
|
||||||
@@ -52,10 +56,10 @@ describe('renewCert (T13)', () => {
|
|||||||
it('installs a fresh cert atomically on success (same key)', async () => {
|
it('installs a fresh cert atomically on success (same key)', async () => {
|
||||||
const { dir, ks } = enrolledKs()
|
const { dir, ks } = enrolledKs()
|
||||||
const before = ks.loadIdentity()!.publicKey
|
const before = ks.loadIdentity()!.publicKey
|
||||||
const fetchImpl = vi.fn(async () => jsonRes(200, { cert: 'NEWCERT', caChain: 'NEWCA' }))
|
const fetchImpl = vi.fn(async () => jsonRes(200, { cert: 'NEWCERT', caChain: ['NEWCA'] }))
|
||||||
const out = await renewCert(CFG, ks.loadIdentity()!, ks, fetchImpl as unknown as typeof fetch)
|
const out = await renewCert(CFG, ks.loadIdentity()!, ks, fetchImpl as unknown as typeof fetch)
|
||||||
expect(out).toBe('rotated')
|
expect(out).toBe('rotated')
|
||||||
expect(ks.loadCert()).toEqual({ certPem: 'NEWCERT', caChainPem: 'NEWCA' })
|
expect(ks.loadCert()!.certPem).toContain('NEWCERT'); expect(ks.loadCert()!.caChainPem).toContain('NEWCA')
|
||||||
// pubkey unchanged — only the cert rotated
|
// pubkey unchanged — only the cert rotated
|
||||||
expect(Buffer.from(ks.loadIdentity()!.publicKey).equals(Buffer.from(before))).toBe(true)
|
expect(Buffer.from(ks.loadIdentity()!.publicKey).equals(Buffer.from(before))).toBe(true)
|
||||||
rmSync(dir, { recursive: true, force: true })
|
rmSync(dir, { recursive: true, force: true })
|
||||||
@@ -101,7 +105,7 @@ describe('createCertRotator (T13)', () => {
|
|||||||
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
|
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
|
||||||
timer,
|
timer,
|
||||||
renewBeforeMs: 1000,
|
renewBeforeMs: 1000,
|
||||||
fetchImpl: (async () => jsonRes(200, { cert: 'NEWCERT', caChain: 'NEWCA' })) as unknown as typeof fetch,
|
fetchImpl: (async () => jsonRes(200, { cert: 'NEWCERT', caChain: ['NEWCA'] })) as unknown as typeof fetch,
|
||||||
now: () => new Date(0),
|
now: () => new Date(0),
|
||||||
parseCert: () => new Date(2000),
|
parseCert: () => new Date(2000),
|
||||||
})
|
})
|
||||||
@@ -113,8 +117,194 @@ describe('createCertRotator (T13)', () => {
|
|||||||
timer.advance(1000)
|
timer.advance(1000)
|
||||||
await flush()
|
await flush()
|
||||||
expect(rotated).toBe(1)
|
expect(rotated).toBe(1)
|
||||||
expect(ks.loadCert()!.certPem).toBe('NEWCERT')
|
expect(ks.loadCert()!.certPem).toContain('NEWCERT')
|
||||||
|
rotator.stop()
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('invokes onError and retries with backoff (not renewBeforeMs) when a renewal throws', async () => {
|
||||||
|
const { dir, ks } = enrolledKs()
|
||||||
|
const timer = new FakeTimer()
|
||||||
|
let calls = 0
|
||||||
|
const fetchImpl = (async () => {
|
||||||
|
calls += 1
|
||||||
|
if (calls === 1) throw new Error('network down')
|
||||||
|
return jsonRes(200, { cert: 'NEWCERT', caChain: ['NEWCA'] })
|
||||||
|
}) as unknown as typeof fetch
|
||||||
|
const errors: unknown[] = []
|
||||||
|
let rotated = 0
|
||||||
|
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
|
||||||
|
timer,
|
||||||
|
renewBeforeMs: 1000,
|
||||||
|
retryBackoff: createBackoff({ baseMs: 500, jitter: false }),
|
||||||
|
fetchImpl,
|
||||||
|
now: () => new Date(0),
|
||||||
|
parseCert: () => new Date(2000), // initial renewal scheduled at ~1000ms
|
||||||
|
})
|
||||||
|
rotator.onError((e) => errors.push(e))
|
||||||
|
rotator.onRotated(() => {
|
||||||
|
rotated += 1
|
||||||
|
})
|
||||||
|
rotator.start()
|
||||||
|
|
||||||
|
timer.advance(1000) // first attempt fires → throws
|
||||||
|
await flush()
|
||||||
|
expect(errors).toHaveLength(1)
|
||||||
|
expect(rotated).toBe(0)
|
||||||
|
|
||||||
|
// The retry is armed at the 500ms backoff delay, NOT renewBeforeMs (1000): advancing only 500
|
||||||
|
// must fire it. A crash-loop never escapes here (the supervisor keeps running).
|
||||||
|
timer.advance(500)
|
||||||
|
await flush()
|
||||||
|
expect(rotated).toBe(1)
|
||||||
|
expect(ks.loadCert()!.certPem).toContain('NEWCERT')
|
||||||
rotator.stop()
|
rotator.stop()
|
||||||
rmSync(dir, { recursive: true, force: true })
|
rmSync(dir, { recursive: true, force: true })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expired-leaf recovery (production deadlock, 2026-07): `/renew` is mTLS-authenticated by the leaf
|
||||||
|
* it renews, so a lapsed leaf can never renew itself — and nginx will not even forward an expired
|
||||||
|
* client cert. Recovery is therefore a PLAIN POST to `/recover` carrying the expired cert in the
|
||||||
|
* body, plus a terminal signal once the grace window is spent so the agent stops retrying forever.
|
||||||
|
*/
|
||||||
|
describe('expired-leaf recovery', () => {
|
||||||
|
const HOUR = 3_600_000
|
||||||
|
|
||||||
|
it('derives /recover as a sibling PATH on the same enroll host', () => {
|
||||||
|
expect(recoveryUrlFor({ ...CFG, enrollUrl: 'https://enroll.terminal.example.com/enroll' })).toBe(
|
||||||
|
'https://enroll.terminal.example.com/recover',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('honours an explicit recoverUrl over the derivation', () => {
|
||||||
|
expect(recoveryUrlFor({ ...CFG, recoverUrl: 'https://elsewhere.example.com/recover' })).toBe(
|
||||||
|
'https://elsewhere.example.com/recover',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('recoverCert posts the EXPIRED cert plus a CSR, with no client cert, and installs the result', async () => {
|
||||||
|
const { dir, ks } = enrolledKs()
|
||||||
|
let seenUrl = ''
|
||||||
|
let seenBody: { cert?: string; csr?: string } = {}
|
||||||
|
const fetchImpl = (async (u: string, init: { body: string }) => {
|
||||||
|
seenUrl = u
|
||||||
|
seenBody = JSON.parse(init.body)
|
||||||
|
return jsonRes(201, { cert: 'FRESHCERT', caChain: ['FRESHCA'] })
|
||||||
|
}) as unknown as typeof fetch
|
||||||
|
|
||||||
|
const outcome = await recoverCert(
|
||||||
|
{ ...CFG, enrollUrl: 'https://enroll.terminal.example.com/enroll' },
|
||||||
|
ks.loadIdentity()!,
|
||||||
|
ks,
|
||||||
|
fetchImpl,
|
||||||
|
)
|
||||||
|
expect(outcome).toBe('rotated')
|
||||||
|
expect(seenUrl).toBe('https://enroll.terminal.example.com/recover')
|
||||||
|
expect(seenBody.cert).toBe('OLDCERT') // the lapsed leaf travels in the BODY, not the TLS layer
|
||||||
|
expect(seenBody.csr).toBeTruthy()
|
||||||
|
expect(ks.loadCert()!.certPem).toContain('FRESHCERT')
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a still-valid leaf uses the mTLS /renew fetch, never the recovery one', async () => {
|
||||||
|
const { dir, ks } = enrolledKs()
|
||||||
|
const timer = new FakeTimer()
|
||||||
|
let renewCalls = 0
|
||||||
|
let recoverCalls = 0
|
||||||
|
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
|
||||||
|
timer,
|
||||||
|
renewBeforeMs: 1000,
|
||||||
|
fetchImpl: (async () => {
|
||||||
|
renewCalls += 1
|
||||||
|
return jsonRes(200, { cert: 'NEWCERT', caChain: ['NEWCA'] })
|
||||||
|
}) as unknown as typeof fetch,
|
||||||
|
recoverFetchImpl: (async () => {
|
||||||
|
recoverCalls += 1
|
||||||
|
return jsonRes(200, {})
|
||||||
|
}) as unknown as typeof fetch,
|
||||||
|
now: () => new Date(0),
|
||||||
|
parseCert: () => new Date(2000), // valid at now=0
|
||||||
|
})
|
||||||
|
rotator.start()
|
||||||
|
timer.advance(1000)
|
||||||
|
await flush()
|
||||||
|
expect(renewCalls).toBe(1)
|
||||||
|
expect(recoverCalls).toBe(0)
|
||||||
|
rotator.stop()
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('an expired leaf INSIDE the grace window switches to the recovery fetch', async () => {
|
||||||
|
const { dir, ks } = enrolledKs()
|
||||||
|
const timer = new FakeTimer()
|
||||||
|
let renewCalls = 0
|
||||||
|
let recoverCalls = 0
|
||||||
|
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
|
||||||
|
timer,
|
||||||
|
renewBeforeMs: 1000,
|
||||||
|
expiredGraceMs: 30 * 24 * HOUR,
|
||||||
|
fetchImpl: (async () => {
|
||||||
|
renewCalls += 1
|
||||||
|
return jsonRes(200, {})
|
||||||
|
}) as unknown as typeof fetch,
|
||||||
|
recoverFetchImpl: (async () => {
|
||||||
|
recoverCalls += 1
|
||||||
|
return jsonRes(201, { cert: 'NEWCERT', caChain: ['NEWCA'] })
|
||||||
|
}) as unknown as typeof fetch,
|
||||||
|
now: () => new Date(8 * 24 * HOUR), // 8 days after the leaf lapsed
|
||||||
|
parseCert: () => new Date(0),
|
||||||
|
})
|
||||||
|
let rotated = 0
|
||||||
|
rotator.onRotated(() => {
|
||||||
|
rotated += 1
|
||||||
|
})
|
||||||
|
rotator.start()
|
||||||
|
timer.advance(0)
|
||||||
|
await flush()
|
||||||
|
expect(recoverCalls).toBe(1)
|
||||||
|
expect(renewCalls).toBe(0)
|
||||||
|
expect(rotated).toBe(1)
|
||||||
|
rotator.stop()
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('BEYOND the grace window it fires onExhausted, issues no request, and stops retrying', async () => {
|
||||||
|
const { dir, ks } = enrolledKs()
|
||||||
|
const timer = new FakeTimer()
|
||||||
|
let requests = 0
|
||||||
|
const countingFetch = (async () => {
|
||||||
|
requests += 1
|
||||||
|
return jsonRes(201, {})
|
||||||
|
}) as unknown as typeof fetch
|
||||||
|
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
|
||||||
|
timer,
|
||||||
|
renewBeforeMs: 1000,
|
||||||
|
expiredGraceMs: 30 * 24 * HOUR,
|
||||||
|
retryBackoff: createBackoff({ baseMs: 500, jitter: false }),
|
||||||
|
fetchImpl: countingFetch,
|
||||||
|
recoverFetchImpl: countingFetch,
|
||||||
|
now: () => new Date(31 * 24 * HOUR), // 31 days stale ⇒ past a 30-day grace
|
||||||
|
parseCert: () => new Date(0),
|
||||||
|
})
|
||||||
|
const errors: unknown[] = []
|
||||||
|
let exhausted: CertExpiredBeyondGraceError | null = null
|
||||||
|
rotator.onError((e) => errors.push(e))
|
||||||
|
rotator.onExhausted((e) => {
|
||||||
|
exhausted = e
|
||||||
|
})
|
||||||
|
rotator.start()
|
||||||
|
timer.advance(0)
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
expect(exhausted).toBeInstanceOf(CertExpiredBeyondGraceError)
|
||||||
|
expect(requests).toBe(0) // nothing is even attempted — it cannot succeed
|
||||||
|
expect(errors).toHaveLength(0)
|
||||||
|
// Terminal: no retry armed. Retrying forever is what produced 6380 identical warnings.
|
||||||
|
timer.advance(60_000)
|
||||||
|
await flush()
|
||||||
|
expect(requests).toBe(0)
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -66,6 +66,25 @@ Kover ≥80% on the pure modules); this checklist is what a human runs on real h
|
|||||||
- [ ] adaptive: compact = stack, expanded/tablet = list+detail (`NavigationSuiteScaffold` +
|
- [ ] adaptive: compact = stack, expanded/tablet = list+detail (`NavigationSuiteScaffold` +
|
||||||
`ListDetailPaneScaffold`); pointer secondary-click context menu on a tablet (sw≥600).
|
`ListDetailPaneScaffold`); pointer secondary-click context menu on a tablet (sw≥600).
|
||||||
|
|
||||||
|
## Projects / git parity (W5 — presenters JVM-tested, Compose device-QA)
|
||||||
|
- [ ] Project card **sync chip**: `↑ahead` / `↓behind` render only when non-zero; no chip when there is
|
||||||
|
no upstream (fields absent).
|
||||||
|
- [ ] Project detail **PR chip**: `availability=ok` → tappable chip opens the PR in the browser ONLY when
|
||||||
|
the url is https (a non-https / junk url is inert, non-clickable); `no-pr` / `not-installed` /
|
||||||
|
`unauthenticated` / `disabled` / `error` each render the degraded copy inertly; check-count colour
|
||||||
|
(fail=red / pending=amber / pass=green).
|
||||||
|
- [ ] Project detail **recent commits**: list renders short-hash + subject inertly; unavailable state on a
|
||||||
|
log failure does NOT hide the rest of the detail (failure-isolated).
|
||||||
|
- [ ] **New worktree** inline form: valid `branch` (+optional `base`) → create → list refreshes; an invalid
|
||||||
|
branch name is rejected with NO network call; a disabled-403 shows the server's safe message.
|
||||||
|
- [ ] Per-worktree **remove**: the button is absent on the `main` worktree; the confirm dialog offers a
|
||||||
|
**Force** checkbox; a dirty-worktree 409 surfaces "force required" inertly; **prune** button works.
|
||||||
|
- [ ] Diff **base-rev** input: entering a rev enters base mode (Working/Staged toggle hidden, `vs <rev>`
|
||||||
|
shown, git-write controls hidden); Clear returns to working/staged; junk rev → server 400 surfaced.
|
||||||
|
- [ ] Diff **stage/unstage**: per-file button (Working→"暂存", Staged→"取消暂存") posts the file and
|
||||||
|
refreshes; **commit** field + button (empty message rejected client-side; Ok shows the short sha) ;
|
||||||
|
**push** button (Ok shows branch→remote; 409 shows the inert server message; 429 shows rate-limited).
|
||||||
|
|
||||||
## Known minor gaps (tracked, non-blocking — see PROGRESS_ANDROID.md)
|
## Known minor gaps (tracked, non-blocking — see PROGRESS_ANDROID.md)
|
||||||
- [ ] push body-tap opens the app (not yet the specific gate — the notification `openAppIntent` doesn't
|
- [ ] push body-tap opens the app (not yet the specific gate — the notification `openAppIntent` doesn't
|
||||||
carry the sessionId; the gate is still visible in the terminal). MEDIUM.
|
carry the sessionId; the gate is still visible in the terminal). MEDIUM.
|
||||||
|
|||||||
@@ -9,22 +9,20 @@ This directory is a **Gradle multi-module** project. The module set mirrors the
|
|||||||
SPM package set and inherits its rule: *dependencies only flow down; nothing points
|
SPM package set and inherits its rule: *dependencies only flow down; nothing points
|
||||||
upward* (ARCHITECTURE §1).
|
upward* (ARCHITECTURE §1).
|
||||||
|
|
||||||
## ⚠️ No-SDK constraint (why only 5 modules build here)
|
## Build environment (SDK installed — all modules build)
|
||||||
|
|
||||||
The current build environment has **no Android SDK**. Everything that can be pure
|
The Android SDK **is installed** and every module — pure Kotlin/JVM and Android-framework
|
||||||
**Kotlin/JVM** (`kotlin("jvm")`) is built and unit-tested now; anything that needs the
|
alike — builds and unit-tests here. AGP 9.2.1 (built-in Kotlin) + Gradle 9.6.1 build
|
||||||
Android framework (`com.android.*` plugins) is **scaffolded but disabled**.
|
against SDK 35/36.
|
||||||
|
|
||||||
- **Enabled now (pure Kotlin/JVM, `./gradlew test`-able):**
|
- **Pure Kotlin/JVM (`./gradlew test`):** `:wire-protocol`, `:session-core`, `:api-client`,
|
||||||
`:wire-protocol`, `:session-core`, `:api-client`, `:client-tls`, `:test-support`.
|
`:client-tls`, `:test-support`, `:transport-okhttp`.
|
||||||
- **Scaffolded but COMMENTED OUT** in [`settings.gradle.kts`](settings.gradle.kts)
|
- **Android-framework (online in [`settings.gradle.kts`](settings.gradle.kts)):**
|
||||||
(dirs + a `build.gradle.kts` stub exist, marked `// TODO(android-sdk)`):
|
|
||||||
`:app`, `:terminal-view`, `:host-registry`, `:client-tls-android`.
|
`:app`, `:terminal-view`, `:host-registry`, `:client-tls-android`.
|
||||||
|
|
||||||
To bring the Android modules online later: install an SDK, add
|
Setup: `local.properties` → `sdk.dir=/usr/local/share/android-commandlinetools`;
|
||||||
`local.properties` → `sdk.dir`, add the Android Gradle Plugin + `google()` to
|
`google()` is in `pluginManagement`/`dependencyResolutionManagement`. Green gate:
|
||||||
`pluginManagement`, then uncomment the `include(...)` lines and the plugin blocks in
|
`./gradlew test :app:assembleDebug koverVerify`.
|
||||||
each stub.
|
|
||||||
|
|
||||||
## Module map (mirror of the iOS SPM packages — plan §3)
|
## Module map (mirror of the iOS SPM packages — plan §3)
|
||||||
|
|
||||||
@@ -35,10 +33,10 @@ each stub.
|
|||||||
| APIClient | `:api-client` | pure Kotlin/JVM | ✅ built |
|
| APIClient | `:api-client` | pure Kotlin/JVM | ✅ built |
|
||||||
| ClientTLS (pure half) | `:client-tls` | pure Kotlin/JVM | ✅ built |
|
| ClientTLS (pure half) | `:client-tls` | pure Kotlin/JVM | ✅ built |
|
||||||
| TestSupport | `:test-support` | pure Kotlin/JVM (fakes) | ✅ built |
|
| TestSupport | `:test-support` | pure Kotlin/JVM (fakes) | ✅ built |
|
||||||
| ClientTLS (fwk half) | `:client-tls-android` | Android (AndroidKeyStore/Tink)| ⏸ SDK-gated |
|
| ClientTLS (fwk half) | `:client-tls-android` | Android (AndroidKeyStore/Tink)| ✅ built |
|
||||||
| HostRegistry | `:host-registry` | Android (DataStore) | ⏸ SDK-gated |
|
| HostRegistry | `:host-registry` | Android (DataStore) | ✅ built |
|
||||||
| SwiftTerm host view | `:terminal-view` | Android (Termux wrap) | ⏸ SDK-gated |
|
| SwiftTerm host view | `:terminal-view` | Android (Termux wrap) | ✅ built |
|
||||||
| App/WebTerm | `:app` | Android app (Compose/Hilt/FCM)| ⏸ SDK-gated |
|
| App/WebTerm | `:app` | Android app (Compose/Hilt/FCM)| ✅ built |
|
||||||
|
|
||||||
> Not yet scaffolded: `:transport-okhttp` (OkHttp `TermTransport`/`HttpTransport`
|
> Not yet scaffolded: `:transport-okhttp` (OkHttp `TermTransport`/`HttpTransport`
|
||||||
> impls, JVM) is owned by task **A7** and will be added then. The iOS
|
> impls, JVM) is owned by task **A7** and will be added then. The iOS
|
||||||
@@ -47,16 +45,16 @@ each stub.
|
|||||||
### Dependency graph (arrows = "depends on")
|
### Dependency graph (arrows = "depends on")
|
||||||
|
|
||||||
```
|
```
|
||||||
:app (SDK-gated)
|
:app
|
||||||
┌───────────────┬───┴────┬──────────────┬───────────────┐
|
┌───────────────┬───┴────┬──────────────┬───────────────┐
|
||||||
▼ ▼ ▼ ▼ ▼
|
▼ ▼ ▼ ▼ ▼
|
||||||
:terminal-view :session-core :api-client :host-registry :client-tls-android
|
:terminal-view :session-core :api-client :host-registry :client-tls-android
|
||||||
(SDK-gated) │ │ (SDK-gated) │
|
│ │ │ │
|
||||||
│ │ │ ▼
|
│ │ │ ▼
|
||||||
│ │ │ :client-tls (pure)
|
│ │ │ :client-tls (pure)
|
||||||
└──────┬───────┴──────────┴──────────────┬────────────────┘
|
└──────┬───────┴──────────┴──────────────┬────────────────┘
|
||||||
▼ ▼
|
▼ ▼
|
||||||
:wire-protocol ◀──────────── :transport-okhttp (A7, not yet)
|
:wire-protocol ◀──────────── :transport-okhttp
|
||||||
▲
|
▲
|
||||||
└──────── :test-support → test source sets only
|
└──────── :test-support → test source sets only
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package wang.yaojia.webterm.api.enroll
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B4 · Manual, canonical-DER encoder for a P-256 PKCS#10 `CertificationRequest` — a byte-for-byte
|
||||||
|
* port of the iOS `ClientTLS.CertificateSigningRequest`.
|
||||||
|
*
|
||||||
|
* Built by hand (no JCA CSR helper) so the exact bytes are under our control and the request is
|
||||||
|
* signed by the [CsrSigner] (an AndroidKeyStore hardware key in production, a software P-256 key in
|
||||||
|
* tests) via `SHA256withECDSA`. The output must satisfy the control-plane `verifyCsrPoPEc`: an EC
|
||||||
|
* P-256 `SubjectPublicKeyInfo` (`id-ecPublicKey` + `prime256v1`), an `ecdsa-with-SHA256`
|
||||||
|
* self-signature, and a valid PoP. Encoding is strictly canonical DER (minimal lengths) so the
|
||||||
|
* server's re-serialization of `CertificationRequestInfo` matches the bytes we signed.
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* CertificationRequest ::= SEQUENCE {
|
||||||
|
* certificationRequestInfo CertificationRequestInfo,
|
||||||
|
* signatureAlgorithm AlgorithmIdentifier, -- ecdsa-with-SHA256
|
||||||
|
* signature BIT STRING } -- X9.62 DER ECDSA-Sig
|
||||||
|
*
|
||||||
|
* CertificationRequestInfo ::= SEQUENCE {
|
||||||
|
* version INTEGER { v1(0) },
|
||||||
|
* subject Name,
|
||||||
|
* subjectPKInfo SubjectPublicKeyInfo,
|
||||||
|
* attributes [0] IMPLICIT SET OF Attribute } -- empty
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
public object CertificateSigningRequest {
|
||||||
|
/** P-256 uncompressed public point is `0x04 || X(32) || Y(32)` = 65 bytes. */
|
||||||
|
private const val UNCOMPRESSED_P256_POINT_LENGTH = 65
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build and self-sign a P-256 PKCS#10 CSR DER for [signer]'s key.
|
||||||
|
*
|
||||||
|
* @param subjectCommonName the CSR subject CN. The device leaf's identity is driven server-side
|
||||||
|
* by the ownership-verified subdomain SAN, so this is descriptive only; it must be non-empty.
|
||||||
|
* @param signer the P-256 hardware key that provides the public key and signs the
|
||||||
|
* `CertificationRequestInfo`.
|
||||||
|
* @throws CsrException.InvalidSubject on an empty CN; [CsrException.InvalidPublicKey] if the
|
||||||
|
* signer's public key is not a 65-byte X9.63 P-256 point.
|
||||||
|
*/
|
||||||
|
public fun der(subjectCommonName: String, signer: CsrSigner): ByteArray {
|
||||||
|
if (subjectCommonName.isEmpty()) throw CsrException.InvalidSubject
|
||||||
|
|
||||||
|
val publicPoint = signer.publicKeyX963()
|
||||||
|
if (publicPoint.size != UNCOMPRESSED_P256_POINT_LENGTH || publicPoint[0].toInt() != 0x04) {
|
||||||
|
throw CsrException.InvalidPublicKey
|
||||||
|
}
|
||||||
|
|
||||||
|
val requestInfo = certificationRequestInfo(subjectCommonName, publicPoint)
|
||||||
|
val signature = signer.sign(requestInfo)
|
||||||
|
|
||||||
|
return DerWriter.sequence(
|
||||||
|
listOf(
|
||||||
|
requestInfo,
|
||||||
|
ECDSA_WITH_SHA256_ALGORITHM_IDENTIFIER,
|
||||||
|
DerWriter.bitString(signature),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CertificationRequestInfo ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun certificationRequestInfo(subjectCommonName: String, publicPoint: ByteArray): ByteArray =
|
||||||
|
DerWriter.sequence(
|
||||||
|
listOf(
|
||||||
|
DerWriter.INTEGER_0, // version v1(0)
|
||||||
|
name(subjectCommonName),
|
||||||
|
subjectPublicKeyInfo(publicPoint),
|
||||||
|
DerWriter.EMPTY_ATTRIBUTES_CONTEXT0, // [0] IMPLICIT SET OF Attribute (empty)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `Name ::= SEQUENCE OF RelativeDistinguishedName` with a single CN RDN. */
|
||||||
|
private fun name(commonName: String): ByteArray {
|
||||||
|
val attribute = DerWriter.sequence(
|
||||||
|
listOf(DerWriter.oid(Oid.COMMON_NAME), DerWriter.utf8String(commonName)),
|
||||||
|
)
|
||||||
|
val rdn = DerWriter.set(listOf(attribute))
|
||||||
|
return DerWriter.sequence(listOf(rdn))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `SubjectPublicKeyInfo` for an EC P-256 key: `id-ecPublicKey` + `prime256v1` named curve, then
|
||||||
|
* the uncompressed point as a BIT STRING.
|
||||||
|
*/
|
||||||
|
private fun subjectPublicKeyInfo(publicPoint: ByteArray): ByteArray {
|
||||||
|
val algorithm = DerWriter.sequence(
|
||||||
|
listOf(DerWriter.oid(Oid.EC_PUBLIC_KEY), DerWriter.oid(Oid.PRIME256V1)),
|
||||||
|
)
|
||||||
|
return DerWriter.sequence(listOf(algorithm, DerWriter.bitString(publicPoint)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `AlgorithmIdentifier` for `ecdsa-with-SHA256` — no parameters (absent, per RFC 5758), which is
|
||||||
|
* exactly what the server's verifier expects.
|
||||||
|
*/
|
||||||
|
private val ECDSA_WITH_SHA256_ALGORITHM_IDENTIFIER: ByteArray =
|
||||||
|
DerWriter.sequence(listOf(DerWriter.oid(Oid.ECDSA_WITH_SHA256)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Object identifiers (DER content bytes; tag/length added by [DerWriter.oid]). */
|
||||||
|
private object Oid {
|
||||||
|
/** 1.2.840.10045.2.1 — id-ecPublicKey. */
|
||||||
|
val EC_PUBLIC_KEY = byteArrayOf(0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x02, 0x01)
|
||||||
|
|
||||||
|
/** 1.2.840.10045.3.1.7 — prime256v1 / secp256r1. */
|
||||||
|
val PRIME256V1 = byteArrayOf(0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x03, 0x01, 0x07)
|
||||||
|
|
||||||
|
/** 1.2.840.10045.4.3.2 — ecdsa-with-SHA256. */
|
||||||
|
val ECDSA_WITH_SHA256 = byteArrayOf(0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x04, 0x03, 0x02)
|
||||||
|
|
||||||
|
/** 2.5.4.3 — id-at-commonName. */
|
||||||
|
val COMMON_NAME = byteArrayOf(0x55, 0x04, 0x03)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tiny canonical-DER encoder. Every helper returns a fully-formed TLV so callers just concatenate
|
||||||
|
* children — canonical minimal-length encoding throughout. Internal so its byte layout is
|
||||||
|
* unit-testable in isolation.
|
||||||
|
*/
|
||||||
|
internal object DerWriter {
|
||||||
|
private const val TAG_INTEGER: Byte = 0x02
|
||||||
|
private const val TAG_BIT_STRING: Byte = 0x03
|
||||||
|
private const val TAG_OID: Byte = 0x06
|
||||||
|
private const val TAG_UTF8_STRING: Byte = 0x0C
|
||||||
|
private const val TAG_SEQUENCE: Byte = 0x30
|
||||||
|
private const val TAG_SET: Byte = 0x31
|
||||||
|
private const val TAG_CONTEXT0_CONSTRUCTED: Byte = 0xA0.toByte()
|
||||||
|
|
||||||
|
/** `INTEGER 0` — the fixed PKCS#10 version v1(0). */
|
||||||
|
val INTEGER_0: ByteArray = byteArrayOf(TAG_INTEGER, 0x01, 0x00)
|
||||||
|
|
||||||
|
/** `[0] IMPLICIT SET OF Attribute`, empty — `A0 00`. */
|
||||||
|
val EMPTY_ATTRIBUTES_CONTEXT0: ByteArray = byteArrayOf(TAG_CONTEXT0_CONSTRUCTED, 0x00)
|
||||||
|
|
||||||
|
fun sequence(children: List<ByteArray>): ByteArray = tlv(TAG_SEQUENCE, concat(children))
|
||||||
|
|
||||||
|
fun set(children: List<ByteArray>): ByteArray = tlv(TAG_SET, concat(children))
|
||||||
|
|
||||||
|
fun oid(content: ByteArray): ByteArray = tlv(TAG_OID, content)
|
||||||
|
|
||||||
|
fun utf8String(value: String): ByteArray = tlv(TAG_UTF8_STRING, value.encodeToByteArray())
|
||||||
|
|
||||||
|
/** BIT STRING with zero unused bits (all our bit strings are byte-aligned). */
|
||||||
|
fun bitString(content: ByteArray): ByteArray = tlv(TAG_BIT_STRING, byteArrayOf(0x00) + content)
|
||||||
|
|
||||||
|
/** Tag-Length-Value with canonical DER length encoding. */
|
||||||
|
private fun tlv(tag: Byte, value: ByteArray): ByteArray = byteArrayOf(tag) + length(value.size) + value
|
||||||
|
|
||||||
|
/** DER length: short form (<128) or long form (`0x80 | byteCount`, big-endian). */
|
||||||
|
private fun length(count: Int): ByteArray {
|
||||||
|
if (count < 0x80) return byteArrayOf(count.toByte())
|
||||||
|
var value = count
|
||||||
|
val bytes = ArrayDeque<Byte>()
|
||||||
|
while (value > 0) {
|
||||||
|
bytes.addFirst((value and 0xFF).toByte())
|
||||||
|
value = value ushr 8
|
||||||
|
}
|
||||||
|
return byteArrayOf((0x80 or bytes.size).toByte()) + bytes.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun concat(chunks: List<ByteArray>): ByteArray {
|
||||||
|
val total = chunks.sumOf { it.size }
|
||||||
|
val out = ByteArray(total)
|
||||||
|
var offset = 0
|
||||||
|
for (chunk in chunks) {
|
||||||
|
System.arraycopy(chunk, 0, out, offset, chunk.size)
|
||||||
|
offset += chunk.size
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package wang.yaojia.webterm.api.enroll
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B4 · The signing key abstraction the PKCS#10 CSR encoder ([CertificateSigningRequest]) drives —
|
||||||
|
* the Android analogue of iOS `P256HardwareKey`.
|
||||||
|
*
|
||||||
|
* In production this is backed by a NON-EXPORTABLE P-256 key living inside AndroidKeyStore
|
||||||
|
* (StrongBox → TEE), so `sign` runs inside secure hardware and the private key never leaves it
|
||||||
|
* (`:client-tls-android` `HardwareBackedKey`). In JVM unit tests it is backed by a software P-256
|
||||||
|
* key via the SAME `Signature("SHA256withECDSA")` path, so the CSR-encoding bytes are exercised
|
||||||
|
* identically without an emulator.
|
||||||
|
*/
|
||||||
|
public interface CsrSigner {
|
||||||
|
/**
|
||||||
|
* The public key in ANSI X9.63 uncompressed form: `0x04 || X(32) || Y(32)` (65 bytes for
|
||||||
|
* P-256). This is exactly what wraps into the CSR's `SubjectPublicKeyInfo` BIT STRING.
|
||||||
|
*/
|
||||||
|
public fun publicKeyX963(): ByteArray
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ECDSA-sign `message` over SHA-256, returning the X9.62 DER signature
|
||||||
|
* (`SEQUENCE { r INTEGER, s INTEGER }`) — exactly the shape a PKCS#10 `signature` BIT STRING
|
||||||
|
* and the server's `verifyCsrPoPEc` expect. The digest is computed by the algorithm
|
||||||
|
* (`SHA256withECDSA`), so callers pass the raw message (the DER of `CertificationRequestInfo`),
|
||||||
|
* NOT a pre-hash.
|
||||||
|
*/
|
||||||
|
public fun sign(message: ByteArray): ByteArray
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Structural failures building a CSR — the client refuses to emit a malformed request. */
|
||||||
|
public sealed class CsrException(message: String) : Exception(message) {
|
||||||
|
/** The signer's public key was not the expected 65-byte X9.63 uncompressed P-256 point. */
|
||||||
|
public data object InvalidPublicKey : CsrException("CSR public key is not a 65-byte X9.63 P-256 point")
|
||||||
|
|
||||||
|
/** The subject CN was empty (not encodable / rejected by the server). */
|
||||||
|
public data object InvalidSubject : CsrException("CSR subject common name must not be empty")
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package wang.yaojia.webterm.api.enroll
|
||||||
|
|
||||||
|
import wang.yaojia.webterm.wire.HttpMethod
|
||||||
|
import wang.yaojia.webterm.wire.HttpRequest
|
||||||
|
import wang.yaojia.webterm.wire.HttpResponse
|
||||||
|
import wang.yaojia.webterm.wire.HttpTransport
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B4 · Talks to the control-plane device-enrollment API over the shared [HttpTransport] seam (the
|
||||||
|
* same seam `:transport-okhttp` implements and `:test-support` fakes), so the enroll flow rides the
|
||||||
|
* app's normal HTTP stack. Android analogue of iOS `DeviceEnrollmentClient`, extended with the login
|
||||||
|
* step (B4 pinned contract):
|
||||||
|
*
|
||||||
|
* `POST /auth/login` `{ password }` → 201 `{ enrollToken, accountId, expiresIn }`
|
||||||
|
* `POST /device/enroll` [Bearer enrollToken] `{ csr, keyAlg:'ec-p256', subdomain,
|
||||||
|
* deviceName, attestation? }` → 201 `{ deviceId, cert, caChain,
|
||||||
|
* notBefore, notAfter, renewAfter }`
|
||||||
|
* `POST /device/:id/renew` [mTLS current device cert] `{ csr }` ONLY → 201 (same shape). The
|
||||||
|
* server schema is `.strict()`; NO keyAlg/subdomain/deviceName.
|
||||||
|
*
|
||||||
|
* Deliberately logic-free about TLS/keys: it only builds requests and maps responses. The `csr` is
|
||||||
|
* sent as standard base64(DER), which the server's `decodeCsrWire` accepts directly; response DERs
|
||||||
|
* are standard-base64 (`bytesToBase64` = Node `Buffer.toString('base64')`).
|
||||||
|
*
|
||||||
|
* Immutable: constructed once with a [baseUrl] + [http]; the short-lived enroll bearer is passed
|
||||||
|
* per-call and never held/logged (leaked-bearer blast radius).
|
||||||
|
*/
|
||||||
|
public class DeviceEnrollmentClient(
|
||||||
|
baseUrl: String,
|
||||||
|
private val http: HttpTransport,
|
||||||
|
) {
|
||||||
|
/** Base control-plane URL with any trailing slash removed, so `base + path` is well-formed. */
|
||||||
|
private val base: String = baseUrl.trim().trimEnd('/')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time operator login → a short-lived `device:enroll` bearer. An empty [password] is
|
||||||
|
* rejected client-side (`InvalidRequest`) before any network I/O — never send a blank credential.
|
||||||
|
*/
|
||||||
|
public suspend fun login(password: String): LoginResult {
|
||||||
|
if (password.isEmpty()) throw DeviceEnrollmentError.InvalidRequest
|
||||||
|
val body = EnrollJson.encodeToString(LoginRequestBody.serializer(), LoginRequestBody(password))
|
||||||
|
val response = http.send(jsonRequest(HttpMethod.POST, PATH_LOGIN, body.encodeToByteArray(), bearer = null))
|
||||||
|
val dto = decodeOn201(response, LoginResponseDto.serializer())
|
||||||
|
return LoginResult(
|
||||||
|
enrollToken = dto.enrollToken,
|
||||||
|
accountId = dto.accountId,
|
||||||
|
expiresInSeconds = dto.expiresIn,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enroll a freshly-generated hardware key: POST the [csrDer] under the enroll [bearerToken],
|
||||||
|
* receive the leaf. Required fields are validated client-side (`InvalidRequest`) before any I/O.
|
||||||
|
*/
|
||||||
|
public suspend fun enroll(
|
||||||
|
bearerToken: String,
|
||||||
|
csrDer: ByteArray,
|
||||||
|
subdomain: String,
|
||||||
|
deviceName: String,
|
||||||
|
attestation: String? = null,
|
||||||
|
): EnrollmentResult {
|
||||||
|
if (bearerToken.isEmpty() || csrDer.isEmpty() || subdomain.isEmpty() || deviceName.isEmpty()) {
|
||||||
|
throw DeviceEnrollmentError.InvalidRequest
|
||||||
|
}
|
||||||
|
val body = EnrollJson.encodeToString(
|
||||||
|
EnrollRequestBody.serializer(),
|
||||||
|
EnrollRequestBody(
|
||||||
|
csr = base64(csrDer),
|
||||||
|
keyAlg = KEY_ALG_EC_P256,
|
||||||
|
subdomain = subdomain,
|
||||||
|
deviceName = deviceName,
|
||||||
|
attestation = attestation,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val response = http.send(jsonRequest(HttpMethod.POST, PATH_ENROLL, body.encodeToByteArray(), bearerToken))
|
||||||
|
return toResult(decodeOn201(response, EnrollResponseDto.serializer()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renew against the SAME hardware key: a fresh CSR to `/device/:id/renew` (silent-rotation seam).
|
||||||
|
*
|
||||||
|
* The renew endpoint is authenticated by the CURRENT device certificate over mTLS — the body is
|
||||||
|
* `{ csr }` ONLY and NO bearer is sent (mirrors iOS, which passes `bearerToken: nil`). [bearerToken]
|
||||||
|
* is therefore OPTIONAL and defaults to absent; the `Authorization` header is omitted when it is
|
||||||
|
* null/blank. The seam still accepts a bearer for a hypothetical bearer-authenticated renew, but the
|
||||||
|
* production caller passes none. [deviceId] and [csrDer] are validated client-side before any I/O.
|
||||||
|
*/
|
||||||
|
public suspend fun renew(deviceId: String, csrDer: ByteArray, bearerToken: String? = null): EnrollmentResult {
|
||||||
|
if (deviceId.isEmpty() || csrDer.isEmpty()) {
|
||||||
|
throw DeviceEnrollmentError.InvalidRequest
|
||||||
|
}
|
||||||
|
// Body is `{ csr }` ONLY — the renew endpoint authenticates by the presented mTLS device cert
|
||||||
|
// and its schema is `.strict()`, so any enroll-only extra (keyAlg/subdomain/deviceName) is
|
||||||
|
// rejected. Identity/key come from the current cert + registry record, never the body.
|
||||||
|
val body = EnrollJson.encodeToString(
|
||||||
|
RenewRequestBody.serializer(),
|
||||||
|
RenewRequestBody(csr = base64(csrDer)),
|
||||||
|
)
|
||||||
|
val path = "$PATH_DEVICE/${encodePathSegment(deviceId)}/renew"
|
||||||
|
val response = http.send(jsonRequest(HttpMethod.POST, path, body.encodeToByteArray(), bearerToken))
|
||||||
|
return toResult(decodeOn201(response, EnrollResponseDto.serializer()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Request/response plumbing ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun jsonRequest(
|
||||||
|
method: HttpMethod,
|
||||||
|
path: String,
|
||||||
|
jsonBody: ByteArray,
|
||||||
|
bearer: String?,
|
||||||
|
): HttpRequest {
|
||||||
|
val headers = LinkedHashMap<String, String>()
|
||||||
|
headers[HEADER_CONTENT_TYPE] = CONTENT_TYPE_JSON
|
||||||
|
// Omit Authorization entirely when there is no bearer (the mTLS-only renew path) — an empty
|
||||||
|
// string must never emit a bare "Bearer " header.
|
||||||
|
if (!bearer.isNullOrEmpty()) headers[HEADER_AUTHORIZATION] = "$BEARER_PREFIX$bearer"
|
||||||
|
return HttpRequest(method = method, url = base + path, headers = headers, body = jsonBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 201 → decode with [serializer]; else → [DeviceEnrollmentError.Http] with the server's `error`
|
||||||
|
* code (never the raw body); an undecodable 201 body → [DeviceEnrollmentError.MalformedResponse]. */
|
||||||
|
private fun <T> decodeOn201(response: HttpResponse, serializer: kotlinx.serialization.KSerializer<T>): T {
|
||||||
|
if (response.status != HTTP_CREATED) {
|
||||||
|
throw DeviceEnrollmentError.Http(response.status, errorCode(response.body))
|
||||||
|
}
|
||||||
|
return runCatching { EnrollJson.decodeFromString(serializer, response.body.decodeToString()) }
|
||||||
|
.getOrNull() ?: throw DeviceEnrollmentError.MalformedResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toResult(dto: EnrollResponseDto): EnrollmentResult {
|
||||||
|
val certificate = decodeBase64OrNull(dto.cert) ?: throw DeviceEnrollmentError.MalformedResponse
|
||||||
|
val chain = dto.caChain.map { entry ->
|
||||||
|
decodeBase64OrNull(entry) ?: throw DeviceEnrollmentError.MalformedResponse
|
||||||
|
}
|
||||||
|
return EnrollmentResult(
|
||||||
|
deviceId = dto.deviceId,
|
||||||
|
certificate = certificate,
|
||||||
|
caChain = chain,
|
||||||
|
notBefore = parseInstantOrNull(dto.notBefore),
|
||||||
|
notAfter = parseInstantOrNull(dto.notAfter),
|
||||||
|
renewAfter = parseInstantOrNull(dto.renewAfter),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun errorCode(body: ByteArray): String? =
|
||||||
|
runCatching { EnrollJson.decodeFromString(ErrorDto.serializer(), body.decodeToString()).error }.getOrNull()
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val PATH_LOGIN = "/auth/login"
|
||||||
|
const val PATH_ENROLL = "/device/enroll"
|
||||||
|
const val PATH_DEVICE = "/device"
|
||||||
|
const val KEY_ALG_EC_P256 = "ec-p256"
|
||||||
|
const val HTTP_CREATED = 201
|
||||||
|
|
||||||
|
const val HEADER_CONTENT_TYPE = "Content-Type"
|
||||||
|
const val HEADER_AUTHORIZATION = "Authorization"
|
||||||
|
const val CONTENT_TYPE_JSON = "application/json"
|
||||||
|
const val BEARER_PREFIX = "Bearer "
|
||||||
|
|
||||||
|
private val BASE64_ENCODER = java.util.Base64.getEncoder()
|
||||||
|
private val BASE64_DECODER = java.util.Base64.getDecoder()
|
||||||
|
|
||||||
|
fun base64(bytes: ByteArray): String = BASE64_ENCODER.encodeToString(bytes)
|
||||||
|
|
||||||
|
fun decodeBase64OrNull(text: String): ByteArray? =
|
||||||
|
runCatching { BASE64_DECODER.decode(text) }.getOrNull()
|
||||||
|
|
||||||
|
/** Percent-encode a `:id` path segment's non-unreserved bytes (defence: device ids are
|
||||||
|
* server-minted UUIDs, but never build a URL from an unescaped field). */
|
||||||
|
fun encodePathSegment(value: String): String {
|
||||||
|
val sb = StringBuilder()
|
||||||
|
for (byte in value.encodeToByteArray()) {
|
||||||
|
val code = byte.toInt() and 0xFF
|
||||||
|
val ch = code.toChar()
|
||||||
|
if (ch in UNRESERVED) sb.append(ch) else sb.append('%').append(HEX[code ushr 4]).append(HEX[code and 0x0F])
|
||||||
|
}
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val UNRESERVED: Set<Char> =
|
||||||
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~".toSet()
|
||||||
|
private val HEX = "0123456789ABCDEF".toCharArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package wang.yaojia.webterm.api.enroll
|
||||||
|
|
||||||
|
import java.math.BigInteger
|
||||||
|
import java.security.interfaces.ECPublicKey
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B4 · Pure encoder from a JCA [ECPublicKey] to the ANSI X9.63 uncompressed point
|
||||||
|
* `0x04 || X || Y` that a P-256 `SubjectPublicKeyInfo` BIT STRING carries.
|
||||||
|
*
|
||||||
|
* Kept in the pure `:api-client` module (no Android dependency) so it is reused by BOTH the
|
||||||
|
* JVM-unit-test software signer AND the framework `HardwareBackedKey` (`:client-tls-android`),
|
||||||
|
* and so this security-load-bearing byte layout is unit-tested at JVM speed.
|
||||||
|
*/
|
||||||
|
public object EcPointEncoding {
|
||||||
|
/** P-256 field element width in bytes (256 bits). */
|
||||||
|
public const val P256_COORDINATE_BYTES: Int = 32
|
||||||
|
|
||||||
|
/** Uncompressed-point prefix (`0x04`) per SEC 1 §2.3.3. */
|
||||||
|
private const val UNCOMPRESSED_PREFIX: Byte = 0x04
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode [publicKey]'s affine (x, y) as `0x04 || X(32) || Y(32)` (65 bytes). Each coordinate is
|
||||||
|
* an unsigned big-endian integer left-padded (or, defensively, high-byte-trimmed) to exactly
|
||||||
|
* [P256_COORDINATE_BYTES]. Throws [IllegalArgumentException] if a coordinate genuinely does not
|
||||||
|
* fit 32 bytes (i.e. the key is not on a 256-bit curve).
|
||||||
|
*/
|
||||||
|
public fun x963(publicKey: ECPublicKey): ByteArray {
|
||||||
|
val point = publicKey.w
|
||||||
|
val x = toFixedLengthUnsigned(point.affineX, P256_COORDINATE_BYTES)
|
||||||
|
val y = toFixedLengthUnsigned(point.affineY, P256_COORDINATE_BYTES)
|
||||||
|
val out = ByteArray(1 + P256_COORDINATE_BYTES * 2)
|
||||||
|
out[0] = UNCOMPRESSED_PREFIX
|
||||||
|
System.arraycopy(x, 0, out, 1, P256_COORDINATE_BYTES)
|
||||||
|
System.arraycopy(y, 0, out, 1 + P256_COORDINATE_BYTES, P256_COORDINATE_BYTES)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a non-negative [value] to a big-endian byte array of exactly [length] bytes. A
|
||||||
|
* `BigInteger` may carry a leading 0x00 sign byte (drop it) or be shorter than [length]
|
||||||
|
* (left-pad with zeros). A value that needs MORE than [length] significant bytes is rejected —
|
||||||
|
* silently truncating a coordinate would corrupt the key.
|
||||||
|
*/
|
||||||
|
internal fun toFixedLengthUnsigned(value: BigInteger, length: Int): ByteArray {
|
||||||
|
require(value.signum() >= 0) { "EC coordinate must be non-negative" }
|
||||||
|
val raw = value.toByteArray() // big-endian, possibly with a leading 0x00 sign byte
|
||||||
|
val start = if (raw.size > length && raw[0].toInt() == 0) 1 else 0
|
||||||
|
val significant = raw.size - start
|
||||||
|
require(significant <= length) { "EC coordinate does not fit $length bytes (got $significant)" }
|
||||||
|
val out = ByteArray(length)
|
||||||
|
System.arraycopy(raw, start, out, length - significant, significant)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package wang.yaojia.webterm.api.enroll
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B4 · Typed result of the one-time operator login (`POST /auth/login`). The [enrollToken] is a
|
||||||
|
* short-lived `device:enroll` bearer — hold it ONLY for the immediately-following enroll call and
|
||||||
|
* NEVER persist or log it. [accountId] identifies the tenant the device will be scoped under.
|
||||||
|
*/
|
||||||
|
public data class LoginResult(
|
||||||
|
val enrollToken: String,
|
||||||
|
val accountId: String,
|
||||||
|
val expiresInSeconds: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B4 · Typed result of a successful `POST /device/enroll` (or `/device/:id/renew`): the issued leaf
|
||||||
|
* plus its issuer chain and rotation timing. The private key is NOT here — it stays non-exportable
|
||||||
|
* in AndroidKeyStore. Mirrors iOS `EnrollmentResult`.
|
||||||
|
*
|
||||||
|
* NOTE: [certificate]/[caChain] are `ByteArray`, so the generated `data class` equality is by
|
||||||
|
* reference (transient DER carriers, not value-equality keys) — compare with `contentEquals`.
|
||||||
|
*/
|
||||||
|
public data class EnrollmentResult(
|
||||||
|
val deviceId: String,
|
||||||
|
/** Leaf certificate DER (decoded from the response's base64). */
|
||||||
|
val certificate: ByteArray,
|
||||||
|
/** Issuer chain DERs (device-CA etc.), leaf excluded. */
|
||||||
|
val caChain: List<ByteArray>,
|
||||||
|
val notBefore: Instant?,
|
||||||
|
val notAfter: Instant?,
|
||||||
|
/** When to renew from the same hardware key (~2/3 of the lifetime). */
|
||||||
|
val renewAfter: Instant?,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* The rotation seam: is the leaf due for renewal as of [now]? A missing [renewAfter] never
|
||||||
|
* triggers (fail-safe — the TLS stack is the real gate; the scheduler only pre-empts expiry).
|
||||||
|
*/
|
||||||
|
public fun isRenewalDue(now: Instant = Instant.now()): Boolean {
|
||||||
|
val due = renewAfter ?: return false
|
||||||
|
return !now.isBefore(due) // now >= renewAfter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Typed failures for the device-enrollment surface. Transport-level errors propagate UNWRAPPED. */
|
||||||
|
public sealed class DeviceEnrollmentError(message: String) : Exception(message) {
|
||||||
|
/**
|
||||||
|
* A non-success HTTP status with the server's uniform `{ error }` code, if any (401
|
||||||
|
* missing/rejected token, 403 subdomain-not-owned, 429 rate_limited, 400 rejected
|
||||||
|
* CSR/subdomain). Never leaks the response body.
|
||||||
|
*/
|
||||||
|
public data class Http(val status: Int, val code: String?) :
|
||||||
|
DeviceEnrollmentError("device enrollment rejected: HTTP $status" + (code?.let { " ($it)" } ?: ""))
|
||||||
|
|
||||||
|
/** A success body that did not decode to the expected shape (or an undecodable base64 cert). */
|
||||||
|
public data object MalformedResponse :
|
||||||
|
DeviceEnrollmentError("device enrollment response was not the expected shape")
|
||||||
|
|
||||||
|
/** A required request field was empty — rejected client-side BEFORE any network I/O. */
|
||||||
|
public data object InvalidRequest :
|
||||||
|
DeviceEnrollmentError("device enrollment request was missing a required field")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wire DTOs + JSON config (internal to the enroll package) ─────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ENCODE omits absent optionals (`encodeDefaults = false` drops the default-null `attestation`;
|
||||||
|
* `explicitNulls = false` never writes an explicit `null`) and DECODE is tolerant of unknown keys
|
||||||
|
* (the server is untrusted at this boundary). `keyAlg` carries NO default, so it is ALWAYS encoded.
|
||||||
|
*/
|
||||||
|
internal val EnrollJson: Json = Json {
|
||||||
|
encodeDefaults = false
|
||||||
|
explicitNulls = false
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
isLenient = true
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class LoginRequestBody(val password: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class EnrollRequestBody(
|
||||||
|
val csr: String,
|
||||||
|
val keyAlg: String,
|
||||||
|
val subdomain: String,
|
||||||
|
val deviceName: String,
|
||||||
|
val attestation: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `/device/:id/renew` request body. The endpoint authenticates by the presented mTLS device cert
|
||||||
|
* and its server schema is `{ csr }` ONLY (`.strict()`), so it carries the single new CSR and NO
|
||||||
|
* enroll-only fields (keyAlg/subdomain/deviceName) — an extra key would be rejected as a 400.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
internal data class RenewRequestBody(val csr: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class LoginResponseDto(
|
||||||
|
val enrollToken: String,
|
||||||
|
val accountId: String,
|
||||||
|
val expiresIn: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class EnrollResponseDto(
|
||||||
|
val deviceId: String,
|
||||||
|
val cert: String,
|
||||||
|
val caChain: List<String> = emptyList(),
|
||||||
|
val notBefore: String? = null,
|
||||||
|
val notAfter: String? = null,
|
||||||
|
val renewAfter: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class ErrorDto(val error: String? = null)
|
||||||
|
|
||||||
|
/** Parse an ISO-8601 instant, degrading an absent/unparseable value to null (dates are advisory). */
|
||||||
|
internal fun parseInstantOrNull(text: String?): Instant? {
|
||||||
|
if (text == null) return null
|
||||||
|
return runCatching { Instant.parse(text) }.getOrNull()
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package wang.yaojia.webterm.api.models
|
||||||
|
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One commit from `git log` (`src/types.ts` `CommitLogEntry`). [hash] and [at] are REQUIRED — a
|
||||||
|
* commit missing either is dropped by the list-lossy [CommitLogEntryListSerializer] (its siblings
|
||||||
|
* survive). [subject] defaults to empty so a subject-less commit still decodes. `at` = `%ct * 1000`
|
||||||
|
* (epoch millis). All fields are rendered INERT (plain text; no autolink) at the screen (plan §8).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
public data class CommitLogEntry(
|
||||||
|
val hash: String,
|
||||||
|
val at: Long,
|
||||||
|
val subject: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /projects/log` result (`src/types.ts` `GitLogResult`). [truncated] = more commits exist
|
||||||
|
* beyond the server cap. The commit list decodes lossily (drop-one-keep-rest).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
public data class GitLogResult(
|
||||||
|
@Serializable(with = CommitLogEntryListSerializer::class)
|
||||||
|
val commits: List<CommitLogEntry> = emptyList(),
|
||||||
|
val truncated: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Drops a commit missing `hash`/`at`, keeps the rest (nested list-lossy, like worktrees). */
|
||||||
|
internal object CommitLogEntryListSerializer :
|
||||||
|
KSerializer<List<CommitLogEntry>> by LossyListSerializer(CommitLogEntry.serializer())
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package wang.yaojia.webterm.api.models
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A client result union for the six guarded git-write ops (worktree create/remove/prune, git
|
||||||
|
* stage/commit/push). It carries the server's SAFE body only — never raw git stderr (the server
|
||||||
|
* classifies + sanitizes every failure, `src/http/git-ops.ts` / `worktrees.ts`, SEC-M10):
|
||||||
|
*
|
||||||
|
* - [Ok] — a 200 with the op's route-specific payload [T].
|
||||||
|
* - [Rejected] — a 4xx/5xx with the server's inert `error` string ([message]) to display verbatim.
|
||||||
|
* 403 is OVERLOADED (Origin-guard failure AND the disabled kill-switch both 403) so the client
|
||||||
|
* cannot tell them apart by status — it surfaces [message] inertly rather than inventing a typed
|
||||||
|
* variant (plan Edge cases / Security).
|
||||||
|
* - [RateLimited] — a 429 (stage/commit share one limiter, push a tighter one). Do NOT auto-retry.
|
||||||
|
*
|
||||||
|
* Nothing here throws on a bad body: a missing/garbled payload degrades to defaults (empty sha,
|
||||||
|
* empty pruned list) rather than crashing (tolerant-decode discipline, plan §8).
|
||||||
|
*/
|
||||||
|
public sealed interface GitWriteOutcome<out T> {
|
||||||
|
/** 200 — the op succeeded; [payload] is the route-specific success body. */
|
||||||
|
public data class Ok<out T>(val payload: T) : GitWriteOutcome<T>
|
||||||
|
|
||||||
|
/** A 4xx/5xx failure carrying the server's SAFE [message] (inert; may be null if unparseable). */
|
||||||
|
public data class Rejected(val status: Int, val message: String?) : GitWriteOutcome<Nothing>
|
||||||
|
|
||||||
|
/** 429 — the server rate-limited this write. */
|
||||||
|
public data object RateLimited : GitWriteOutcome<Nothing>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-op 200 payloads (all fields optional/defaulted → a garbled body degrades, never throws) ──
|
||||||
|
|
||||||
|
/** `POST /projects/git/stage` 200 → `{ ok, staged, count }`. */
|
||||||
|
@Serializable
|
||||||
|
public data class StageResult(val staged: Boolean = false, val count: Int = 0)
|
||||||
|
|
||||||
|
/** `POST /projects/git/commit` 200 → `{ ok, commit }` (short sha; may be `""` — empty is valid). */
|
||||||
|
@Serializable
|
||||||
|
public data class CommitResult(val commit: String = "")
|
||||||
|
|
||||||
|
/** `POST /projects/git/push` 200 → `{ ok, branch, remote }`. */
|
||||||
|
@Serializable
|
||||||
|
public data class PushResult(val branch: String? = null, val remote: String? = null)
|
||||||
|
|
||||||
|
/** `POST /projects/worktree` 200 → `{ ok, path, branch }`. */
|
||||||
|
@Serializable
|
||||||
|
public data class CreateWorktreeResult(val path: String? = null, val branch: String? = null)
|
||||||
|
|
||||||
|
/** `DELETE /projects/worktree` 200 → `{ ok, path }` (git's canonical removed path). */
|
||||||
|
@Serializable
|
||||||
|
public data class RemoveWorktreeResult(val path: String? = null)
|
||||||
|
|
||||||
|
/** `POST /projects/worktree/prune` 200 → `{ ok, pruned: [...] }` (empty = nothing to prune). */
|
||||||
|
@Serializable
|
||||||
|
public data class PruneWorktreesResult(val pruned: List<String> = emptyList())
|
||||||
|
|
||||||
|
/** Shape of a failure body — worktree routes emit `{ error }`, git-ops `{ ok:false, error }`; both
|
||||||
|
* carry `error` as a SAFE string. Decoded to surface [error] inertly. */
|
||||||
|
@Serializable
|
||||||
|
internal data class GitErrorBody(val ok: Boolean = false, val error: String? = null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a guarded 200 body into [T], degrading a missing/garbled body to the payload's defaults
|
||||||
|
* (never throws — the caller already knows the status is 200).
|
||||||
|
*/
|
||||||
|
internal fun <T> decodeGitPayload(bytes: ByteArray, deserializer: kotlinx.serialization.KSerializer<T>): T =
|
||||||
|
LossyDecode.objectOrNull(bytes, deserializer) ?: ModelJson.decodeFromString(deserializer, "{}")
|
||||||
|
|
||||||
|
/** Read the SAFE `error` string from a failure body; null when the body is empty/unparseable. */
|
||||||
|
internal fun decodeGitError(bytes: ByteArray): String? =
|
||||||
|
LossyDecode.objectOrNull(bytes, GitErrorBody.serializer())?.error
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package wang.yaojia.webterm.api.models
|
||||||
|
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||||
|
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||||
|
import kotlinx.serialization.encoding.Decoder
|
||||||
|
import kotlinx.serialization.encoding.Encoder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why a [PrStatus] has (or lacks) PR data (`src/types.ts` `PrAvailability`). Drives the detail
|
||||||
|
* chip's copy. Decoded via [PrAvailabilitySerializer]: an unknown/future value **degrades to
|
||||||
|
* [ERROR]** (never throws) — a new server availability must never make the chip crash.
|
||||||
|
*/
|
||||||
|
public enum class PrAvailability(public val wire: String) {
|
||||||
|
/** A PR exists for the current branch; the sibling fields are populated. */
|
||||||
|
OK("ok"),
|
||||||
|
|
||||||
|
/** gh works but the branch has no PR (or no remote/default repo). */
|
||||||
|
NO_PR("no-pr"),
|
||||||
|
|
||||||
|
/** `gh` binary not found on PATH (ENOENT). */
|
||||||
|
NOT_INSTALLED("not-installed"),
|
||||||
|
|
||||||
|
/** gh present but not logged in (needs `gh auth login`). */
|
||||||
|
UNAUTHENTICATED("unauthenticated"),
|
||||||
|
|
||||||
|
/** `GH_ENABLED=0` — feature off, never spawns gh. */
|
||||||
|
DISABLED("disabled"),
|
||||||
|
|
||||||
|
/** gh spawned but failed for another reason (timeout, etc.); also the unknown/missing fallback. */
|
||||||
|
ERROR("error"),
|
||||||
|
|
||||||
|
;
|
||||||
|
|
||||||
|
public companion object {
|
||||||
|
/** Map the wire string; unknown → [ERROR] (mirror of the FE never treating non-`ok` as fatal). */
|
||||||
|
public fun fromWire(wire: String): PrAvailability =
|
||||||
|
entries.firstOrNull { it.wire == wire } ?: ERROR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode [PrAvailability] by its `wire` value; an unknown/future value maps to [PrAvailability.ERROR]
|
||||||
|
* rather than throwing (mirror of [ClaudeStatusSerializer]). Serializes back the `wire` string.
|
||||||
|
*/
|
||||||
|
internal object PrAvailabilitySerializer : KSerializer<PrAvailability> {
|
||||||
|
override val descriptor: SerialDescriptor =
|
||||||
|
PrimitiveSerialDescriptor("PrAvailability", PrimitiveKind.STRING)
|
||||||
|
|
||||||
|
override fun deserialize(decoder: Decoder): PrAvailability =
|
||||||
|
PrAvailability.fromWire(decoder.decodeString())
|
||||||
|
|
||||||
|
override fun serialize(encoder: Encoder, value: PrAvailability) =
|
||||||
|
encoder.encodeString(value.wire)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rolled-up CI check counts from gh's statusCheckRollup (`src/types.ts` `PrCheckSummary`). */
|
||||||
|
@Serializable
|
||||||
|
public data class PrCheckSummary(
|
||||||
|
val total: Int = 0,
|
||||||
|
val passing: Int = 0,
|
||||||
|
val failing: Int = 0,
|
||||||
|
val pending: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /projects/pr` result (`src/types.ts` `PrStatus`). Every field except [availability] is
|
||||||
|
* optional (present only when `availability == ok`); [availability] itself defaults to
|
||||||
|
* [PrAvailability.ERROR] so a body missing the field still decodes (never throws). `state` /
|
||||||
|
* `mergeable` are lower-cased string unions on the wire — kept as raw INERT strings here (rendered
|
||||||
|
* as plain text; no enum needed for display).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
public data class PrStatus(
|
||||||
|
@Serializable(with = PrAvailabilitySerializer::class)
|
||||||
|
val availability: PrAvailability = PrAvailability.ERROR,
|
||||||
|
val number: Int? = null,
|
||||||
|
val title: String? = null,
|
||||||
|
val url: String? = null,
|
||||||
|
val state: String? = null,
|
||||||
|
val isDraft: Boolean? = null,
|
||||||
|
val mergeable: String? = null,
|
||||||
|
val headRefName: String? = null,
|
||||||
|
val baseRefName: String? = null,
|
||||||
|
val checks: PrCheckSummary? = null,
|
||||||
|
)
|
||||||
@@ -34,6 +34,12 @@ public data class ProjectInfo(
|
|||||||
val dirty: Boolean? = null,
|
val dirty: Boolean? = null,
|
||||||
/** Newest `~/.claude/projects` mtime for this cwd (ms) — the sort key. */
|
/** Newest `~/.claude/projects` mtime for this cwd (ms) — the sort key. */
|
||||||
val lastActiveMs: Long? = null,
|
val lastActiveMs: Long? = null,
|
||||||
|
/** W3 sync chip — commits on HEAD not on `@{u}` (best-effort; absent when no upstream). */
|
||||||
|
val ahead: Int? = null,
|
||||||
|
/** W3 sync chip — commits on `@{u}` not on HEAD (best-effort; absent when no upstream). */
|
||||||
|
val behind: Int? = null,
|
||||||
|
/** HEAD commit time in ms (`git log -1 --format=%ct * 1000`); absent on a fresh/empty repo. */
|
||||||
|
val lastCommitMs: Long? = null,
|
||||||
@Serializable(with = ProjectSessionRefListSerializer::class)
|
@Serializable(with = ProjectSessionRefListSerializer::class)
|
||||||
val sessions: List<ProjectSessionRef> = emptyList(),
|
val sessions: List<ProjectSessionRef> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
package wang.yaojia.webterm.api.routes
|
package wang.yaojia.webterm.api.routes
|
||||||
|
|
||||||
|
import wang.yaojia.webterm.api.models.CommitResult
|
||||||
|
import wang.yaojia.webterm.api.models.CreateWorktreeResult
|
||||||
|
import wang.yaojia.webterm.api.models.GitLogResult
|
||||||
|
import wang.yaojia.webterm.api.models.GitWriteOutcome
|
||||||
import wang.yaojia.webterm.api.models.HookDecision
|
import wang.yaojia.webterm.api.models.HookDecision
|
||||||
import wang.yaojia.webterm.api.models.LiveSessionInfo
|
import wang.yaojia.webterm.api.models.LiveSessionInfo
|
||||||
import wang.yaojia.webterm.api.models.LossyDecode
|
import wang.yaojia.webterm.api.models.LossyDecode
|
||||||
|
import wang.yaojia.webterm.api.models.PrStatus
|
||||||
import wang.yaojia.webterm.api.models.ProjectDetail
|
import wang.yaojia.webterm.api.models.ProjectDetail
|
||||||
import wang.yaojia.webterm.api.models.ProjectInfo
|
import wang.yaojia.webterm.api.models.ProjectInfo
|
||||||
|
import wang.yaojia.webterm.api.models.PruneWorktreesResult
|
||||||
|
import wang.yaojia.webterm.api.models.PushResult
|
||||||
|
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
|
||||||
import wang.yaojia.webterm.api.models.SessionPreview
|
import wang.yaojia.webterm.api.models.SessionPreview
|
||||||
|
import wang.yaojia.webterm.api.models.StageResult
|
||||||
import wang.yaojia.webterm.api.models.UiConfig
|
import wang.yaojia.webterm.api.models.UiConfig
|
||||||
import wang.yaojia.webterm.api.models.UiPrefs
|
import wang.yaojia.webterm.api.models.UiPrefs
|
||||||
|
import wang.yaojia.webterm.api.models.decodeGitError
|
||||||
|
import wang.yaojia.webterm.api.models.decodeGitPayload
|
||||||
import wang.yaojia.webterm.wire.HostEndpoint
|
import wang.yaojia.webterm.wire.HostEndpoint
|
||||||
import wang.yaojia.webterm.wire.HttpResponse
|
import wang.yaojia.webterm.wire.HttpResponse
|
||||||
import wang.yaojia.webterm.wire.HttpTransport
|
import wang.yaojia.webterm.wire.HttpTransport
|
||||||
@@ -87,6 +98,43 @@ public class ApiClient(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /projects/pr?path=` — PR + CI status for the project's current branch. The PR *degrade*
|
||||||
|
* (gh missing / unauth / no-PR / disabled) is `availability` inside a **200** body, NOT an HTTP
|
||||||
|
* status — so every valid git dir returns 200 and the chip renders from [PrStatus.availability].
|
||||||
|
* A garbled body degrades to `availability=ERROR` (tolerant decode). 400→path invalid; 404→not a
|
||||||
|
* repo. Empty path rejected client-side before any I/O.
|
||||||
|
*/
|
||||||
|
public suspend fun projectPr(path: String): PrStatus {
|
||||||
|
if (path.isEmpty()) throw ApiClientError.ProjectPathInvalid
|
||||||
|
val response = perform(Endpoints.projectPr(path))
|
||||||
|
return when (response.status) {
|
||||||
|
HttpStatus.OK -> LossyDecode.objectOrNull(response.body, PrStatus.serializer())
|
||||||
|
?: PrStatus() // availability defaults to ERROR — never throw on a bad PR body
|
||||||
|
HttpStatus.BAD_REQUEST -> throw ApiClientError.ProjectPathInvalid
|
||||||
|
HttpStatus.NOT_FOUND -> throw ApiClientError.ProjectNotFound
|
||||||
|
else -> throw ApiClientError.UnexpectedStatus(response.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /projects/log?path=[&n=]` — recent commits (list-lossy: malformed commits dropped). 400→
|
||||||
|
* path invalid; 404→not a repo; 500→[ApiClientError.GitLogUnavailable]. Empty path rejected
|
||||||
|
* client-side before any I/O; `n` is clamped in the route builder.
|
||||||
|
*/
|
||||||
|
public suspend fun projectLog(path: String, n: Int? = null): GitLogResult {
|
||||||
|
if (path.isEmpty()) throw ApiClientError.ProjectPathInvalid
|
||||||
|
val response = perform(Endpoints.projectLog(path, n))
|
||||||
|
return when (response.status) {
|
||||||
|
HttpStatus.OK -> LossyDecode.objectOrNull(response.body, GitLogResult.serializer())
|
||||||
|
?: throw ApiClientError.InvalidResponseBody
|
||||||
|
HttpStatus.BAD_REQUEST -> throw ApiClientError.ProjectPathInvalid
|
||||||
|
HttpStatus.NOT_FOUND -> throw ApiClientError.ProjectNotFound
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR -> throw ApiClientError.GitLogUnavailable
|
||||||
|
else -> throw ApiClientError.UnexpectedStatus(response.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** `GET /prefs` — the cross-device favourites/collapse blob. A non-object body throws
|
/** `GET /prefs` — the cross-device favourites/collapse blob. A non-object body throws
|
||||||
* `InvalidResponseBody` (never silently degrades — an empty-based PUT would wipe the blob). */
|
* `InvalidResponseBody` (never silently degrades — an empty-based PUT would wipe the blob). */
|
||||||
public suspend fun prefs(): UiPrefs {
|
public suspend fun prefs(): UiPrefs {
|
||||||
@@ -132,6 +180,50 @@ public class ApiClient(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── G: git-write ops (worktree + git stage/commit/push) → GitWriteOutcome ──────────────
|
||||||
|
|
||||||
|
/** `POST /projects/worktree` — create a worktree for `branch` (off optional `base`). */
|
||||||
|
public suspend fun createWorktree(path: String, branch: String, base: String? = null): GitWriteOutcome<CreateWorktreeResult> =
|
||||||
|
gitWrite(Endpoints.createWorktree(path, branch, base), CreateWorktreeResult.serializer())
|
||||||
|
|
||||||
|
/** `DELETE /projects/worktree` — remove a worktree (409 "uncommitted" unless `force`). */
|
||||||
|
public suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean = false): GitWriteOutcome<RemoveWorktreeResult> =
|
||||||
|
gitWrite(Endpoints.removeWorktree(path, worktreePath, force), RemoveWorktreeResult.serializer())
|
||||||
|
|
||||||
|
/** `POST /projects/worktree/prune` — reclaim stale worktrees (idempotent). */
|
||||||
|
public suspend fun pruneWorktrees(path: String): GitWriteOutcome<PruneWorktreesResult> =
|
||||||
|
gitWrite(Endpoints.pruneWorktrees(path), PruneWorktreesResult.serializer())
|
||||||
|
|
||||||
|
/** `POST /projects/git/stage` — stage (`stage=true`) or unstage the given files. */
|
||||||
|
public suspend fun gitStage(path: String, files: List<String>, stage: Boolean = true): GitWriteOutcome<StageResult> =
|
||||||
|
gitWrite(Endpoints.gitStage(path, files, stage), StageResult.serializer())
|
||||||
|
|
||||||
|
/** `POST /projects/git/commit` — commit the staged changes (empty sha possible). */
|
||||||
|
public suspend fun gitCommit(path: String, message: String): GitWriteOutcome<CommitResult> =
|
||||||
|
gitWrite(Endpoints.gitCommit(path, message), CommitResult.serializer())
|
||||||
|
|
||||||
|
/** `POST /projects/git/push` — push the current branch to its upstream (tighter rate limit). */
|
||||||
|
public suspend fun gitPush(path: String): GitWriteOutcome<PushResult> =
|
||||||
|
gitWrite(Endpoints.gitPush(path), PushResult.serializer())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared guarded-write dispatch + status mapping (plan §4.3): 200→[GitWriteOutcome.Ok] with the
|
||||||
|
* decoded payload; 429→[GitWriteOutcome.RateLimited]; any other 4xx/5xx→[GitWriteOutcome.Rejected]
|
||||||
|
* carrying the server's SAFE `error` string (403 is overloaded — Origin-guard AND disabled
|
||||||
|
* kill-switch both 403 — so the message, not a typed variant, is surfaced). A non-HTTP status
|
||||||
|
* (e.g. an odd 2xx/3xx) is [ApiClientError.UnexpectedStatus].
|
||||||
|
*/
|
||||||
|
private suspend fun <T> gitWrite(route: ApiRoute, serializer: kotlinx.serialization.KSerializer<T>): GitWriteOutcome<T> {
|
||||||
|
val response = perform(route)
|
||||||
|
return when (response.status) {
|
||||||
|
HttpStatus.OK -> GitWriteOutcome.Ok(decodeGitPayload(response.body, serializer))
|
||||||
|
HttpStatus.TOO_MANY_REQUESTS -> GitWriteOutcome.RateLimited
|
||||||
|
in HttpStatus.CLIENT_ERROR_MIN..HttpStatus.SERVER_ERROR_MAX ->
|
||||||
|
GitWriteOutcome.Rejected(response.status, decodeGitError(response.body))
|
||||||
|
else -> throw ApiClientError.UnexpectedStatus(response.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** `POST /push/fcm-token` — register this device's FCM token (idempotent upsert → 204). Invalid
|
/** `POST /push/fcm-token` — register this device's FCM token (idempotent upsert → 204). Invalid
|
||||||
* tokens are rejected client-side (`InvalidFcmToken`) before any network I/O. */
|
* tokens are rejected client-side (`InvalidFcmToken`) before any network I/O. */
|
||||||
public suspend fun registerFcmToken(token: String) {
|
public suspend fun registerFcmToken(token: String) {
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ public sealed class ApiClientError(public val userMessage: String) : Exception(u
|
|||||||
/** 500 from `GET /projects/detail` — the server failed reading the repo. */
|
/** 500 from `GET /projects/detail` — the server failed reading the repo. */
|
||||||
public data object ProjectDetailUnavailable : ApiClientError("读取项目详情失败,请稍后再试。")
|
public data object ProjectDetailUnavailable : ApiClientError("读取项目详情失败,请稍后再试。")
|
||||||
|
|
||||||
|
/** 500 from `GET /projects/log` — the server failed reading the git log. */
|
||||||
|
public data object GitLogUnavailable : ApiClientError("读取提交记录失败,请稍后再试。")
|
||||||
|
|
||||||
/** Any other non-success status code. */
|
/** Any other non-success status code. */
|
||||||
public data class UnexpectedStatus(val status: Int) : ApiClientError("服务器返回了意外状态码 $status。")
|
public data class UnexpectedStatus(val status: Int) : ApiClientError("服务器返回了意外状态码 $status。")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ internal object HttpStatus {
|
|||||||
const val NOT_FOUND = 404
|
const val NOT_FOUND = 404
|
||||||
const val TOO_MANY_REQUESTS = 429
|
const val TOO_MANY_REQUESTS = 429
|
||||||
const val INTERNAL_SERVER_ERROR = 500
|
const val INTERNAL_SERVER_ERROR = 500
|
||||||
|
|
||||||
|
/** Inclusive bounds of the 4xx/5xx band a guarded-write maps to a `Rejected` outcome. */
|
||||||
|
const val CLIENT_ERROR_MIN = 400
|
||||||
|
const val SERVER_ERROR_MAX = 599
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Header / content-type names (no magic strings inline). */
|
/** Header / content-type names (no magic strings inline). */
|
||||||
|
|||||||
@@ -57,6 +57,28 @@ internal object Endpoints {
|
|||||||
fun getPrefs(): ApiRoute =
|
fun getPrefs(): ApiRoute =
|
||||||
ApiRoute(HttpMethod.GET, "/prefs", OriginPolicy.READ_ONLY)
|
ApiRoute(HttpMethod.GET, "/prefs", OriginPolicy.READ_ONLY)
|
||||||
|
|
||||||
|
/** `GET /projects/pr?path=` — RO PR + CI status. `path` strict-percent-encoded (as detail). */
|
||||||
|
fun projectPr(path: String): ApiRoute =
|
||||||
|
ApiRoute(
|
||||||
|
HttpMethod.GET,
|
||||||
|
"/projects/pr",
|
||||||
|
OriginPolicy.READ_ONLY,
|
||||||
|
percentEncodedQuery = "path=${percentEncode(path)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /projects/log?path=[&n=<int>]` — RO recent-commit log. `n` is clamped client-side to
|
||||||
|
* `1..GIT_LOG_MAX` (the server re-clamps regardless); a null/out-of-range `n` omits the param.
|
||||||
|
*/
|
||||||
|
fun projectLog(path: String, n: Int?): ApiRoute {
|
||||||
|
val query = StringBuilder("path=").append(percentEncode(path))
|
||||||
|
if (n != null) {
|
||||||
|
val clamped = n.coerceIn(1, GIT_LOG_MAX)
|
||||||
|
query.append("&n=").append(clamped)
|
||||||
|
}
|
||||||
|
return ApiRoute(HttpMethod.GET, "/projects/log", OriginPolicy.READ_ONLY, percentEncodedQuery = query.toString())
|
||||||
|
}
|
||||||
|
|
||||||
// ── G ────────────────────────────────────────────────────────────────────────────────
|
// ── G ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fun killSession(id: UUID): ApiRoute =
|
fun killSession(id: UUID): ApiRoute =
|
||||||
@@ -92,6 +114,58 @@ internal object Endpoints {
|
|||||||
|
|
||||||
private const val FCM_TOKEN_PATH = "/push/fcm-token"
|
private const val FCM_TOKEN_PATH = "/push/fcm-token"
|
||||||
|
|
||||||
|
// ── G: worktree write (create / remove / prune) ────────────────────────────────────────
|
||||||
|
|
||||||
|
/** `POST /projects/worktree` — `{ path, branch[, base] }`. `base` omitted when null. */
|
||||||
|
fun createWorktree(path: String, branch: String, base: String?): ApiRoute =
|
||||||
|
jsonBodyRoute(
|
||||||
|
HttpMethod.POST,
|
||||||
|
"/projects/worktree",
|
||||||
|
CreateWorktreeBody.serializer(),
|
||||||
|
CreateWorktreeBody(path, branch, base),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `DELETE /projects/worktree` — `{ path, worktreePath, force }` (DELETE **with** a JSON body). */
|
||||||
|
fun removeWorktree(path: String, worktreePath: String, force: Boolean): ApiRoute =
|
||||||
|
jsonBodyRoute(
|
||||||
|
HttpMethod.DELETE,
|
||||||
|
"/projects/worktree",
|
||||||
|
RemoveWorktreeBody.serializer(),
|
||||||
|
RemoveWorktreeBody(path, worktreePath, force),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `POST /projects/worktree/prune` — `{ path }`. */
|
||||||
|
fun pruneWorktrees(path: String): ApiRoute =
|
||||||
|
jsonBodyRoute(HttpMethod.POST, "/projects/worktree/prune", PruneBody.serializer(), PruneBody(path))
|
||||||
|
|
||||||
|
// ── G: git write (stage / commit / push) ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** `POST /projects/git/stage` — `{ path, files, stage }`. */
|
||||||
|
fun gitStage(path: String, files: List<String>, stage: Boolean): ApiRoute =
|
||||||
|
jsonBodyRoute(HttpMethod.POST, "/projects/git/stage", StageBody.serializer(), StageBody(path, files, stage))
|
||||||
|
|
||||||
|
/** `POST /projects/git/commit` — `{ path, message }`. */
|
||||||
|
fun gitCommit(path: String, message: String): ApiRoute =
|
||||||
|
jsonBodyRoute(HttpMethod.POST, "/projects/git/commit", CommitBody.serializer(), CommitBody(path, message))
|
||||||
|
|
||||||
|
/** `POST /projects/git/push` — `{ path }`. */
|
||||||
|
fun gitPush(path: String): ApiRoute =
|
||||||
|
jsonBodyRoute(HttpMethod.POST, "/projects/git/push", PushBody.serializer(), PushBody(path))
|
||||||
|
|
||||||
|
/** Build a GUARDED route with a `ModelJson`-encoded JSON body (Origin stamped in [ApiRoute]). */
|
||||||
|
private fun <T> jsonBodyRoute(
|
||||||
|
method: HttpMethod,
|
||||||
|
path: String,
|
||||||
|
serializer: kotlinx.serialization.KSerializer<T>,
|
||||||
|
value: T,
|
||||||
|
): ApiRoute {
|
||||||
|
val body = ModelJson.encodeToString(serializer, value).encodeToByteArray()
|
||||||
|
return ApiRoute(method, path, OriginPolicy.GUARDED, body = body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirror of `src/http/git-log.ts` `GIT_LOG_MAX` — the server-side `?n=` clamp ceiling. */
|
||||||
|
private const val GIT_LOG_MAX = 50
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server session ids are lowercase `crypto.randomUUID()` strings and `:id` route params are
|
* Server session ids are lowercase `crypto.randomUUID()` strings and `:id` route params are
|
||||||
* matched as EXACT strings — always serialize lowercase. `UUID.toString()` is already lowercase
|
* matched as EXACT strings — always serialize lowercase. `UUID.toString()` is already lowercase
|
||||||
@@ -124,4 +198,22 @@ internal object Endpoints {
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
private data class FcmTokenBody(val token: String)
|
private data class FcmTokenBody(val token: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class CreateWorktreeBody(val path: String, val branch: String, val base: String? = null)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class RemoveWorktreeBody(val path: String, val worktreePath: String, val force: Boolean)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class PruneBody(val path: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class StageBody(val path: String, val files: List<String>, val stage: Boolean)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class CommitBody(val path: String, val message: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class PushBody(val path: String)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package wang.yaojia.webterm.api.enroll
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertArrayEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertThrows
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.security.KeyPairGenerator
|
||||||
|
import java.security.Signature
|
||||||
|
import java.security.interfaces.ECPublicKey
|
||||||
|
import java.security.spec.ECGenParameterSpec
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B4 · Proves the manual PKCS#10 encoder produces a well-formed, self-signed P-256 CSR that the
|
||||||
|
* control-plane `verifyCsrPoPEc` (id-ecPublicKey + prime256v1 SPKI, ecdsa-with-SHA256
|
||||||
|
* self-signature) accepts. Runs headless with a SOFTWARE P-256 key via the SAME
|
||||||
|
* `Signature("SHA256withECDSA")` path the on-device AndroidKeyStore key uses — so the signing path
|
||||||
|
* is byte-identical. Real StrongBox keygen is device-only (`:client-tls-android`).
|
||||||
|
*/
|
||||||
|
class CertificateSigningRequestTest {
|
||||||
|
/** Software P-256 signer via the SAME JCA `SHA256withECDSA` path used on-device (no StrongBox). */
|
||||||
|
private class SoftwareEcSigner : CsrSigner {
|
||||||
|
val keyPair = KeyPairGenerator.getInstance("EC").apply {
|
||||||
|
initialize(ECGenParameterSpec("secp256r1"))
|
||||||
|
}.generateKeyPair()
|
||||||
|
|
||||||
|
override fun publicKeyX963(): ByteArray = EcPointEncoding.x963(keyPair.public as ECPublicKey)
|
||||||
|
|
||||||
|
override fun sign(message: ByteArray): ByteArray =
|
||||||
|
Signature.getInstance("SHA256withECDSA").apply {
|
||||||
|
initSign(keyPair.private)
|
||||||
|
update(message)
|
||||||
|
}.sign()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun csrIsCanonicalPkcs10SequenceOfExactlyThreeElements() {
|
||||||
|
val signer = SoftwareEcSigner()
|
||||||
|
|
||||||
|
val der = CertificateSigningRequest.der("web-terminal-device", signer)
|
||||||
|
|
||||||
|
val outer = TestDer.read(der, 0)!!
|
||||||
|
assertEquals(0x30, outer.tag, "outer CertificationRequest is a SEQUENCE")
|
||||||
|
assertEquals(der.size, outer.end, "no trailing garbage after the CSR")
|
||||||
|
val parts = TestDer.children(der, outer)
|
||||||
|
assertEquals(3, parts.size)
|
||||||
|
assertEquals(0x30, parts[0].tag) // certificationRequestInfo
|
||||||
|
assertEquals(0x30, parts[1].tag) // signatureAlgorithm
|
||||||
|
assertEquals(0x03, parts[2].tag) // signature BIT STRING
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun csrSelfSignatureVerifiesAgainstTheEmbeddedP256Key() {
|
||||||
|
val signer = SoftwareEcSigner()
|
||||||
|
|
||||||
|
val der = CertificateSigningRequest.der("web-terminal-device", signer)
|
||||||
|
|
||||||
|
// Extract the exact CertificationRequestInfo bytes that were signed and the ECDSA signature
|
||||||
|
// (the same crypto check verifyCsrPoPEc's req.verify() runs).
|
||||||
|
val outer = TestDer.read(der, 0)!!
|
||||||
|
val parts = TestDer.children(der, outer)
|
||||||
|
val infoBytes = der.copyOfRange(parts[0].start, parts[0].end)
|
||||||
|
val bitString = parts[2] // BIT STRING: first content byte is unused-bits (0x00)
|
||||||
|
val signature = der.copyOfRange(bitString.valueStart + 1, bitString.valueEnd)
|
||||||
|
|
||||||
|
val ok = Signature.getInstance("SHA256withECDSA").apply {
|
||||||
|
initVerify(signer.keyPair.public)
|
||||||
|
update(infoBytes)
|
||||||
|
}.verify(signature)
|
||||||
|
assertTrue(ok, "the CSR self-signature must verify against its own SubjectPublicKeyInfo")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun csrEmbedsAP256SubjectPublicKeyInfoTheServerVerifierAccepts() {
|
||||||
|
val signer = SoftwareEcSigner()
|
||||||
|
val point = signer.publicKeyX963()
|
||||||
|
|
||||||
|
val der = CertificateSigningRequest.der("web-terminal-device", signer)
|
||||||
|
|
||||||
|
val outer = TestDer.read(der, 0)!!
|
||||||
|
val info = TestDer.children(der, outer)[0]
|
||||||
|
val infoChildren = TestDer.children(der, info)
|
||||||
|
assertEquals(4, infoChildren.size)
|
||||||
|
// version v1(0)
|
||||||
|
assertArrayEquals(byteArrayOf(0x02, 0x01, 0x00), der.copyOfRange(infoChildren[0].start, infoChildren[0].end))
|
||||||
|
assertEquals(0xA0, infoChildren[3].tag) // [0] IMPLICIT attributes
|
||||||
|
assertEquals(0, infoChildren[3].valueEnd - infoChildren[3].valueStart) // empty SET
|
||||||
|
|
||||||
|
// subjectPublicKeyInfo ::= SEQUENCE { AlgorithmIdentifier, BIT STRING point }
|
||||||
|
val spki = infoChildren[2]
|
||||||
|
val spkiChildren = TestDer.children(der, spki)
|
||||||
|
assertEquals(2, spkiChildren.size)
|
||||||
|
val algIdChildren = TestDer.children(der, spkiChildren[0])
|
||||||
|
// AlgorithmIdentifier { id-ecPublicKey, prime256v1 } — the exact OIDs verifyCsrPoPEc pins.
|
||||||
|
assertArrayEquals(
|
||||||
|
byteArrayOf(0x06, 0x07, 0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x02, 0x01),
|
||||||
|
der.copyOfRange(algIdChildren[0].start, algIdChildren[0].end),
|
||||||
|
)
|
||||||
|
assertArrayEquals(
|
||||||
|
byteArrayOf(0x06, 0x08, 0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x03, 0x01, 0x07),
|
||||||
|
der.copyOfRange(algIdChildren[1].start, algIdChildren[1].end),
|
||||||
|
)
|
||||||
|
// BIT STRING content = 0x00 unused-bits + the exact 65-byte point.
|
||||||
|
val bitString = spkiChildren[1]
|
||||||
|
assertEquals(0x03, bitString.tag)
|
||||||
|
assertEquals(0x00, der[bitString.valueStart].toInt() and 0xFF)
|
||||||
|
assertArrayEquals(point, der.copyOfRange(bitString.valueStart + 1, bitString.valueEnd))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun signatureAlgorithmIsEcdsaWithSha256() {
|
||||||
|
val signer = SoftwareEcSigner()
|
||||||
|
val der = CertificateSigningRequest.der("web-terminal-device", signer)
|
||||||
|
val outer = TestDer.read(der, 0)!!
|
||||||
|
val algId = TestDer.children(der, outer)[1]
|
||||||
|
val oid = TestDer.children(der, algId)[0]
|
||||||
|
assertArrayEquals(
|
||||||
|
byteArrayOf(0x06, 0x08, 0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x04, 0x03, 0x02),
|
||||||
|
der.copyOfRange(oid.start, oid.end),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun subjectCommonNameIsEncodedAsUtf8String() {
|
||||||
|
val signer = SoftwareEcSigner()
|
||||||
|
val der = CertificateSigningRequest.der("my-pixel", signer)
|
||||||
|
val outer = TestDer.read(der, 0)!!
|
||||||
|
val info = TestDer.children(der, outer)[0]
|
||||||
|
val name = TestDer.children(der, info)[1] // subject Name
|
||||||
|
val rdn = TestDer.children(der, name)[0] // SET
|
||||||
|
val attr = TestDer.children(der, rdn)[0] // SEQUENCE { OID, value }
|
||||||
|
val attrChildren = TestDer.children(der, attr)
|
||||||
|
// OID 2.5.4.3 (commonName), then a UTF8String (tag 0x0C) carrying the CN bytes.
|
||||||
|
assertArrayEquals(byteArrayOf(0x06, 0x03, 0x55, 0x04, 0x03), der.copyOfRange(attrChildren[0].start, attrChildren[0].end))
|
||||||
|
assertEquals(0x0C, attrChildren[1].tag)
|
||||||
|
assertArrayEquals("my-pixel".toByteArray(), der.copyOfRange(attrChildren[1].valueStart, attrChildren[1].valueEnd))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun emptySubjectCommonNameIsRejected() {
|
||||||
|
val signer = SoftwareEcSigner()
|
||||||
|
assertThrows(CsrException.InvalidSubject::class.java) {
|
||||||
|
CertificateSigningRequest.der("", signer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aNon65BytePublicKeyIsRejected() {
|
||||||
|
val badSigner = object : CsrSigner {
|
||||||
|
override fun publicKeyX963(): ByteArray = ByteArray(64) { 0x04 } // wrong length
|
||||||
|
override fun sign(message: ByteArray): ByteArray = ByteArray(0)
|
||||||
|
}
|
||||||
|
assertThrows(CsrException.InvalidPublicKey::class.java) {
|
||||||
|
CertificateSigningRequest.der("d", badSigner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aPublicKeyWithoutTheUncompressedPrefixIsRejected() {
|
||||||
|
val badSigner = object : CsrSigner {
|
||||||
|
override fun publicKeyX963(): ByteArray = ByteArray(65) { 0x02 } // right length, wrong prefix
|
||||||
|
override fun sign(message: ByteArray): ByteArray = ByteArray(0)
|
||||||
|
}
|
||||||
|
assertThrows(CsrException.InvalidPublicKey::class.java) {
|
||||||
|
CertificateSigningRequest.der("d", badSigner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A throwaway canonical-DER reader for structural assertions (the enroll path itself does no DER
|
||||||
|
* parsing — the server verifies; this mirrors the iOS `TestDER` test helper).
|
||||||
|
*/
|
||||||
|
internal object TestDer {
|
||||||
|
data class Element(val tag: Int, val start: Int, val valueStart: Int, val valueEnd: Int) {
|
||||||
|
val end: Int get() = valueEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
fun read(bytes: ByteArray, start: Int): Element? {
|
||||||
|
if (start < 0 || start + 1 >= bytes.size) return null
|
||||||
|
val tag = bytes[start].toInt() and 0xFF
|
||||||
|
var index = start + 1
|
||||||
|
val first = bytes[index].toInt() and 0xFF
|
||||||
|
index += 1
|
||||||
|
var length = 0
|
||||||
|
if (first and 0x80 == 0) {
|
||||||
|
length = first
|
||||||
|
} else {
|
||||||
|
val count = first and 0x7F
|
||||||
|
if (count == 0 || count > 4 || index + count > bytes.size) return null
|
||||||
|
repeat(count) {
|
||||||
|
length = (length shl 8) or (bytes[index].toInt() and 0xFF)
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val valueEnd = index + length
|
||||||
|
if (valueEnd > bytes.size) return null
|
||||||
|
return Element(tag = tag, start = start, valueStart = index, valueEnd = valueEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun children(bytes: ByteArray, parent: Element): List<Element> {
|
||||||
|
val elements = mutableListOf<Element>()
|
||||||
|
var index = parent.valueStart
|
||||||
|
while (index < parent.valueEnd) {
|
||||||
|
val element = read(bytes, index) ?: break
|
||||||
|
elements.add(element)
|
||||||
|
index = element.valueEnd
|
||||||
|
}
|
||||||
|
return elements
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
package wang.yaojia.webterm.api.enroll
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
||||||
|
import wang.yaojia.webterm.wire.HttpMethod
|
||||||
|
import wang.yaojia.webterm.wire.HttpRequest
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.Base64
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B4 · DeviceEnrollmentClient request-building + response-mapping against the pinned login/enroll
|
||||||
|
* contract, driven by the shared `FakeHttpTransport` (no network). Mirrors the iOS
|
||||||
|
* `DeviceEnrollmentClientTests`, extended with the login step.
|
||||||
|
*/
|
||||||
|
class DeviceEnrollmentClientTest {
|
||||||
|
private companion object {
|
||||||
|
const val BASE = "https://cp.terminal.yaojia.wang"
|
||||||
|
const val BEARER = "device-enroll-token-abc"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val transport = FakeHttpTransport()
|
||||||
|
private val client = DeviceEnrollmentClient(BASE, transport)
|
||||||
|
|
||||||
|
private fun bodyObject(request: HttpRequest): JsonObject =
|
||||||
|
Json.parseToJsonElement(request.body!!.decodeToString()) as JsonObject
|
||||||
|
|
||||||
|
private fun enrollResponse(
|
||||||
|
deviceId: String = "dev-1",
|
||||||
|
cert: ByteArray = byteArrayOf(0x30, 0x01, 0x02),
|
||||||
|
caChain: List<ByteArray> = listOf(byteArrayOf(0x30, 0xAA.toByte())),
|
||||||
|
notBefore: String = "2026-07-08T00:00:00.000Z",
|
||||||
|
notAfter: String = "2026-10-06T00:00:00.000Z",
|
||||||
|
renewAfter: String = "2026-09-05T00:00:00.000Z",
|
||||||
|
): ByteArray {
|
||||||
|
val b64 = Base64.getEncoder()
|
||||||
|
val chainJson = caChain.joinToString(",") { "\"${b64.encodeToString(it)}\"" }
|
||||||
|
return """
|
||||||
|
{"deviceId":"$deviceId","cert":"${b64.encodeToString(cert)}","caChain":[$chainJson],
|
||||||
|
"notBefore":"$notBefore","notAfter":"$notAfter","renewAfter":"$renewAfter"}
|
||||||
|
""".trimIndent().toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── login ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun loginPostsPasswordAndMapsThe201Bearer() = runTest {
|
||||||
|
transport.queueSuccess(
|
||||||
|
method = HttpMethod.POST,
|
||||||
|
url = "$BASE/auth/login",
|
||||||
|
status = 201,
|
||||||
|
body = """{"enrollToken":"tok-xyz","accountId":"acct-1","expiresIn":600}""".toByteArray(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = client.login("hunter2")
|
||||||
|
|
||||||
|
val request = transport.recordedRequests.single()
|
||||||
|
assertEquals(HttpMethod.POST, request.method)
|
||||||
|
assertEquals("$BASE/auth/login", request.url)
|
||||||
|
assertEquals("application/json", request.headers["Content-Type"])
|
||||||
|
assertNull(request.headers["Authorization"], "login carries no bearer")
|
||||||
|
assertEquals("hunter2", bodyObject(request)["password"]!!.jsonPrimitive.content)
|
||||||
|
|
||||||
|
assertEquals("tok-xyz", result.enrollToken)
|
||||||
|
assertEquals("acct-1", result.accountId)
|
||||||
|
assertEquals(600L, result.expiresInSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun loginRejectsAnEmptyPasswordBeforeAnyNetworkIo() = runTest {
|
||||||
|
val error = runCatching { client.login("") }.exceptionOrNull()
|
||||||
|
assertEquals(DeviceEnrollmentError.InvalidRequest, error)
|
||||||
|
assertTrue(transport.recordedRequests.isEmpty(), "must not hit the network for an empty password")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun loginSurfacesA401AsHttpWithTheServerCode() = runTest {
|
||||||
|
transport.queueSuccess(
|
||||||
|
method = HttpMethod.POST,
|
||||||
|
url = "$BASE/auth/login",
|
||||||
|
status = 401,
|
||||||
|
body = """{"error":"rejected"}""".toByteArray(),
|
||||||
|
)
|
||||||
|
val error = runCatching { client.login("wrong") }.exceptionOrNull()
|
||||||
|
assertEquals(DeviceEnrollmentError.Http(401, "rejected"), error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── enroll ───────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun enrollBuildsABearerAuthenticatedPostWithTheContractBody() = runTest {
|
||||||
|
transport.queueSuccess(
|
||||||
|
method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201, body = enrollResponse(),
|
||||||
|
)
|
||||||
|
val csr = byteArrayOf(0xDE.toByte(), 0xAD.toByte(), 0xBE.toByte(), 0xEF.toByte())
|
||||||
|
|
||||||
|
client.enroll(BEARER, csr, subdomain = "alice", deviceName = "Alice Pixel")
|
||||||
|
|
||||||
|
val request = transport.recordedRequests.single()
|
||||||
|
assertEquals(HttpMethod.POST, request.method)
|
||||||
|
assertEquals("$BASE/device/enroll", request.url)
|
||||||
|
assertEquals("Bearer $BEARER", request.headers["Authorization"])
|
||||||
|
assertEquals("application/json", request.headers["Content-Type"])
|
||||||
|
|
||||||
|
val obj = bodyObject(request)
|
||||||
|
assertEquals(Base64.getEncoder().encodeToString(csr), obj["csr"]!!.jsonPrimitive.content)
|
||||||
|
assertEquals("ec-p256", obj["keyAlg"]!!.jsonPrimitive.content)
|
||||||
|
assertEquals("alice", obj["subdomain"]!!.jsonPrimitive.content)
|
||||||
|
assertEquals("Alice Pixel", obj["deviceName"]!!.jsonPrimitive.content)
|
||||||
|
assertFalse(obj.containsKey("attestation"), "attestation is omitted when not provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun enrollForwardsAttestationWhenProvided() = runTest {
|
||||||
|
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201, body = enrollResponse())
|
||||||
|
client.enroll(BEARER, byteArrayOf(0x01), "a", "d", attestation = "attest-blob")
|
||||||
|
assertEquals("attest-blob", bodyObject(transport.recordedRequests.single())["attestation"]!!.jsonPrimitive.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun enrollMapsA201IntoATypedResult() = runTest {
|
||||||
|
val cert = byteArrayOf(0x30, 0x82.toByte(), 0x01, 0x23)
|
||||||
|
val ca = byteArrayOf(0x30, 0x82.toByte(), 0x02, 0x00)
|
||||||
|
transport.queueSuccess(
|
||||||
|
method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201,
|
||||||
|
body = enrollResponse(deviceId = "dev-xyz", cert = cert, caChain = listOf(ca)),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = client.enroll(BEARER, byteArrayOf(0x01), "alice", "Pixel")
|
||||||
|
|
||||||
|
assertEquals("dev-xyz", result.deviceId)
|
||||||
|
assertArrayEquals(cert, result.certificate)
|
||||||
|
assertEquals(1, result.caChain.size)
|
||||||
|
assertArrayEquals(ca, result.caChain.single())
|
||||||
|
assertTrue(result.renewAfter!!.isBefore(result.notAfter))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun enrollRejectsEmptyRequiredFieldsBeforeAnyNetworkIo() = runTest {
|
||||||
|
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.enroll("", byteArrayOf(1), "a", "d") }.exceptionOrNull())
|
||||||
|
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.enroll(BEARER, ByteArray(0), "a", "d") }.exceptionOrNull())
|
||||||
|
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.enroll(BEARER, byteArrayOf(1), "", "d") }.exceptionOrNull())
|
||||||
|
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.enroll(BEARER, byteArrayOf(1), "a", "") }.exceptionOrNull())
|
||||||
|
assertTrue(transport.recordedRequests.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun enrollSurfacesA403SubdomainNotOwnedWithTheServerCode() = runTest {
|
||||||
|
transport.queueSuccess(
|
||||||
|
method = HttpMethod.POST, url = "$BASE/device/enroll", status = 403,
|
||||||
|
body = """{"error":"rejected"}""".toByteArray(),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
DeviceEnrollmentError.Http(403, "rejected"),
|
||||||
|
runCatching { client.enroll(BEARER, byteArrayOf(1), "bob", "d") }.exceptionOrNull(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun enrollSurfacesA429RateLimited() = runTest {
|
||||||
|
transport.queueSuccess(
|
||||||
|
method = HttpMethod.POST, url = "$BASE/device/enroll", status = 429,
|
||||||
|
body = """{"error":"rate_limited"}""".toByteArray(),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
DeviceEnrollmentError.Http(429, "rate_limited"),
|
||||||
|
runCatching { client.enroll(BEARER, byteArrayOf(1), "a", "d") }.exceptionOrNull(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun enrollThrowsMalformedResponseOnANonJson201Body() = runTest {
|
||||||
|
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201, body = "not json".toByteArray())
|
||||||
|
assertEquals(
|
||||||
|
DeviceEnrollmentError.MalformedResponse,
|
||||||
|
runCatching { client.enroll(BEARER, byteArrayOf(1), "a", "d") }.exceptionOrNull(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun enrollThrowsMalformedResponseWhenTheCertIsNotValidBase64() = runTest {
|
||||||
|
transport.queueSuccess(
|
||||||
|
method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201,
|
||||||
|
body = """{"deviceId":"d","cert":"@@not-base64@@","caChain":[]}""".toByteArray(),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
DeviceEnrollmentError.MalformedResponse,
|
||||||
|
runCatching { client.enroll(BEARER, byteArrayOf(1), "a", "d") }.exceptionOrNull(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun enrollDegradesAbsentDatesToNull() = runTest {
|
||||||
|
transport.queueSuccess(
|
||||||
|
method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201,
|
||||||
|
body = """{"deviceId":"d","cert":"MAEC","caChain":[]}""".toByteArray(),
|
||||||
|
)
|
||||||
|
val result = client.enroll(BEARER, byteArrayOf(1), "a", "d")
|
||||||
|
assertNull(result.notAfter)
|
||||||
|
assertNull(result.renewAfter)
|
||||||
|
assertFalse(result.isRenewalDue(Instant.parse("2030-01-01T00:00:00Z")), "absent renewAfter never triggers")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── renew (silent rotation seam — mTLS-only, NO bearer) ─────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun renewTargetsDeviceIdRenewWithTheMinimalBodyAndNoAuthorizationHeader() = runTest {
|
||||||
|
transport.queueSuccess(
|
||||||
|
method = HttpMethod.POST, url = "$BASE/device/dev-9/renew", status = 201, body = enrollResponse(deviceId = "dev-9"),
|
||||||
|
)
|
||||||
|
val csr = byteArrayOf(0x02)
|
||||||
|
|
||||||
|
// Production renew passes NO bearer — the endpoint authenticates by the current device cert (mTLS).
|
||||||
|
val result = client.renew("dev-9", csr)
|
||||||
|
|
||||||
|
val request = transport.recordedRequests.single()
|
||||||
|
assertEquals("$BASE/device/dev-9/renew", request.url)
|
||||||
|
assertNull(request.headers["Authorization"], "renew authenticates by mTLS — it must send NO Authorization header")
|
||||||
|
val obj = bodyObject(request)
|
||||||
|
assertEquals(Base64.getEncoder().encodeToString(csr), obj["csr"]!!.jsonPrimitive.content)
|
||||||
|
// The server's /device/:id/renew authenticates by the presented mTLS device cert and its body
|
||||||
|
// schema is `{ csr }` ONLY (.strict()) — any enroll-only extra (keyAlg/subdomain/deviceName)
|
||||||
|
// is rejected. The renew wire body must therefore carry the single `csr` key and nothing else.
|
||||||
|
assertEquals(setOf("csr"), obj.keys, "renew body is {csr}-only — no keyAlg/subdomain/deviceName")
|
||||||
|
assertFalse(obj.containsKey("keyAlg"), "renew must not send the enroll-only keyAlg field")
|
||||||
|
assertFalse(obj.containsKey("subdomain"), "renew body carries no subdomain/deviceName")
|
||||||
|
assertEquals("dev-9", result.deviceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun renewForwardsAnExplicitBearerWhenTheOptionalSeamIsUsed() = runTest {
|
||||||
|
transport.queueSuccess(
|
||||||
|
method = HttpMethod.POST, url = "$BASE/device/dev-9/renew", status = 201, body = enrollResponse(deviceId = "dev-9"),
|
||||||
|
)
|
||||||
|
|
||||||
|
// The bearer is optional/absent by default; when a caller DOES pass one it rides as a header.
|
||||||
|
client.renew("dev-9", byteArrayOf(0x02), bearerToken = BEARER)
|
||||||
|
|
||||||
|
assertEquals("Bearer $BEARER", transport.recordedRequests.single().headers["Authorization"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun renewRejectsEmptyDeviceIdOrCsrBeforeAnyNetworkIo() = runTest {
|
||||||
|
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.renew("", byteArrayOf(1)) }.exceptionOrNull())
|
||||||
|
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.renew("d", ByteArray(0)) }.exceptionOrNull())
|
||||||
|
assertTrue(transport.recordedRequests.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── isRenewalDue seam ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isRenewalDueFlipsAtRenewAfter() = runTest {
|
||||||
|
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201, body = enrollResponse())
|
||||||
|
val result = client.enroll(BEARER, byteArrayOf(1), "a", "d")
|
||||||
|
assertFalse(result.isRenewalDue(Instant.parse("2026-09-04T00:00:00Z")))
|
||||||
|
assertTrue(result.isRenewalDue(Instant.parse("2026-09-06T00:00:00Z")))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertArrayEquals(expected: ByteArray, actual: ByteArray) =
|
||||||
|
org.junit.jupiter.api.Assertions.assertArrayEquals(expected, actual)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package wang.yaojia.webterm.api.enroll
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertThrows
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.math.BigInteger
|
||||||
|
import java.security.KeyPairGenerator
|
||||||
|
import java.security.interfaces.ECPublicKey
|
||||||
|
import java.security.spec.ECGenParameterSpec
|
||||||
|
|
||||||
|
/** B4 · The X9.63 uncompressed-point encoder — the security-load-bearing SubjectPublicKeyInfo bytes. */
|
||||||
|
class EcPointEncodingTest {
|
||||||
|
@Test
|
||||||
|
fun encodesAGeneratedP256KeyAs65UncompressedBytesRoundTrippingToTheCoordinates() {
|
||||||
|
val kp = KeyPairGenerator.getInstance("EC").apply {
|
||||||
|
initialize(ECGenParameterSpec("secp256r1"))
|
||||||
|
}.generateKeyPair()
|
||||||
|
val pub = kp.public as ECPublicKey
|
||||||
|
|
||||||
|
val encoded = EcPointEncoding.x963(pub)
|
||||||
|
|
||||||
|
assertEquals(65, encoded.size, "0x04 || X(32) || Y(32)")
|
||||||
|
assertEquals(0x04, encoded[0].toInt() and 0xFF, "uncompressed-point prefix")
|
||||||
|
// The 32-byte big-endian halves must be exactly the affine coordinates.
|
||||||
|
val x = BigInteger(1, encoded.copyOfRange(1, 33))
|
||||||
|
val y = BigInteger(1, encoded.copyOfRange(33, 65))
|
||||||
|
assertEquals(pub.w.affineX, x)
|
||||||
|
assertEquals(pub.w.affineY, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun leftPadsAShortCoordinateToTheFixedWidth() {
|
||||||
|
// A small value must be left-padded with leading zeros to exactly 32 bytes.
|
||||||
|
val padded = EcPointEncoding.toFixedLengthUnsigned(BigInteger.valueOf(1), 32)
|
||||||
|
assertEquals(32, padded.size)
|
||||||
|
assertEquals(1, padded[31].toInt())
|
||||||
|
assertEquals(0, padded[0].toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dropsTheBigIntegerSignByteWhenPresent() {
|
||||||
|
// A value whose top bit is set carries a leading 0x00 sign byte in BigInteger.toByteArray();
|
||||||
|
// it must be dropped, not counted toward the width.
|
||||||
|
val highBit = BigInteger(1, ByteArray(32) { 0xFF.toByte() })
|
||||||
|
val encoded = EcPointEncoding.toFixedLengthUnsigned(highBit, 32)
|
||||||
|
assertEquals(32, encoded.size)
|
||||||
|
assertEquals(0xFF, encoded[0].toInt() and 0xFF)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectsACoordinateThatDoesNotFit() {
|
||||||
|
val tooBig = BigInteger.ONE.shiftLeft(256) // needs 33 bytes
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
EcPointEncoding.toFixedLengthUnsigned(tooBig, 32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package wang.yaojia.webterm.api.models
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GitLogResult list-lossy decode (plan Phase A.2): a well-formed `{commits,truncated}` decodes; a
|
||||||
|
* commit missing `hash`/`at` is dropped while its siblings survive; `truncated` passes through; a
|
||||||
|
* subject-less commit still decodes (subject defaults to empty).
|
||||||
|
*/
|
||||||
|
class GitLogTest {
|
||||||
|
|
||||||
|
private fun decode(json: String): GitLogResult? =
|
||||||
|
LossyDecode.objectOrNull(json.toByteArray(), GitLogResult.serializer())
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `decodes commits and truncated`() {
|
||||||
|
val json = """
|
||||||
|
{ "truncated": true, "commits": [
|
||||||
|
{ "hash":"abc123", "at": 1710000000000, "subject":"first" },
|
||||||
|
{ "hash":"def456", "at": 1710000005000, "subject":"second" }
|
||||||
|
] }
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val result = decode(json)!!
|
||||||
|
assertTrue(result.truncated)
|
||||||
|
assertEquals(2, result.commits.size)
|
||||||
|
assertEquals("abc123", result.commits[0].hash)
|
||||||
|
assertEquals(1710000000000L, result.commits[0].at)
|
||||||
|
assertEquals("first", result.commits[0].subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `drops a commit missing hash or at, keeping the rest`() {
|
||||||
|
val json = """
|
||||||
|
{ "truncated": false, "commits": [
|
||||||
|
{ "at": 1, "subject":"no hash" },
|
||||||
|
{ "hash":"keep", "at": 2, "subject":"kept" },
|
||||||
|
{ "hash":"noAt", "subject":"no at" }
|
||||||
|
] }
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val result = decode(json)!!
|
||||||
|
assertFalse(result.truncated)
|
||||||
|
assertEquals(1, result.commits.size)
|
||||||
|
assertEquals("keep", result.commits.single().hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a subject-less commit still decodes with an empty subject`() {
|
||||||
|
val result = decode("""{ "commits":[ { "hash":"h", "at": 5 } ] }""")!!
|
||||||
|
assertEquals(1, result.commits.size)
|
||||||
|
assertEquals("", result.commits.single().subject)
|
||||||
|
assertFalse(result.truncated) // default
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a non-object body degrades to null`() {
|
||||||
|
org.junit.jupiter.api.Assertions.assertNull(decode("[]"))
|
||||||
|
org.junit.jupiter.api.Assertions.assertNull(decode("garbage"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package wang.yaojia.webterm.api.models
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GitWrite payload + error decode (plan Phase A.3): each 200 payload decodes; a failure body
|
||||||
|
* `{ok:false,error:"…"}` (git-ops) and `{error:"…"}` (worktrees) both yield the SAFE `error` string;
|
||||||
|
* a garbled 200 body degrades to the payload defaults (never throws). The empty-sha commit case is
|
||||||
|
* exercised (server can return `{ok:true, commit:""}`).
|
||||||
|
*/
|
||||||
|
class GitWriteTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `stage payload decodes staged and count`() {
|
||||||
|
val r = decodeGitPayload("""{"ok":true,"staged":true,"count":3}""".toByteArray(), StageResult.serializer())
|
||||||
|
assertEquals(StageResult(staged = true, count = 3), r)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `commit payload decodes the sha and tolerates an empty sha`() {
|
||||||
|
assertEquals("a1b2c3", decodeGitPayload("""{"ok":true,"commit":"a1b2c3"}""".toByteArray(), CommitResult.serializer()).commit)
|
||||||
|
assertEquals("", decodeGitPayload("""{"ok":true,"commit":""}""".toByteArray(), CommitResult.serializer()).commit)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `push payload decodes branch and remote`() {
|
||||||
|
val r = decodeGitPayload("""{"ok":true,"branch":"main","remote":"origin"}""".toByteArray(), PushResult.serializer())
|
||||||
|
assertEquals("main", r.branch)
|
||||||
|
assertEquals("origin", r.remote)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `worktree create and remove and prune payloads decode`() {
|
||||||
|
val create = decodeGitPayload("""{"ok":true,"path":"/wt/x","branch":"feat"}""".toByteArray(), CreateWorktreeResult.serializer())
|
||||||
|
assertEquals("/wt/x", create.path)
|
||||||
|
assertEquals("feat", create.branch)
|
||||||
|
|
||||||
|
val remove = decodeGitPayload("""{"ok":true,"path":"/wt/x"}""".toByteArray(), RemoveWorktreeResult.serializer())
|
||||||
|
assertEquals("/wt/x", remove.path)
|
||||||
|
|
||||||
|
val prune = decodeGitPayload("""{"ok":true,"pruned":["a","b"]}""".toByteArray(), PruneWorktreesResult.serializer())
|
||||||
|
assertEquals(listOf("a", "b"), prune.pruned)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a garbled 200 body degrades to payload defaults, never throwing`() {
|
||||||
|
assertEquals(StageResult(), decodeGitPayload("not json".toByteArray(), StageResult.serializer()))
|
||||||
|
assertEquals(CommitResult(), decodeGitPayload("[]".toByteArray(), CommitResult.serializer()))
|
||||||
|
assertTrue(decodeGitPayload("{}".toByteArray(), PruneWorktreesResult.serializer()).pruned.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a git-ops failure body yields the safe error string`() {
|
||||||
|
assertEquals("Nothing to commit.", decodeGitError("""{"ok":false,"error":"Nothing to commit."}""".toByteArray()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a worktree failure body (no ok field) still yields the error string`() {
|
||||||
|
assertEquals("Worktree creation is disabled.", decodeGitError("""{"error":"Worktree creation is disabled."}""".toByteArray()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty or errorless failure body yields null`() {
|
||||||
|
assertNull(decodeGitError(ByteArray(0)))
|
||||||
|
assertNull(decodeGitError("""{"ok":false}""".toByteArray()))
|
||||||
|
assertNull(decodeGitError("not json".toByteArray()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package wang.yaojia.webterm.api.models
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PrStatus tolerant decode (plan Phase A.1): a full `availability:"ok"` body decodes every field; an
|
||||||
|
* unknown/missing `availability` degrades to [PrAvailability.ERROR] (never throws); `PrCheckSummary`
|
||||||
|
* counts round-trip; a non-object body degrades rather than crashing. Mirrors the FE never treating a
|
||||||
|
* non-`ok` availability as an HTTP error.
|
||||||
|
*/
|
||||||
|
class PrStatusTest {
|
||||||
|
|
||||||
|
private fun decode(json: String): PrStatus? =
|
||||||
|
LossyDecode.objectOrNull(json.toByteArray(), PrStatus.serializer())
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `decodes a full ok body with all fields and check counts`() {
|
||||||
|
val json = """
|
||||||
|
{ "availability":"ok", "number":42, "title":"Add worktrees", "url":"https://x/pull/42",
|
||||||
|
"state":"open", "isDraft":false, "mergeable":"mergeable",
|
||||||
|
"headRefName":"feat/wt", "baseRefName":"main",
|
||||||
|
"checks": { "total":5, "passing":3, "failing":1, "pending":1 } }
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val pr = decode(json)!!
|
||||||
|
assertEquals(PrAvailability.OK, pr.availability)
|
||||||
|
assertEquals(42, pr.number)
|
||||||
|
assertEquals("Add worktrees", pr.title)
|
||||||
|
assertEquals("https://x/pull/42", pr.url)
|
||||||
|
assertEquals("open", pr.state)
|
||||||
|
assertEquals(false, pr.isDraft)
|
||||||
|
assertEquals("mergeable", pr.mergeable)
|
||||||
|
assertEquals("feat/wt", pr.headRefName)
|
||||||
|
assertEquals("main", pr.baseRefName)
|
||||||
|
assertEquals(PrCheckSummary(total = 5, passing = 3, failing = 1, pending = 1), pr.checks)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unknown availability degrades to ERROR, never throwing`() {
|
||||||
|
val pr = decode("""{ "availability":"quantum-flux" }""")!!
|
||||||
|
assertEquals(PrAvailability.ERROR, pr.availability)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a body missing availability defaults to ERROR and leaves optional fields null`() {
|
||||||
|
val pr = decode("""{ "number":7 }""")!!
|
||||||
|
assertEquals(PrAvailability.ERROR, pr.availability)
|
||||||
|
assertEquals(7, pr.number)
|
||||||
|
assertNull(pr.title)
|
||||||
|
assertNull(pr.checks)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `each known availability maps from its wire value`() {
|
||||||
|
assertEquals(PrAvailability.NO_PR, PrAvailability.fromWire("no-pr"))
|
||||||
|
assertEquals(PrAvailability.NOT_INSTALLED, PrAvailability.fromWire("not-installed"))
|
||||||
|
assertEquals(PrAvailability.UNAUTHENTICATED, PrAvailability.fromWire("unauthenticated"))
|
||||||
|
assertEquals(PrAvailability.DISABLED, PrAvailability.fromWire("disabled"))
|
||||||
|
assertEquals(PrAvailability.ERROR, PrAvailability.fromWire("error"))
|
||||||
|
assertEquals(PrAvailability.ERROR, PrAvailability.fromWire("")) // empty → ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a non-object body degrades to null rather than throwing`() {
|
||||||
|
assertNull(decode("[]"))
|
||||||
|
assertNull(decode("not json"))
|
||||||
|
assertNull(LossyDecode.objectOrNull(ByteArray(0), PrStatus.serializer()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unknown top-level keys are ignored`() {
|
||||||
|
val pr = decode("""{ "availability":"ok", "futureField":123, "nested":{"a":1} }""")!!
|
||||||
|
assertEquals(PrAvailability.OK, pr.availability)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package wang.yaojia.webterm.api.routes
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import wang.yaojia.webterm.api.models.GitWriteOutcome
|
||||||
|
import wang.yaojia.webterm.api.models.PrAvailability
|
||||||
|
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
||||||
|
import wang.yaojia.webterm.wire.HostEndpoint
|
||||||
|
import wang.yaojia.webterm.wire.HttpMethod
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status-code → outcome mapping for the W5 git surface (plan Phase A.5): PR 200/400/404; log
|
||||||
|
* decode + errors; each guarded write 200→Ok, 403→Rejected(body.error), 409→Rejected, 429→
|
||||||
|
* RateLimited. Also asserts the transport RECEIVED an Origin on writes and NOT on reads.
|
||||||
|
*/
|
||||||
|
class ApiClientGitTest {
|
||||||
|
private companion object {
|
||||||
|
const val BASE = "http://h:3000"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val transport = FakeHttpTransport()
|
||||||
|
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
|
||||||
|
|
||||||
|
private suspend fun errorOf(block: suspend () -> Unit): Throwable? = runCatching { block() }.exceptionOrNull()
|
||||||
|
|
||||||
|
// ── PR (RO; degrade lives in the 200 body, not the status) ───────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `projectPr decodes a 200 degrade body and maps 400 404`() = runTest {
|
||||||
|
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", body = """{"availability":"not-installed"}""".toByteArray())
|
||||||
|
assertEquals(PrAvailability.NOT_INSTALLED, client.projectPr("/r").availability)
|
||||||
|
|
||||||
|
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", status = 400)
|
||||||
|
assertEquals(ApiClientError.ProjectPathInvalid, errorOf { client.projectPr("/r") })
|
||||||
|
|
||||||
|
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", status = 404)
|
||||||
|
assertEquals(ApiClientError.ProjectNotFound, errorOf { client.projectPr("/r") })
|
||||||
|
|
||||||
|
// Empty path is rejected before any I/O.
|
||||||
|
assertEquals(ApiClientError.ProjectPathInvalid, errorOf { client.projectPr("") })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `projectPr never treats a garbled 200 body as an error (degrades to ERROR)`() = runTest {
|
||||||
|
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", body = "not json".toByteArray())
|
||||||
|
assertEquals(PrAvailability.ERROR, client.projectPr("/r").availability)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── log ──────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `projectLog decodes 200 and maps 404 and 500`() = runTest {
|
||||||
|
transport.queueSuccess(
|
||||||
|
url = "$BASE/projects/log?path=%2Fr",
|
||||||
|
body = """{"commits":[{"hash":"h","at":1,"subject":"s"}],"truncated":false}""".toByteArray(),
|
||||||
|
)
|
||||||
|
assertEquals(1, client.projectLog("/r").commits.size)
|
||||||
|
|
||||||
|
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fr", status = 404)
|
||||||
|
assertEquals(ApiClientError.ProjectNotFound, errorOf { client.projectLog("/r") })
|
||||||
|
|
||||||
|
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fr", status = 500)
|
||||||
|
assertEquals(ApiClientError.GitLogUnavailable, errorOf { client.projectLog("/r") })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── guarded writes: outcome mapping ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a guarded write 200 yields Ok with the decoded payload`() = runTest {
|
||||||
|
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/commit", body = """{"ok":true,"commit":"abc"}""".toByteArray())
|
||||||
|
val outcome = client.gitCommit("/r", "msg")
|
||||||
|
assertTrue(outcome is GitWriteOutcome.Ok)
|
||||||
|
assertEquals("abc", (outcome as GitWriteOutcome.Ok).payload.commit)
|
||||||
|
// The write stamped an Origin.
|
||||||
|
assertTrue(transport.recordedRequests.last().headers.containsKey(HeaderName.ORIGIN))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `403 disabled and 409 both surface Rejected with the safe error string`() = runTest {
|
||||||
|
transport.queueSuccess(
|
||||||
|
method = HttpMethod.POST, url = "$BASE/projects/worktree",
|
||||||
|
status = 403, body = """{"error":"Worktree creation is disabled."}""".toByteArray(),
|
||||||
|
)
|
||||||
|
val disabled = client.createWorktree("/r", "b", null)
|
||||||
|
assertEquals(GitWriteOutcome.Rejected(403, "Worktree creation is disabled."), disabled)
|
||||||
|
|
||||||
|
transport.queueSuccess(
|
||||||
|
method = HttpMethod.DELETE, url = "$BASE/projects/worktree",
|
||||||
|
status = 409, body = """{"error":"Worktree has uncommitted changes; force required."}""".toByteArray(),
|
||||||
|
)
|
||||||
|
val dirty = client.removeWorktree("/r", "/r/x", false)
|
||||||
|
assertEquals(GitWriteOutcome.Rejected(409, "Worktree has uncommitted changes; force required."), dirty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `429 yields RateLimited and never auto-retries`() = runTest {
|
||||||
|
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/push", status = 429, body = """{"error":"Too many requests."}""".toByteArray())
|
||||||
|
assertEquals(GitWriteOutcome.RateLimited, client.gitPush("/r"))
|
||||||
|
assertEquals(1, transport.recordedRequests.size) // exactly one attempt
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a stage 200 decodes staged and count and threads the files body`() = runTest {
|
||||||
|
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/stage", body = """{"ok":true,"staged":true,"count":2}""".toByteArray())
|
||||||
|
val outcome = client.gitStage("/r", listOf("a", "b"), stage = true)
|
||||||
|
assertTrue(outcome is GitWriteOutcome.Ok)
|
||||||
|
assertEquals(2, (outcome as GitWriteOutcome.Ok).payload.count)
|
||||||
|
assertEquals("""{"path":"/r","files":["a","b"],"stage":true}""", transport.recordedRequests.last().body?.decodeToString())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package wang.yaojia.webterm.api.routes
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
||||||
|
import wang.yaojia.webterm.wire.HostEndpoint
|
||||||
|
import wang.yaojia.webterm.wire.HttpMethod
|
||||||
|
import wang.yaojia.webterm.wire.HttpRequest
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request-shape + Origin-iff-guarded (plan §4.3 铁律) for the W5 git surface: the two NEW reads
|
||||||
|
* (`/projects/pr`, `/projects/log`) carry **no** Origin; the six writes (worktree×3, git×3) carry a
|
||||||
|
* byte-equal Origin and a JSON body — including a `DELETE /projects/worktree` that carries a body
|
||||||
|
* (the highest-risk integration gotcha). A route reclassified read↔write turns this red.
|
||||||
|
*/
|
||||||
|
class GitRouteShapeTest {
|
||||||
|
private companion object {
|
||||||
|
const val BASE = "http://192.168.1.5:3000"
|
||||||
|
const val ORIGIN = "http://192.168.1.5:3000"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val transport = FakeHttpTransport()
|
||||||
|
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
|
||||||
|
|
||||||
|
private fun last(): HttpRequest = transport.recordedRequests.last()
|
||||||
|
|
||||||
|
private fun assertGuarded(r: HttpRequest) =
|
||||||
|
assertEquals(ORIGIN, r.headers[HeaderName.ORIGIN], "guarded write must stamp byte-equal Origin")
|
||||||
|
|
||||||
|
private fun assertReadOnly(r: HttpRequest) =
|
||||||
|
assertFalse(r.headers.containsKey(HeaderName.ORIGIN), "read-only route must NOT stamp Origin")
|
||||||
|
|
||||||
|
// ── reads: no Origin, correct verb + strict-encoded query ────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `projectPr is a read-only GET with a strict-encoded path and no Origin`() = runTest {
|
||||||
|
val path = "/home/me/my repo/a+b&c"
|
||||||
|
val url = "$BASE/projects/pr?path=%2Fhome%2Fme%2Fmy%20repo%2Fa%2Bb%26c"
|
||||||
|
transport.queueSuccess(url = url, body = """{"availability":"no-pr"}""".toByteArray())
|
||||||
|
|
||||||
|
client.projectPr(path)
|
||||||
|
|
||||||
|
val r = last()
|
||||||
|
assertEquals(HttpMethod.GET, r.method)
|
||||||
|
assertEquals(url, r.url)
|
||||||
|
assertReadOnly(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `projectLog omits n when null and appends a clamped n when set`() = runTest {
|
||||||
|
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fp", body = """{"commits":[],"truncated":false}""".toByteArray())
|
||||||
|
client.projectLog("/p", n = null)
|
||||||
|
assertEquals("$BASE/projects/log?path=%2Fp", last().url)
|
||||||
|
assertReadOnly(last())
|
||||||
|
|
||||||
|
// n above GIT_LOG_MAX (50) clamps to 50; below 1 clamps to 1.
|
||||||
|
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fp&n=50", body = """{"commits":[],"truncated":false}""".toByteArray())
|
||||||
|
client.projectLog("/p", n = 999)
|
||||||
|
assertEquals("$BASE/projects/log?path=%2Fp&n=50", last().url)
|
||||||
|
|
||||||
|
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fp&n=1", body = """{"commits":[],"truncated":false}""".toByteArray())
|
||||||
|
client.projectLog("/p", n = 0)
|
||||||
|
assertEquals("$BASE/projects/log?path=%2Fp&n=1", last().url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── writes: Origin stamped, correct verb, JSON body ──────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `createWorktree is a guarded POST with a path-branch-base body`() = runTest {
|
||||||
|
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
|
||||||
|
client.createWorktree("/repo", "feat/x", base = "main")
|
||||||
|
|
||||||
|
val r = last()
|
||||||
|
assertEquals(HttpMethod.POST, r.method)
|
||||||
|
assertEquals("$BASE/projects/worktree", r.url)
|
||||||
|
assertGuarded(r)
|
||||||
|
assertEquals(ContentType.JSON, r.headers[HeaderName.CONTENT_TYPE])
|
||||||
|
assertEquals("""{"path":"/repo","branch":"feat/x","base":"main"}""", r.body?.decodeToString())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `createWorktree omits base when null`() = runTest {
|
||||||
|
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
|
||||||
|
client.createWorktree("/repo", "feat/x", base = null)
|
||||||
|
assertEquals("""{"path":"/repo","branch":"feat/x"}""", last().body?.decodeToString())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `removeWorktree is a guarded DELETE that CARRIES a JSON body`() = runTest {
|
||||||
|
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
|
||||||
|
client.removeWorktree("/repo", "/repo-worktrees/x", force = true)
|
||||||
|
|
||||||
|
val r = last()
|
||||||
|
assertEquals(HttpMethod.DELETE, r.method)
|
||||||
|
assertEquals("$BASE/projects/worktree", r.url)
|
||||||
|
assertGuarded(r)
|
||||||
|
assertNotNull(r.body, "DELETE /projects/worktree MUST carry a request body")
|
||||||
|
assertEquals("""{"path":"/repo","worktreePath":"/repo-worktrees/x","force":true}""", r.body?.decodeToString())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pruneWorktrees is a guarded POST with a path body`() = runTest {
|
||||||
|
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree/prune", body = """{"ok":true,"pruned":[]}""".toByteArray())
|
||||||
|
client.pruneWorktrees("/repo")
|
||||||
|
|
||||||
|
val r = last()
|
||||||
|
assertEquals(HttpMethod.POST, r.method)
|
||||||
|
assertEquals("$BASE/projects/worktree/prune", r.url)
|
||||||
|
assertGuarded(r)
|
||||||
|
assertEquals("""{"path":"/repo"}""", r.body?.decodeToString())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `gitStage commit push are guarded POSTs with exact bodies`() = runTest {
|
||||||
|
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/stage", body = """{"ok":true}""".toByteArray())
|
||||||
|
client.gitStage("/repo", listOf("a.kt", "b.kt"), stage = true)
|
||||||
|
assertEquals("$BASE/projects/git/stage", last().url)
|
||||||
|
assertGuarded(last())
|
||||||
|
assertEquals("""{"path":"/repo","files":["a.kt","b.kt"],"stage":true}""", last().body?.decodeToString())
|
||||||
|
|
||||||
|
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/commit", body = """{"ok":true,"commit":"x"}""".toByteArray())
|
||||||
|
client.gitCommit("/repo", "a message")
|
||||||
|
assertEquals("""{"path":"/repo","message":"a message"}""", last().body?.decodeToString())
|
||||||
|
assertGuarded(last())
|
||||||
|
|
||||||
|
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/push", body = """{"ok":true}""".toByteArray())
|
||||||
|
client.gitPush("/repo")
|
||||||
|
assertEquals("$BASE/projects/git/push", last().url)
|
||||||
|
assertEquals("""{"path":"/repo"}""", last().body?.decodeToString())
|
||||||
|
assertGuarded(last())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every guarded write carries Origin and every read does not (batch invariant)`() = runTest {
|
||||||
|
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", body = """{"availability":"ok"}""".toByteArray())
|
||||||
|
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fr", body = """{"commits":[],"truncated":false}""".toByteArray())
|
||||||
|
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
|
||||||
|
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
|
||||||
|
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree/prune", body = """{"ok":true}""".toByteArray())
|
||||||
|
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/stage", body = """{"ok":true}""".toByteArray())
|
||||||
|
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/commit", body = """{"ok":true}""".toByteArray())
|
||||||
|
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/push", body = """{"ok":true}""".toByteArray())
|
||||||
|
|
||||||
|
client.projectPr("/r"); client.projectLog("/r", null)
|
||||||
|
assertReadOnly(transport.recordedRequests[0])
|
||||||
|
assertReadOnly(transport.recordedRequests[1])
|
||||||
|
|
||||||
|
client.createWorktree("/r", "b", null)
|
||||||
|
client.removeWorktree("/r", "/r/x", false)
|
||||||
|
client.pruneWorktrees("/r")
|
||||||
|
client.gitStage("/r", listOf("f"), true)
|
||||||
|
client.gitCommit("/r", "m")
|
||||||
|
client.gitPush("/r")
|
||||||
|
assertTrue(transport.recordedRequests.drop(2).all { it.headers.containsKey(HeaderName.ORIGIN) })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,8 +11,11 @@ import okhttp3.OkHttpClient
|
|||||||
import wang.yaojia.webterm.tlsandroid.AndroidIdentityRepository
|
import wang.yaojia.webterm.tlsandroid.AndroidIdentityRepository
|
||||||
import wang.yaojia.webterm.tlsandroid.AndroidKeyStoreImporter
|
import wang.yaojia.webterm.tlsandroid.AndroidKeyStoreImporter
|
||||||
import wang.yaojia.webterm.tlsandroid.CertStore
|
import wang.yaojia.webterm.tlsandroid.CertStore
|
||||||
|
import wang.yaojia.webterm.tlsandroid.EnrollmentRecordStore
|
||||||
|
import wang.yaojia.webterm.tlsandroid.IdentityCacheRefresher
|
||||||
import wang.yaojia.webterm.tlsandroid.IdentityRepository
|
import wang.yaojia.webterm.tlsandroid.IdentityRepository
|
||||||
import wang.yaojia.webterm.tlsandroid.TinkCertStore
|
import wang.yaojia.webterm.tlsandroid.TinkCertStore
|
||||||
|
import wang.yaojia.webterm.tlsandroid.TinkEnrollmentRecordStore
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,16 +44,38 @@ public object TlsModule {
|
|||||||
@Singleton
|
@Singleton
|
||||||
public fun provideCertStore(@ApplicationContext context: Context): CertStore = TinkCertStore(context)
|
public fun provideCertStore(@ApplicationContext context: Context): CertStore = TinkCertStore(context)
|
||||||
|
|
||||||
|
/** The Tink-AEAD-encrypted enrollment record (deviceId + key alias) the zero-`.p12` renew path reads. */
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
public fun provideIdentityRepository(
|
public fun provideEnrollmentRecordStore(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
): EnrollmentRecordStore = TinkEnrollmentRecordStore(context)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ONE mTLS device-identity repository, provided as the concrete type so BOTH the
|
||||||
|
* [IdentityRepository] surface (import/rotate/remove/summary) and the narrow [IdentityCacheRefresher]
|
||||||
|
* seam (B4 enroll cache-freshness) resolve to the SAME singleton instance.
|
||||||
|
*/
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
public fun provideAndroidIdentityRepository(
|
||||||
importer: AndroidKeyStoreImporter,
|
importer: AndroidKeyStoreImporter,
|
||||||
certStore: CertStore,
|
certStore: CertStore,
|
||||||
connectionPool: ConnectionPool,
|
connectionPool: ConnectionPool,
|
||||||
): IdentityRepository {
|
): AndroidIdentityRepository {
|
||||||
// Evict-only client: no mTLS of its own, just the SHARED pool so remove()/rotate()'s
|
// Evict-only client: no mTLS of its own, just the SHARED pool so remove()/rotate()/refresh's
|
||||||
// `connectionPool.evictAll()` clears the connections the real transports pooled (R4/§8).
|
// `connectionPool.evictAll()` clears the connections the real transports pooled (R4/§8).
|
||||||
val evictClient = OkHttpClient.Builder().connectionPool(connectionPool).build()
|
val evictClient = OkHttpClient.Builder().connectionPool(connectionPool).build()
|
||||||
return AndroidIdentityRepository(importer, certStore, evictClient)
|
return AndroidIdentityRepository(importer, certStore, evictClient)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
public fun provideIdentityRepository(repository: AndroidIdentityRepository): IdentityRepository = repository
|
||||||
|
|
||||||
|
/** FIX 3: the enroll/renew commit refreshes THIS same repository's in-memory cache (no restart). */
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
public fun provideIdentityCacheRefresher(repository: AndroidIdentityRepository): IdentityCacheRefresher =
|
||||||
|
repository
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ public fun WebTermNavHost(
|
|||||||
|
|
||||||
composable(NavRoutes.CERT) { ClientCertPane(env = env, onBack = { navController.popBackStack() }) }
|
composable(NavRoutes.CERT) { ClientCertPane(env = env, onBack = { navController.popBackStack() }) }
|
||||||
|
|
||||||
|
composable(NavRoutes.ENROLL) { EnrollmentPane(env = env, onBack = { navController.popBackStack() }) }
|
||||||
|
|
||||||
composable(
|
composable(
|
||||||
route = TERMINAL_ROUTE,
|
route = TERMINAL_ROUTE,
|
||||||
arguments = listOf(
|
arguments = listOf(
|
||||||
@@ -211,6 +213,9 @@ public object NavRoutes {
|
|||||||
/** Device-certificate management (A27). */
|
/** Device-certificate management (A27). */
|
||||||
public const val CERT: String = "cert"
|
public const val CERT: String = "cert"
|
||||||
|
|
||||||
|
/** Zero-`.p12` device enrollment (B4) — obtains a hardware-bound cert with no file. */
|
||||||
|
public const val ENROLL: String = "enroll"
|
||||||
|
|
||||||
/** Terminal route pattern with the required host + session path args (A21). */
|
/** Terminal route pattern with the required host + session path args (A21). */
|
||||||
public const val TERMINAL_PATTERN: String = "terminal/{${NavArg.HOST_ID}}/{${NavArg.SESSION_ID}}"
|
public const val TERMINAL_PATTERN: String = "terminal/{${NavArg.HOST_ID}}/{${NavArg.SESSION_ID}}"
|
||||||
|
|
||||||
|
|||||||
@@ -20,10 +20,13 @@ import wang.yaojia.webterm.designsystem.Spacing
|
|||||||
import wang.yaojia.webterm.hostregistry.Host
|
import wang.yaojia.webterm.hostregistry.Host
|
||||||
import wang.yaojia.webterm.screens.ClientCertScreen
|
import wang.yaojia.webterm.screens.ClientCertScreen
|
||||||
import wang.yaojia.webterm.screens.DiffScreen
|
import wang.yaojia.webterm.screens.DiffScreen
|
||||||
|
import wang.yaojia.webterm.screens.EnrollmentScreen
|
||||||
import wang.yaojia.webterm.screens.PairingScreen
|
import wang.yaojia.webterm.screens.PairingScreen
|
||||||
import wang.yaojia.webterm.screens.ProjectDetailScreen
|
import wang.yaojia.webterm.screens.ProjectDetailScreen
|
||||||
|
import wang.yaojia.webterm.viewmodels.ApiClientGitWriteGateway
|
||||||
import wang.yaojia.webterm.viewmodels.ApiClientProjectsGateway
|
import wang.yaojia.webterm.viewmodels.ApiClientProjectsGateway
|
||||||
import wang.yaojia.webterm.viewmodels.ClientCertViewModel
|
import wang.yaojia.webterm.viewmodels.ClientCertViewModel
|
||||||
|
import wang.yaojia.webterm.viewmodels.EnrollmentViewModel
|
||||||
import wang.yaojia.webterm.viewmodels.DiffViewModel
|
import wang.yaojia.webterm.viewmodels.DiffViewModel
|
||||||
import wang.yaojia.webterm.viewmodels.HttpDiffFetcher
|
import wang.yaojia.webterm.viewmodels.HttpDiffFetcher
|
||||||
import wang.yaojia.webterm.viewmodels.PairingViewModel
|
import wang.yaojia.webterm.viewmodels.PairingViewModel
|
||||||
@@ -74,6 +77,38 @@ public fun ClientCertPane(
|
|||||||
ClientCertScreen(viewModel = viewModel, onBack = onBack, modifier = modifier)
|
ClientCertScreen(viewModel = viewModel, onBack = onBack, modifier = modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Device enrollment (B4, zero-.p12 auto-cert) ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hosts [EnrollmentScreen] over a fresh [EnrollmentViewModel] (mirrors iOS `AppCoordinator`'s
|
||||||
|
* `makeEnrollmentViewModel`). The enroll flow and the installed-summary read run OFF `Main`
|
||||||
|
* (`Dispatchers.IO`) — resolving the enroller resolves the lazy shared client (keystore/TLS I/O) and the
|
||||||
|
* enroll itself does hardware-keygen + network. The typed control-plane URL builds the enroller per attempt.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
public fun EnrollmentPane(
|
||||||
|
env: AppEnvironment,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val viewModel = remember(env) {
|
||||||
|
EnrollmentViewModel(
|
||||||
|
enrollOperation = { password, subdomain, deviceName, controlPlaneUrl ->
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
env.enrollmentFlowFactory.create(controlPlaneUrl).enroll(password, subdomain, deviceName)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
loadSummary = {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
runCatching { env.identityRepository.currentSummary() }.getOrNull()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaultDeviceName = android.os.Build.MODEL ?: "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
EnrollmentScreen(viewModel = viewModel, onBack = onBack, modifier = modifier)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Project detail (A23) ──────────────────────────────────────────────────────────────────────────────
|
// ── Project detail (A23) ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -106,6 +141,7 @@ public fun ProjectDetailPane(
|
|||||||
path = path,
|
path = path,
|
||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
onOpenClaude = { cwd -> navController.navigate(newTerminalRoute(resolved.id, cwd)) },
|
onOpenClaude = { cwd -> navController.navigate(newTerminalRoute(resolved.id, cwd)) },
|
||||||
|
onViewDiff = { diffPath -> navController.navigate(diffRoute(resolved.id, diffPath)) },
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -118,9 +154,10 @@ public fun ProjectDetailContent(
|
|||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onOpenClaude: (String) -> Unit,
|
onOpenClaude: (String) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
onViewDiff: (String) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val viewModel = remember(gateway, path) { ProjectDetailViewModel.forGateway(gateway, path) }
|
val viewModel = remember(gateway, path) { ProjectDetailViewModel.forGateway(gateway, path) }
|
||||||
ProjectDetailScreen(viewModel = viewModel, onBack = onBack, onOpenClaude = onOpenClaude, modifier = modifier)
|
ProjectDetailScreen(viewModel = viewModel, onBack = onBack, onOpenClaude = onOpenClaude, modifier = modifier, onViewDiff = onViewDiff)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Diff viewer (A24) ─────────────────────────────────────────────────────────────────────────────────
|
// ── Diff viewer (A24) ─────────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -151,7 +188,12 @@ public fun DiffPane(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
val viewModel = remember(resolved, path) {
|
val viewModel = remember(resolved, path) {
|
||||||
DiffViewModel(fetcher = HttpDiffFetcher(resolved.endpoint, env.httpTransport), path = path)
|
DiffViewModel(
|
||||||
|
fetcher = HttpDiffFetcher(resolved.endpoint, env.httpTransport),
|
||||||
|
path = path,
|
||||||
|
// Guarded git-write flows through :api-client's single Origin-stamping point (plan §Security).
|
||||||
|
writer = ApiClientGitWriteGateway(env.apiClientFactory.create(resolved.endpoint)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
DiffScreen(viewModel = viewModel, modifier = modifier, onBack = onBack)
|
DiffScreen(viewModel = viewModel, modifier = modifier, onBack = onBack)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ public fun ProjectsHome(
|
|||||||
path = path,
|
path = path,
|
||||||
onBack = { selectedPath = null },
|
onBack = { selectedPath = null },
|
||||||
onOpenClaude = { cwd -> navController.navigate(newTerminalRoute(resolved.id, cwd)) },
|
onOpenClaude = { cwd -> navController.navigate(newTerminalRoute(resolved.id, cwd)) },
|
||||||
|
onViewDiff = { diffPath -> navController.navigate(diffRoute(resolved.id, diffPath)) },
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
DetailPlaceholder("选择一个项目查看详情。")
|
DetailPlaceholder("选择一个项目查看详情。")
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ public fun SessionsHome(
|
|||||||
onOpenSession = openSession,
|
onOpenSession = openSession,
|
||||||
onNewSession = openNewSession,
|
onNewSession = openNewSession,
|
||||||
onPairHost = { navController.navigate(NavRoutes.PAIRING) },
|
onPairHost = { navController.navigate(NavRoutes.PAIRING) },
|
||||||
|
onEnroll = { navController.navigate(NavRoutes.ENROLL) },
|
||||||
onImportCert = { navController.navigate(NavRoutes.CERT) },
|
onImportCert = { navController.navigate(NavRoutes.CERT) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,13 +15,17 @@ import androidx.compose.material3.CircularProgressIndicator
|
|||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@@ -44,15 +48,17 @@ import wang.yaojia.webterm.viewmodels.DiffPhase
|
|||||||
import wang.yaojia.webterm.viewmodels.DiffRow
|
import wang.yaojia.webterm.viewmodels.DiffRow
|
||||||
import wang.yaojia.webterm.viewmodels.DiffUiState
|
import wang.yaojia.webterm.viewmodels.DiffUiState
|
||||||
import wang.yaojia.webterm.viewmodels.DiffViewModel
|
import wang.yaojia.webterm.viewmodels.DiffViewModel
|
||||||
|
import wang.yaojia.webterm.viewmodels.DiffWriteBanner
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* # DiffScreen (A24) — the read-only staged/unstaged git-diff viewer.
|
* # DiffScreen (A24 + W5) — the git-diff viewer with base-compare + git-write.
|
||||||
*
|
*
|
||||||
* Renders the presenter's flattened files→hunks→lines list in a `LazyColumn`, with a Working/Staged
|
* Renders the presenter's flattened files→hunks→lines list in a `LazyColumn`, with a Working/Staged
|
||||||
* toggle in the header. Every server-derived string (paths, hunk headers, code lines) is rendered as
|
* toggle (hidden in base mode), a **base-rev** input (a third mode), per-file **Stage/Unstage** buttons
|
||||||
* **inert monospaced [Text]** — plain `Text`, never `ClickableText`/`LinkAnnotation`/autolink/markdown
|
* (working/staged mode only), a **commit** message field + **Commit** / **Push** buttons, and a result
|
||||||
* — so a hostile diff cannot inject a tappable link or markup (plan §8). Line kinds carry the A13
|
* **banner**. Every server-derived string (paths, hunk headers, code lines, git error messages) is
|
||||||
* colour tokens (added → green, removed → red). Layout/interaction is device-QA (plan §7).
|
* rendered as **inert [Text]** — never `ClickableText`/autolink/markdown (plan §8). Interaction is
|
||||||
|
* device-QA (plan §7).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
public fun DiffScreen(
|
public fun DiffScreen(
|
||||||
@@ -61,29 +67,37 @@ public fun DiffScreen(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onRefresh: () -> Unit = {},
|
onRefresh: () -> Unit = {},
|
||||||
onBack: (() -> Unit)? = null,
|
onBack: (() -> Unit)? = null,
|
||||||
|
onSetBase: (String?) -> Unit = {},
|
||||||
|
onToggleStage: (String, Boolean) -> Unit = { _, _ -> },
|
||||||
|
onCommit: (String) -> Unit = {},
|
||||||
|
onPush: () -> Unit = {},
|
||||||
|
onDismissBanner: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
DiffHeader(staged = state.staged, onSelectStaged = onSelectStaged, onBack = onBack)
|
DiffHeader(state = state, onSelectStaged = onSelectStaged, onBack = onBack, onSetBase = onSetBase)
|
||||||
if (state.truncated) {
|
if (state.truncated) DiffNotice("Diff truncated — too large to display fully.")
|
||||||
DiffNotice("Diff truncated — too large to display fully.")
|
state.writeBanner?.let { WriteBanner(it, onDismissBanner) }
|
||||||
}
|
|
||||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline, thickness = Stroke.hairline)
|
HorizontalDivider(color = MaterialTheme.colorScheme.outline, thickness = Stroke.hairline)
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||||
when (state.phase) {
|
when (state.phase) {
|
||||||
DiffPhase.IDLE, DiffPhase.LOADING -> CenteredContent { CircularProgressIndicator() }
|
DiffPhase.IDLE, DiffPhase.LOADING -> CenteredContent { CircularProgressIndicator() }
|
||||||
DiffPhase.EMPTY -> CenteredMessage("No changes")
|
DiffPhase.EMPTY -> CenteredMessage("No changes")
|
||||||
DiffPhase.ERROR -> DiffError(onRetry = onRefresh)
|
DiffPhase.ERROR -> DiffError(onRetry = onRefresh)
|
||||||
DiffPhase.LOADED -> DiffList(rows = state.rows)
|
DiffPhase.LOADED -> DiffList(rows = state.rows, writeEnabled = state.writeEnabled, staged = state.staged, onToggleStage = onToggleStage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (state.writeEnabled) {
|
||||||
|
HorizontalDivider(color = MaterialTheme.colorScheme.outline, thickness = Stroke.hairline)
|
||||||
|
CommitBar(writing = state.writing, onCommit = onCommit, onPush = onPush)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stateful entry point: binds [viewModel] to a lifecycle scope, collects its state, and wires the
|
* Stateful entry point: binds [viewModel] to a lifecycle scope, collects its state, and wires the
|
||||||
* toggle/refresh callbacks. The nav layer supplies the already-constructed presenter (host + path).
|
* toggle/refresh/base/git-write callbacks. The nav layer supplies the already-constructed presenter.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
public fun DiffScreen(
|
public fun DiffScreen(
|
||||||
@@ -92,7 +106,6 @@ public fun DiffScreen(
|
|||||||
onBack: (() -> Unit)? = null,
|
onBack: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
// Bind to the LaunchedEffect scope (cancelled when this screen leaves composition), then load.
|
|
||||||
LaunchedEffect(viewModel) { viewModel.bind(this) }
|
LaunchedEffect(viewModel) { viewModel.bind(this) }
|
||||||
DiffScreen(
|
DiffScreen(
|
||||||
state = state,
|
state = state,
|
||||||
@@ -100,50 +113,60 @@ public fun DiffScreen(
|
|||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onRefresh = viewModel::refresh,
|
onRefresh = viewModel::refresh,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
|
onSetBase = viewModel::setBase,
|
||||||
|
onToggleStage = viewModel::toggleStage,
|
||||||
|
onCommit = viewModel::commit,
|
||||||
|
onPush = viewModel::push,
|
||||||
|
onDismissBanner = viewModel::clearWriteBanner,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DiffHeader(
|
private fun DiffHeader(
|
||||||
staged: Boolean,
|
state: DiffUiState,
|
||||||
onSelectStaged: (Boolean) -> Unit,
|
onSelectStaged: (Boolean) -> Unit,
|
||||||
onBack: (() -> Unit)?,
|
onBack: (() -> Unit)?,
|
||||||
|
onSetBase: (String?) -> Unit,
|
||||||
) {
|
) {
|
||||||
Row(
|
var baseInput by remember(state.base) { mutableStateOf(state.base ?: "") }
|
||||||
modifier = Modifier
|
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.sm8)) {
|
||||||
.fillMaxWidth()
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||||
.padding(horizontal = Spacing.md12, vertical = Spacing.sm8),
|
if (onBack != null) TextButton(onClick = onBack) { Text("Back") }
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
Text(text = "Diff", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onBackground)
|
||||||
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
|
||||||
) {
|
|
||||||
if (onBack != null) {
|
|
||||||
TextButton(onClick = onBack) { Text("Back") }
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = "Diff",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(Spacing.sm8))
|
Spacer(modifier = Modifier.width(Spacing.sm8))
|
||||||
FilterChip(
|
if (state.base == null) {
|
||||||
selected = !staged,
|
// Working/Staged toggle is suppressed in base mode (server ignores staged then).
|
||||||
onClick = { onSelectStaged(false) },
|
FilterChip(selected = !state.staged, onClick = { onSelectStaged(false) }, label = { Text("Working") })
|
||||||
label = { Text("Working") },
|
FilterChip(selected = state.staged, onClick = { onSelectStaged(true) }, label = { Text("Staged") })
|
||||||
)
|
} else {
|
||||||
FilterChip(
|
Text(text = "vs ${state.base}", style = WebTermType.metaMono, color = MaterialTheme.colorScheme.primary)
|
||||||
selected = staged,
|
}
|
||||||
onClick = { onSelectStaged(true) },
|
}
|
||||||
label = { Text("Staged") },
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.padding(top = Spacing.xs4)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = baseInput,
|
||||||
|
onValueChange = { baseInput = it },
|
||||||
|
label = { Text("对比基点 (base rev)") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
|
OutlinedButton(onClick = { onSetBase(baseInput.takeIf { it.isNotBlank() }) }) { Text("对比") }
|
||||||
|
if (state.base != null) OutlinedButton(onClick = { baseInput = ""; onSetBase(null) }) { Text("清除") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DiffList(rows: List<DiffRow>) {
|
private fun DiffList(
|
||||||
|
rows: List<DiffRow>,
|
||||||
|
writeEnabled: Boolean,
|
||||||
|
staged: Boolean,
|
||||||
|
onToggleStage: (String, Boolean) -> Unit,
|
||||||
|
) {
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
items(items = rows, key = { it.id }) { row ->
|
items(items = rows, key = { it.id }) { row ->
|
||||||
when (row) {
|
when (row) {
|
||||||
is DiffFileHeaderRow -> FileHeader(row)
|
is DiffFileHeaderRow -> FileHeader(row, writeEnabled = writeEnabled, staged = staged, onToggleStage = onToggleStage)
|
||||||
is DiffHunkHeaderRow -> DiffText(row.header, MaterialTheme.colorScheme.primary)
|
is DiffHunkHeaderRow -> DiffText(row.header, MaterialTheme.colorScheme.primary)
|
||||||
is DiffLineRow -> DiffText(markerFor(row.kind) + row.text, lineColor(row.kind))
|
is DiffLineRow -> DiffText(markerFor(row.kind) + row.text, lineColor(row.kind))
|
||||||
is DiffBinaryRow -> DiffText("Binary file", MaterialTheme.colorScheme.onSurfaceVariant)
|
is DiffBinaryRow -> DiffText("Binary file", MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
@@ -153,11 +176,14 @@ private fun DiffList(rows: List<DiffRow>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun FileHeader(row: DiffFileHeaderRow) {
|
private fun FileHeader(
|
||||||
|
row: DiffFileHeaderRow,
|
||||||
|
writeEnabled: Boolean,
|
||||||
|
staged: Boolean,
|
||||||
|
onToggleStage: (String, Boolean) -> Unit,
|
||||||
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.sm8),
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = Spacing.md12, vertical = Spacing.sm8),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
@@ -172,6 +198,42 @@ private fun FileHeader(row: DiffFileHeaderRow) {
|
|||||||
)
|
)
|
||||||
Text("+${row.added}", style = WebTermType.metaMono, color = WebTermColors.statusWorking)
|
Text("+${row.added}", style = WebTermType.metaMono, color = WebTermColors.statusWorking)
|
||||||
Text("-${row.removed}", style = WebTermType.metaMono, color = WebTermColors.statusStuck)
|
Text("-${row.removed}", style = WebTermType.metaMono, color = WebTermColors.statusStuck)
|
||||||
|
if (writeEnabled) {
|
||||||
|
// In staged view we offer Unstage; in working view we offer Stage.
|
||||||
|
TextButton(onClick = { onToggleStage(row.stagePath, !staged) }) { Text(if (staged) "取消暂存" else "暂存") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CommitBar(writing: Boolean, onCommit: (String) -> Unit, onPush: () -> Unit) {
|
||||||
|
var message by remember { mutableStateOf("") }
|
||||||
|
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.sm8), verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = message,
|
||||||
|
onValueChange = { message = it },
|
||||||
|
label = { Text("提交信息") },
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !writing,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||||
|
OutlinedButton(enabled = !writing, onClick = { onCommit(message); message = "" }) { Text("提交") }
|
||||||
|
OutlinedButton(enabled = !writing, onClick = onPush) { Text("推送") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WriteBanner(banner: DiffWriteBanner, onDismiss: () -> Unit) {
|
||||||
|
val color = if (banner.isError) WebTermColors.statusStuck else WebTermColors.statusWorking
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.xs4),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||||
|
) {
|
||||||
|
Text(text = banner.message, style = WebTermType.metaMono, color = color, modifier = Modifier.weight(1f))
|
||||||
|
TextButton(onClick = onDismiss) { Text("×") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,9 +247,7 @@ private fun DiffText(text: String, color: Color) {
|
|||||||
softWrap = false,
|
softWrap = false,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Clip,
|
overflow = TextOverflow.Clip,
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = 1.dp),
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = Spacing.md12, vertical = 1.dp),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,17 +267,13 @@ private fun DiffNotice(message: String) {
|
|||||||
text = message,
|
text = message,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.xs4),
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = Spacing.md12, vertical = Spacing.xs4),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CenteredMessage(message: String) {
|
private fun CenteredMessage(message: String) {
|
||||||
CenteredContent {
|
CenteredContent { Text(message, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||||
Text(message, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -246,17 +302,15 @@ private fun markerFor(kind: DiffLineKind): String = when (kind) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun DiffScreenPreview() {
|
private fun DiffScreenPreview() {
|
||||||
val rows = listOf<DiffRow>(
|
val rows = listOf<DiffRow>(
|
||||||
DiffFileHeaderRow(0, "src/app/Main.kt", "modified", added = 2, removed = 1),
|
DiffFileHeaderRow(0, "src/app/Main.kt", "src/app/Main.kt", "modified", added = 2, removed = 1),
|
||||||
DiffHunkHeaderRow(1, "@@ -1,3 +1,4 @@"),
|
DiffHunkHeaderRow(1, "@@ -1,3 +1,4 @@"),
|
||||||
DiffLineRow(2, DiffLineKind.CONTEXT, "fun main() {"),
|
DiffLineRow(2, DiffLineKind.CONTEXT, "fun main() {"),
|
||||||
DiffLineRow(3, DiffLineKind.REMOVED, " println(\"old\")"),
|
DiffLineRow(3, DiffLineKind.REMOVED, " println(\"old\")"),
|
||||||
DiffLineRow(4, DiffLineKind.ADDED, " println(\"new\")"),
|
DiffLineRow(4, DiffLineKind.ADDED, " println(\"new\")"),
|
||||||
DiffLineRow(5, DiffLineKind.ADDED, " println(\"added\")"),
|
|
||||||
DiffLineRow(6, DiffLineKind.CONTEXT, "}"),
|
|
||||||
)
|
)
|
||||||
WebTermTheme {
|
WebTermTheme {
|
||||||
DiffScreen(
|
DiffScreen(
|
||||||
state = DiffUiState(staged = false, phase = DiffPhase.LOADED, rows = rows, truncated = true),
|
state = DiffUiState(staged = false, phase = DiffPhase.LOADED, rows = rows, truncated = true, canWrite = true),
|
||||||
onSelectStaged = {},
|
onSelectStaged = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,385 @@
|
|||||||
|
package wang.yaojia.webterm.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import wang.yaojia.webterm.designsystem.Spacing
|
||||||
|
import wang.yaojia.webterm.designsystem.WebTermColors
|
||||||
|
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||||
|
import wang.yaojia.webterm.designsystem.WebTermType
|
||||||
|
import wang.yaojia.webterm.viewmodels.CertSummaryView
|
||||||
|
import wang.yaojia.webterm.viewmodels.EnrollError
|
||||||
|
import wang.yaojia.webterm.viewmodels.EnrollPhase
|
||||||
|
import wang.yaojia.webterm.viewmodels.EnrollmentUiState
|
||||||
|
import wang.yaojia.webterm.viewmodels.EnrollmentViewModel
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # EnrollmentScreen (B4) — zero-`.p12` device enrollment (the phone half of zero-touch).
|
||||||
|
*
|
||||||
|
* The Compose shell over [EnrollmentViewModel], reached from the session-list host menu "自动获取证书"
|
||||||
|
* (mirrors iOS `EnrollmentScreen` presented from `SessionListScreen`'s host menu). One operator login
|
||||||
|
* generates a NON-EXPORTABLE hardware key + CSR and obtains a device cert with no file at all; the cert is
|
||||||
|
* committed to the shared store and presented automatically on the existing mTLS path (cache-refreshed, so
|
||||||
|
* no restart). The manual `.p12` path ([ClientCertScreen]) remains available alongside this one.
|
||||||
|
*
|
||||||
|
* ### Secrets & trust discipline (plan §8)
|
||||||
|
* - The operator password is a masked field bound straight to the ViewModel and cleared after every
|
||||||
|
* attempt — never logged, persisted, or echoed. The private key is generated non-exportably in secure
|
||||||
|
* hardware inside the library; this screen never sees it.
|
||||||
|
* - Every cert-derived string (CNs, expiry) and every error message is inert [Text] — no autolink/markdown.
|
||||||
|
* Error copy is app-authored, never a server/exception string.
|
||||||
|
*
|
||||||
|
* The keystore/network I/O it drives is device-QA (plan §7); the state machine is JVM-tested in
|
||||||
|
* `EnrollmentViewModelTest`.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
public fun EnrollmentScreen(
|
||||||
|
viewModel: EnrollmentViewModel,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onBack: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
LaunchedEffect(viewModel) { viewModel.bind(this) }
|
||||||
|
|
||||||
|
EnrollmentScreen(
|
||||||
|
state = state,
|
||||||
|
onControlPlaneUrlChange = viewModel::onControlPlaneUrlChange,
|
||||||
|
onSubdomainChange = viewModel::onSubdomainChange,
|
||||||
|
onDeviceNameChange = viewModel::onDeviceNameChange,
|
||||||
|
onPasswordChange = viewModel::onPasswordChange,
|
||||||
|
onEnroll = viewModel::enroll,
|
||||||
|
onDismissError = viewModel::clearError,
|
||||||
|
modifier = modifier,
|
||||||
|
onBack = onBack,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stateless body — pure inputs so a [Preview] and any host can drive it without the VM machinery. */
|
||||||
|
@Composable
|
||||||
|
public fun EnrollmentScreen(
|
||||||
|
state: EnrollmentUiState,
|
||||||
|
onControlPlaneUrlChange: (String) -> Unit,
|
||||||
|
onSubdomainChange: (String) -> Unit,
|
||||||
|
onDeviceNameChange: (String) -> Unit,
|
||||||
|
onPasswordChange: (String) -> Unit,
|
||||||
|
onEnroll: () -> Unit,
|
||||||
|
onDismissError: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onBack: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(Spacing.lg16),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(Spacing.md12),
|
||||||
|
) {
|
||||||
|
Header(onBack = onBack)
|
||||||
|
|
||||||
|
if (state.phase == EnrollPhase.LOADING) {
|
||||||
|
LoadingRow()
|
||||||
|
return@Column
|
||||||
|
}
|
||||||
|
|
||||||
|
InstalledSection(summary = state.summary)
|
||||||
|
|
||||||
|
state.error?.let { ErrorCard(error = it, onDismiss = onDismissError) }
|
||||||
|
if (state.didSucceed && state.error == null) SuccessCard()
|
||||||
|
|
||||||
|
EnrollForm(
|
||||||
|
state = state,
|
||||||
|
onControlPlaneUrlChange = onControlPlaneUrlChange,
|
||||||
|
onSubdomainChange = onSubdomainChange,
|
||||||
|
onDeviceNameChange = onDeviceNameChange,
|
||||||
|
onPasswordChange = onPasswordChange,
|
||||||
|
onEnroll = onEnroll,
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = EnrollCopy.FOOTER,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Header(onBack: (() -> Unit)?) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||||
|
) {
|
||||||
|
if (onBack != null) {
|
||||||
|
TextButton(onClick = onBack) { Text("返回") }
|
||||||
|
}
|
||||||
|
Text(text = EnrollCopy.TITLE, style = MaterialTheme.typography.headlineSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The currently-installed identity summary (or "none" copy) — the "已安装证书" section. */
|
||||||
|
@Composable
|
||||||
|
private fun InstalledSection(summary: CertSummaryView?) {
|
||||||
|
if (summary == null) {
|
||||||
|
Text(
|
||||||
|
text = EnrollCopy.NONE_INSTALLED,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(Spacing.lg16),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||||
|
) {
|
||||||
|
SummaryRow(label = "设备(CN)", value = summary.subjectCommonName)
|
||||||
|
SummaryRow(label = "签发方(CN)", value = summary.issuerCommonName)
|
||||||
|
SummaryRow(label = "到期", value = summary.expiry)
|
||||||
|
if (summary.isExpired) {
|
||||||
|
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||||
|
Text(
|
||||||
|
text = "⚠ 证书已过期,请重新注册。",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = WebTermColors.statusStuck,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SummaryRow(label: String, value: String) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(Spacing.md12),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
// Cert-derived value: inert monospaced Text — no linkify/markdown (plan §8).
|
||||||
|
Text(text = value, style = WebTermType.monoTabular(13), color = MaterialTheme.colorScheme.onSurface)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EnrollForm(
|
||||||
|
state: EnrollmentUiState,
|
||||||
|
onControlPlaneUrlChange: (String) -> Unit,
|
||||||
|
onSubdomainChange: (String) -> Unit,
|
||||||
|
onDeviceNameChange: (String) -> Unit,
|
||||||
|
onPasswordChange: (String) -> Unit,
|
||||||
|
onEnroll: () -> Unit,
|
||||||
|
) {
|
||||||
|
val enrolling = state.phase == EnrollPhase.ENROLLING
|
||||||
|
Text(
|
||||||
|
text = if (state.summary == null) EnrollCopy.ENROLL_HEADER else EnrollCopy.ROTATE_HEADER,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.controlPlaneUrl,
|
||||||
|
onValueChange = onControlPlaneUrlChange,
|
||||||
|
label = { Text(EnrollCopy.CONTROL_PLANE_URL) },
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !enrolling,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.subdomain,
|
||||||
|
onValueChange = onSubdomainChange,
|
||||||
|
label = { Text(EnrollCopy.SUBDOMAIN) },
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !enrolling,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.deviceName,
|
||||||
|
onValueChange = onDeviceNameChange,
|
||||||
|
label = { Text(EnrollCopy.DEVICE_NAME) },
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !enrolling,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.password,
|
||||||
|
onValueChange = onPasswordChange,
|
||||||
|
label = { Text(EnrollCopy.PASSWORD) },
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !enrolling,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = onEnroll,
|
||||||
|
enabled = state.canEnroll,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
if (enrolling) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.padding(Spacing.xs4))
|
||||||
|
} else {
|
||||||
|
Text(if (state.summary == null) EnrollCopy.ENROLL_ACTION else EnrollCopy.ROTATE_ACTION)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ErrorCard(error: EnrollError, onDismiss: () -> Unit) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(Spacing.md12),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(Spacing.xs4),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = errorCopy(error),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
)
|
||||||
|
TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) { Text("知道了") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SuccessCard() {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = EnrollCopy.SUCCESS,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(Spacing.md12),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LoadingRow() {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps the coarse [EnrollError] to inert, app-authored copy (never an exception/server string, §8). */
|
||||||
|
private fun errorCopy(error: EnrollError): String = when (error) {
|
||||||
|
EnrollError.INVALID_URL -> "控制面地址无效,请输入 https:// 开头的完整地址。"
|
||||||
|
EnrollError.MISSING_FIELDS -> "请填写子域名、设备名称和操作口令。"
|
||||||
|
EnrollError.BAD_CREDENTIAL -> "操作口令错误,请重试。"
|
||||||
|
EnrollError.SUBDOMAIN_NOT_OWNED -> "该账号未拥有此子域名,无法签发证书。"
|
||||||
|
EnrollError.RATE_LIMITED -> "请求过于频繁,请稍后再试。"
|
||||||
|
EnrollError.REJECTED -> "请求被拒绝:子域名或证书请求无效。"
|
||||||
|
EnrollError.ENROLL_FAILED -> "注册失败,请稍后重试。"
|
||||||
|
EnrollError.SERVER -> "服务器返回异常,请稍后重试。"
|
||||||
|
EnrollError.KEYGEN -> "生成硬件密钥失败(本设备可能不支持安全硬件)。"
|
||||||
|
EnrollError.UNKNOWN -> "注册失败,请重试。"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User-facing copy (Chinese), mirroring iOS `EnrollmentCopy`. */
|
||||||
|
private object EnrollCopy {
|
||||||
|
const val TITLE = "自动获取证书"
|
||||||
|
const val NONE_INSTALLED = "尚未安装设备证书。"
|
||||||
|
const val ENROLL_HEADER = "注册本设备"
|
||||||
|
const val ROTATE_HEADER = "重新注册"
|
||||||
|
const val CONTROL_PLANE_URL = "控制面地址"
|
||||||
|
const val SUBDOMAIN = "子域名(你拥有的隧道名)"
|
||||||
|
const val DEVICE_NAME = "设备名称"
|
||||||
|
const val PASSWORD = "操作口令"
|
||||||
|
const val ENROLL_ACTION = "注册本设备"
|
||||||
|
const val ROTATE_ACTION = "重新注册"
|
||||||
|
const val SUCCESS = "已注册,证书已保存到本设备安全硬件,连接隧道主机时将自动出示。"
|
||||||
|
const val FOOTER =
|
||||||
|
"首次登录一次即可:本设备在安全硬件(StrongBox / TEE)生成不可导出的私钥,向控制面申请证书并自动保存;" +
|
||||||
|
"之后连接隧道主机时自动出示,无需再手动导入 .p12。"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(name = "EnrollmentScreen — none installed")
|
||||||
|
@Composable
|
||||||
|
private fun EnrollmentScreenNonePreview() {
|
||||||
|
WebTermTheme {
|
||||||
|
EnrollmentScreen(
|
||||||
|
state = EnrollmentUiState(
|
||||||
|
controlPlaneUrl = "https://cp.terminal.yaojia.wang",
|
||||||
|
subdomain = "alice",
|
||||||
|
deviceName = "Pixel 8",
|
||||||
|
phase = EnrollPhase.IDLE,
|
||||||
|
),
|
||||||
|
onControlPlaneUrlChange = {},
|
||||||
|
onSubdomainChange = {},
|
||||||
|
onDeviceNameChange = {},
|
||||||
|
onPasswordChange = {},
|
||||||
|
onEnroll = {},
|
||||||
|
onDismissError = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(name = "EnrollmentScreen — installed + error")
|
||||||
|
@Composable
|
||||||
|
private fun EnrollmentScreenInstalledPreview() {
|
||||||
|
WebTermTheme {
|
||||||
|
EnrollmentScreen(
|
||||||
|
state = EnrollmentUiState(
|
||||||
|
controlPlaneUrl = "https://cp.terminal.yaojia.wang",
|
||||||
|
subdomain = "alice",
|
||||||
|
deviceName = "Pixel 8",
|
||||||
|
summary = CertSummaryView("alice-pixel", "webterm-device-ca", "2027年1月8日", isExpired = false),
|
||||||
|
phase = EnrollPhase.IDLE,
|
||||||
|
error = EnrollError.SUBDOMAIN_NOT_OWNED,
|
||||||
|
),
|
||||||
|
onControlPlaneUrlChange = {},
|
||||||
|
onSubdomainChange = {},
|
||||||
|
onDeviceNameChange = {},
|
||||||
|
onPasswordChange = {},
|
||||||
|
onEnroll = {},
|
||||||
|
onDismissError = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,23 +9,36 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.AssistChip
|
||||||
|
import androidx.compose.material3.AssistChipDefaults
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import wang.yaojia.webterm.api.models.CommitLogEntry
|
||||||
|
import wang.yaojia.webterm.api.models.PrAvailability
|
||||||
|
import wang.yaojia.webterm.api.models.PrStatus
|
||||||
import wang.yaojia.webterm.api.models.ProjectDetail
|
import wang.yaojia.webterm.api.models.ProjectDetail
|
||||||
import wang.yaojia.webterm.api.models.ProjectSessionRef
|
import wang.yaojia.webterm.api.models.ProjectSessionRef
|
||||||
import wang.yaojia.webterm.api.models.WorktreeInfo
|
import wang.yaojia.webterm.api.models.WorktreeInfo
|
||||||
@@ -36,19 +49,19 @@ import wang.yaojia.webterm.designsystem.WebTermColors
|
|||||||
import wang.yaojia.webterm.designsystem.WebTermTheme
|
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||||
import wang.yaojia.webterm.designsystem.WebTermType
|
import wang.yaojia.webterm.designsystem.WebTermType
|
||||||
import wang.yaojia.webterm.viewmodels.ProjectDetailViewModel
|
import wang.yaojia.webterm.viewmodels.ProjectDetailViewModel
|
||||||
import wang.yaojia.webterm.viewmodels.ProjectsCopy
|
import wang.yaojia.webterm.viewmodels.WorktreeViewModel
|
||||||
|
import java.net.URI
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* # ProjectDetailScreen (A23) — one project's detail (branch · worktrees · sessions · CLAUDE.md) plus
|
* # ProjectDetailScreen (A23 + W5) — one project's detail (branch · worktrees · sessions · CLAUDE.md),
|
||||||
* "open Claude here". Mirrors web `renderProjectDetail` / iOS `ProjectDetailScreen`.
|
* plus the W5 additions: a **PR + CI chip** (tappable only when the PR url parses as https), a
|
||||||
|
* **recent-commits** section, and guarded **worktree create / remove / prune** actions.
|
||||||
*
|
*
|
||||||
* Every server string (name/path/branch/worktree/CLAUDE.md body) is rendered as **inert [Text]** — no
|
* Every server string (name/path/branch/worktree/CLAUDE.md/commit subject/PR title/error) is rendered
|
||||||
* autolink/markdown (plan §8); the CLAUDE.md body is shown verbatim in a monospaced block. The three
|
* as **inert [Text]** — no autolink/markdown (plan §8). The single exception is the PR chip, which is a
|
||||||
* failure buckets ([ProjectDetailViewModel.Failure]) map to copy + a retry action.
|
* link ONLY when its url is a valid https URL (scheme-validated before it is made clickable). The
|
||||||
*
|
* worktree actions drive [ProjectDetailViewModel.worktree]; a remove force-confirms in a dialog and a
|
||||||
* @param onBack pop back to the projects grid.
|
* main worktree is never removable.
|
||||||
* @param onOpenClaude open a new session in the project cwd (`attach(null, cwd)`); the nav layer routes
|
|
||||||
* it through [wang.yaojia.webterm.viewmodels.ProjectsViewModel.requestOpenClaude] (path re-validated).
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
public fun ProjectDetailScreen(
|
public fun ProjectDetailScreen(
|
||||||
@@ -56,8 +69,11 @@ public fun ProjectDetailScreen(
|
|||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onOpenClaude: (String) -> Unit,
|
onOpenClaude: (String) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
onViewDiff: (String) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val phase by viewModel.phase.collectAsStateWithLifecycle()
|
val phase by viewModel.phase.collectAsStateWithLifecycle()
|
||||||
|
val prChip by viewModel.prChip.collectAsStateWithLifecycle()
|
||||||
|
val recent by viewModel.recentCommits.collectAsStateWithLifecycle()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
LaunchedEffect(viewModel) { viewModel.load() }
|
LaunchedEffect(viewModel) { viewModel.load() }
|
||||||
|
|
||||||
@@ -71,7 +87,14 @@ public fun ProjectDetailScreen(
|
|||||||
is ProjectDetailViewModel.Phase.Failed ->
|
is ProjectDetailViewModel.Phase.Failed ->
|
||||||
Failure(current.failure, onRetry = { scope.launch { viewModel.load() } })
|
Failure(current.failure, onRetry = { scope.launch { viewModel.load() } })
|
||||||
is ProjectDetailViewModel.Phase.Loaded ->
|
is ProjectDetailViewModel.Phase.Loaded ->
|
||||||
DetailBody(detail = current.detail, onOpenClaude = onOpenClaude)
|
DetailBody(
|
||||||
|
detail = current.detail,
|
||||||
|
prChip = prChip,
|
||||||
|
recent = recent,
|
||||||
|
worktree = viewModel.worktree,
|
||||||
|
onOpenClaude = onOpenClaude,
|
||||||
|
onViewDiff = onViewDiff,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,7 +113,14 @@ private fun DetailHeaderBar(onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DetailBody(detail: ProjectDetail, onOpenClaude: (String) -> Unit) {
|
private fun DetailBody(
|
||||||
|
detail: ProjectDetail,
|
||||||
|
prChip: ProjectDetailViewModel.PrChip,
|
||||||
|
recent: ProjectDetailViewModel.RecentCommits,
|
||||||
|
worktree: WorktreeViewModel?,
|
||||||
|
onOpenClaude: (String) -> Unit,
|
||||||
|
onViewDiff: (String) -> Unit = {},
|
||||||
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -113,37 +143,236 @@ private fun DetailBody(detail: ProjectDetail, onOpenClaude: (String) -> Unit) {
|
|||||||
}
|
}
|
||||||
Text(text = detail.path, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text(text = detail.path, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
|
||||||
SectionTitle(if (detail.worktrees.size > 1) "工作树" else "分支")
|
PrChipRow(prChip)
|
||||||
if (!detail.isGit) {
|
|
||||||
EmptyLine("不是 git 仓库。")
|
if (detail.isGit && worktree != null) {
|
||||||
} else if (detail.worktrees.isEmpty()) {
|
WorktreeSection(detail = detail, worktree = worktree)
|
||||||
EmptyLine(detail.branch?.let { "当前分支 $it" } ?: "无工作树信息。")
|
|
||||||
} else {
|
} else {
|
||||||
for (worktree in detail.worktrees) WorktreeRow(worktree)
|
SectionTitle(if (detail.worktrees.size > 1) "工作树" else "分支")
|
||||||
|
if (!detail.isGit) EmptyLine("不是 git 仓库。")
|
||||||
|
else if (detail.worktrees.isEmpty()) EmptyLine(detail.branch?.let { "当前分支 $it" } ?: "无工作树信息。")
|
||||||
|
else for (w in detail.worktrees) WorktreeRow(w, onRemove = null)
|
||||||
}
|
}
|
||||||
|
|
||||||
val running = detail.sessions.filter { !it.exited }
|
val running = detail.sessions.filter { !it.exited }
|
||||||
SectionTitle("运行中的会话(${running.size})")
|
SectionTitle("运行中的会话(${running.size})")
|
||||||
if (running.isEmpty()) {
|
if (running.isEmpty()) EmptyLine("没有运行中的会话 —— 在下方开一个。")
|
||||||
EmptyLine("没有运行中的会话 —— 在下方开一个。")
|
else for (session in running) SessionRow(session)
|
||||||
} else {
|
|
||||||
for (session in running) SessionRow(session)
|
RecentCommitsSection(recent)
|
||||||
}
|
|
||||||
|
|
||||||
SectionTitle("CLAUDE.md")
|
SectionTitle("CLAUDE.md")
|
||||||
val claudeMd = detail.claudeMd
|
val claudeMd = detail.claudeMd
|
||||||
if (detail.hasClaudeMd && claudeMd != null) {
|
if (detail.hasClaudeMd && claudeMd != null) ClaudeMdBlock(claudeMd)
|
||||||
ClaudeMdBlock(claudeMd)
|
else EmptyLine("还没有 CLAUDE.md —— 生成一个以给 Claude 项目专属指令。")
|
||||||
} else {
|
|
||||||
EmptyLine("还没有 CLAUDE.md —— 生成一个以给 Claude 项目专属指令。")
|
|
||||||
}
|
|
||||||
|
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||||
TextButton(onClick = { onOpenClaude(detail.path) }) { Text("在此启动 Claude") }
|
TextButton(onClick = { onOpenClaude(detail.path) }) { Text("在此启动 Claude") }
|
||||||
|
if (detail.isGit) TextButton(onClick = { onViewDiff(detail.path) }) { Text("查看改动 (diff)") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PR + CI chip (link only when https) ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PrChipRow(prChip: ProjectDetailViewModel.PrChip) {
|
||||||
|
when (prChip) {
|
||||||
|
ProjectDetailViewModel.PrChip.Hidden -> Unit
|
||||||
|
ProjectDetailViewModel.PrChip.Loading -> EmptyLine("正在读取 PR 状态…")
|
||||||
|
ProjectDetailViewModel.PrChip.Unavailable -> EmptyLine("PR 状态不可用。")
|
||||||
|
is ProjectDetailViewModel.PrChip.Loaded -> PrChipContent(prChip.status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WorktreeRow(worktree: WorktreeInfo) {
|
private fun PrChipContent(pr: PrStatus) {
|
||||||
|
val uriHandler = LocalUriHandler.current
|
||||||
|
val httpsUrl = pr.url?.let { if (isHttpsUrl(it)) it else null } // link ONLY when https (plan §Security)
|
||||||
|
val label = prChipLabel(pr)
|
||||||
|
val color = prChipColor(pr)
|
||||||
|
if (httpsUrl != null && pr.availability == PrAvailability.OK) {
|
||||||
|
AssistChip(
|
||||||
|
onClick = { runCatching { uriHandler.openUri(httpsUrl) } },
|
||||||
|
label = { Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||||
|
colors = AssistChipDefaults.assistChipColors(labelColor = color),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Non-ok / non-https → an INERT, non-clickable line (never make a hostile url tappable).
|
||||||
|
Text(text = label, style = WebTermType.metaMono, color = color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun prChipLabel(pr: PrStatus): String = when (pr.availability) {
|
||||||
|
PrAvailability.OK -> {
|
||||||
|
val num = pr.number?.let { "#$it " } ?: ""
|
||||||
|
val checks = pr.checks?.let { " (${it.passing}/${it.total})" } ?: ""
|
||||||
|
"PR $num${pr.title ?: ""}$checks".trim()
|
||||||
|
}
|
||||||
|
PrAvailability.NO_PR -> "当前分支没有 PR"
|
||||||
|
PrAvailability.NOT_INSTALLED -> "未安装 gh,无法读取 PR"
|
||||||
|
PrAvailability.UNAUTHENTICATED -> "gh 未登录,无法读取 PR"
|
||||||
|
PrAvailability.DISABLED -> "PR 集成已禁用"
|
||||||
|
PrAvailability.ERROR -> "PR 状态读取失败"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun prChipColor(pr: PrStatus): androidx.compose.ui.graphics.Color = when {
|
||||||
|
pr.availability != PrAvailability.OK -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
(pr.checks?.failing ?: 0) > 0 -> WebTermColors.statusStuck
|
||||||
|
(pr.checks?.pending ?: 0) > 0 -> WebTermColors.statusWaiting
|
||||||
|
else -> WebTermColors.statusWorking
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isHttpsUrl(url: String): Boolean =
|
||||||
|
runCatching { URI(url.trim()).scheme?.lowercase() == "https" }.getOrDefault(false)
|
||||||
|
|
||||||
|
// ── Worktree section (create / remove / prune) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WorktreeSection(detail: ProjectDetail, worktree: WorktreeViewModel) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val phase by worktree.phase.collectAsStateWithLifecycle()
|
||||||
|
var branch by remember { mutableStateOf("") }
|
||||||
|
var base by remember { mutableStateOf("") }
|
||||||
|
var removeTarget by remember { mutableStateOf<WorktreeInfo?>(null) }
|
||||||
|
|
||||||
|
SectionTitle(if (detail.worktrees.size > 1) "工作树" else "分支")
|
||||||
|
if (detail.worktrees.isEmpty()) {
|
||||||
|
EmptyLine(detail.branch?.let { "当前分支 $it" } ?: "无工作树信息。")
|
||||||
|
} else {
|
||||||
|
for (w in detail.worktrees) WorktreeRow(w, onRemove = { if (!w.isMain) removeTarget = w })
|
||||||
|
}
|
||||||
|
|
||||||
|
// New-worktree inline form.
|
||||||
|
WebTermCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = branch,
|
||||||
|
onValueChange = { branch = it },
|
||||||
|
label = { Text("新工作树分支名") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = base,
|
||||||
|
onValueChange = { base = it },
|
||||||
|
label = { Text("基点(可选)") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||||
|
OutlinedButton(
|
||||||
|
enabled = phase != WorktreeViewModel.Phase.Working,
|
||||||
|
onClick = { scope.launch { worktree.create(branch, base) } },
|
||||||
|
) { Text("新建工作树") }
|
||||||
|
OutlinedButton(
|
||||||
|
enabled = phase != WorktreeViewModel.Phase.Working,
|
||||||
|
onClick = { scope.launch { worktree.prune() } },
|
||||||
|
) { Text("清理") }
|
||||||
|
}
|
||||||
|
WorktreePhaseBanner(phase, onDismiss = { worktree.reset() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val target = removeTarget
|
||||||
|
if (target != null) {
|
||||||
|
RemoveWorktreeDialog(
|
||||||
|
worktree = target,
|
||||||
|
onConfirm = { force ->
|
||||||
|
removeTarget = null
|
||||||
|
scope.launch { worktree.remove(target, force) }
|
||||||
|
},
|
||||||
|
onDismiss = { removeTarget = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WorktreePhaseBanner(phase: WorktreeViewModel.Phase, onDismiss: () -> Unit) {
|
||||||
|
when (phase) {
|
||||||
|
WorktreeViewModel.Phase.Idle -> Unit
|
||||||
|
WorktreeViewModel.Phase.Working -> Text("处理中…", style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
is WorktreeViewModel.Phase.Done -> BannerLine(phase.message, WebTermColors.statusWorking, onDismiss)
|
||||||
|
is WorktreeViewModel.Phase.Failed -> BannerLine(phase.message, WebTermColors.statusStuck, onDismiss)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BannerLine(message: String, color: androidx.compose.ui.graphics.Color, onDismiss: () -> Unit) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||||
|
Text(text = message, style = WebTermType.metaMono, color = color, modifier = Modifier.weight(1f))
|
||||||
|
TextButton(onClick = onDismiss) { Text("知道了") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RemoveWorktreeDialog(
|
||||||
|
worktree: WorktreeInfo,
|
||||||
|
onConfirm: (force: Boolean) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
var force by remember { mutableStateOf(false) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("删除工作树") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
|
||||||
|
Text(text = worktree.path, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurface)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Checkbox(checked = force, onCheckedChange = { force = it })
|
||||||
|
Text("强制删除(丢弃未提交改动)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = { onConfirm(force) }) { Text("删除") } },
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Recent commits ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RecentCommitsSection(recent: ProjectDetailViewModel.RecentCommits) {
|
||||||
|
when (recent) {
|
||||||
|
ProjectDetailViewModel.RecentCommits.Hidden -> Unit
|
||||||
|
ProjectDetailViewModel.RecentCommits.Loading -> {
|
||||||
|
SectionTitle("最近提交"); EmptyLine("正在读取提交记录…")
|
||||||
|
}
|
||||||
|
ProjectDetailViewModel.RecentCommits.Unavailable -> {
|
||||||
|
SectionTitle("最近提交"); EmptyLine("提交记录不可用。")
|
||||||
|
}
|
||||||
|
is ProjectDetailViewModel.RecentCommits.Loaded -> {
|
||||||
|
SectionTitle("最近提交")
|
||||||
|
if (recent.result.commits.isEmpty()) EmptyLine("暂无提交。")
|
||||||
|
else for (commit in recent.result.commits) CommitRow(commit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CommitRow(commit: CommitLogEntry) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = commit.hash.take(7),
|
||||||
|
style = WebTermType.metaMono,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = commit.subject,
|
||||||
|
style = WebTermType.metaMono,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rows / helpers (reused from A23) ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WorktreeRow(worktree: WorktreeInfo, onRemove: (() -> Unit)?) {
|
||||||
val label = worktree.branch ?: worktree.head?.let { "detached @ $it" } ?: "detached"
|
val label = worktree.branch ?: worktree.head?.let { "detached @ $it" } ?: "detached"
|
||||||
WebTermCard(modifier = Modifier.fillMaxWidth()) {
|
WebTermCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
|
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
|
||||||
@@ -152,6 +381,7 @@ private fun WorktreeRow(worktree: WorktreeInfo) {
|
|||||||
if (worktree.isMain) Tag("main")
|
if (worktree.isMain) Tag("main")
|
||||||
if (worktree.isCurrent) Tag("current")
|
if (worktree.isCurrent) Tag("current")
|
||||||
if (worktree.locked == true) Tag("locked")
|
if (worktree.locked == true) Tag("locked")
|
||||||
|
if (onRemove != null && !worktree.isMain) TextButton(onClick = onRemove) { Text("删除") }
|
||||||
}
|
}
|
||||||
Text(text = worktree.path, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
Text(text = worktree.path, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
}
|
}
|
||||||
@@ -178,7 +408,6 @@ private fun SessionRow(session: ProjectSessionRef) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ClaudeMdBlock(text: String) {
|
private fun ClaudeMdBlock(text: String) {
|
||||||
WebTermCard(modifier = Modifier.fillMaxWidth()) {
|
WebTermCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
// Inert monospaced block — CLAUDE.md is server content; never linkify/markdown (§8).
|
|
||||||
Text(text = text, style = WebTermType.monoTabular(12), color = MaterialTheme.colorScheme.onSurface)
|
Text(text = text, style = WebTermType.monoTabular(12), color = MaterialTheme.colorScheme.onSurface)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,6 +467,17 @@ private fun ProjectDetailScreenPreview() {
|
|||||||
claudeMd = "# CLAUDE.md\n\nProject instructions…",
|
claudeMd = "# CLAUDE.md\n\nProject instructions…",
|
||||||
)
|
)
|
||||||
WebTermTheme {
|
WebTermTheme {
|
||||||
DetailBody(detail = detail, onOpenClaude = {})
|
DetailBody(
|
||||||
|
detail = detail,
|
||||||
|
prChip = ProjectDetailViewModel.PrChip.Loaded(PrStatus(availability = PrAvailability.NO_PR)),
|
||||||
|
recent = ProjectDetailViewModel.RecentCommits.Loaded(
|
||||||
|
wang.yaojia.webterm.api.models.GitLogResult(
|
||||||
|
commits = listOf(CommitLogEntry("abc1234", 1, "Initial commit")),
|
||||||
|
truncated = false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
worktree = null,
|
||||||
|
onOpenClaude = {},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,13 +222,21 @@ private fun ProjectCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
project.branch?.let { branch ->
|
project.branch?.let { branch ->
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
|
||||||
Text(
|
Text(
|
||||||
text = branch,
|
text = branch,
|
||||||
style = WebTermType.metaMono,
|
style = WebTermType.metaMono,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
)
|
)
|
||||||
|
// W3 sync chip: commits ahead/behind upstream (best-effort; only shown when non-zero).
|
||||||
|
val ahead = project.ahead ?: 0
|
||||||
|
val behind = project.behind ?: 0
|
||||||
|
if (ahead > 0) Text(text = "↑$ahead", style = WebTermType.metaMono, color = WebTermColors.statusWorking)
|
||||||
|
if (behind > 0) Text(text = "↓$behind", style = WebTermType.metaMono, color = WebTermColors.statusWaiting)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val running = project.sessions.count { !it.exited }
|
val running = project.sessions.count { !it.exited }
|
||||||
if (running > 0) {
|
if (running > 0) {
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ import java.util.UUID
|
|||||||
* @param onOpenSession open the terminal (A21) for a tapped session id (its dot is cleared first).
|
* @param onOpenSession open the terminal (A21) for a tapped session id (its dot is cleared first).
|
||||||
* @param onNewSession start a new session on the active host (`attach(null)`; A21 owns the terminal).
|
* @param onNewSession start a new session on the active host (`attach(null)`; A21 owns the terminal).
|
||||||
* @param onPairHost host-menu **配对新主机** → the pairing flow (A19).
|
* @param onPairHost host-menu **配对新主机** → the pairing flow (A19).
|
||||||
|
* @param onEnroll host-menu **自动获取证书** → the zero-`.p12` enrollment screen (B4).
|
||||||
* @param onImportCert host-menu **设备证书** → the device-cert screen (A27).
|
* @param onImportCert host-menu **设备证书** → the device-cert screen (A27).
|
||||||
* @param thumbnails off-screen preview seam; production wires it to the active host's
|
* @param thumbnails off-screen preview seam; production wires it to the active host's
|
||||||
* [ThumbnailPipeline][wang.yaojia.webterm.wiring.ThumbnailPipeline] (§6.7). `null` = placeholder tiles.
|
* [ThumbnailPipeline][wang.yaojia.webterm.wiring.ThumbnailPipeline] (§6.7). `null` = placeholder tiles.
|
||||||
@@ -87,6 +88,7 @@ public fun SessionListScreen(
|
|||||||
onOpenSession: (UUID) -> Unit,
|
onOpenSession: (UUID) -> Unit,
|
||||||
onNewSession: () -> Unit,
|
onNewSession: () -> Unit,
|
||||||
onPairHost: () -> Unit,
|
onPairHost: () -> Unit,
|
||||||
|
onEnroll: () -> Unit,
|
||||||
onImportCert: () -> Unit,
|
onImportCert: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
thumbnails: SessionThumbnails? = null,
|
thumbnails: SessionThumbnails? = null,
|
||||||
@@ -110,6 +112,7 @@ public fun SessionListScreen(
|
|||||||
onSelectHost = { id -> scope.launch { viewModel.selectHost(id) } },
|
onSelectHost = { id -> scope.launch { viewModel.selectHost(id) } },
|
||||||
onNewSession = onNewSession,
|
onNewSession = onNewSession,
|
||||||
onPairHost = onPairHost,
|
onPairHost = onPairHost,
|
||||||
|
onEnroll = onEnroll,
|
||||||
onImportCert = onImportCert,
|
onImportCert = onImportCert,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -138,6 +141,7 @@ private fun SessionListTopBar(
|
|||||||
onSelectHost: (String) -> Unit,
|
onSelectHost: (String) -> Unit,
|
||||||
onNewSession: () -> Unit,
|
onNewSession: () -> Unit,
|
||||||
onPairHost: () -> Unit,
|
onPairHost: () -> Unit,
|
||||||
|
onEnroll: () -> Unit,
|
||||||
onImportCert: () -> Unit,
|
onImportCert: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var menuOpen by remember { mutableStateOf(false) }
|
var menuOpen by remember { mutableStateOf(false) }
|
||||||
@@ -156,6 +160,7 @@ private fun SessionListTopBar(
|
|||||||
onDismiss = { menuOpen = false },
|
onDismiss = { menuOpen = false },
|
||||||
onSelectHost = { menuOpen = false; onSelectHost(it) },
|
onSelectHost = { menuOpen = false; onSelectHost(it) },
|
||||||
onPairHost = { menuOpen = false; onPairHost() },
|
onPairHost = { menuOpen = false; onPairHost() },
|
||||||
|
onEnroll = { menuOpen = false; onEnroll() },
|
||||||
onImportCert = { menuOpen = false; onImportCert() },
|
onImportCert = { menuOpen = false; onImportCert() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -163,7 +168,7 @@ private fun SessionListTopBar(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The host menu: paired hosts (✓ on the active one, 🔒 when a device cert is installed) + the two actions. */
|
/** The host menu: paired hosts (✓ on the active one, 🔒 when a device cert is installed) + the actions. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun HostMenu(
|
private fun HostMenu(
|
||||||
expanded: Boolean,
|
expanded: Boolean,
|
||||||
@@ -171,6 +176,7 @@ private fun HostMenu(
|
|||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onSelectHost: (String) -> Unit,
|
onSelectHost: (String) -> Unit,
|
||||||
onPairHost: () -> Unit,
|
onPairHost: () -> Unit,
|
||||||
|
onEnroll: () -> Unit,
|
||||||
onImportCert: () -> Unit,
|
onImportCert: () -> Unit,
|
||||||
) {
|
) {
|
||||||
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
|
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
|
||||||
@@ -184,6 +190,8 @@ private fun HostMenu(
|
|||||||
}
|
}
|
||||||
if (hosts.isNotEmpty()) HorizontalDivider()
|
if (hosts.isNotEmpty()) HorizontalDivider()
|
||||||
DropdownMenuItem(text = { Text("配对新主机") }, onClick = onPairHost)
|
DropdownMenuItem(text = { Text("配对新主机") }, onClick = onPairHost)
|
||||||
|
// 自动获取证书 (zero-.p12 enroll) sits beside 设备证书 (.p12 import) — same host-menu surface as iOS.
|
||||||
|
DropdownMenuItem(text = { Text("自动获取证书") }, onClick = onEnroll)
|
||||||
DropdownMenuItem(text = { Text("设备证书") }, onClick = onImportCert)
|
DropdownMenuItem(text = { Text("设备证书") }, onClick = onImportCert)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ import kotlinx.serialization.json.JsonObject
|
|||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlinx.serialization.json.booleanOrNull
|
import kotlinx.serialization.json.booleanOrNull
|
||||||
import kotlinx.serialization.json.intOrNull
|
import kotlinx.serialization.json.intOrNull
|
||||||
|
import wang.yaojia.webterm.api.models.CommitResult
|
||||||
|
import wang.yaojia.webterm.api.models.GitWriteOutcome
|
||||||
|
import wang.yaojia.webterm.api.models.PushResult
|
||||||
|
import wang.yaojia.webterm.api.models.StageResult
|
||||||
|
import wang.yaojia.webterm.api.routes.ApiClient
|
||||||
import wang.yaojia.webterm.wire.HostEndpoint
|
import wang.yaojia.webterm.wire.HostEndpoint
|
||||||
import wang.yaojia.webterm.wire.HttpMethod
|
import wang.yaojia.webterm.wire.HttpMethod
|
||||||
import wang.yaojia.webterm.wire.HttpRequest
|
import wang.yaojia.webterm.wire.HttpRequest
|
||||||
@@ -47,14 +52,17 @@ import java.net.URI
|
|||||||
public class DiffViewModel(
|
public class DiffViewModel(
|
||||||
private val fetcher: DiffFetcher,
|
private val fetcher: DiffFetcher,
|
||||||
private val path: String,
|
private val path: String,
|
||||||
|
/** Guarded git-write seam (stage/commit/push). Null → the diff is inert read-only (no buttons). */
|
||||||
|
private val writer: GitWriteGateway? = null,
|
||||||
) {
|
) {
|
||||||
private val _uiState = MutableStateFlow(DiffUiState())
|
private val _uiState = MutableStateFlow(DiffUiState(canWrite = writer != null))
|
||||||
|
|
||||||
/** The single snapshot `DiffScreen` renders from. */
|
/** The single snapshot `DiffScreen` renders from. */
|
||||||
public val uiState: StateFlow<DiffUiState> = _uiState.asStateFlow()
|
public val uiState: StateFlow<DiffUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
private var scope: CoroutineScope? = null
|
private var scope: CoroutineScope? = null
|
||||||
private var job: Job? = null
|
private var job: Job? = null
|
||||||
|
private var writeJob: Job? = null
|
||||||
|
|
||||||
/** Bind the scope loads launch into (the screen passes a lifecycle scope) and kick the first load. */
|
/** Bind the scope loads launch into (the screen passes a lifecycle scope) and kick the first load. */
|
||||||
public fun bind(scope: CoroutineScope) {
|
public fun bind(scope: CoroutineScope) {
|
||||||
@@ -62,27 +70,46 @@ public class DiffViewModel(
|
|||||||
reload()
|
reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Switch between the working-tree (`staged=false`) and staged (`staged=true`) diff; re-fetches. */
|
/** Switch between the working-tree (`staged=false`) and staged (`staged=true`) diff; re-fetches.
|
||||||
|
* No-op in base mode (the toggle is hidden there — the server ignores `staged` when `base` is set). */
|
||||||
public fun selectStaged(staged: Boolean) {
|
public fun selectStaged(staged: Boolean) {
|
||||||
|
if (_uiState.value.base != null) return
|
||||||
if (_uiState.value.staged == staged) return
|
if (_uiState.value.staged == staged) return
|
||||||
_uiState.value = _uiState.value.copy(staged = staged)
|
_uiState.value = _uiState.value.copy(staged = staged)
|
||||||
reload()
|
reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enter/leave base mode: a non-blank [rev] diffs HEAD against that base (staged toggle suppressed,
|
||||||
|
* git-write disabled — parity with public/diff.ts); null/blank returns to the working/staged view.
|
||||||
|
*/
|
||||||
|
public fun setBase(rev: String?) {
|
||||||
|
val next = rev?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
if (_uiState.value.base == next) return
|
||||||
|
_uiState.value = _uiState.value.copy(base = next, writeBanner = null)
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
/** Re-fetch the current view (pull-to-refresh / retry after an error). */
|
/** Re-fetch the current view (pull-to-refresh / retry after an error). */
|
||||||
public fun refresh() {
|
public fun refresh() {
|
||||||
reload()
|
reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Dismiss the git-write result banner. */
|
||||||
|
public fun clearWriteBanner() {
|
||||||
|
_uiState.value = _uiState.value.copy(writeBanner = null)
|
||||||
|
}
|
||||||
|
|
||||||
private fun reload() {
|
private fun reload() {
|
||||||
val scope = scope ?: return
|
val scope = scope ?: return
|
||||||
job?.cancel()
|
job?.cancel()
|
||||||
val staged = _uiState.value.staged
|
val staged = _uiState.value.staged
|
||||||
|
val base = _uiState.value.base
|
||||||
_uiState.value = _uiState.value.copy(phase = DiffPhase.LOADING)
|
_uiState.value = _uiState.value.copy(phase = DiffPhase.LOADING)
|
||||||
job = scope.launch {
|
job = scope.launch {
|
||||||
// Rethrow cancellation (a superseding load) so a stale fetch can't overwrite fresh state.
|
// Rethrow cancellation (a superseding load) so a stale fetch can't overwrite fresh state.
|
||||||
val outcome = try {
|
val outcome = try {
|
||||||
Result.success(fetcher.fetch(path, staged))
|
Result.success(fetcher.fetch(path, staged, base))
|
||||||
} catch (cancel: CancellationException) {
|
} catch (cancel: CancellationException) {
|
||||||
throw cancel
|
throw cancel
|
||||||
} catch (error: Throwable) {
|
} catch (error: Throwable) {
|
||||||
@@ -101,6 +128,102 @@ public class DiffViewModel(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Git write (working/staged mode only — never in base mode, plan §Security/Edge cases) ──────
|
||||||
|
|
||||||
|
/** Stage (`staged=true`) or unstage a single file, then re-fetch so the view reflects the index. */
|
||||||
|
public fun toggleStage(newPath: String, staged: Boolean) {
|
||||||
|
runWrite { writer!!.gitStage(path, listOf(newPath), staged) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Commit the staged changes with [message]. An empty message is rejected client-side (no I/O). */
|
||||||
|
public fun commit(message: String) {
|
||||||
|
if (message.isBlank()) {
|
||||||
|
_uiState.value = _uiState.value.copy(writeBanner = DiffWriteBanner(DiffCopy.COMMIT_EMPTY, isError = true))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runWrite { writer!!.gitCommit(path, message) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Push the current branch to its upstream (tighter server-side rate limit; never auto-retried). */
|
||||||
|
public fun push() {
|
||||||
|
runWrite { writer!!.gitPush(path) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared guarded-write runner: guards on write availability + base mode, sets [DiffUiState.writing],
|
||||||
|
* maps the [GitWriteOutcome] to a banner, and re-fetches the diff on success. Serialized via a single
|
||||||
|
* [writeJob] so a rapid double-tap never races.
|
||||||
|
*/
|
||||||
|
private fun <T> runWrite(op: suspend () -> GitWriteOutcome<T>) {
|
||||||
|
val scope = scope ?: return
|
||||||
|
if (writer == null || _uiState.value.base != null || _uiState.value.writing) return
|
||||||
|
writeJob?.cancel()
|
||||||
|
_uiState.value = _uiState.value.copy(writing = true, writeBanner = null)
|
||||||
|
writeJob = scope.launch {
|
||||||
|
val outcome = try {
|
||||||
|
Result.success(op())
|
||||||
|
} catch (cancel: CancellationException) {
|
||||||
|
throw cancel
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
Result.failure(error)
|
||||||
|
}
|
||||||
|
val banner = outcome.fold(
|
||||||
|
onSuccess = { bannerFor(it) },
|
||||||
|
onFailure = { DiffWriteBanner(DiffCopy.writeFailed(errorDetail(it)), isError = true) },
|
||||||
|
)
|
||||||
|
_uiState.value = _uiState.value.copy(writing = false, writeBanner = banner)
|
||||||
|
if (!banner.isError) reload() // refresh the diff after a successful write
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> bannerFor(outcome: GitWriteOutcome<T>): DiffWriteBanner = when (outcome) {
|
||||||
|
is GitWriteOutcome.Ok -> DiffWriteBanner(DiffCopy.okBanner(outcome.payload), isError = false)
|
||||||
|
is GitWriteOutcome.Rejected -> DiffWriteBanner(outcome.message ?: DiffCopy.WRITE_REJECTED, isError = true)
|
||||||
|
GitWriteOutcome.RateLimited -> DiffWriteBanner(DiffCopy.RATE_LIMITED, isError = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A thrown ApiClientError's message IS its userMessage (super(userMessage)); transport errors carry
|
||||||
|
// their own message — so message is already the display copy.
|
||||||
|
private fun errorDetail(error: Throwable): String = error.message ?: error.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The guarded git-write seam DiffViewModel drives (stage/commit/push). Prod: [ApiClientGitWriteGateway]. */
|
||||||
|
public interface GitWriteGateway {
|
||||||
|
public suspend fun gitStage(path: String, files: List<String>, stage: Boolean): GitWriteOutcome<StageResult>
|
||||||
|
public suspend fun gitCommit(path: String, message: String): GitWriteOutcome<CommitResult>
|
||||||
|
public suspend fun gitPush(path: String): GitWriteOutcome<PushResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Production [GitWriteGateway] delegating to a per-host [ApiClient] (Origin stamped in :api-client). */
|
||||||
|
public class ApiClientGitWriteGateway(private val api: ApiClient) : GitWriteGateway {
|
||||||
|
override suspend fun gitStage(path: String, files: List<String>, stage: Boolean): GitWriteOutcome<StageResult> =
|
||||||
|
api.gitStage(path, files, stage)
|
||||||
|
|
||||||
|
override suspend fun gitCommit(path: String, message: String): GitWriteOutcome<CommitResult> =
|
||||||
|
api.gitCommit(path, message)
|
||||||
|
|
||||||
|
override suspend fun gitPush(path: String): GitWriteOutcome<PushResult> = api.gitPush(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A one-line git-write result banner. [isError] drives the colour token (green ok / red failure). */
|
||||||
|
public data class DiffWriteBanner(val message: String, val isError: Boolean)
|
||||||
|
|
||||||
|
/** User-visible git-write copy (Chinese named constants; server strings are surfaced verbatim/inert). */
|
||||||
|
public object DiffCopy {
|
||||||
|
public const val COMMIT_EMPTY: String = "请填写提交信息。"
|
||||||
|
public const val WRITE_REJECTED: String = "操作被服务器拒绝。"
|
||||||
|
public const val RATE_LIMITED: String = "操作过于频繁,服务器已限流,请稍后再试。"
|
||||||
|
|
||||||
|
public fun writeFailed(detail: String): String = "Git 操作失败:$detail"
|
||||||
|
|
||||||
|
/** Success banner keyed off the payload type (short sha / branch→remote / staged count). */
|
||||||
|
public fun okBanner(payload: Any?): String = when (payload) {
|
||||||
|
is StageResult -> if (payload.staged) "已暂存 ${payload.count} 个文件" else "已取消暂存 ${payload.count} 个文件"
|
||||||
|
is CommitResult -> if (payload.commit.isEmpty()) "已提交" else "已提交 ${payload.commit}"
|
||||||
|
is PushResult -> "已推送 ${payload.branch ?: "分支"} → ${payload.remote ?: "远端"}"
|
||||||
|
else -> "操作完成"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The load phase the screen renders (loading spinner / empty / error / list). */
|
/** The load phase the screen renders (loading spinner / empty / error / list). */
|
||||||
@@ -108,14 +231,25 @@ public enum class DiffPhase { IDLE, LOADING, LOADED, EMPTY, ERROR }
|
|||||||
|
|
||||||
/** The immutable snapshot the diff screen renders. */
|
/** The immutable snapshot the diff screen renders. */
|
||||||
public data class DiffUiState(
|
public data class DiffUiState(
|
||||||
/** `false` = working tree, `true` = staged (index). Drives the `staged=1|0` query. */
|
/** `false` = working tree, `true` = staged (index). Drives the `staged=1|0` query. Ignored in base mode. */
|
||||||
val staged: Boolean = false,
|
val staged: Boolean = false,
|
||||||
|
/** Non-null = base mode: diff HEAD against this revision (staged toggle + git-write suppressed). */
|
||||||
|
val base: String? = null,
|
||||||
val phase: DiffPhase = DiffPhase.IDLE,
|
val phase: DiffPhase = DiffPhase.IDLE,
|
||||||
/** files→hunks→lines flattened into one ordered list (empty until loaded). */
|
/** files→hunks→lines flattened into one ordered list (empty until loaded). */
|
||||||
val rows: List<DiffRow> = emptyList(),
|
val rows: List<DiffRow> = emptyList(),
|
||||||
/** Server capped the diff (too large) — the screen shows a truncation notice. */
|
/** Server capped the diff (too large) — the screen shows a truncation notice. */
|
||||||
val truncated: Boolean = false,
|
val truncated: Boolean = false,
|
||||||
)
|
/** True once a git-write is in flight — the screen disables the write controls. */
|
||||||
|
val writing: Boolean = false,
|
||||||
|
/** The last git-write result (ok/failure), or null. Dismissed via [DiffViewModel.clearWriteBanner]. */
|
||||||
|
val writeBanner: DiffWriteBanner? = null,
|
||||||
|
/** Whether git-write controls are offered at all (a writer gateway was supplied). */
|
||||||
|
val canWrite: Boolean = false,
|
||||||
|
) {
|
||||||
|
/** Stage/commit/push are offered only in working/staged mode with a writer bound (never base mode). */
|
||||||
|
val writeEnabled: Boolean get() = canWrite && base == null
|
||||||
|
}
|
||||||
|
|
||||||
// ── The flattened lazy-list model (files → hunks → lines, in order) ──────────────────────────────
|
// ── The flattened lazy-list model (files → hunks → lines, in order) ──────────────────────────────
|
||||||
|
|
||||||
@@ -125,10 +259,12 @@ public sealed interface DiffRow {
|
|||||||
public val id: Long
|
public val id: Long
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A per-file header: the display path plus its `+added/-removed` numstat and status. */
|
/** A per-file header: the display path plus its `+added/-removed` numstat and status. [stagePath] is the
|
||||||
|
* file's `newPath` used verbatim for `git add`/`restore` (the display [path] may be an `old → new` rename). */
|
||||||
public data class DiffFileHeaderRow(
|
public data class DiffFileHeaderRow(
|
||||||
override val id: Long,
|
override val id: Long,
|
||||||
val path: String,
|
val path: String,
|
||||||
|
val stagePath: String,
|
||||||
val status: String,
|
val status: String,
|
||||||
val added: Int,
|
val added: Int,
|
||||||
val removed: Int,
|
val removed: Int,
|
||||||
@@ -153,7 +289,7 @@ public fun flattenDiff(result: DiffResult): List<DiffRow> {
|
|||||||
val rows = ArrayList<DiffRow>()
|
val rows = ArrayList<DiffRow>()
|
||||||
var id = 0L
|
var id = 0L
|
||||||
for (file in result.files) {
|
for (file in result.files) {
|
||||||
rows.add(DiffFileHeaderRow(id++, headerPath(file), file.status, file.added, file.removed))
|
rows.add(DiffFileHeaderRow(id++, headerPath(file), file.newPath, file.status, file.added, file.removed))
|
||||||
if (file.binary) {
|
if (file.binary) {
|
||||||
rows.add(DiffBinaryRow(id++))
|
rows.add(DiffBinaryRow(id++))
|
||||||
continue
|
continue
|
||||||
@@ -212,7 +348,13 @@ public data class DiffFile(
|
|||||||
val hunks: List<DiffHunk>,
|
val hunks: List<DiffHunk>,
|
||||||
)
|
)
|
||||||
|
|
||||||
public data class DiffResult(val files: List<DiffFile>, val staged: Boolean, val truncated: Boolean)
|
public data class DiffResult(
|
||||||
|
val files: List<DiffFile>,
|
||||||
|
val staged: Boolean,
|
||||||
|
val truncated: Boolean,
|
||||||
|
/** Echoed by the server when the diff was against a base revision (`?base=<rev>`); null otherwise. */
|
||||||
|
val base: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
/** Tolerant JSON: unknown keys ignored, lenient — the untrusted-server config (mirror of `ModelJson`). */
|
/** Tolerant JSON: unknown keys ignored, lenient — the untrusted-server config (mirror of `ModelJson`). */
|
||||||
private val DiffJson: Json = Json {
|
private val DiffJson: Json = Json {
|
||||||
@@ -230,7 +372,12 @@ internal fun decodeDiffResult(bytes: ByteArray): DiffResult {
|
|||||||
.getOrNull() as? JsonObject
|
.getOrNull() as? JsonObject
|
||||||
?: return DiffResult(emptyList(), staged = false, truncated = false)
|
?: return DiffResult(emptyList(), staged = false, truncated = false)
|
||||||
val files = (root["files"] as? JsonArray).orEmpty().mapNotNull(::decodeFile)
|
val files = (root["files"] as? JsonArray).orEmpty().mapNotNull(::decodeFile)
|
||||||
return DiffResult(files = files, staged = root.bool("staged", false), truncated = root.bool("truncated", false))
|
return DiffResult(
|
||||||
|
files = files,
|
||||||
|
staged = root.bool("staged", false),
|
||||||
|
truncated = root.bool("truncated", false),
|
||||||
|
base = root.str("base"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A file needs a string `newPath` to be renderable; anything else drops it (keeps the rest). */
|
/** A file needs a string `newPath` to be renderable; anything else drops it (keeps the rest). */
|
||||||
@@ -278,8 +425,12 @@ private fun JsonObject.bool(key: String, default: Boolean): Boolean =
|
|||||||
|
|
||||||
/** Fetches + decodes a diff for a project path. Seam so the presenter is driven by a fake in tests. */
|
/** Fetches + decodes a diff for a project path. Seam so the presenter is driven by a fake in tests. */
|
||||||
public interface DiffFetcher {
|
public interface DiffFetcher {
|
||||||
/** @throws DiffUnavailable on a non-200 status; transport errors propagate. */
|
/**
|
||||||
public suspend fun fetch(path: String, staged: Boolean): DiffResult
|
* @param base when non-null, diff HEAD against this base revision — the server IGNORES [staged]
|
||||||
|
* in base mode (server.ts:831), so callers omit it and the screen hides the Working/Staged toggle.
|
||||||
|
* @throws DiffUnavailable on a non-200 status; transport errors propagate.
|
||||||
|
*/
|
||||||
|
public suspend fun fetch(path: String, staged: Boolean, base: String?): DiffResult
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A non-200 from the diff route (400 bad path / 404 not a repo / 500 git failed). */
|
/** A non-200 from the diff route (400 bad path / 404 not a repo / 500 git failed). */
|
||||||
@@ -293,8 +444,8 @@ public class HttpDiffFetcher(
|
|||||||
private val endpoint: HostEndpoint,
|
private val endpoint: HostEndpoint,
|
||||||
private val http: HttpTransport,
|
private val http: HttpTransport,
|
||||||
) : DiffFetcher {
|
) : DiffFetcher {
|
||||||
override suspend fun fetch(path: String, staged: Boolean): DiffResult {
|
override suspend fun fetch(path: String, staged: Boolean, base: String?): DiffResult {
|
||||||
val url = diffUrl(endpoint.baseUrl, path, staged) ?: throw DiffUnavailable(HTTP_BAD_REQUEST)
|
val url = diffUrl(endpoint.baseUrl, path, staged, base) ?: throw DiffUnavailable(HTTP_BAD_REQUEST)
|
||||||
val response = http.send(HttpRequest(method = HttpMethod.GET, url = url))
|
val response = http.send(HttpRequest(method = HttpMethod.GET, url = url))
|
||||||
if (response.status != HTTP_OK) throw DiffUnavailable(response.status)
|
if (response.status != HTTP_OK) throw DiffUnavailable(response.status)
|
||||||
return decodeDiffResult(response.body)
|
return decodeDiffResult(response.body)
|
||||||
@@ -305,20 +456,28 @@ private const val HTTP_OK = 200
|
|||||||
private const val HTTP_BAD_REQUEST = 400
|
private const val HTTP_BAD_REQUEST = 400
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build `<scheme>://host[:port]/projects/diff?path=<enc>&staged=1|0` from the dialed base URL,
|
* Build `<scheme>://host[:port]/projects/diff?path=<enc>[&staged=1|0][&base=<enc>]` from the dialed
|
||||||
* keeping the dialed port verbatim (mirror of `ApiRoute.buildUrl`). `staged` serializes as the
|
* base URL, keeping the dialed port verbatim (mirror of `ApiRoute.buildUrl`). In **base mode**
|
||||||
* literal `"1"`/`"0"` string the server matches with `=== '1'` (NOT a boolean). Returns null if the
|
* ([base] non-null/non-blank) the server ignores `staged` (server.ts:831), so `staged` is OMITTED and
|
||||||
* base URL cannot be parsed. `internal` so the JVM test asserts the exact query value.
|
* `&base=<enc>` is appended (percent-encoded; the server's `isPlausibleRev` rejects junk with a 400).
|
||||||
|
* Otherwise `staged` serializes as the literal `"1"`/`"0"` string the server matches with `=== '1'`
|
||||||
|
* (NOT a boolean). Returns null if the base URL cannot be parsed. `internal` so the JVM test asserts
|
||||||
|
* the exact query value.
|
||||||
*/
|
*/
|
||||||
internal fun diffUrl(baseUrl: String, path: String, staged: Boolean): String? {
|
internal fun diffUrl(baseUrl: String, path: String, staged: Boolean, base: String? = null): String? {
|
||||||
val uri = runCatching { URI(baseUrl.trim()) }.getOrNull() ?: return null
|
val uri = runCatching { URI(baseUrl.trim()) }.getOrNull() ?: return null
|
||||||
val scheme = uri.scheme?.lowercase() ?: return null
|
val scheme = uri.scheme?.lowercase() ?: return null
|
||||||
val host = uri.host ?: return null
|
val host = uri.host ?: return null
|
||||||
if (host.isEmpty()) return null
|
if (host.isEmpty()) return null
|
||||||
val serializedHost = if (host.contains(":") && !host.startsWith("[")) "[$host]" else host
|
val serializedHost = if (host.contains(":") && !host.startsWith("[")) "[$host]" else host
|
||||||
val portPart = if (uri.port != -1) ":${uri.port}" else ""
|
val portPart = if (uri.port != -1) ":${uri.port}" else ""
|
||||||
val stagedValue = if (staged) "1" else "0"
|
val prefix = "$scheme://$serializedHost$portPart/projects/diff?path=${percentEncode(path)}"
|
||||||
return "$scheme://$serializedHost$portPart/projects/diff?path=${percentEncode(path)}&staged=$stagedValue"
|
val trimmedBase = base?.trim()
|
||||||
|
return if (!trimmedBase.isNullOrEmpty()) {
|
||||||
|
"$prefix&base=${percentEncode(trimmedBase)}" // base mode: no staged (server ignores it)
|
||||||
|
} else {
|
||||||
|
"$prefix&staged=${if (staged) "1" else "0"}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Strict RFC 3986 unreserved set — everything else percent-encoded over UTF-8 (mirror of Endpoints). */
|
/** Strict RFC 3986 unreserved set — everything else percent-encoded over UTF-8 (mirror of Endpoints). */
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
package wang.yaojia.webterm.viewmodels
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import wang.yaojia.webterm.api.enroll.DeviceEnrollmentError
|
||||||
|
import wang.yaojia.webterm.clienttls.CertificateSummary
|
||||||
|
import java.net.URI
|
||||||
|
import java.security.GeneralSecurityException
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # EnrollmentViewModel (B4) — the zero-`.p12` device-enrollment presenter.
|
||||||
|
*
|
||||||
|
* The Android port of iOS `EnrollmentViewModel` (`EnrollmentScreen.swift`). Drives the "自动获取证书"
|
||||||
|
* screen reached from the session-list host menu: one operator login (password) → a short-lived
|
||||||
|
* `device:enroll` bearer → a NON-EXPORTABLE hardware key + PKCS#10 CSR → `POST /device/enroll` → the
|
||||||
|
* returned leaf is committed to the shared cert store and presented AUTOMATICALLY on the existing mTLS
|
||||||
|
* path (no manual `.p12` import). Cache freshness (FIX 3) means the enrolled cert is live without a restart.
|
||||||
|
*
|
||||||
|
* A **plain presenter** (mirrors [ClientCertViewModel] / [PairingViewModel]), NOT an
|
||||||
|
* `androidx.lifecycle.ViewModel`, so it runs under `runTest` virtual time with no `Dispatchers.Main`. The
|
||||||
|
* enroll flow and the installed-summary read are injected as suspend closures ([enrollOperation] /
|
||||||
|
* [loadSummary]) so the VM is unit-testable without a keystore or network; production wires them (in the
|
||||||
|
* enrollment pane) to [wang.yaojia.webterm.wiring.EnrollmentFlowFactory] over a `Dispatchers.IO` hop.
|
||||||
|
*
|
||||||
|
* ### Secrets discipline (plan §8)
|
||||||
|
* The operator password lives only in [EnrollmentUiState] and is **cleared after every attempt** (success
|
||||||
|
* OR failure) so it never lingers in memory; it is never logged and never placed in error copy. Errors are
|
||||||
|
* a coarse [EnrollError] enum the screen maps to app-authored, inert copy — never a server/exception string.
|
||||||
|
*
|
||||||
|
* @param enrollOperation login → hardware keygen+CSR → enroll → commit, yielding the installed leaf summary.
|
||||||
|
* @param loadSummary the currently-installed device-cert summary (for the "已安装证书" section), or null.
|
||||||
|
* @param defaultControlPlaneUrl prefilled control-plane URL (the operator can edit it).
|
||||||
|
* @param defaultDeviceName prefilled device name (the Android device/model in production).
|
||||||
|
* @param zone / now formatting + expiry clock for the installed-cert summary (fixed in tests).
|
||||||
|
*/
|
||||||
|
public class EnrollmentViewModel(
|
||||||
|
private val enrollOperation: suspend (password: String, subdomain: String, deviceName: String, controlPlaneUrl: String) -> CertificateSummary,
|
||||||
|
private val loadSummary: suspend () -> CertificateSummary?,
|
||||||
|
defaultControlPlaneUrl: String = DEFAULT_CONTROL_PLANE_URL,
|
||||||
|
defaultDeviceName: String = "",
|
||||||
|
private val zone: ZoneId = ZoneId.systemDefault(),
|
||||||
|
private val now: () -> Instant = Instant::now,
|
||||||
|
) {
|
||||||
|
private val _uiState = MutableStateFlow(
|
||||||
|
EnrollmentUiState(controlPlaneUrl = defaultControlPlaneUrl, deviceName = defaultDeviceName),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The single snapshot the enrollment screen renders from. */
|
||||||
|
public val uiState: StateFlow<EnrollmentUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private var scope: CoroutineScope? = null
|
||||||
|
private var job: Job? = null
|
||||||
|
|
||||||
|
/** Bind the scope actions launch into (the screen passes a lifecycle scope) and load any installed cert. */
|
||||||
|
public fun bind(scope: CoroutineScope) {
|
||||||
|
this.scope = scope
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Field edits (two-way bound from the Compose form) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
public fun onControlPlaneUrlChange(value: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(controlPlaneUrl = value)
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun onSubdomainChange(value: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(subdomain = value)
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun onDeviceNameChange(value: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(deviceName = value)
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun onPasswordChange(value: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(password = value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dismiss the current error banner. */
|
||||||
|
public fun clearError() {
|
||||||
|
_uiState.value = _uiState.value.copy(error = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refresh() {
|
||||||
|
val scope = scope ?: return
|
||||||
|
job?.cancel()
|
||||||
|
_uiState.value = _uiState.value.copy(phase = EnrollPhase.LOADING)
|
||||||
|
job = scope.launch {
|
||||||
|
// loadSummary is designed to degrade to null on a storage fault (never throw); guard anyway.
|
||||||
|
val summary = runCatching { loadSummary() }.getOrNull()
|
||||||
|
_uiState.value = _uiState.value.copy(phase = EnrollPhase.IDLE, summary = summary?.toView())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run enrollment: validate the control-plane URL (must be `https` with a host) and the fields BEFORE
|
||||||
|
* any network, then drive [enrollOperation]. The password is cleared after every attempt so it never
|
||||||
|
* lingers. A double-tap while an enroll is in flight is ignored.
|
||||||
|
*/
|
||||||
|
public fun enroll() {
|
||||||
|
val scope = scope ?: return
|
||||||
|
val snapshot = _uiState.value
|
||||||
|
if (snapshot.phase == EnrollPhase.ENROLLING) return
|
||||||
|
|
||||||
|
val url = validControlPlaneUrl(snapshot.controlPlaneUrl)
|
||||||
|
if (url == null) {
|
||||||
|
_uiState.value = snapshot.copy(error = EnrollError.INVALID_URL, didSucceed = false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val subdomain = snapshot.subdomain.trim()
|
||||||
|
val deviceName = snapshot.deviceName.trim()
|
||||||
|
val password = snapshot.password
|
||||||
|
if (subdomain.isEmpty() || deviceName.isEmpty() || password.isEmpty()) {
|
||||||
|
_uiState.value = snapshot.copy(error = EnrollError.MISSING_FIELDS, didSucceed = false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.value = snapshot.copy(phase = EnrollPhase.ENROLLING, error = null, didSucceed = false)
|
||||||
|
job?.cancel()
|
||||||
|
job = scope.launch {
|
||||||
|
val outcome = try {
|
||||||
|
Result.success(enrollOperation(password, subdomain, deviceName, url))
|
||||||
|
} catch (cancel: CancellationException) {
|
||||||
|
throw cancel
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
Result.failure(error)
|
||||||
|
}
|
||||||
|
// Clear the password whatever the outcome — never linger after an attempt.
|
||||||
|
_uiState.value = outcome.fold(
|
||||||
|
onSuccess = { summary ->
|
||||||
|
_uiState.value.copy(
|
||||||
|
phase = EnrollPhase.IDLE,
|
||||||
|
summary = summary.toView(),
|
||||||
|
password = "",
|
||||||
|
didSucceed = true,
|
||||||
|
error = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
_uiState.value.copy(
|
||||||
|
phase = EnrollPhase.IDLE,
|
||||||
|
password = "",
|
||||||
|
didSucceed = false,
|
||||||
|
error = classifyEnroll(e),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fields all present + not already enrolling → the button is enabled (deeper URL check on tap). */
|
||||||
|
private fun CertificateSummary.toView() = toSummaryView(now(), zone)
|
||||||
|
|
||||||
|
public companion object {
|
||||||
|
/** The default control-plane URL (matches iOS `EnrollmentCopy.defaultControlPlaneURL`). */
|
||||||
|
public const val DEFAULT_CONTROL_PLANE_URL: String = "https://cp.terminal.yaojia.wang"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a control-plane URL exactly as iOS does before any network: it must parse, be `https`, and
|
||||||
|
* carry a non-blank host. Returns the trimmed URL on success, or null (→ [EnrollError.INVALID_URL]).
|
||||||
|
*/
|
||||||
|
internal fun validControlPlaneUrl(raw: String): String? {
|
||||||
|
val trimmed = raw.trim()
|
||||||
|
if (trimmed.isEmpty()) return null
|
||||||
|
val uri = runCatching { URI(trimmed) }.getOrNull() ?: return null
|
||||||
|
if (!"https".equals(uri.scheme, ignoreCase = true)) return null
|
||||||
|
if (uri.host.isNullOrBlank()) return null
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map an enroll throwable to the coarse, non-secret [EnrollError] the screen renders inertly. Android's
|
||||||
|
* login + enroll share one [DeviceEnrollmentError.Http] type, so the HTTP status disambiguates (401 login
|
||||||
|
* vs 403 subdomain-not-owned vs 429 vs 400). Keystore/hardware faults (incl. StrongBox-unavailable, a
|
||||||
|
* [GeneralSecurityException] subclass) map to KEYGEN. Never carries the password/bearer.
|
||||||
|
*/
|
||||||
|
internal fun classifyEnroll(error: Throwable): EnrollError = when (error) {
|
||||||
|
is DeviceEnrollmentError.Http -> when (error.status) {
|
||||||
|
401 -> EnrollError.BAD_CREDENTIAL
|
||||||
|
403 -> EnrollError.SUBDOMAIN_NOT_OWNED
|
||||||
|
429 -> EnrollError.RATE_LIMITED
|
||||||
|
400 -> EnrollError.REJECTED
|
||||||
|
else -> EnrollError.ENROLL_FAILED
|
||||||
|
}
|
||||||
|
is DeviceEnrollmentError.MalformedResponse -> EnrollError.SERVER
|
||||||
|
is DeviceEnrollmentError -> EnrollError.ENROLL_FAILED // InvalidRequest etc. (post-validation, rare)
|
||||||
|
is GeneralSecurityException -> EnrollError.KEYGEN // hardware key / StrongBox / keystore fault
|
||||||
|
else -> EnrollError.UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The load/action phase the enrollment screen renders. */
|
||||||
|
public enum class EnrollPhase {
|
||||||
|
/** The initial installed-summary read is in flight. */
|
||||||
|
LOADING,
|
||||||
|
|
||||||
|
/** Idle — the form is editable and [EnrollmentUiState.summary] shows any installed cert. */
|
||||||
|
IDLE,
|
||||||
|
|
||||||
|
/** An enroll is in flight (login → keygen+CSR → enroll → commit). */
|
||||||
|
ENROLLING,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The coarse, non-secret enrollment failure taxonomy — the screen maps each to app-authored inert copy (§8). */
|
||||||
|
public enum class EnrollError {
|
||||||
|
/** The control-plane URL is not a valid `https://…` URL with a host (set before any network). */
|
||||||
|
INVALID_URL,
|
||||||
|
|
||||||
|
/** A required field (subdomain / device name / password) was empty (set before any network). */
|
||||||
|
MISSING_FIELDS,
|
||||||
|
|
||||||
|
/** Operator login rejected (401) — the password is wrong. */
|
||||||
|
BAD_CREDENTIAL,
|
||||||
|
|
||||||
|
/** The account does not own the requested subdomain (403) — no cert can be issued. */
|
||||||
|
SUBDOMAIN_NOT_OWNED,
|
||||||
|
|
||||||
|
/** Too many requests (429) — back off and retry. */
|
||||||
|
RATE_LIMITED,
|
||||||
|
|
||||||
|
/** The subdomain or CSR was rejected (400). */
|
||||||
|
REJECTED,
|
||||||
|
|
||||||
|
/** Any other enroll failure (server-side or transport). */
|
||||||
|
ENROLL_FAILED,
|
||||||
|
|
||||||
|
/** The server returned a malformed response. */
|
||||||
|
SERVER,
|
||||||
|
|
||||||
|
/** Hardware key generation failed (no StrongBox/TEE, or a keystore fault). */
|
||||||
|
KEYGEN,
|
||||||
|
|
||||||
|
/** An unclassified failure. */
|
||||||
|
UNKNOWN,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The immutable snapshot the enrollment screen renders. */
|
||||||
|
public data class EnrollmentUiState(
|
||||||
|
val controlPlaneUrl: String = "",
|
||||||
|
val subdomain: String = "",
|
||||||
|
val deviceName: String = "",
|
||||||
|
val password: String = "",
|
||||||
|
/** The currently-installed device identity's display summary, or `null` when none is installed. */
|
||||||
|
val summary: CertSummaryView? = null,
|
||||||
|
val phase: EnrollPhase = EnrollPhase.LOADING,
|
||||||
|
/** The last enroll failure, or `null`. Surfaced as inert, app-authored copy. */
|
||||||
|
val error: EnrollError? = null,
|
||||||
|
/** Whether the most recent enroll succeeded (drives the success affordance). */
|
||||||
|
val didSucceed: Boolean = false,
|
||||||
|
) {
|
||||||
|
/** Every field present and no enroll in flight → the submit button is enabled. */
|
||||||
|
val canEnroll: Boolean
|
||||||
|
get() = phase != EnrollPhase.ENROLLING &&
|
||||||
|
controlPlaneUrl.trim().isNotEmpty() &&
|
||||||
|
subdomain.trim().isNotEmpty() &&
|
||||||
|
deviceName.trim().isNotEmpty() &&
|
||||||
|
password.isNotEmpty()
|
||||||
|
}
|
||||||
@@ -4,26 +4,32 @@ import kotlinx.coroutines.CancellationException
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import wang.yaojia.webterm.api.models.GitLogResult
|
||||||
|
import wang.yaojia.webterm.api.models.PrStatus
|
||||||
import wang.yaojia.webterm.api.models.ProjectDetail
|
import wang.yaojia.webterm.api.models.ProjectDetail
|
||||||
import wang.yaojia.webterm.api.routes.ApiClientError
|
import wang.yaojia.webterm.api.routes.ApiClientError
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* # ProjectDetailViewModel (A23) — one project's detail (`GET /projects/detail?path=`), a phase state
|
* # ProjectDetailViewModel (A23 + W5) — one project's detail (`GET /projects/detail?path=`), a phase
|
||||||
* machine (same discipline as [DiffViewModel]/iOS `ProjectDetailViewModel`).
|
* state machine, PLUS two failure-ISOLATED side fetches: the PR + CI chip (`GET /projects/pr`) and the
|
||||||
|
* recent-commits list (`GET /projects/log`). A failure of either side fetch NEVER fails the detail load
|
||||||
|
* (each has its own StateFlow) — the chip/list simply render an unavailable state.
|
||||||
*
|
*
|
||||||
* The [fetch] closure is injected — production wraps [ProjectsGateway.projectDetail] (the builder's
|
* The main [fetch] closure is injected (production wraps [ProjectsGateway.projectDetail]); [fetchPr] /
|
||||||
* percent-encoding + 400/404/500 → typed [ApiClientError] mapping lives in `:api-client`), tests inject a
|
* [fetchLog] are optional side fetches (null → the chip/list stay [PrChip.Hidden] / [RecentCommits.Hidden]).
|
||||||
* fake. This VM only reduces the three user-visible outcomes:
|
* [worktree] (when wired) drives the guarded create/remove/prune actions and re-fetches this detail on
|
||||||
* - success → [Phase.Loaded] (sessions/worktrees/hasClaudeMd/claudeMd passed through, rendered INERT);
|
* success (via [load]).
|
||||||
* - 400 / [ApiClientError.InvalidRequest] → [Failure.PATH_INVALID];
|
|
||||||
* - 404 → [Failure.NOT_FOUND]; 500 / decode / transport → [Failure.UNAVAILABLE] — all retryable via [load].
|
|
||||||
*
|
*
|
||||||
* A plain presenter (not `androidx.lifecycle.ViewModel`) so it runs under `runTest` with no
|
* A plain presenter (not `androidx.lifecycle.ViewModel`) so it runs under `runTest`. The screen calls
|
||||||
* `Dispatchers.Main`. The screen calls [load] in a lifecycle scope; the retry action re-calls it.
|
* [load] in a lifecycle scope; the retry action re-calls it.
|
||||||
*/
|
*/
|
||||||
public class ProjectDetailViewModel(
|
public class ProjectDetailViewModel(
|
||||||
public val path: String,
|
public val path: String,
|
||||||
private val fetch: suspend () -> ProjectDetail,
|
private val fetch: suspend () -> ProjectDetail,
|
||||||
|
private val fetchPr: (suspend () -> PrStatus)? = null,
|
||||||
|
private val fetchLog: (suspend () -> GitLogResult)? = null,
|
||||||
|
/** Guarded worktree actions bound to this project; null when the gateway isn't wired (tests). */
|
||||||
|
public val worktree: WorktreeViewModel? = null,
|
||||||
) {
|
) {
|
||||||
/** User-visible failure buckets (copy mapped in `ProjectDetailScreen`). */
|
/** User-visible failure buckets (copy mapped in `ProjectDetailScreen`). */
|
||||||
public enum class Failure { PATH_INVALID, NOT_FOUND, UNAVAILABLE }
|
public enum class Failure { PATH_INVALID, NOT_FOUND, UNAVAILABLE }
|
||||||
@@ -35,12 +41,40 @@ public class ProjectDetailViewModel(
|
|||||||
public data class Failed(val failure: Failure) : Phase
|
public data class Failed(val failure: Failure) : Phase
|
||||||
}
|
}
|
||||||
|
|
||||||
private val _phase = MutableStateFlow<Phase>(Phase.Loading)
|
/** The PR + CI chip's own state (isolated from the detail load). */
|
||||||
|
public sealed interface PrChip {
|
||||||
|
public data object Hidden : PrChip
|
||||||
|
public data object Loading : PrChip
|
||||||
|
public data class Loaded(val status: PrStatus) : PrChip
|
||||||
|
public data object Unavailable : PrChip
|
||||||
|
}
|
||||||
|
|
||||||
/** The single snapshot `ProjectDetailScreen` renders from. */
|
/** The recent-commits section's own state (isolated from the detail load). */
|
||||||
|
public sealed interface RecentCommits {
|
||||||
|
public data object Hidden : RecentCommits
|
||||||
|
public data object Loading : RecentCommits
|
||||||
|
public data class Loaded(val result: GitLogResult) : RecentCommits
|
||||||
|
public data object Unavailable : RecentCommits
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _phase = MutableStateFlow<Phase>(Phase.Loading)
|
||||||
|
private val _prChip = MutableStateFlow<PrChip>(PrChip.Hidden)
|
||||||
|
private val _recentCommits = MutableStateFlow<RecentCommits>(RecentCommits.Hidden)
|
||||||
|
|
||||||
|
/** The main detail snapshot `ProjectDetailScreen` renders from. */
|
||||||
public val phase: StateFlow<Phase> = _phase.asStateFlow()
|
public val phase: StateFlow<Phase> = _phase.asStateFlow()
|
||||||
|
|
||||||
/** Fetch and present. Also the retry path: callable again after a [Phase.Failed]. */
|
/** The PR chip snapshot (renders one chip from [PrStatus.availability]). */
|
||||||
|
public val prChip: StateFlow<PrChip> = _prChip.asStateFlow()
|
||||||
|
|
||||||
|
/** The recent-commits snapshot. */
|
||||||
|
public val recentCommits: StateFlow<RecentCommits> = _recentCommits.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch and present. Also the retry path: callable again after a [Phase.Failed]. On a successful
|
||||||
|
* detail load it runs the two side fetches, each failure-isolated (a PR/log failure does not fail
|
||||||
|
* the detail load nor each other).
|
||||||
|
*/
|
||||||
public suspend fun load() {
|
public suspend fun load() {
|
||||||
_phase.value = Phase.Loading
|
_phase.value = Phase.Loading
|
||||||
_phase.value = try {
|
_phase.value = try {
|
||||||
@@ -53,6 +87,34 @@ public class ProjectDetailViewModel(
|
|||||||
// Transport/decode etc. — a retryable catch-all.
|
// Transport/decode etc. — a retryable catch-all.
|
||||||
Phase.Failed(Failure.UNAVAILABLE)
|
Phase.Failed(Failure.UNAVAILABLE)
|
||||||
}
|
}
|
||||||
|
if (_phase.value is Phase.Loaded) {
|
||||||
|
loadPr()
|
||||||
|
loadRecentCommits()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadPr() {
|
||||||
|
val fetcher = fetchPr ?: return
|
||||||
|
_prChip.value = PrChip.Loading
|
||||||
|
_prChip.value = try {
|
||||||
|
PrChip.Loaded(fetcher())
|
||||||
|
} catch (cancel: CancellationException) {
|
||||||
|
throw cancel
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
PrChip.Unavailable // isolated: a PR fetch failure never touches the detail phase
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadRecentCommits() {
|
||||||
|
val fetcher = fetchLog ?: return
|
||||||
|
_recentCommits.value = RecentCommits.Loading
|
||||||
|
_recentCommits.value = try {
|
||||||
|
RecentCommits.Loaded(fetcher())
|
||||||
|
} catch (cancel: CancellationException) {
|
||||||
|
throw cancel
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
RecentCommits.Unavailable
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun failureFor(error: ApiClientError): Failure = when (error) {
|
private fun failureFor(error: ApiClientError): Failure = when (error) {
|
||||||
@@ -62,8 +124,22 @@ public class ProjectDetailViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
public companion object {
|
public companion object {
|
||||||
/** Production assembly seam ([ProjectsViewModel.makeDetailViewModel] mints via this). */
|
/**
|
||||||
public fun forGateway(gateway: ProjectsGateway, path: String): ProjectDetailViewModel =
|
* Production assembly seam ([ProjectsViewModel.makeDetailViewModel] mints via this). Wires the
|
||||||
ProjectDetailViewModel(path) { gateway.projectDetail(path) }
|
* detail + PR + log fetches and a [WorktreeViewModel] whose successes re-fetch this detail.
|
||||||
|
*/
|
||||||
|
public fun forGateway(gateway: ProjectsGateway, path: String): ProjectDetailViewModel {
|
||||||
|
var self: ProjectDetailViewModel? = null
|
||||||
|
val worktree = WorktreeViewModel(gateway, path, onChanged = { self?.load() })
|
||||||
|
val vm = ProjectDetailViewModel(
|
||||||
|
path = path,
|
||||||
|
fetch = { gateway.projectDetail(path) },
|
||||||
|
fetchPr = { gateway.projectPr(path) },
|
||||||
|
fetchLog = { gateway.projectLog(path, null) },
|
||||||
|
worktree = worktree,
|
||||||
|
)
|
||||||
|
self = vm
|
||||||
|
return vm
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,14 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import wang.yaojia.webterm.api.models.CreateWorktreeResult
|
||||||
|
import wang.yaojia.webterm.api.models.GitLogResult
|
||||||
|
import wang.yaojia.webterm.api.models.GitWriteOutcome
|
||||||
|
import wang.yaojia.webterm.api.models.PrStatus
|
||||||
import wang.yaojia.webterm.api.models.ProjectDetail
|
import wang.yaojia.webterm.api.models.ProjectDetail
|
||||||
import wang.yaojia.webterm.api.models.ProjectInfo
|
import wang.yaojia.webterm.api.models.ProjectInfo
|
||||||
|
import wang.yaojia.webterm.api.models.PruneWorktreesResult
|
||||||
|
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
|
||||||
import wang.yaojia.webterm.api.models.UiPrefs
|
import wang.yaojia.webterm.api.models.UiPrefs
|
||||||
import wang.yaojia.webterm.api.routes.ApiClient
|
import wang.yaojia.webterm.api.routes.ApiClient
|
||||||
import wang.yaojia.webterm.api.routes.ApiClientError
|
import wang.yaojia.webterm.api.routes.ApiClientError
|
||||||
@@ -396,12 +402,26 @@ public data class ProjectsUiState(
|
|||||||
|
|
||||||
// ── Gateway seam (abstracts ApiClient so the VM is JVM-tested against a fake) ─────────────────────
|
// ── Gateway seam (abstracts ApiClient so the VM is JVM-tested against a fake) ─────────────────────
|
||||||
|
|
||||||
/** Per-host projects gateway. Production is [ApiClientProjectsGateway]; tests queue canned responses. */
|
/**
|
||||||
|
* Per-host projects gateway. Production is [ApiClientProjectsGateway]; tests queue canned responses.
|
||||||
|
* The W5 additions (PR / recent commits / worktree create-remove-prune) let the detail page and the
|
||||||
|
* [WorktreeViewModel] stay JVM-tested against a fake; the guarded worktree writes flow through the
|
||||||
|
* :api-client Origin-stamping point (plan §Security).
|
||||||
|
*/
|
||||||
public interface ProjectsGateway {
|
public interface ProjectsGateway {
|
||||||
public suspend fun projects(): List<ProjectInfo>
|
public suspend fun projects(): List<ProjectInfo>
|
||||||
public suspend fun prefs(): UiPrefs
|
public suspend fun prefs(): UiPrefs
|
||||||
public suspend fun putPrefs(prefs: UiPrefs): UiPrefs
|
public suspend fun putPrefs(prefs: UiPrefs): UiPrefs
|
||||||
public suspend fun projectDetail(path: String): ProjectDetail
|
public suspend fun projectDetail(path: String): ProjectDetail
|
||||||
|
|
||||||
|
// ── W5: read-only PR + recent commits ──────────────────────────────────────────────────
|
||||||
|
public suspend fun projectPr(path: String): PrStatus
|
||||||
|
public suspend fun projectLog(path: String, n: Int? = null): GitLogResult
|
||||||
|
|
||||||
|
// ── W5: guarded worktree write ─────────────────────────────────────────────────────────
|
||||||
|
public suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome<CreateWorktreeResult>
|
||||||
|
public suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean): GitWriteOutcome<RemoveWorktreeResult>
|
||||||
|
public suspend fun pruneWorktrees(path: String): GitWriteOutcome<PruneWorktreesResult>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Production [ProjectsGateway] delegating to a per-host [ApiClient] over the shared mTLS transport. */
|
/** Production [ProjectsGateway] delegating to a per-host [ApiClient] over the shared mTLS transport. */
|
||||||
@@ -410,6 +430,15 @@ public class ApiClientProjectsGateway(private val api: ApiClient) : ProjectsGate
|
|||||||
override suspend fun prefs(): UiPrefs = api.prefs()
|
override suspend fun prefs(): UiPrefs = api.prefs()
|
||||||
override suspend fun putPrefs(prefs: UiPrefs): UiPrefs = api.putPrefs(prefs)
|
override suspend fun putPrefs(prefs: UiPrefs): UiPrefs = api.putPrefs(prefs)
|
||||||
override suspend fun projectDetail(path: String): ProjectDetail = api.projectDetail(path)
|
override suspend fun projectDetail(path: String): ProjectDetail = api.projectDetail(path)
|
||||||
|
override suspend fun projectPr(path: String): PrStatus = api.projectPr(path)
|
||||||
|
override suspend fun projectLog(path: String, n: Int?): GitLogResult = api.projectLog(path, n)
|
||||||
|
override suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome<CreateWorktreeResult> =
|
||||||
|
api.createWorktree(path, branch, base)
|
||||||
|
|
||||||
|
override suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean): GitWriteOutcome<RemoveWorktreeResult> =
|
||||||
|
api.removeWorktree(path, worktreePath, force)
|
||||||
|
|
||||||
|
override suspend fun pruneWorktrees(path: String): GitWriteOutcome<PruneWorktreesResult> = api.pruneWorktrees(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** User-visible copy (Chinese named constants; labels are local UI text — only group KEYS are frozen). */
|
/** User-visible copy (Chinese named constants; labels are local UI text — only group KEYS are frozen). */
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package wang.yaojia.webterm.viewmodels
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import wang.yaojia.webterm.api.models.CreateWorktreeResult
|
||||||
|
import wang.yaojia.webterm.api.models.GitWriteOutcome
|
||||||
|
import wang.yaojia.webterm.api.models.PruneWorktreesResult
|
||||||
|
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
|
||||||
|
import wang.yaojia.webterm.api.models.WorktreeInfo
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # WorktreeViewModel (W5) — the guarded worktree write actions for one project.
|
||||||
|
*
|
||||||
|
* A phase machine (`Idle → Working → Done | Failed`) over the three guarded routes
|
||||||
|
* (`POST /projects/worktree`, `DELETE /projects/worktree`, `POST /projects/worktree/prune`), all
|
||||||
|
* flowing through the :api-client Origin-stamping point (plan §Security). On a successful op it invokes
|
||||||
|
* [onChanged] so the detail screen re-fetches and the worktree list refreshes.
|
||||||
|
*
|
||||||
|
* ### Defense in depth (UX, not the security boundary)
|
||||||
|
* The branch name is pre-validated client-side ([isValidBranchName], a mirror of the server's
|
||||||
|
* `validateBranchName`) so an obviously bad name fails with NO network I/O; a **main** worktree removal
|
||||||
|
* is blocked client-side ([WorktreeInfo.isMain]) — the server re-validates + realpath-contains
|
||||||
|
* regardless. Server `error` strings (disabled kill-switch, "uncommitted changes; force required") are
|
||||||
|
* surfaced INERT (plain text; never linkified).
|
||||||
|
*
|
||||||
|
* A plain presenter (not `androidx.lifecycle.ViewModel`) so it runs under `runTest`. The screen calls
|
||||||
|
* the suspend actions from a lifecycle scope; [reset] clears a settled banner back to [Phase.Idle].
|
||||||
|
*/
|
||||||
|
public class WorktreeViewModel(
|
||||||
|
private val gateway: ProjectsGateway,
|
||||||
|
private val repoPath: String,
|
||||||
|
/** Invoked after any successful write so the detail page re-fetches (list refresh). */
|
||||||
|
private val onChanged: suspend () -> Unit = {},
|
||||||
|
) {
|
||||||
|
/** The action phase the screen renders (idle / spinner / success banner / failure banner). */
|
||||||
|
public sealed interface Phase {
|
||||||
|
public data object Idle : Phase
|
||||||
|
public data object Working : Phase
|
||||||
|
public data class Done(val message: String) : Phase
|
||||||
|
public data class Failed(val message: String) : Phase
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _phase = MutableStateFlow<Phase>(Phase.Idle)
|
||||||
|
|
||||||
|
/** The single snapshot the worktree sheet/dialog renders from. */
|
||||||
|
public val phase: StateFlow<Phase> = _phase.asStateFlow()
|
||||||
|
|
||||||
|
/** Create a worktree for [branch] (off optional [base]). Invalid branch → [Phase.Failed], no I/O. */
|
||||||
|
public suspend fun create(branch: String, base: String? = null) {
|
||||||
|
val trimmed = branch.trim()
|
||||||
|
if (!isValidBranchName(trimmed)) {
|
||||||
|
_phase.value = Phase.Failed(WorktreeCopy.INVALID_BRANCH)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (_phase.value == Phase.Working) return
|
||||||
|
_phase.value = Phase.Working
|
||||||
|
val cleanBase = base?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
_phase.value = runOp { gateway.createWorktree(repoPath, trimmed, cleanBase) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove [worktree] ([force] to discard uncommitted changes). A **main** worktree is blocked here. */
|
||||||
|
public suspend fun remove(worktree: WorktreeInfo, force: Boolean) {
|
||||||
|
if (worktree.isMain) {
|
||||||
|
_phase.value = Phase.Failed(WorktreeCopy.CANNOT_REMOVE_MAIN)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (_phase.value == Phase.Working) return
|
||||||
|
_phase.value = Phase.Working
|
||||||
|
_phase.value = runOp { gateway.removeWorktree(repoPath, worktree.path, force) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reclaim stale worktree admin dirs (idempotent). */
|
||||||
|
public suspend fun prune() {
|
||||||
|
if (_phase.value == Phase.Working) return
|
||||||
|
_phase.value = Phase.Working
|
||||||
|
_phase.value = runOp { gateway.pruneWorktrees(repoPath) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear a settled banner (Done/Failed) back to Idle after the user dismisses it. */
|
||||||
|
public fun reset() {
|
||||||
|
_phase.value = Phase.Idle
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run one guarded write, mapping its [GitWriteOutcome] to a phase. On success it re-fetches the
|
||||||
|
* detail (via [onChanged]) BEFORE settling to [Phase.Done] so the list is fresh when the banner shows.
|
||||||
|
*/
|
||||||
|
private suspend fun <T> runOp(op: suspend () -> GitWriteOutcome<T>): Phase {
|
||||||
|
val outcome = try {
|
||||||
|
op()
|
||||||
|
} catch (cancel: CancellationException) {
|
||||||
|
throw cancel
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
return Phase.Failed(WorktreeCopy.failed(error.message ?: error.toString()))
|
||||||
|
}
|
||||||
|
return when (outcome) {
|
||||||
|
is GitWriteOutcome.Ok -> {
|
||||||
|
runCatching { onChanged() } // a refresh failure must not turn a successful write into a failure
|
||||||
|
Phase.Done(WorktreeCopy.okMessage(outcome.payload))
|
||||||
|
}
|
||||||
|
is GitWriteOutcome.Rejected -> Phase.Failed(outcome.message ?: WorktreeCopy.REJECTED)
|
||||||
|
GitWriteOutcome.RateLimited -> Phase.Failed(WorktreeCopy.RATE_LIMITED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public companion object {
|
||||||
|
/** Longest branch name the server accepts (`src/http/worktrees.ts` `MAX_BRANCH_LEN`). */
|
||||||
|
private const val MAX_BRANCH_LEN = 250
|
||||||
|
|
||||||
|
/** Mirror of the server's `FORBIDDEN_BRANCH_CHARS`: control/DEL, whitespace, `~^:?*[\`. */
|
||||||
|
private val FORBIDDEN_BRANCH_CHARS = Regex("[\\u0000-\\u001f\\u007f\\s~^:?*\\[\\\\]")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side mirror of `validateBranchName` (worktrees.ts:95) — a fast UX pre-check ONLY; the
|
||||||
|
* server re-validates. Rejects empty/overlong, leading `-`, bad slashes, `..`, `.lock`/trailing
|
||||||
|
* `.`, `@{`, and any forbidden char.
|
||||||
|
*/
|
||||||
|
public fun isValidBranchName(branch: String): Boolean {
|
||||||
|
if (branch.isEmpty() || branch.length > MAX_BRANCH_LEN) return false
|
||||||
|
if (branch.startsWith("-")) return false
|
||||||
|
if (branch.startsWith("/") || branch.endsWith("/") || branch.contains("//")) return false
|
||||||
|
if (branch.contains("..")) return false
|
||||||
|
if (branch.endsWith(".lock") || branch.endsWith(".")) return false
|
||||||
|
if (branch.contains("@{")) return false
|
||||||
|
if (FORBIDDEN_BRANCH_CHARS.containsMatchIn(branch)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User-visible worktree-action copy (Chinese named constants; server strings surfaced inert). */
|
||||||
|
public object WorktreeCopy {
|
||||||
|
public const val INVALID_BRANCH: String = "分支名不合法(含非法字符或格式)。"
|
||||||
|
public const val CANNOT_REMOVE_MAIN: String = "不能删除主工作树。"
|
||||||
|
public const val REJECTED: String = "操作被服务器拒绝。"
|
||||||
|
public const val RATE_LIMITED: String = "操作过于频繁,服务器已限流,请稍后再试。"
|
||||||
|
|
||||||
|
public fun failed(detail: String): String = "工作树操作失败:$detail"
|
||||||
|
|
||||||
|
public fun okMessage(payload: Any?): String = when (payload) {
|
||||||
|
is CreateWorktreeResult -> "已创建工作树 ${payload.branch ?: ""}".trim()
|
||||||
|
is RemoveWorktreeResult -> "已删除工作树"
|
||||||
|
is PruneWorktreesResult ->
|
||||||
|
if (payload.pruned.isEmpty()) "没有可清理的工作树" else "已清理 ${payload.pruned.size} 个工作树"
|
||||||
|
else -> "操作完成"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,11 @@ public class AppEnvironment @Inject constructor(
|
|||||||
public val apiClientFactory: ApiClientFactory,
|
public val apiClientFactory: ApiClientFactory,
|
||||||
public val sessionEngineFactory: SessionEngineFactory,
|
public val sessionEngineFactory: SessionEngineFactory,
|
||||||
public val coldStartPolicy: ColdStartPolicy,
|
public val coldStartPolicy: ColdStartPolicy,
|
||||||
|
/**
|
||||||
|
* B4 · Builds a [DeviceEnroller][wang.yaojia.webterm.tlsandroid.DeviceEnroller] per control-plane URL
|
||||||
|
* for the zero-`.p12` enrollment screen (A-enroll). App-scoped; the screen calls it off `Main`.
|
||||||
|
*/
|
||||||
|
public val enrollmentFlowFactory: EnrollmentFlowFactory,
|
||||||
private val identityRepositoryLazy: Lazy<IdentityRepository>,
|
private val identityRepositoryLazy: Lazy<IdentityRepository>,
|
||||||
private val httpTransportLazy: Lazy<HttpTransport>,
|
private val httpTransportLazy: Lazy<HttpTransport>,
|
||||||
private val termTransportLazy: Lazy<TermTransport>,
|
private val termTransportLazy: Lazy<TermTransport>,
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package wang.yaojia.webterm.wiring
|
||||||
|
|
||||||
|
import dagger.Lazy
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import wang.yaojia.webterm.api.enroll.DeviceEnrollmentClient
|
||||||
|
import wang.yaojia.webterm.tlsandroid.CertStore
|
||||||
|
import wang.yaojia.webterm.tlsandroid.DeviceEnroller
|
||||||
|
import wang.yaojia.webterm.tlsandroid.EnrollmentRecordStore
|
||||||
|
import wang.yaojia.webterm.tlsandroid.IdentityCacheRefresher
|
||||||
|
import wang.yaojia.webterm.wire.HttpTransport
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B4 · Builds a [DeviceEnroller] bound to a user-supplied control-plane URL, over the app's frozen object
|
||||||
|
* graph — the Android analogue of iOS `makeDeviceEnrollmentFlow` (`DeviceEnrollmentWiring.swift`).
|
||||||
|
*
|
||||||
|
* The control-plane base URL is a RUNTIME value (the operator types it on the enrollment screen), so the
|
||||||
|
* enroller cannot be a plain singleton; this factory is the singleton and mints one enroller per enroll
|
||||||
|
* attempt with the typed URL, wiring in the app-scoped collaborators:
|
||||||
|
* - the SAME shared [HttpTransport] / [OkHttpClient] the mTLS transports use, so the renew ride presents
|
||||||
|
* the current device cert and the pool eviction drops stale connections,
|
||||||
|
* - the shared [CertStore] + [EnrollmentRecordStore] the running [IdentityRepository] resolves from, and
|
||||||
|
* - the [IdentityCacheRefresher] (FIX 3) so a freshly enrolled leaf is presented with no restart.
|
||||||
|
*
|
||||||
|
* ### Off-`Main` discipline
|
||||||
|
* [httpTransport] and [sharedClient] are behind `dagger.Lazy` because resolving either builds the shared
|
||||||
|
* `OkHttpClient` (mTLS/keystore I/O). [create] therefore MUST be called off the UI thread — the enrollment
|
||||||
|
* ViewModel invokes it inside a `Dispatchers.IO` hop (mirroring `AppEnvironment.warmUp`).
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
public class EnrollmentFlowFactory @Inject constructor(
|
||||||
|
private val httpTransport: Lazy<HttpTransport>,
|
||||||
|
private val sharedClient: Lazy<OkHttpClient>,
|
||||||
|
private val certStore: CertStore,
|
||||||
|
private val recordStore: EnrollmentRecordStore,
|
||||||
|
private val cacheRefresher: IdentityCacheRefresher,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Mint a [DeviceEnroller] targeting [controlPlaneBaseUrl] (any trailing slash is trimmed by the
|
||||||
|
* client). Call OFF `Main` — resolving the lazy transport/client does keystore/TLS I/O.
|
||||||
|
*/
|
||||||
|
public fun create(controlPlaneBaseUrl: String): DeviceEnroller =
|
||||||
|
DeviceEnroller(
|
||||||
|
client = DeviceEnrollmentClient(controlPlaneBaseUrl, httpTransport.get()),
|
||||||
|
certStore = certStore,
|
||||||
|
recordStore = recordStore,
|
||||||
|
sharedClient = sharedClient.get(),
|
||||||
|
cacheRefresher = cacheRefresher,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,8 +6,14 @@ import kotlinx.coroutines.test.StandardTestDispatcher
|
|||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
import wang.yaojia.webterm.api.models.CommitResult
|
||||||
|
import wang.yaojia.webterm.api.models.GitWriteOutcome
|
||||||
|
import wang.yaojia.webterm.api.models.PushResult
|
||||||
|
import wang.yaojia.webterm.api.models.StageResult
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A24 DiffViewModel — the JVM-testable read-only diff logic (plan §4.2 / §1): the STRING staged flag
|
* A24 DiffViewModel — the JVM-testable read-only diff logic (plan §4.2 / §1): the STRING staged flag
|
||||||
@@ -101,8 +107,10 @@ class DiffViewModelTest {
|
|||||||
// ── DiffViewModel phase transitions + staged re-fetch ───────────────────────────────────────
|
// ── DiffViewModel phase transitions + staged re-fetch ───────────────────────────────────────
|
||||||
private class FakeFetcher(private val result: DiffResult?, private val error: Throwable? = null) : DiffFetcher {
|
private class FakeFetcher(private val result: DiffResult?, private val error: Throwable? = null) : DiffFetcher {
|
||||||
val calls = mutableListOf<Boolean>() // records the staged arg of each fetch
|
val calls = mutableListOf<Boolean>() // records the staged arg of each fetch
|
||||||
override suspend fun fetch(path: String, staged: Boolean): DiffResult {
|
val bases = mutableListOf<String?>() // records the base arg of each fetch
|
||||||
|
override suspend fun fetch(path: String, staged: Boolean, base: String?): DiffResult {
|
||||||
calls += staged
|
calls += staged
|
||||||
|
bases += base
|
||||||
error?.let { throw it }
|
error?.let { throw it }
|
||||||
return result!!
|
return result!!
|
||||||
}
|
}
|
||||||
@@ -154,4 +162,133 @@ class DiffViewModelTest {
|
|||||||
assertTrue(vm.uiState.value.staged)
|
assertTrue(vm.uiState.value.staged)
|
||||||
assertEquals(listOf(false, true), fetcher.calls) // exactly two fetches, not three
|
assertEquals(listOf(false, true), fetcher.calls) // exactly two fetches, not three
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── diffUrl base mode (Phase B) ───────────────────────────────────────────────────────────────
|
||||||
|
@Test
|
||||||
|
fun `diffUrl appends staged in working mode and base (omitting staged) in base mode`() {
|
||||||
|
assertEquals(
|
||||||
|
"http://h:3000/projects/diff?path=%2Frepo&staged=1",
|
||||||
|
diffUrl("http://h:3000", "/repo", staged = true, base = null),
|
||||||
|
)
|
||||||
|
// base mode: no staged param, base percent-encoded.
|
||||||
|
assertEquals(
|
||||||
|
"http://h:3000/projects/diff?path=%2Frepo&base=feature%2Fx",
|
||||||
|
diffUrl("http://h:3000", "/repo", staged = true, base = "feature/x"),
|
||||||
|
)
|
||||||
|
// a blank base is treated as working mode.
|
||||||
|
assertEquals(
|
||||||
|
"http://h:3000/projects/diff?path=%2Frepo&staged=0",
|
||||||
|
diffUrl("http://h:3000", "/repo", staged = false, base = " "),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DiffViewModel base mode (Phase B) ─────────────────────────────────────────────────────────
|
||||||
|
@Test
|
||||||
|
fun `setBase enters base mode, threads base to the fetcher, and suppresses the staged toggle`() = runTest {
|
||||||
|
val fetcher = FakeFetcher(oneFileResult(false))
|
||||||
|
val vm = DiffViewModel(fetcher, "/repo")
|
||||||
|
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
|
||||||
|
|
||||||
|
vm.bind(scope); advanceUntilIdle()
|
||||||
|
vm.setBase("main"); advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals("main", vm.uiState.value.base)
|
||||||
|
assertEquals(listOf(null, "main"), fetcher.bases) // base threaded on the re-fetch
|
||||||
|
|
||||||
|
// In base mode the staged toggle is a no-op (server ignores staged when base is set).
|
||||||
|
vm.selectStaged(true); advanceUntilIdle()
|
||||||
|
assertFalse(vm.uiState.value.staged)
|
||||||
|
assertEquals(2, fetcher.calls.size, "selectStaged must not re-fetch in base mode")
|
||||||
|
|
||||||
|
// Leaving base mode returns to the working/staged view.
|
||||||
|
vm.setBase(null); advanceUntilIdle()
|
||||||
|
assertNull(vm.uiState.value.base)
|
||||||
|
assertEquals(listOf(null, "main", null), fetcher.bases)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DiffViewModel git-write (Phase C) ─────────────────────────────────────────────────────────
|
||||||
|
private class FakeWriter(
|
||||||
|
var stage: GitWriteOutcome<StageResult> = GitWriteOutcome.Ok(StageResult(staged = true, count = 1)),
|
||||||
|
var commit: GitWriteOutcome<CommitResult> = GitWriteOutcome.Ok(CommitResult(commit = "abc123")),
|
||||||
|
var push: GitWriteOutcome<PushResult> = GitWriteOutcome.Ok(PushResult(branch = "main", remote = "origin")),
|
||||||
|
) : GitWriteGateway {
|
||||||
|
val stageCalls = mutableListOf<Triple<String, List<String>, Boolean>>()
|
||||||
|
var commitCalls = 0; var pushCalls = 0
|
||||||
|
override suspend fun gitStage(path: String, files: List<String>, stage: Boolean): GitWriteOutcome<StageResult> {
|
||||||
|
stageCalls += Triple(path, files, stage); return this.stage
|
||||||
|
}
|
||||||
|
override suspend fun gitCommit(path: String, message: String): GitWriteOutcome<CommitResult> { commitCalls++; return commit }
|
||||||
|
override suspend fun gitPush(path: String): GitWriteOutcome<PushResult> { pushCalls++; return push }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toggleStage posts the file and refreshes the diff`() = runTest {
|
||||||
|
val fetcher = FakeFetcher(oneFileResult(false))
|
||||||
|
val writer = FakeWriter()
|
||||||
|
val vm = DiffViewModel(fetcher, "/repo", writer)
|
||||||
|
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
|
||||||
|
|
||||||
|
vm.bind(scope); advanceUntilIdle()
|
||||||
|
vm.toggleStage("src/A.kt", staged = true); advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(Triple("/repo", listOf("src/A.kt"), true), writer.stageCalls.single())
|
||||||
|
assertEquals(2, fetcher.calls.size, "a successful stage must refresh the diff")
|
||||||
|
assertEquals(false, vm.uiState.value.writeBanner?.isError)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `commit surfaces an Ok banner and an empty message is rejected client-side with no I O`() = runTest {
|
||||||
|
val fetcher = FakeFetcher(oneFileResult(false))
|
||||||
|
val writer = FakeWriter()
|
||||||
|
val vm = DiffViewModel(fetcher, "/repo", writer)
|
||||||
|
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
|
||||||
|
vm.bind(scope); advanceUntilIdle()
|
||||||
|
|
||||||
|
vm.commit(" "); advanceUntilIdle() // blank → client-side reject
|
||||||
|
assertEquals(0, writer.commitCalls, "a blank commit message must not hit the network")
|
||||||
|
assertEquals(true, vm.uiState.value.writeBanner?.isError)
|
||||||
|
|
||||||
|
vm.commit("real message"); advanceUntilIdle()
|
||||||
|
assertEquals(1, writer.commitCalls)
|
||||||
|
assertEquals(false, vm.uiState.value.writeBanner?.isError)
|
||||||
|
assertTrue(vm.uiState.value.writeBanner!!.message.contains("abc123"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `push maps a 409 rejection to the inert server message and does not refresh`() = runTest {
|
||||||
|
val fetcher = FakeFetcher(oneFileResult(false))
|
||||||
|
val writer = FakeWriter(push = GitWriteOutcome.Rejected(409, "Push rejected: remote has diverged."))
|
||||||
|
val vm = DiffViewModel(fetcher, "/repo", writer)
|
||||||
|
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
|
||||||
|
vm.bind(scope); advanceUntilIdle()
|
||||||
|
|
||||||
|
vm.push(); advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(1, writer.pushCalls)
|
||||||
|
assertEquals(true, vm.uiState.value.writeBanner?.isError)
|
||||||
|
assertEquals("Push rejected: remote has diverged.", vm.uiState.value.writeBanner?.message)
|
||||||
|
assertEquals(1, fetcher.calls.size, "a failed push must NOT refresh the diff")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `git-write is disabled in base mode`() = runTest {
|
||||||
|
val fetcher = FakeFetcher(oneFileResult(false))
|
||||||
|
val writer = FakeWriter()
|
||||||
|
val vm = DiffViewModel(fetcher, "/repo", writer)
|
||||||
|
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
|
||||||
|
vm.bind(scope); advanceUntilIdle()
|
||||||
|
vm.setBase("main"); advanceUntilIdle()
|
||||||
|
|
||||||
|
vm.toggleStage("a.kt", true); vm.commit("m"); vm.push(); advanceUntilIdle()
|
||||||
|
|
||||||
|
assertTrue(writer.stageCalls.isEmpty() && writer.commitCalls == 0 && writer.pushCalls == 0)
|
||||||
|
assertFalse(vm.uiState.value.writeEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `writeEnabled is false without a writer and true with one in working mode`() {
|
||||||
|
assertFalse(DiffUiState(canWrite = false).writeEnabled)
|
||||||
|
assertTrue(DiffUiState(canWrite = true).writeEnabled)
|
||||||
|
assertFalse(DiffUiState(canWrite = true, base = "main").writeEnabled)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
package wang.yaojia.webterm.viewmodels
|
||||||
|
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import wang.yaojia.webterm.api.enroll.DeviceEnrollmentError
|
||||||
|
import wang.yaojia.webterm.clienttls.CertificateSummary
|
||||||
|
import java.security.KeyStoreException
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [EnrollmentViewModel] / [validControlPlaneUrl] / [classifyEnroll] (B4) — the JVM-tested zero-`.p12`
|
||||||
|
* enrollment core, mirroring iOS `EnrollmentViewModelTests`. The enroll flow itself is covered headlessly
|
||||||
|
* in `DeviceEnrollerTest`; these cover the VM's boundary validation, success bookkeeping (summary +
|
||||||
|
* password-clear), and the error→[EnrollError] mapping — including that the password never lingers after
|
||||||
|
* an attempt. The keystore / network / Compose shell is device-QA (plan §7); THIS is the pure core.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class EnrollmentViewModelTest {
|
||||||
|
|
||||||
|
/** Records enroll invocations and replays a scripted result/throwable (mirrors iOS EnrollScript). */
|
||||||
|
private class EnrollScript(private val result: Result<CertificateSummary>) {
|
||||||
|
val calls = mutableListOf<Call>()
|
||||||
|
data class Call(val password: String, val subdomain: String, val deviceName: String, val url: String)
|
||||||
|
|
||||||
|
suspend fun run(password: String, subdomain: String, deviceName: String, url: String): CertificateSummary {
|
||||||
|
calls.add(Call(password, subdomain, deviceName, url))
|
||||||
|
return result.getOrThrow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val fixedNow = Instant.parse("2026-01-01T00:00:00Z")
|
||||||
|
private val utc = ZoneId.of("UTC")
|
||||||
|
|
||||||
|
private fun summary(
|
||||||
|
subject: String? = "alice-pixel",
|
||||||
|
issuer: String? = "webterm-device-ca",
|
||||||
|
notAfter: Instant? = Instant.parse("2026-10-06T00:00:00Z"),
|
||||||
|
) = CertificateSummary(subjectCommonName = subject, issuerCommonName = issuer, notAfter = notAfter)
|
||||||
|
|
||||||
|
private fun newVm(
|
||||||
|
script: EnrollScript,
|
||||||
|
installed: CertificateSummary? = null,
|
||||||
|
controlPlaneUrl: String = "https://cp.terminal.yaojia.wang",
|
||||||
|
subdomain: String = "alice",
|
||||||
|
deviceName: String = "Pixel 8",
|
||||||
|
): EnrollmentViewModel {
|
||||||
|
val vm = EnrollmentViewModel(
|
||||||
|
enrollOperation = { p, s, d, u -> script.run(p, s, d, u) },
|
||||||
|
loadSummary = { installed },
|
||||||
|
defaultControlPlaneUrl = controlPlaneUrl,
|
||||||
|
defaultDeviceName = deviceName,
|
||||||
|
zone = utc,
|
||||||
|
now = { fixedNow },
|
||||||
|
)
|
||||||
|
vm.onSubdomainChange(subdomain)
|
||||||
|
return vm
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Success ─────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a successful enroll sets the summary, flags success and clears the password`() =
|
||||||
|
runTest(UnconfinedTestDispatcher()) {
|
||||||
|
val script = EnrollScript(Result.success(summary(subject = "alice-pixel")))
|
||||||
|
val vm = newVm(script)
|
||||||
|
vm.bind(backgroundScope)
|
||||||
|
vm.onPasswordChange("operator-secret")
|
||||||
|
|
||||||
|
vm.enroll()
|
||||||
|
|
||||||
|
val state = vm.uiState.value
|
||||||
|
assertEquals(1, script.calls.size)
|
||||||
|
assertEquals("operator-secret", script.calls.single().password, "the entered password reaches the flow")
|
||||||
|
assertEquals("alice-pixel", state.summary?.subjectCommonName)
|
||||||
|
assertTrue(state.didSucceed)
|
||||||
|
assertNull(state.error)
|
||||||
|
assertEquals("", state.password, "the password must never linger after a successful enroll")
|
||||||
|
assertEquals(EnrollPhase.IDLE, state.phase)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Boundary validation (no network) ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a non-https control-plane URL is rejected before any network`() =
|
||||||
|
runTest(UnconfinedTestDispatcher()) {
|
||||||
|
val script = EnrollScript(Result.success(summary()))
|
||||||
|
val vm = newVm(script, controlPlaneUrl = "http://cp.terminal.yaojia.wang")
|
||||||
|
vm.bind(backgroundScope)
|
||||||
|
vm.onPasswordChange("pw")
|
||||||
|
|
||||||
|
vm.enroll()
|
||||||
|
|
||||||
|
assertTrue(script.calls.isEmpty(), "an insecure URL must never hit the network")
|
||||||
|
assertEquals(EnrollError.INVALID_URL, vm.uiState.value.error)
|
||||||
|
assertFalse(vm.uiState.value.didSucceed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing fields are rejected before any network`() = runTest(UnconfinedTestDispatcher()) {
|
||||||
|
val script = EnrollScript(Result.success(summary()))
|
||||||
|
val vm = newVm(script, subdomain = " ") // whitespace-only subdomain
|
||||||
|
vm.bind(backgroundScope)
|
||||||
|
vm.onPasswordChange("pw")
|
||||||
|
|
||||||
|
vm.enroll()
|
||||||
|
|
||||||
|
assertTrue(script.calls.isEmpty())
|
||||||
|
assertEquals(EnrollError.MISSING_FIELDS, vm.uiState.value.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Error → copy mapping (password still cleared) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a 403 subdomain-not-owned maps to actionable copy and clears the password`() =
|
||||||
|
runTest(UnconfinedTestDispatcher()) {
|
||||||
|
val script = EnrollScript(Result.failure(DeviceEnrollmentError.Http(403, "rejected")))
|
||||||
|
val vm = newVm(script)
|
||||||
|
vm.bind(backgroundScope)
|
||||||
|
vm.onPasswordChange("operator-secret")
|
||||||
|
|
||||||
|
vm.enroll()
|
||||||
|
|
||||||
|
val state = vm.uiState.value
|
||||||
|
assertEquals(EnrollError.SUBDOMAIN_NOT_OWNED, state.error)
|
||||||
|
assertFalse(state.didSucceed)
|
||||||
|
assertEquals("", state.password, "the password is cleared even after a failed enroll")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a 401 login maps to the bad-credential copy`() = runTest(UnconfinedTestDispatcher()) {
|
||||||
|
val script = EnrollScript(Result.failure(DeviceEnrollmentError.Http(401, "rejected")))
|
||||||
|
val vm = newVm(script)
|
||||||
|
vm.bind(backgroundScope)
|
||||||
|
vm.onPasswordChange("wrong")
|
||||||
|
|
||||||
|
vm.enroll()
|
||||||
|
|
||||||
|
assertEquals(EnrollError.BAD_CREDENTIAL, vm.uiState.value.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a hardware keystore fault maps to the keygen copy`() = runTest(UnconfinedTestDispatcher()) {
|
||||||
|
val script = EnrollScript(Result.failure(KeyStoreException("no StrongBox / TEE")))
|
||||||
|
val vm = newVm(script)
|
||||||
|
vm.bind(backgroundScope)
|
||||||
|
vm.onPasswordChange("operator-secret")
|
||||||
|
|
||||||
|
vm.enroll()
|
||||||
|
|
||||||
|
assertEquals(EnrollError.KEYGEN, vm.uiState.value.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── canEnroll gating ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `canEnroll requires every field including a non-empty password`() =
|
||||||
|
runTest(UnconfinedTestDispatcher()) {
|
||||||
|
val vm = newVm(EnrollScript(Result.success(summary())))
|
||||||
|
vm.bind(backgroundScope)
|
||||||
|
|
||||||
|
assertFalse(vm.uiState.value.canEnroll, "password empty → disabled")
|
||||||
|
vm.onPasswordChange("pw")
|
||||||
|
assertTrue(vm.uiState.value.canEnroll)
|
||||||
|
vm.onSubdomainChange(" ")
|
||||||
|
assertFalse(vm.uiState.value.canEnroll, "whitespace-only subdomain → disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clearError dismisses a surfaced error`() = runTest(UnconfinedTestDispatcher()) {
|
||||||
|
val vm = newVm(newFailing())
|
||||||
|
vm.bind(backgroundScope)
|
||||||
|
vm.onPasswordChange("pw")
|
||||||
|
vm.enroll()
|
||||||
|
assertEquals(EnrollError.REJECTED, vm.uiState.value.error)
|
||||||
|
|
||||||
|
vm.clearError()
|
||||||
|
|
||||||
|
assertNull(vm.uiState.value.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun newFailing() = EnrollScript(Result.failure(DeviceEnrollmentError.Http(400, "rejected")))
|
||||||
|
|
||||||
|
// ── Pure helpers ──────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `validControlPlaneUrl accepts https with a host and rejects everything else`() {
|
||||||
|
assertEquals("https://cp.terminal.yaojia.wang", validControlPlaneUrl(" https://cp.terminal.yaojia.wang "))
|
||||||
|
assertNull(validControlPlaneUrl("http://cp.terminal.yaojia.wang"), "http is rejected")
|
||||||
|
assertNull(validControlPlaneUrl("https://"), "no host is rejected")
|
||||||
|
assertNull(validControlPlaneUrl("not a url"), "unparseable is rejected")
|
||||||
|
assertNull(validControlPlaneUrl(""), "empty is rejected")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `classifyEnroll maps each throwable to its coarse EnrollError`() {
|
||||||
|
assertEquals(EnrollError.BAD_CREDENTIAL, classifyEnroll(DeviceEnrollmentError.Http(401, null)))
|
||||||
|
assertEquals(EnrollError.SUBDOMAIN_NOT_OWNED, classifyEnroll(DeviceEnrollmentError.Http(403, null)))
|
||||||
|
assertEquals(EnrollError.RATE_LIMITED, classifyEnroll(DeviceEnrollmentError.Http(429, null)))
|
||||||
|
assertEquals(EnrollError.REJECTED, classifyEnroll(DeviceEnrollmentError.Http(400, null)))
|
||||||
|
assertEquals(EnrollError.ENROLL_FAILED, classifyEnroll(DeviceEnrollmentError.Http(500, null)))
|
||||||
|
assertEquals(EnrollError.SERVER, classifyEnroll(DeviceEnrollmentError.MalformedResponse))
|
||||||
|
assertEquals(EnrollError.ENROLL_FAILED, classifyEnroll(DeviceEnrollmentError.InvalidRequest))
|
||||||
|
assertEquals(EnrollError.KEYGEN, classifyEnroll(KeyStoreException("x")))
|
||||||
|
assertEquals(EnrollError.UNKNOWN, classifyEnroll(IllegalStateException("x")))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package wang.yaojia.webterm.viewmodels
|
||||||
|
|
||||||
|
import wang.yaojia.webterm.api.models.CreateWorktreeResult
|
||||||
|
import wang.yaojia.webterm.api.models.GitLogResult
|
||||||
|
import wang.yaojia.webterm.api.models.GitWriteOutcome
|
||||||
|
import wang.yaojia.webterm.api.models.PrStatus
|
||||||
|
import wang.yaojia.webterm.api.models.ProjectDetail
|
||||||
|
import wang.yaojia.webterm.api.models.ProjectInfo
|
||||||
|
import wang.yaojia.webterm.api.models.PruneWorktreesResult
|
||||||
|
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
|
||||||
|
import wang.yaojia.webterm.api.models.UiPrefs
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A configurable [ProjectsGateway] double for the W5 presenter tests (WorktreeViewModel,
|
||||||
|
* ProjectDetailViewModel PR/log). Records the guarded-write call args and returns canned outcomes;
|
||||||
|
* PR/log return canned values or throw to exercise failure-isolation. The list-page methods
|
||||||
|
* (projects/prefs) are unused here and throw if called.
|
||||||
|
*/
|
||||||
|
class FakeWorktreeGateway(
|
||||||
|
private val detail: ProjectDetail? = null,
|
||||||
|
private val prResult: PrStatus? = null,
|
||||||
|
private val prThrows: Boolean = false,
|
||||||
|
private val logResult: GitLogResult? = null,
|
||||||
|
private val logThrows: Boolean = false,
|
||||||
|
private val createOutcome: GitWriteOutcome<CreateWorktreeResult> = GitWriteOutcome.Ok(CreateWorktreeResult()),
|
||||||
|
private val removeOutcome: GitWriteOutcome<RemoveWorktreeResult> = GitWriteOutcome.Ok(RemoveWorktreeResult()),
|
||||||
|
private val pruneOutcome: GitWriteOutcome<PruneWorktreesResult> = GitWriteOutcome.Ok(PruneWorktreesResult()),
|
||||||
|
) : ProjectsGateway {
|
||||||
|
val createCalls = mutableListOf<Triple<String, String, String?>>()
|
||||||
|
val removeCalls = mutableListOf<Triple<String, String, Boolean>>()
|
||||||
|
val pruneCalls = mutableListOf<String>()
|
||||||
|
var detailCalls = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
override suspend fun projects(): List<ProjectInfo> = throw NotImplementedError()
|
||||||
|
override suspend fun prefs(): UiPrefs = throw NotImplementedError()
|
||||||
|
override suspend fun putPrefs(prefs: UiPrefs): UiPrefs = throw NotImplementedError()
|
||||||
|
|
||||||
|
override suspend fun projectDetail(path: String): ProjectDetail {
|
||||||
|
detailCalls++
|
||||||
|
return detail ?: throw NotImplementedError("no detail configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun projectPr(path: String): PrStatus {
|
||||||
|
if (prThrows) throw RuntimeException("pr unavailable")
|
||||||
|
return prResult ?: throw NotImplementedError("no pr configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun projectLog(path: String, n: Int?): GitLogResult {
|
||||||
|
if (logThrows) throw RuntimeException("log unavailable")
|
||||||
|
return logResult ?: throw NotImplementedError("no log configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome<CreateWorktreeResult> {
|
||||||
|
createCalls += Triple(path, branch, base)
|
||||||
|
return createOutcome
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean): GitWriteOutcome<RemoveWorktreeResult> {
|
||||||
|
removeCalls += Triple(path, worktreePath, force)
|
||||||
|
return removeOutcome
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun pruneWorktrees(path: String): GitWriteOutcome<PruneWorktreesResult> {
|
||||||
|
pruneCalls += path
|
||||||
|
return pruneOutcome
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package wang.yaojia.webterm.viewmodels
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import wang.yaojia.webterm.api.models.CommitLogEntry
|
||||||
|
import wang.yaojia.webterm.api.models.GitLogResult
|
||||||
|
import wang.yaojia.webterm.api.models.PrAvailability
|
||||||
|
import wang.yaojia.webterm.api.models.PrStatus
|
||||||
|
import wang.yaojia.webterm.api.models.ProjectDetail
|
||||||
|
|
||||||
|
/**
|
||||||
|
* W5 ProjectDetailViewModel side fetches (JVM). The PR chip and recent-commits list are failure-
|
||||||
|
* ISOLATED: a failure of either NEVER fails the detail load nor the other; a non-`ok` availability
|
||||||
|
* renders a degraded (but Loaded) chip; the commit list decodes into its own state.
|
||||||
|
*/
|
||||||
|
class ProjectDetailPrLogTest {
|
||||||
|
|
||||||
|
private val detail = ProjectDetail(name = "repo", path = "/repo", isGit = true, branch = "main")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `detail plus PR plus log all load`() = runTest {
|
||||||
|
val gateway = FakeWorktreeGateway(
|
||||||
|
detail = detail,
|
||||||
|
prResult = PrStatus(availability = PrAvailability.OK, number = 7, title = "A PR"),
|
||||||
|
logResult = GitLogResult(commits = listOf(CommitLogEntry("h", 1, "s")), truncated = false),
|
||||||
|
)
|
||||||
|
val vm = ProjectDetailViewModel.forGateway(gateway, "/repo")
|
||||||
|
|
||||||
|
vm.load()
|
||||||
|
|
||||||
|
assertTrue(vm.phase.value is ProjectDetailViewModel.Phase.Loaded)
|
||||||
|
val chip = vm.prChip.value as ProjectDetailViewModel.PrChip.Loaded
|
||||||
|
assertEquals(PrAvailability.OK, chip.status.availability)
|
||||||
|
val commits = vm.recentCommits.value as ProjectDetailViewModel.RecentCommits.Loaded
|
||||||
|
assertEquals(1, commits.result.commits.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a PR fetch failure does not fail the detail load nor the log`() = runTest {
|
||||||
|
val gateway = FakeWorktreeGateway(
|
||||||
|
detail = detail,
|
||||||
|
prThrows = true,
|
||||||
|
logResult = GitLogResult(commits = emptyList(), truncated = false),
|
||||||
|
)
|
||||||
|
val vm = ProjectDetailViewModel.forGateway(gateway, "/repo")
|
||||||
|
|
||||||
|
vm.load()
|
||||||
|
|
||||||
|
assertTrue(vm.phase.value is ProjectDetailViewModel.Phase.Loaded, "detail must still load")
|
||||||
|
assertEquals(ProjectDetailViewModel.PrChip.Unavailable, vm.prChip.value)
|
||||||
|
assertTrue(vm.recentCommits.value is ProjectDetailViewModel.RecentCommits.Loaded, "log stays isolated")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a log fetch failure isolates to the recent-commits state only`() = runTest {
|
||||||
|
val gateway = FakeWorktreeGateway(
|
||||||
|
detail = detail,
|
||||||
|
prResult = PrStatus(availability = PrAvailability.NO_PR),
|
||||||
|
logThrows = true,
|
||||||
|
)
|
||||||
|
val vm = ProjectDetailViewModel.forGateway(gateway, "/repo")
|
||||||
|
|
||||||
|
vm.load()
|
||||||
|
|
||||||
|
assertTrue(vm.phase.value is ProjectDetailViewModel.Phase.Loaded)
|
||||||
|
assertEquals(ProjectDetailViewModel.RecentCommits.Unavailable, vm.recentCommits.value)
|
||||||
|
// A non-ok availability is still a Loaded chip (degraded copy is a render concern).
|
||||||
|
val chip = vm.prChip.value as ProjectDetailViewModel.PrChip.Loaded
|
||||||
|
assertEquals(PrAvailability.NO_PR, chip.status.availability)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the wired worktree VM shares the repo path and refreshes the detail on a successful create`() = runTest {
|
||||||
|
val gateway = FakeWorktreeGateway(
|
||||||
|
detail = detail,
|
||||||
|
prResult = PrStatus(availability = PrAvailability.DISABLED),
|
||||||
|
logResult = GitLogResult(),
|
||||||
|
)
|
||||||
|
val vm = ProjectDetailViewModel.forGateway(gateway, "/repo")
|
||||||
|
vm.load()
|
||||||
|
val detailCallsAfterLoad = gateway.detailCalls
|
||||||
|
|
||||||
|
vm.worktree!!.create("feat/x")
|
||||||
|
|
||||||
|
assertTrue(gateway.detailCalls > detailCallsAfterLoad, "create success re-fetches the detail")
|
||||||
|
assertEquals("/repo", gateway.createCalls.single().first)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -202,6 +202,11 @@ class ProjectsViewModelTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun projectDetail(path: String): ProjectDetail = throw NotImplementedError()
|
override suspend fun projectDetail(path: String): ProjectDetail = throw NotImplementedError()
|
||||||
|
override suspend fun projectPr(path: String) = throw NotImplementedError()
|
||||||
|
override suspend fun projectLog(path: String, n: Int?) = throw NotImplementedError()
|
||||||
|
override suspend fun createWorktree(path: String, branch: String, base: String?) = throw NotImplementedError()
|
||||||
|
override suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean) = throw NotImplementedError()
|
||||||
|
override suspend fun pruneWorktrees(path: String) = throw NotImplementedError()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun proj(
|
private fun proj(
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package wang.yaojia.webterm.viewmodels
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import wang.yaojia.webterm.api.models.CreateWorktreeResult
|
||||||
|
import wang.yaojia.webterm.api.models.GitWriteOutcome
|
||||||
|
import wang.yaojia.webterm.api.models.PruneWorktreesResult
|
||||||
|
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
|
||||||
|
import wang.yaojia.webterm.api.models.WorktreeInfo
|
||||||
|
|
||||||
|
/**
|
||||||
|
* W5 WorktreeViewModel (JVM). The guarded worktree write phase machine: client-side branch validation
|
||||||
|
* (no I/O on a bad name), main-worktree removal blocked client-side, the force flag threaded, and the
|
||||||
|
* server's SAFE error strings (disabled 403 / 429) surfaced inertly. On success it re-fetches the detail.
|
||||||
|
*/
|
||||||
|
class WorktreeViewModelTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an invalid branch name fails with no network I O`() = runTest {
|
||||||
|
val gateway = FakeWorktreeGateway()
|
||||||
|
val vm = WorktreeViewModel(gateway, "/repo")
|
||||||
|
|
||||||
|
vm.create("bad branch~name") // whitespace + '~' are forbidden
|
||||||
|
|
||||||
|
assertTrue(vm.phase.value is WorktreeViewModel.Phase.Failed)
|
||||||
|
assertEquals(WorktreeCopy.INVALID_BRANCH, (vm.phase.value as WorktreeViewModel.Phase.Failed).message)
|
||||||
|
assertEquals(0, gateway.createCalls.size, "an invalid branch must never hit the network")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a leading dash and dotdot and trailing dot are all rejected client-side`() {
|
||||||
|
assertTrue(WorktreeViewModel.isValidBranchName("feat/ok-name"))
|
||||||
|
assertTrue(WorktreeViewModel.isValidBranchName("release/1.2.x"))
|
||||||
|
listOf("-flag", "a..b", "ends.", "has space", "a~b", "a:b", "@{now}", "", "//x", "/lead", "trail/").forEach {
|
||||||
|
assertTrue(!WorktreeViewModel.isValidBranchName(it), "should reject '$it'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `create success settles Done and re-fetches the detail`() = runTest {
|
||||||
|
val gateway = FakeWorktreeGateway(
|
||||||
|
createOutcome = GitWriteOutcome.Ok(CreateWorktreeResult(path = "/repo-worktrees/feat", branch = "feat/x")),
|
||||||
|
)
|
||||||
|
var refreshes = 0
|
||||||
|
val vm = WorktreeViewModel(gateway, "/repo", onChanged = { refreshes++ })
|
||||||
|
|
||||||
|
vm.create("feat/x", base = "main")
|
||||||
|
|
||||||
|
assertTrue(vm.phase.value is WorktreeViewModel.Phase.Done)
|
||||||
|
assertEquals(1, refreshes, "a successful create must re-fetch the detail")
|
||||||
|
assertEquals(Triple("/repo", "feat/x", "main"), gateway.createCalls.single())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `removing a main worktree is blocked client-side with no I O`() = runTest {
|
||||||
|
val gateway = FakeWorktreeGateway()
|
||||||
|
val vm = WorktreeViewModel(gateway, "/repo")
|
||||||
|
|
||||||
|
vm.remove(WorktreeInfo(path = "/repo", branch = "main", isMain = true), force = false)
|
||||||
|
|
||||||
|
assertEquals(WorktreeCopy.CANNOT_REMOVE_MAIN, (vm.phase.value as WorktreeViewModel.Phase.Failed).message)
|
||||||
|
assertEquals(0, gateway.removeCalls.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `remove threads the force flag`() = runTest {
|
||||||
|
val gateway = FakeWorktreeGateway(removeOutcome = GitWriteOutcome.Ok(RemoveWorktreeResult(path = "/wt/x")))
|
||||||
|
val vm = WorktreeViewModel(gateway, "/repo")
|
||||||
|
|
||||||
|
vm.remove(WorktreeInfo(path = "/wt/x", branch = "feat", isMain = false), force = true)
|
||||||
|
|
||||||
|
assertTrue(vm.phase.value is WorktreeViewModel.Phase.Done)
|
||||||
|
assertEquals(Triple("/repo", "/wt/x", true), gateway.removeCalls.single())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a 403 disabled rejection surfaces the safe server message inertly`() = runTest {
|
||||||
|
val gateway = FakeWorktreeGateway(
|
||||||
|
createOutcome = GitWriteOutcome.Rejected(403, "Worktree creation is disabled."),
|
||||||
|
)
|
||||||
|
val vm = WorktreeViewModel(gateway, "/repo")
|
||||||
|
|
||||||
|
vm.create("feat/x")
|
||||||
|
|
||||||
|
assertEquals("Worktree creation is disabled.", (vm.phase.value as WorktreeViewModel.Phase.Failed).message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a 429 rate-limit surfaces the rate-limited copy`() = runTest {
|
||||||
|
val gateway = FakeWorktreeGateway(pruneOutcome = GitWriteOutcome.RateLimited)
|
||||||
|
val vm = WorktreeViewModel(gateway, "/repo")
|
||||||
|
|
||||||
|
vm.prune()
|
||||||
|
|
||||||
|
assertEquals(WorktreeCopy.RATE_LIMITED, (vm.phase.value as WorktreeViewModel.Phase.Failed).message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `prune with nothing to reclaim reports an empty result`() = runTest {
|
||||||
|
val gateway = FakeWorktreeGateway(pruneOutcome = GitWriteOutcome.Ok(PruneWorktreesResult(pruned = emptyList())))
|
||||||
|
val vm = WorktreeViewModel(gateway, "/repo")
|
||||||
|
|
||||||
|
vm.prune()
|
||||||
|
|
||||||
|
val done = vm.phase.value as WorktreeViewModel.Phase.Done
|
||||||
|
assertTrue(done.message.contains("没有"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,17 +29,34 @@ android {
|
|||||||
minSdk = 29
|
minSdk = 29
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
testOptions {
|
||||||
|
unitTests {
|
||||||
|
// The device-enroll orchestration commit logs via android.util.Log — let the JVM unit
|
||||||
|
// tests stub it (return 0) instead of throwing "not mocked". The security-critical paths
|
||||||
|
// (commit sequencing, error handling) run on the JVM with a software key double.
|
||||||
|
isReturnDefaultValues = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
jvmToolchain(17)
|
jvmToolchain(17)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JVM (local) unit tests use JUnit 5 (matching the pure modules); AGP's testDebug/ReleaseUnitTest
|
||||||
|
// tasks are `Test` tasks, so opt them into the JUnit Platform.
|
||||||
|
tasks.withType<Test>().configureEach {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// Pure half: Pkcs12Parse (parse+validate), ClientKeyManagerLogic (alias truth table),
|
// Pure half: Pkcs12Parse (parse+validate), ClientKeyManagerLogic (alias truth table),
|
||||||
// CertificateSummary(Reader). `api` so :app sees the shared ParsedClientIdentity/summary types.
|
// CertificateSummary(Reader). `api` so :app sees the shared ParsedClientIdentity/summary types.
|
||||||
// (No :wire-protocol dep — nothing in src/main references wang.yaojia.webterm.wire*.)
|
// (No :wire-protocol dep — nothing in src/main references wang.yaojia.webterm.wire*.)
|
||||||
api(project(":client-tls"))
|
api(project(":client-tls"))
|
||||||
|
// B4 device-enroll: the pure CSR encoder + login/enroll/renew client + HttpTransport seam live in
|
||||||
|
// :api-client (JVM-unit-tested); the framework HardwareBackedKey/DeviceEnroller drive them.
|
||||||
|
implementation(project(":api-client"))
|
||||||
implementation(libs.tink.android)
|
implementation(libs.tink.android)
|
||||||
implementation(libs.okhttp)
|
implementation(libs.okhttp)
|
||||||
// Mutex serializes the two-store rotation commit (single-commit invariant, A11).
|
// Mutex serializes the two-store rotation commit (single-commit invariant, A11).
|
||||||
@@ -50,4 +67,10 @@ dependencies {
|
|||||||
androidTestImplementation(libs.androidx.test.core)
|
androidTestImplementation(libs.androidx.test.core)
|
||||||
androidTestImplementation(libs.androidx.test.runner)
|
androidTestImplementation(libs.androidx.test.runner)
|
||||||
androidTestImplementation(libs.kotlinx.coroutines.core) // runBlocking for suspend mutators
|
androidTestImplementation(libs.kotlinx.coroutines.core) // runBlocking for suspend mutators
|
||||||
|
|
||||||
|
// Local JVM unit tests (src/test) — the DeviceEnroller enroll/commit orchestration driven with a
|
||||||
|
// software P-256 key double + the shared FakeHttpTransport (no emulator, no AndroidKeyStore).
|
||||||
|
testImplementation(project(":test-support"))
|
||||||
|
testImplementation(libs.bundles.unit.test)
|
||||||
|
testRuntimeOnly(libs.junit.platform.launcher)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package wang.yaojia.webterm.tlsandroid
|
||||||
|
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import java.security.Signature
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertArrayEquals
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import wang.yaojia.webterm.api.enroll.CertificateSigningRequest
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B4 · Instrumented (real AndroidKeyStore — NOT Robolectric, plan §7) proof that the generated
|
||||||
|
* device key is hardware-backed, NON-EXPORTABLE, and produces a self-signed P-256 CSR the
|
||||||
|
* control-plane accepts. COMPILES in CI here; RUNS on a device/emulator during device QA
|
||||||
|
* (StrongBox availability is device-dependent — [HardwareKeyStore.generate] falls back to the TEE).
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class HardwareBackedKeyTest {
|
||||||
|
private val alias = "test-device-enroll-key"
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun clean() = HardwareKeyStore.delete(alias)
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() = HardwareKeyStore.delete(alias)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun generate_producesA65ByteX963PublicPoint() {
|
||||||
|
val key = HardwareKeyStore.generate(alias)
|
||||||
|
val point = key.publicKeyX963()
|
||||||
|
assertEquals(65, point.size)
|
||||||
|
assertEquals(0x04, point[0].toInt() and 0xFF)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun generatedKeyIsNonExportable() {
|
||||||
|
HardwareKeyStore.generate(alias)
|
||||||
|
val loaded = HardwareKeyStore.load(alias)
|
||||||
|
assertNotNull(loaded)
|
||||||
|
// AndroidKeyStore private keys have no exportable encoding — the material never leaves HW.
|
||||||
|
assertNull("AndroidKeyStore key must expose no encoded form", loaded!!.keyHandle.encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun csrSignedByHardwareKeySelfVerifies() {
|
||||||
|
val key = HardwareKeyStore.generate(alias)
|
||||||
|
|
||||||
|
val der = CertificateSigningRequest.der("t1-android", key)
|
||||||
|
|
||||||
|
// Re-parse the CertificationRequestInfo + signature and verify with the embedded public key.
|
||||||
|
val outer = TestDer.read(der, 0)!!
|
||||||
|
val parts = TestDer.children(der, outer)
|
||||||
|
val info = der.copyOfRange(parts[0].start, parts[0].end)
|
||||||
|
val bitString = parts[2]
|
||||||
|
val signature = der.copyOfRange(bitString.valueStart + 1, bitString.valueEnd)
|
||||||
|
|
||||||
|
// Rebuild a JCA public key from the X9.63 point to run the same crypto check the server does.
|
||||||
|
val point = key.publicKeyX963()
|
||||||
|
val pub = X963PublicKeys.p256(point)
|
||||||
|
val ok = Signature.getInstance("SHA256withECDSA").apply {
|
||||||
|
initVerify(pub)
|
||||||
|
update(info)
|
||||||
|
}.verify(signature)
|
||||||
|
assertTrue("hardware-signed CSR must self-verify", ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun loadAfterGenerateReturnsAKeyWithTheSamePublicPoint() {
|
||||||
|
val generated = HardwareKeyStore.generate(alias)
|
||||||
|
val reloaded = HardwareKeyStore.load(alias)
|
||||||
|
assertNotNull(reloaded)
|
||||||
|
assertArrayEquals(generated.publicKeyX963(), reloaded!!.publicKeyX963())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun loadReturnsNullWhenNoKeyExists() {
|
||||||
|
assertNull(HardwareKeyStore.load("absent-alias-xyz"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reconstruct a P-256 public key from an X9.63 uncompressed point, for on-device signature checks. */
|
||||||
|
private object X963PublicKeys {
|
||||||
|
fun p256(point: ByteArray): java.security.PublicKey {
|
||||||
|
val params = java.security.AlgorithmParameters.getInstance("EC").apply {
|
||||||
|
init(java.security.spec.ECGenParameterSpec("secp256r1"))
|
||||||
|
}
|
||||||
|
val spec = params.getParameterSpec(java.security.spec.ECParameterSpec::class.java)
|
||||||
|
val x = java.math.BigInteger(1, point.copyOfRange(1, 33))
|
||||||
|
val y = java.math.BigInteger(1, point.copyOfRange(33, 65))
|
||||||
|
val pubSpec = java.security.spec.ECPublicKeySpec(java.security.spec.ECPoint(x, y), spec)
|
||||||
|
return java.security.KeyFactory.getInstance("EC").generatePublic(pubSpec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A throwaway canonical-DER reader for structural assertions (device-side mirror of the JVM test). */
|
||||||
|
private object TestDer {
|
||||||
|
data class Element(val tag: Int, val start: Int, val valueStart: Int, val valueEnd: Int) {
|
||||||
|
val end: Int get() = valueEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
fun read(bytes: ByteArray, start: Int): Element? {
|
||||||
|
if (start < 0 || start + 1 >= bytes.size) return null
|
||||||
|
val tag = bytes[start].toInt() and 0xFF
|
||||||
|
var index = start + 1
|
||||||
|
val first = bytes[index].toInt() and 0xFF
|
||||||
|
index += 1
|
||||||
|
var length = 0
|
||||||
|
if (first and 0x80 == 0) {
|
||||||
|
length = first
|
||||||
|
} else {
|
||||||
|
val count = first and 0x7F
|
||||||
|
if (count == 0 || count > 4 || index + count > bytes.size) return null
|
||||||
|
repeat(count) {
|
||||||
|
length = (length shl 8) or (bytes[index].toInt() and 0xFF)
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val valueEnd = index + length
|
||||||
|
if (valueEnd > bytes.size) return null
|
||||||
|
return Element(tag, start, index, valueEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun children(bytes: ByteArray, parent: Element): List<Element> {
|
||||||
|
val elements = mutableListOf<Element>()
|
||||||
|
var index = parent.valueStart
|
||||||
|
while (index < parent.valueEnd) {
|
||||||
|
val element = read(bytes, index) ?: break
|
||||||
|
elements.add(element)
|
||||||
|
index = element.valueEnd
|
||||||
|
}
|
||||||
|
return elements
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -100,6 +100,31 @@ class IdentityRepositoryTest {
|
|||||||
assertEquals(importer.primarySlot, certStore.load()?.keyStoreAlias)
|
assertEquals(importer.primarySlot, certStore.load()?.keyStoreAlias)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FIX 3 (cache freshness): a device cert committed OUT OF BAND of a running repository (the zero-`.p12`
|
||||||
|
* [DeviceEnroller] writes the leaf straight into the shared [CertStore] + AndroidKeyStore) is picked up
|
||||||
|
* by [AndroidIdentityRepository.refreshFromStore] WITHOUT a process restart — the cached "no identity"
|
||||||
|
* flips to the freshly-committed leaf and is presented on the next handshake.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun refreshFromStore_publishesAnOutOfBandCommittedIdentity_withoutRestart() = runBlocking {
|
||||||
|
val running = newRepository()
|
||||||
|
// Touch it while nothing is installed — caches the (null) initial identity.
|
||||||
|
assertFalse(running.hasInstalledIdentity())
|
||||||
|
|
||||||
|
// Simulate DeviceEnroller committing an identity out of band (a second repo over the SAME stores).
|
||||||
|
newRepository().importIdentity(Fixtures.leafP12(), Fixtures.PASSPHRASE)
|
||||||
|
|
||||||
|
// The running repo still shows its stale cache (no restart yet).
|
||||||
|
assertFalse("stale cache still reports no identity before a refresh", running.hasInstalledIdentity())
|
||||||
|
|
||||||
|
running.refreshFromStore()
|
||||||
|
|
||||||
|
// The refresh re-read the committed live-pointer → the enrolled leaf is now live.
|
||||||
|
assertTrue("refreshFromStore must publish the out-of-band committed identity", running.hasInstalledIdentity())
|
||||||
|
assertEquals(Fixtures.LEAF_SUBJECT_CN, running.currentSummary()?.subjectCommonName)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun rotateThenRemove_evictsPooledConnections_andClearsIdentity() = runBlocking {
|
fun rotateThenRemove_evictsPooledConnections_andClearsIdentity() = runBlocking {
|
||||||
val repository = newRepository()
|
val repository = newRepository()
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package wang.yaojia.webterm.tlsandroid
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import java.security.cert.CertificateFactory
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import wang.yaojia.webterm.api.enroll.CertificateSigningRequest
|
||||||
|
import wang.yaojia.webterm.api.enroll.DeviceEnrollmentClient
|
||||||
|
import wang.yaojia.webterm.api.enroll.EnrollmentResult
|
||||||
|
import wang.yaojia.webterm.clienttls.CertificateSummary
|
||||||
|
import wang.yaojia.webterm.clienttls.CertificateSummaryReader
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B4 · The Android device-enroll orchestrator — the `.p12`-free path that mirrors iOS
|
||||||
|
* `KeychainClientIdentityStore.enroll/renew`. It composes the five B4 pieces:
|
||||||
|
*
|
||||||
|
* 1. generate a NON-EXPORTABLE hardware key ([HardwareKeyStore]: StrongBox → TEE),
|
||||||
|
* 2. self-sign a P-256 PKCS#10 CSR with it ([CertificateSigningRequest], `:api-client`),
|
||||||
|
* 3. run the login → `POST /device/enroll` flow ([DeviceEnrollmentClient], `:api-client`),
|
||||||
|
* 4. store the returned leaf + issuer chain into the SAME [CertStore] + AndroidKeyStore slot the
|
||||||
|
* existing [AndroidIdentityRepository] resolves from — so it is presented on the EXISTING
|
||||||
|
* re-reading `X509KeyManager` mTLS path with no change to that module, and
|
||||||
|
* 5. expose a silent [renew] against `/device/:id/renew` using the SAME hardware key.
|
||||||
|
*
|
||||||
|
* The mutating methods are serialized by a [Mutex] so an enroll and a rotation can never interleave
|
||||||
|
* the two-store commit (cert live-pointer + enrollment record).
|
||||||
|
*
|
||||||
|
* ### The commit
|
||||||
|
* The cert-store save is THE durable live-pointer flip (identical to the import/rotation path). It is
|
||||||
|
* written LAST, after the enrollment record, so a successful cert-store save always means the mTLS
|
||||||
|
* identity is fully live; the pool is then evicted so the next handshake presents the new leaf.
|
||||||
|
*/
|
||||||
|
public class DeviceEnroller(
|
||||||
|
private val client: DeviceEnrollmentClient,
|
||||||
|
private val certStore: CertStore,
|
||||||
|
private val recordStore: EnrollmentRecordStore,
|
||||||
|
private val sharedClient: OkHttpClient,
|
||||||
|
private val keyAlias: String = AndroidKeyStoreImporter.DEFAULT_ALIAS,
|
||||||
|
private val keyProvider: DeviceKeyProvider = HardwareDeviceKeyProvider,
|
||||||
|
// FIX 3 (cache freshness): the in-memory identity cache (AndroidIdentityRepository) is refreshed
|
||||||
|
// AFTER each commit so a mid-session enroll/renew is presented on the NEXT handshake with no
|
||||||
|
// process restart. Optional so the JVM orchestration tests can construct the enroller without it.
|
||||||
|
private val cacheRefresher: IdentityCacheRefresher? = null,
|
||||||
|
) {
|
||||||
|
private val commitMutex = Mutex()
|
||||||
|
|
||||||
|
/** Raised when a state-changing enroll/renew precondition is not met. Never leaks a secret. */
|
||||||
|
public class EnrollmentStateException(message: String) : Exception(message)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time enrollment: login (operator password → short-lived `device:enroll` bearer) → generate
|
||||||
|
* a non-exportable hardware key → CSR → `POST /device/enroll` → store the leaf + present it.
|
||||||
|
* Returns the installed leaf's display summary. The bearer is held only for this call, never
|
||||||
|
* persisted or logged.
|
||||||
|
*/
|
||||||
|
public suspend fun enroll(
|
||||||
|
password: String,
|
||||||
|
subdomain: String,
|
||||||
|
deviceName: String,
|
||||||
|
): CertificateSummary = commitMutex.withLock {
|
||||||
|
val login = client.login(password)
|
||||||
|
// Generate the hardware key ONLY after a successful login, so a rejected credential never
|
||||||
|
// burns a fresh key slot; overwrites any stale key at the alias.
|
||||||
|
val key = keyProvider.generate(keyAlias)
|
||||||
|
try {
|
||||||
|
val csr = CertificateSigningRequest.der(deviceName, key)
|
||||||
|
val result = client.enroll(login.enrollToken, csr, subdomain, deviceName)
|
||||||
|
commitIdentity(result, deviceName, key.alias)
|
||||||
|
summaryOf(result)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// The enroll failed AFTER keygen: drop the orphan key so a retry starts clean and no
|
||||||
|
// unreferenced key lingers in secure hardware.
|
||||||
|
runCatching { keyProvider.delete(key.alias) }
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Silent rotation: re-CSR from the SAME hardware key and replace the leaf via
|
||||||
|
* `POST /device/:id/renew`. The renew endpoint authenticates by the CURRENT device certificate over
|
||||||
|
* mTLS (the presented client cert), so [bearerToken] is OPTIONAL and defaults to absent — the
|
||||||
|
* production caller passes none (mirrors iOS, which renews with `bearerToken: nil`). The seam still
|
||||||
|
* accepts a bearer for a hypothetical bearer-authenticated renew, but bakes in no credential policy;
|
||||||
|
* it only re-signs and re-commits. Throws [EnrollmentStateException] if there is nothing enrolled to
|
||||||
|
* renew or the key is gone.
|
||||||
|
*/
|
||||||
|
public suspend fun renew(bearerToken: String? = null): CertificateSummary = commitMutex.withLock {
|
||||||
|
val record = recordStore.load()
|
||||||
|
?: throw EnrollmentStateException("no enrollment record — nothing to renew")
|
||||||
|
val key = keyProvider.load(record.keyStoreAlias)
|
||||||
|
?: throw EnrollmentStateException("device key missing — a fresh enroll is required")
|
||||||
|
val csr = CertificateSigningRequest.der(record.deviceName, key)
|
||||||
|
val result = client.renew(record.deviceId, csr, bearerToken)
|
||||||
|
commitIdentity(result, record.deviceName, record.keyStoreAlias)
|
||||||
|
summaryOf(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove the enrolled identity: cert pointer, enrollment record, and the hardware key. */
|
||||||
|
public suspend fun remove(): Unit = commitMutex.withLock {
|
||||||
|
certStore.clear()
|
||||||
|
recordStore.clear()
|
||||||
|
keyProvider.delete(keyAlias)
|
||||||
|
sharedClient.connectionPool.evictAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist the enrollment record (deviceId → renew), THEN commit the cert live-pointer (the mTLS
|
||||||
|
* flip), THEN evict pooled/resumed connections so the next handshake presents the new leaf via
|
||||||
|
* the existing re-reading `X509KeyManager`. The key already lives in AndroidKeyStore at [alias];
|
||||||
|
* the private key never enters storage.
|
||||||
|
*/
|
||||||
|
private fun commitIdentity(result: EnrollmentResult, deviceName: String, alias: String) {
|
||||||
|
val leaf = parseCertificate(result.certificate)
|
||||||
|
val issuers = result.caChain.map { parseCertificate(it) }
|
||||||
|
|
||||||
|
recordStore.save(
|
||||||
|
EnrollmentRecord(
|
||||||
|
deviceId = result.deviceId,
|
||||||
|
deviceName = deviceName,
|
||||||
|
keyStoreAlias = alias,
|
||||||
|
renewAfterEpochSeconds = result.renewAfter?.epochSecond ?: 0L,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
certStore.save(
|
||||||
|
StoredIdentityMetadata(
|
||||||
|
alias = alias,
|
||||||
|
keyAlgorithm = KEY_ALGORITHM_EC,
|
||||||
|
keyStoreAlias = alias,
|
||||||
|
certificateChain = listOf(leaf) + issuers,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
sharedClient.connectionPool.evictAll()
|
||||||
|
// FIX 3: re-read the just-committed live-pointer into the in-memory identity cache so the
|
||||||
|
// newly enrolled/renewed leaf is presented on the NEXT mTLS handshake without a restart. Done
|
||||||
|
// AFTER the durable commit + pool eviction so the cache can never publish an un-committed leaf.
|
||||||
|
cacheRefresher?.refreshFromStore()
|
||||||
|
Log.i(TAG, "Device identity enrolled/renewed and committed for alias '$alias'")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun summaryOf(result: EnrollmentResult): CertificateSummary =
|
||||||
|
CertificateSummaryReader.summarize(parseCertificate(result.certificate))
|
||||||
|
|
||||||
|
private fun parseCertificate(der: ByteArray): X509Certificate =
|
||||||
|
CertificateFactory.getInstance(X509).generateCertificate(der.inputStream()) as X509Certificate
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "DeviceEnroller"
|
||||||
|
const val X509 = "X.509"
|
||||||
|
|
||||||
|
/** AndroidKeyStore EC keys report algorithm "EC" — matched by `ClientKeyManagerLogic`. */
|
||||||
|
const val KEY_ALGORITHM_EC = "EC"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package wang.yaojia.webterm.tlsandroid
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B4 · A seam over the three non-exportable hardware-key operations [DeviceEnroller]'s
|
||||||
|
* enroll/renew orchestration needs. Production wires the real AndroidKeyStore-backed
|
||||||
|
* [HardwareKeyStore] (StrongBox → TEE); a JVM unit test wires a software P-256 double, so the
|
||||||
|
* enroll/commit orchestration (request shaping, error handling, the two-store commit sequencing)
|
||||||
|
* can be exercised without an emulator. NOTHING about the hardware-key policy leaks through this
|
||||||
|
* seam beyond generate/load/delete — the key stays non-exportable in the real implementation.
|
||||||
|
*/
|
||||||
|
public interface DeviceKeyProvider {
|
||||||
|
/** Generate a fresh non-exportable key at [alias], overwriting any prior entry there. */
|
||||||
|
public fun generate(alias: String): HardwareBackedKey
|
||||||
|
|
||||||
|
/** Load a previously-generated key by [alias], or null if no entry exists (pre-enroll state). */
|
||||||
|
public fun load(alias: String): HardwareBackedKey?
|
||||||
|
|
||||||
|
/** Delete the key entry at [alias]. Idempotent (a missing alias is a no-op). */
|
||||||
|
public fun delete(alias: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The production [DeviceKeyProvider] — a thin delegate to the AndroidKeyStore-backed
|
||||||
|
* [HardwareKeyStore]. Kept as a stateless object so it can be the [DeviceEnroller] constructor
|
||||||
|
* default without any wiring.
|
||||||
|
*/
|
||||||
|
public object HardwareDeviceKeyProvider : DeviceKeyProvider {
|
||||||
|
override fun generate(alias: String): HardwareBackedKey = HardwareKeyStore.generate(alias)
|
||||||
|
|
||||||
|
override fun load(alias: String): HardwareBackedKey? = HardwareKeyStore.load(alias)
|
||||||
|
|
||||||
|
override fun delete(alias: String): Unit = HardwareKeyStore.delete(alias)
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package wang.yaojia.webterm.tlsandroid
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.util.Base64
|
||||||
|
import com.google.crypto.tink.Aead
|
||||||
|
import com.google.crypto.tink.KeyTemplates
|
||||||
|
import com.google.crypto.tink.RegistryConfiguration
|
||||||
|
import com.google.crypto.tink.aead.AeadConfig
|
||||||
|
import com.google.crypto.tink.integration.android.AndroidKeysetManager
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.DataInputStream
|
||||||
|
import java.io.DataOutputStream
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B4 · The auxiliary enrollment record needed to drive silent rotation: the server-minted
|
||||||
|
* [deviceId] (the `/device/:id/renew` path segment), the [deviceName] re-used as the renew CSR
|
||||||
|
* subject CN, the AndroidKeyStore [keyStoreAlias] holding the SAME non-exportable key to re-sign
|
||||||
|
* with, and [renewAfterEpochSeconds] (0 = unknown) for the rotation scheduler.
|
||||||
|
*
|
||||||
|
* This is deliberately SEPARATE from [StoredIdentityMetadata] (the mTLS live-pointer): the cert
|
||||||
|
* identity is what the handshake presents; this record only exists so renew can find the device and
|
||||||
|
* its key. The private key is never here — it stays non-exportable in AndroidKeyStore.
|
||||||
|
*/
|
||||||
|
public data class EnrollmentRecord(
|
||||||
|
val deviceId: String,
|
||||||
|
val deviceName: String,
|
||||||
|
val keyStoreAlias: String,
|
||||||
|
val renewAfterEpochSeconds: Long,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(deviceId.isNotBlank()) { "deviceId must not be blank" }
|
||||||
|
require(keyStoreAlias.isNotBlank()) { "keyStoreAlias must not be blank" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Length-prefixed binary codec for [EnrollmentRecord] (KISS — three UTF strings + one long). */
|
||||||
|
public object EnrollmentRecordCodec {
|
||||||
|
public fun encode(record: EnrollmentRecord): ByteArray {
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
DataOutputStream(out).use { data ->
|
||||||
|
data.writeUTF(record.deviceId)
|
||||||
|
data.writeUTF(record.deviceName)
|
||||||
|
data.writeUTF(record.keyStoreAlias)
|
||||||
|
data.writeLong(record.renewAfterEpochSeconds)
|
||||||
|
}
|
||||||
|
return out.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decode [bytes]; any structural failure → [CorruptStoredIdentityException]. */
|
||||||
|
public fun decode(bytes: ByteArray): EnrollmentRecord =
|
||||||
|
try {
|
||||||
|
DataInputStream(ByteArrayInputStream(bytes)).use { data ->
|
||||||
|
EnrollmentRecord(
|
||||||
|
deviceId = data.readUTF(),
|
||||||
|
deviceName = data.readUTF(),
|
||||||
|
keyStoreAlias = data.readUTF(),
|
||||||
|
renewAfterEpochSeconds = data.readLong(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw CorruptStoredIdentityException("Stored enrollment record was truncated/malformed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage contract for the [EnrollmentRecord] — repository pattern so [DeviceEnroller] depends on
|
||||||
|
* the operation set and a fault/blank can be injected in tests. Idempotent [clear].
|
||||||
|
*/
|
||||||
|
public interface EnrollmentRecordStore {
|
||||||
|
public fun save(record: EnrollmentRecord)
|
||||||
|
|
||||||
|
public fun load(): EnrollmentRecord?
|
||||||
|
|
||||||
|
public fun clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tink-AEAD-encrypted [EnrollmentRecordStore] over an app-private `SharedPreferences` file, mirroring
|
||||||
|
* [TinkCertStore]'s custody model (AndroidKeystore-wrapped master key; uninstall-wiped; useless off
|
||||||
|
* this device). Kept in its own key/file namespace so it never collides with the cert live-pointer.
|
||||||
|
*/
|
||||||
|
public class TinkEnrollmentRecordStore(
|
||||||
|
context: Context,
|
||||||
|
private val keysetName: String = DEFAULT_KEYSET_NAME,
|
||||||
|
private val prefFileName: String = DEFAULT_PREF_FILE,
|
||||||
|
private val masterKeyUri: String = DEFAULT_MASTER_KEY_URI,
|
||||||
|
) : EnrollmentRecordStore {
|
||||||
|
private val appContext: Context = context.applicationContext
|
||||||
|
private val aead: Aead by lazy { buildAead() }
|
||||||
|
|
||||||
|
override fun save(record: EnrollmentRecord) {
|
||||||
|
val ciphertext = aead.encrypt(EnrollmentRecordCodec.encode(record), ASSOCIATED_DATA)
|
||||||
|
val committed = prefs().edit()
|
||||||
|
.putString(BLOB_KEY, Base64.encodeToString(ciphertext, Base64.NO_WRAP))
|
||||||
|
.commit()
|
||||||
|
if (!committed) throw java.io.IOException("Failed to durably persist the device enrollment record")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun load(): EnrollmentRecord? {
|
||||||
|
val encoded = prefs().getString(BLOB_KEY, null) ?: return null
|
||||||
|
val ciphertext = try {
|
||||||
|
Base64.decode(encoded, Base64.NO_WRAP)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
throw CorruptStoredIdentityException("Enrollment record blob was not valid base64", e)
|
||||||
|
}
|
||||||
|
val plaintext = try {
|
||||||
|
aead.decrypt(ciphertext, ASSOCIATED_DATA)
|
||||||
|
} catch (e: java.security.GeneralSecurityException) {
|
||||||
|
throw CorruptStoredIdentityException("Enrollment record blob failed AEAD decryption", e)
|
||||||
|
}
|
||||||
|
return EnrollmentRecordCodec.decode(plaintext)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clear() {
|
||||||
|
val committed = prefs().edit().remove(BLOB_KEY).commit()
|
||||||
|
if (!committed) throw java.io.IOException("Failed to durably clear the device enrollment record")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildAead(): Aead {
|
||||||
|
AeadConfig.register()
|
||||||
|
val keysetHandle = AndroidKeysetManager.Builder()
|
||||||
|
.withSharedPref(appContext, keysetName, prefFileName)
|
||||||
|
.withKeyTemplate(KeyTemplates.get(AEAD_KEY_TEMPLATE))
|
||||||
|
.withMasterKeyUri(masterKeyUri)
|
||||||
|
.build()
|
||||||
|
.keysetHandle
|
||||||
|
return keysetHandle.getPrimitive(RegistryConfiguration.get(), Aead::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun prefs(): SharedPreferences =
|
||||||
|
appContext.getSharedPreferences(prefFileName, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
public companion object {
|
||||||
|
private const val DEFAULT_KEYSET_NAME = "webterm_enroll_keyset"
|
||||||
|
private const val DEFAULT_PREF_FILE = "webterm_enroll_record_prefs"
|
||||||
|
private const val DEFAULT_MASTER_KEY_URI = "android-keystore://webterm_enroll_master_key"
|
||||||
|
private const val AEAD_KEY_TEMPLATE = "AES256_GCM"
|
||||||
|
private const val BLOB_KEY = "enrollment_record_blob"
|
||||||
|
private val ASSOCIATED_DATA: ByteArray = "webterm.client-tls.enrollment-record".toByteArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package wang.yaojia.webterm.tlsandroid
|
||||||
|
|
||||||
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
|
import android.security.keystore.KeyProperties
|
||||||
|
import android.security.keystore.StrongBoxUnavailableException
|
||||||
|
import android.util.Log
|
||||||
|
import java.security.KeyPair
|
||||||
|
import java.security.KeyPairGenerator
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.security.PrivateKey
|
||||||
|
import java.security.Signature
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import java.security.interfaces.ECPublicKey
|
||||||
|
import java.security.spec.ECGenParameterSpec
|
||||||
|
import wang.yaojia.webterm.api.enroll.CsrSigner
|
||||||
|
import wang.yaojia.webterm.api.enroll.EcPointEncoding
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B4 · A P-256 signing key that lives ENTIRELY inside AndroidKeyStore and is NON-EXPORTABLE by
|
||||||
|
* construction (AndroidKeyStore has no key-material getter). It is the Android analogue of the iOS
|
||||||
|
* `SecureEnclaveKey`: `sign` runs inside secure hardware (StrongBox → TEE) and drives the same
|
||||||
|
* `Signature("SHA256withECDSA")` path the JVM-unit-test software key uses, so [CsrSigner] callers
|
||||||
|
* (`CertificateSigningRequest`) are exercised identically.
|
||||||
|
*
|
||||||
|
* The wrapped [privateKey] is the opaque AndroidKeyStore handle — presented to the re-reading
|
||||||
|
* `X509KeyManager` for the TLS `CertificateVerify` and never exported. [publicKey] is only used to
|
||||||
|
* emit the CSR's `SubjectPublicKeyInfo`.
|
||||||
|
*/
|
||||||
|
public class HardwareBackedKey internal constructor(
|
||||||
|
public val alias: String,
|
||||||
|
private val privateKey: PrivateKey,
|
||||||
|
private val publicKey: ECPublicKey,
|
||||||
|
) : CsrSigner {
|
||||||
|
|
||||||
|
override fun publicKeyX963(): ByteArray = EcPointEncoding.x963(publicKey)
|
||||||
|
|
||||||
|
override fun sign(message: ByteArray): ByteArray =
|
||||||
|
Signature.getInstance(SIGNATURE_ALGORITHM).apply {
|
||||||
|
initSign(privateKey)
|
||||||
|
update(message)
|
||||||
|
}.sign()
|
||||||
|
|
||||||
|
/** The opaque, non-exportable AndroidKeyStore private-key handle presented on the mTLS path. */
|
||||||
|
public val keyHandle: PrivateKey get() = privateKey
|
||||||
|
|
||||||
|
public companion object {
|
||||||
|
private const val SIGNATURE_ALGORITHM = "SHA256withECDSA"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates / loads / deletes the device's non-exportable P-256 key in AndroidKeyStore.
|
||||||
|
*
|
||||||
|
* Generation prefers **StrongBox** (dedicated secure element) and falls back to the **TEE** when the
|
||||||
|
* device has no StrongBox — the security posture (non-exportable, hardware-backed, silent-signing)
|
||||||
|
* is identical either way; StrongBox is a hardening bonus, not a requirement. The key is
|
||||||
|
* `PURPOSE_SIGN` only with a broad digest set so TLS 1.2/1.3 signature negotiation for the client
|
||||||
|
* `CertificateVerify` works, and NO user-authentication is required so silent enroll/renew never
|
||||||
|
* blocks on a biometric prompt.
|
||||||
|
*/
|
||||||
|
public object HardwareKeyStore {
|
||||||
|
private const val TAG = "HardwareKeyStore"
|
||||||
|
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
|
||||||
|
private const val CURVE = "secp256r1"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a fresh non-exportable P-256 key at [alias], overwriting any prior entry there.
|
||||||
|
* StrongBox-backed when available, else TEE-backed. Throws the underlying keystore exception if
|
||||||
|
* BOTH paths fail (never returns a half-generated key).
|
||||||
|
*/
|
||||||
|
public fun generate(alias: String): HardwareBackedKey {
|
||||||
|
val keyPair = try {
|
||||||
|
generateKeyPair(alias, strongBox = true)
|
||||||
|
} catch (_: StrongBoxUnavailableException) {
|
||||||
|
Log.i(TAG, "StrongBox unavailable; generating a TEE-backed device key (non-exportable)")
|
||||||
|
delete(alias) // clear any partial StrongBox entry before the TEE retry
|
||||||
|
generateKeyPair(alias, strongBox = false)
|
||||||
|
}
|
||||||
|
return HardwareBackedKey(alias, keyPair.private, keyPair.public as ECPublicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a previously-generated key by [alias] (renew path, after relaunch). The public key is
|
||||||
|
* recovered from the self-signed placeholder certificate AndroidKeyStore stored at generation.
|
||||||
|
* Returns null if no key entry exists (the normal pre-enroll state).
|
||||||
|
*/
|
||||||
|
public fun load(alias: String): HardwareBackedKey? {
|
||||||
|
val keyStore = androidKeyStore()
|
||||||
|
val privateKey = keyStore.getKey(alias, null) as? PrivateKey ?: return null
|
||||||
|
val publicKey = (keyStore.getCertificate(alias) as? X509Certificate)?.publicKey as? ECPublicKey
|
||||||
|
?: return null
|
||||||
|
return HardwareBackedKey(alias, privateKey, publicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete the key entry at [alias]. Idempotent (a missing alias is a no-op). */
|
||||||
|
public fun delete(alias: String) {
|
||||||
|
val keyStore = androidKeyStore()
|
||||||
|
if (keyStore.containsAlias(alias)) keyStore.deleteEntry(alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cheap existence check (does NOT read key material). */
|
||||||
|
public fun exists(alias: String): Boolean = androidKeyStore().containsAlias(alias)
|
||||||
|
|
||||||
|
private fun generateKeyPair(alias: String, strongBox: Boolean): KeyPair {
|
||||||
|
val spec = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN)
|
||||||
|
.setAlgorithmParameterSpec(ECGenParameterSpec(CURVE))
|
||||||
|
.setDigests(
|
||||||
|
KeyProperties.DIGEST_NONE,
|
||||||
|
KeyProperties.DIGEST_SHA256,
|
||||||
|
KeyProperties.DIGEST_SHA384,
|
||||||
|
KeyProperties.DIGEST_SHA512,
|
||||||
|
)
|
||||||
|
.setIsStrongBoxBacked(strongBox)
|
||||||
|
.build()
|
||||||
|
val generator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, ANDROID_KEYSTORE)
|
||||||
|
generator.initialize(spec)
|
||||||
|
return generator.generateKeyPair()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun androidKeyStore(): KeyStore =
|
||||||
|
KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
|
||||||
|
}
|
||||||
@@ -56,6 +56,18 @@ public interface IdentityRepository {
|
|||||||
public suspend fun remove()
|
public suspend fun remove()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B4 · A narrow seam the zero-`.p12` enroll/renew commit ([DeviceEnroller]) fires so an in-memory
|
||||||
|
* identity cache re-reads the freshly-committed live-pointer and presents the new leaf on the NEXT
|
||||||
|
* mTLS handshake WITHOUT a process restart. Kept separate from [IdentityRepository] so the enroller
|
||||||
|
* depends only on this one operation (it never needs the import/rotate/remove surface). The production
|
||||||
|
* implementation is [AndroidIdentityRepository]; a JVM test uses a recording double.
|
||||||
|
*/
|
||||||
|
public fun interface IdentityCacheRefresher {
|
||||||
|
/** Reload the persisted live identity into the in-memory cache and drop stale pooled connections. */
|
||||||
|
public fun refreshFromStore()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default [IdentityRepository] over [AndroidKeyStoreImporter] (key home) + [CertStore] (encrypted
|
* Default [IdentityRepository] over [AndroidKeyStoreImporter] (key home) + [CertStore] (encrypted
|
||||||
* live-pointer at rest) + the shared [OkHttpClient] (for `connectionPool.evictAll()`).
|
* live-pointer at rest) + the shared [OkHttpClient] (for `connectionPool.evictAll()`).
|
||||||
@@ -90,7 +102,7 @@ public class AndroidIdentityRepository(
|
|||||||
private val importer: AndroidKeyStoreImporter,
|
private val importer: AndroidKeyStoreImporter,
|
||||||
private val certStore: CertStore,
|
private val certStore: CertStore,
|
||||||
private val sharedClient: OkHttpClient,
|
private val sharedClient: OkHttpClient,
|
||||||
) : IdentityRepository {
|
) : IdentityRepository, IdentityCacheRefresher {
|
||||||
|
|
||||||
/** The live identity as tracked by the repo: the KeyManager view + which physical slot holds the key. */
|
/** The live identity as tracked by the repo: the KeyManager view + which physical slot holds the key. */
|
||||||
private class LiveIdentity(val installed: InstalledIdentity, val keyStoreAlias: String)
|
private class LiveIdentity(val installed: InstalledIdentity, val keyStoreAlias: String)
|
||||||
@@ -150,6 +162,20 @@ public class AndroidIdentityRepository(
|
|||||||
sharedClient.connectionPool.evictAll()
|
sharedClient.connectionPool.evictAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FIX 3 (cache freshness) · Re-read the persisted live-pointer into the in-memory cache. Used when a
|
||||||
|
* device certificate is committed OUT OF BAND of this repository — the zero-`.p12` [DeviceEnroller]
|
||||||
|
* writes the leaf straight into the shared [CertStore] + AndroidKeyStore, so without this the running
|
||||||
|
* repo would keep presenting its cached (pre-enroll) identity until process restart. Publishing the
|
||||||
|
* freshly-loaded snapshot as [liveOverride] and evicting pooled connections makes the enrolled leaf
|
||||||
|
* present on the NEXT handshake. Reloading to `null` (a fault/absent pointer) is a valid outcome and
|
||||||
|
* simply reports "no identity". Not `suspend` — the enroller already runs this off the UI thread.
|
||||||
|
*/
|
||||||
|
override fun refreshFromStore() {
|
||||||
|
liveOverride = Box(loadInstalledOrNull())
|
||||||
|
sharedClient.connectionPool.evictAll()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single-commit install/rotation (see the class KDoc). Validation throws before any mutation;
|
* Single-commit install/rotation (see the class KDoc). Validation throws before any mutation;
|
||||||
* the new key is imported into the non-live slot; the COMMIT is one atomic [CertStore.save] that
|
* the new key is imported into the non-live slot; the COMMIT is one atomic [CertStore.save] that
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
package wang.yaojia.webterm.tlsandroid
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import wang.yaojia.webterm.api.enroll.DeviceEnrollmentClient
|
||||||
|
import wang.yaojia.webterm.api.enroll.DeviceEnrollmentError
|
||||||
|
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
||||||
|
import wang.yaojia.webterm.wire.HttpMethod
|
||||||
|
import java.security.KeyPairGenerator
|
||||||
|
import java.security.interfaces.ECPublicKey
|
||||||
|
import java.security.spec.ECGenParameterSpec
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B4 · JVM unit coverage for the [DeviceEnroller] enroll/renew ORCHESTRATION — the layer that runs
|
||||||
|
* the security-critical two-store commit. Driven with a software P-256 key ([DeviceKeyProvider]
|
||||||
|
* double) + the shared [FakeHttpTransport], so request shaping, error handling, and — most
|
||||||
|
* importantly — the commit SEQUENCING run without an emulator or a real AndroidKeyStore.
|
||||||
|
*
|
||||||
|
* The security-critical invariant under test: the enrollment record is persisted BEFORE the cert
|
||||||
|
* live-pointer flip (the mTLS commit), so a successful cert-store save always means the identity is
|
||||||
|
* fully live (see [DeviceEnroller.commitIdentity]).
|
||||||
|
*/
|
||||||
|
class DeviceEnrollerTest {
|
||||||
|
private companion object {
|
||||||
|
const val BASE = "https://cp.terminal.yaojia.wang"
|
||||||
|
const val ALIAS = "test-device-key"
|
||||||
|
|
||||||
|
// Real self-signed P-256 X.509 certs (base64 DER) so commitIdentity's CertificateFactory /
|
||||||
|
// CertificateSummaryReader parse them exactly as they parse a server-issued leaf.
|
||||||
|
const val LEAF_CN = "t1-device"
|
||||||
|
const val CA_CN = "webterm-device-ca"
|
||||||
|
const val LEAF_B64 =
|
||||||
|
"MIIBfzCCASWgAwIBAgIUH+MotJdtckTE7470KQz73GPZa+IwCgYIKoZIzj0EAwIwFDESMBAGA1UEAwwJdDEt" +
|
||||||
|
"ZGV2aWNlMCAXDTI2MDcxODExMjExMVoYDzIxMjYwNjI0MTEyMTExWjAUMRIwEAYDVQQDDAl0MS1kZXZpY2Uw" +
|
||||||
|
"WTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQLKEwBsNSMTDfKsdr0qtKUtZCcglWICSMJYRowgIN546ctWw+h" +
|
||||||
|
"cXXeZ7ru9F198rt3k2Z4Wesf0n3tUm9jdn/Oo1MwUTAdBgNVHQ4EFgQU1+o809OaRKV3p/P5dhY5yAdOrr0w" +
|
||||||
|
"HwYDVR0jBBgwFoAU1+o809OaRKV3p/P5dhY5yAdOrr0wDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNI" +
|
||||||
|
"ADBFAiEAotIxEXaCEp2rtEG6KLOtmJYS6Jc/JaJFERGRH4Q/qsMCIB4Rkb06AB7pQUsAHLj81BXcYEd04GY" +
|
||||||
|
"cdoleWDlqcMKU"
|
||||||
|
const val CA_B64 =
|
||||||
|
"MIIBjzCCATWgAwIBAgIUXGwe1gOYBewwVZQoVj1IgiirwnUwCgYIKoZIzj0EAwIwHDEaMBgGA1UEAwwRd2Vi" +
|
||||||
|
"dGVybS1kZXZpY2UtY2EwIBcNMjYwNzE4MTEyMTExWhgPMjEyNjA2MjQxMTIxMTFaMBwxGjAYBgNVBAMMEXdl" +
|
||||||
|
"YnRlcm0tZGV2aWNlLWNhMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEkwVx9McuEN+rTZwYfsYl8YPhpyWt" +
|
||||||
|
"e8PT06OpifVsIdCyDH3bPoENOsPJf8mjRqkgoLSHgetuUf2T2Ot28qRiuaNTMFEwHQYDVR0OBBYEFGkPHz9w" +
|
||||||
|
"4FVyZRgo8g1PO8F/v6ggMB8GA1UdIwQYMBaAFGkPHz9w4FVyZRgo8g1PO8F/v6ggMA8GA1UdEwEB/wQFMAMB" +
|
||||||
|
"Af8wCgYIKoZIzj0EAwIDSAAwRQIhAJlUm4M4K2fHMOtip2Hs5LxvS0T7RJwUbflz5wHGQiyJAiAHXp1oNUkQ" +
|
||||||
|
"YloHuEAg+kngzA33m52aWtublai4L+eybg=="
|
||||||
|
|
||||||
|
fun loginBody(): ByteArray =
|
||||||
|
"""{"enrollToken":"tok-xyz","accountId":"acct-1","expiresIn":600}""".toByteArray()
|
||||||
|
|
||||||
|
fun enrollBody(deviceId: String = "dev-1"): ByteArray =
|
||||||
|
"""
|
||||||
|
{"deviceId":"$deviceId","cert":"$LEAF_B64","caChain":["$CA_B64"],
|
||||||
|
"notBefore":"2026-07-08T00:00:00.000Z","notAfter":"2026-10-06T00:00:00.000Z",
|
||||||
|
"renewAfter":"2026-09-05T00:00:00.000Z"}
|
||||||
|
""".trimIndent().toByteArray()
|
||||||
|
|
||||||
|
fun softwareKey(alias: String): HardwareBackedKey {
|
||||||
|
val kpg = KeyPairGenerator.getInstance("EC")
|
||||||
|
kpg.initialize(ECGenParameterSpec("secp256r1"))
|
||||||
|
val kp = kpg.generateKeyPair()
|
||||||
|
return HardwareBackedKey(alias, kp.private, kp.public as ECPublicKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val events = mutableListOf<String>()
|
||||||
|
private val transport = FakeHttpTransport()
|
||||||
|
private val certStore = RecordingCertStore(events)
|
||||||
|
private val recordStore = RecordingRecordStore(events)
|
||||||
|
private val keyProvider = RecordingKeyProvider(events)
|
||||||
|
private val refresher = RecordingRefresher(events)
|
||||||
|
|
||||||
|
private fun enroller(): DeviceEnroller =
|
||||||
|
DeviceEnroller(
|
||||||
|
client = DeviceEnrollmentClient(BASE, transport),
|
||||||
|
certStore = certStore,
|
||||||
|
recordStore = recordStore,
|
||||||
|
sharedClient = OkHttpClient(),
|
||||||
|
keyAlias = ALIAS,
|
||||||
|
keyProvider = keyProvider,
|
||||||
|
cacheRefresher = refresher,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── enroll: commit sequencing (the security-critical invariant) ────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun enrollPersistsTheRecordBeforeTheCertLivePointerFlip() = runTest {
|
||||||
|
transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 201, body = loginBody())
|
||||||
|
transport.queueSuccess(HttpMethod.POST, "$BASE/device/enroll", 201, body = enrollBody())
|
||||||
|
|
||||||
|
val summary = enroller().enroll(password = "hunter2", subdomain = "alice", deviceName = "Alice Pixel")
|
||||||
|
|
||||||
|
// Record.save strictly precedes cert.save — the mTLS pointer flip is written LAST.
|
||||||
|
assertTrue(events.contains("record.save") && events.contains("cert.save"))
|
||||||
|
assertTrue(
|
||||||
|
events.indexOf("record.save") < events.indexOf("cert.save"),
|
||||||
|
"the enrollment record must be committed BEFORE the cert live-pointer flip",
|
||||||
|
)
|
||||||
|
// Both stores received the issued identity; the stored chain is leaf + issuer (from caChain).
|
||||||
|
assertEquals("dev-1", recordStore.saved!!.deviceId)
|
||||||
|
assertEquals(ALIAS, recordStore.saved!!.keyStoreAlias)
|
||||||
|
val chain = certStore.saved!!.certificateChain
|
||||||
|
assertEquals(2, chain.size, "stored chain = leaf + one caChain issuer")
|
||||||
|
assertTrue(chain[0].subjectX500Principal.name.contains(LEAF_CN), "chain[0] is the leaf")
|
||||||
|
assertTrue(chain[1].subjectX500Principal.name.contains(CA_CN), "chain[1] is the device-CA issuer")
|
||||||
|
// The install summary is read off the leaf via the production CertificateSummaryReader.
|
||||||
|
assertEquals(LEAF_CN, summary.subjectCommonName)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun enrollRefreshesTheIdentityCacheAfterTheCommit() = runTest {
|
||||||
|
transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 201, body = loginBody())
|
||||||
|
transport.queueSuccess(HttpMethod.POST, "$BASE/device/enroll", 201, body = enrollBody())
|
||||||
|
|
||||||
|
enroller().enroll(password = "hunter2", subdomain = "alice", deviceName = "Alice Pixel")
|
||||||
|
|
||||||
|
// FIX 3: the in-memory identity cache is refreshed AFTER the durable cert live-pointer flip, so
|
||||||
|
// the newly enrolled leaf is presented on the next handshake with no restart.
|
||||||
|
assertTrue(events.contains("cache.refresh"), "the identity cache must be refreshed on enroll")
|
||||||
|
assertTrue(
|
||||||
|
events.indexOf("cert.save") < events.indexOf("cache.refresh"),
|
||||||
|
"the cache refresh must run AFTER the cert live-pointer commit (never publish an un-committed leaf)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun enrollShapesTheLoginAndEnrollRequests() = runTest {
|
||||||
|
transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 201, body = loginBody())
|
||||||
|
transport.queueSuccess(HttpMethod.POST, "$BASE/device/enroll", 201, body = enrollBody())
|
||||||
|
|
||||||
|
enroller().enroll(password = "hunter2", subdomain = "alice", deviceName = "Alice Pixel")
|
||||||
|
|
||||||
|
val login = transport.recordedRequests[0]
|
||||||
|
assertEquals("$BASE/auth/login", login.url)
|
||||||
|
assertTrue(login.body!!.decodeToString().contains("\"password\":\"hunter2\""))
|
||||||
|
|
||||||
|
val enroll = transport.recordedRequests[1]
|
||||||
|
assertEquals("$BASE/device/enroll", enroll.url)
|
||||||
|
assertEquals("Bearer tok-xyz", enroll.headers["Authorization"], "enroll rides the login bearer")
|
||||||
|
val enrollBodyStr = enroll.body!!.decodeToString()
|
||||||
|
assertTrue(enrollBodyStr.contains("\"subdomain\":\"alice\""))
|
||||||
|
assertTrue(enrollBodyStr.contains("\"deviceName\":\"Alice Pixel\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── enroll: error handling ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun enrollDropsTheOrphanKeyWhenEnrollFailsAfterKeygen() = runTest {
|
||||||
|
transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 201, body = loginBody())
|
||||||
|
transport.queueSuccess(HttpMethod.POST, "$BASE/device/enroll", 403, body = """{"error":"rejected"}""".toByteArray())
|
||||||
|
|
||||||
|
val error = runCatching {
|
||||||
|
enroller().enroll(password = "hunter2", subdomain = "bob", deviceName = "Bob Pixel")
|
||||||
|
}.exceptionOrNull()
|
||||||
|
|
||||||
|
assertEquals(DeviceEnrollmentError.Http(403, "rejected"), error)
|
||||||
|
assertEquals(listOf(ALIAS), keyProvider.generatedAliases, "the key was generated after login")
|
||||||
|
assertEquals(listOf(ALIAS), keyProvider.deletedAliases, "the orphan key is dropped on enroll failure")
|
||||||
|
assertNull(recordStore.saved, "no record is committed when enroll fails")
|
||||||
|
assertNull(certStore.saved, "the cert live-pointer is never flipped when enroll fails")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun enrollNeverBurnsAKeyWhenLoginIsRejected() = runTest {
|
||||||
|
transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 401, body = """{"error":"rejected"}""".toByteArray())
|
||||||
|
|
||||||
|
val error = runCatching {
|
||||||
|
enroller().enroll(password = "wrong", subdomain = "alice", deviceName = "Alice Pixel")
|
||||||
|
}.exceptionOrNull()
|
||||||
|
|
||||||
|
assertEquals(DeviceEnrollmentError.Http(401, "rejected"), error)
|
||||||
|
assertTrue(keyProvider.generatedAliases.isEmpty(), "a rejected credential must not burn a key slot")
|
||||||
|
assertNull(recordStore.saved)
|
||||||
|
assertNull(certStore.saved)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── renew: preconditions + request shaping + commit ────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun renewThrowsWhenNothingIsEnrolled() = runTest {
|
||||||
|
val error = runCatching { enroller().renew() }.exceptionOrNull()
|
||||||
|
assertTrue(error is DeviceEnroller.EnrollmentStateException)
|
||||||
|
assertTrue(transport.recordedRequests.isEmpty(), "no network I/O when there is nothing to renew")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun renewThrowsWhenTheDeviceKeyIsMissing() = runTest {
|
||||||
|
recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, renewAfterEpochSeconds = 0L))
|
||||||
|
// keyProvider has no key at ALIAS → load() returns null.
|
||||||
|
val error = runCatching { enroller().renew() }.exceptionOrNull()
|
||||||
|
assertTrue(error is DeviceEnroller.EnrollmentStateException)
|
||||||
|
assertTrue(transport.recordedRequests.isEmpty(), "no renew call when the hardware key is gone")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun renewReCsrsFromTheSameKeyOverMtlsWithNoBearerAndACsrOnlyBody() = runTest {
|
||||||
|
recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, renewAfterEpochSeconds = 0L))
|
||||||
|
keyProvider.seed(ALIAS, softwareKey(ALIAS))
|
||||||
|
transport.queueSuccess(HttpMethod.POST, "$BASE/device/dev-1/renew", 201, body = enrollBody())
|
||||||
|
|
||||||
|
// FIX 2: production renew passes NO bearer — the endpoint authenticates by the current cert (mTLS).
|
||||||
|
enroller().renew()
|
||||||
|
|
||||||
|
val renew = transport.recordedRequests.single()
|
||||||
|
assertEquals("$BASE/device/dev-1/renew", renew.url)
|
||||||
|
assertNull(renew.headers["Authorization"], "renew authenticates by mTLS — it must send NO Authorization header")
|
||||||
|
val body = renew.body!!.decodeToString()
|
||||||
|
// The renew body is {csr}-only — the server's .strict() schema rejects any enroll-only extra.
|
||||||
|
assertTrue(body.contains("\"csr\":"), "renew sends the fresh CSR")
|
||||||
|
assertFalse(body.contains("keyAlg"), "renew must not send the enroll-only keyAlg")
|
||||||
|
assertFalse(body.contains("subdomain"), "renew must not send subdomain")
|
||||||
|
assertFalse(body.contains("deviceName"), "renew must not send deviceName")
|
||||||
|
// Same commit sequencing on the rotation path: record before cert, then cache refresh last.
|
||||||
|
assertTrue(events.indexOf("record.save") < events.indexOf("cert.save"))
|
||||||
|
assertTrue(events.indexOf("cert.save") < events.indexOf("cache.refresh"), "cache refresh runs after the renew commit")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── remove: full teardown ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun removeClearsBothStoresAndDeletesTheKey() = runTest {
|
||||||
|
recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, renewAfterEpochSeconds = 0L))
|
||||||
|
keyProvider.seed(ALIAS, softwareKey(ALIAS))
|
||||||
|
|
||||||
|
enroller().remove()
|
||||||
|
|
||||||
|
assertTrue(certStore.cleared)
|
||||||
|
assertTrue(recordStore.cleared)
|
||||||
|
assertEquals(listOf(ALIAS), keyProvider.deletedAliases, "the hardware key is deleted on remove")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── recording doubles ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private class RecordingCertStore(private val events: MutableList<String>) : CertStore {
|
||||||
|
var saved: StoredIdentityMetadata? = null
|
||||||
|
var cleared = false
|
||||||
|
|
||||||
|
override fun save(metadata: StoredIdentityMetadata) {
|
||||||
|
saved = metadata
|
||||||
|
events += "cert.save"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun load(): StoredIdentityMetadata? = saved
|
||||||
|
|
||||||
|
override fun clear() {
|
||||||
|
cleared = true
|
||||||
|
saved = null
|
||||||
|
events += "cert.clear"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class RecordingRecordStore(private val events: MutableList<String>) : EnrollmentRecordStore {
|
||||||
|
var saved: EnrollmentRecord? = null
|
||||||
|
var cleared = false
|
||||||
|
private var current: EnrollmentRecord? = null
|
||||||
|
|
||||||
|
fun seed(record: EnrollmentRecord) {
|
||||||
|
current = record
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun save(record: EnrollmentRecord) {
|
||||||
|
saved = record
|
||||||
|
current = record
|
||||||
|
events += "record.save"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun load(): EnrollmentRecord? = current
|
||||||
|
|
||||||
|
override fun clear() {
|
||||||
|
cleared = true
|
||||||
|
current = null
|
||||||
|
events += "record.clear"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class RecordingRefresher(private val events: MutableList<String>) : IdentityCacheRefresher {
|
||||||
|
override fun refreshFromStore() {
|
||||||
|
events += "cache.refresh"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class RecordingKeyProvider(private val events: MutableList<String>) : DeviceKeyProvider {
|
||||||
|
private val keys = mutableMapOf<String, HardwareBackedKey>()
|
||||||
|
val generatedAliases = mutableListOf<String>()
|
||||||
|
val deletedAliases = mutableListOf<String>()
|
||||||
|
|
||||||
|
fun seed(alias: String, key: HardwareBackedKey) {
|
||||||
|
keys[alias] = key
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun generate(alias: String): HardwareBackedKey {
|
||||||
|
val key = softwareKey(alias)
|
||||||
|
keys[alias] = key
|
||||||
|
generatedAliases += alias
|
||||||
|
events += "generate:$alias"
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun load(alias: String): HardwareBackedKey? = keys[alias]
|
||||||
|
|
||||||
|
override fun delete(alias: String) {
|
||||||
|
keys.remove(alias)
|
||||||
|
deletedAliases += alias
|
||||||
|
events += "delete:$alias"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
control-panel/.gitignore
vendored
Normal file
4
control-panel/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
public/build/
|
||||||
|
coverage/
|
||||||
|
*.log
|
||||||
37
control-panel/build.mjs
Normal file
37
control-panel/build.mjs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Frontend build — mirrors the base app's esbuild style (vanilla TS → ESM bundle). Emits
|
||||||
|
* public/build/{app.js, index.html, styles.css}. index.html + styles.css are copied verbatim
|
||||||
|
* (index.html already references ./app.js and ./styles.css, both siblings in the build dir).
|
||||||
|
*/
|
||||||
|
import { build } from 'esbuild'
|
||||||
|
import { mkdir, copyFile } from 'node:fs/promises'
|
||||||
|
import { dirname, resolve } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const pub = resolve(here, 'public')
|
||||||
|
const out = resolve(pub, 'build')
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await mkdir(out, { recursive: true })
|
||||||
|
|
||||||
|
await build({
|
||||||
|
entryPoints: [resolve(pub, 'app.ts')],
|
||||||
|
bundle: true,
|
||||||
|
format: 'esm',
|
||||||
|
target: 'es2022',
|
||||||
|
minify: true,
|
||||||
|
sourcemap: true,
|
||||||
|
outfile: resolve(out, 'app.js'),
|
||||||
|
logLevel: 'info',
|
||||||
|
})
|
||||||
|
|
||||||
|
await copyFile(resolve(pub, 'index.html'), resolve(out, 'index.html'))
|
||||||
|
await copyFile(resolve(pub, 'styles.css'), resolve(out, 'styles.css'))
|
||||||
|
process.stdout.write('[control-panel] frontend build → public/build\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
process.stderr.write(`[control-panel] build failed: ${err instanceof Error ? err.message : String(err)}\n`)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
2902
control-panel/package-lock.json
generated
Normal file
2902
control-panel/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
control-panel/package.json
Normal file
35
control-panel/package.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "control-panel",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Web control panel for the zero-touch tunnel system. Loopback-only Fastify backend that gates an operator session behind a password, then proxies the control-plane admin API (list hosts / issue pairing codes / revoke hosts), minting a fresh short-TTL `manage` capability token per call. Vanilla-TS + esbuild SPA. NEVER logs secrets/tokens/passwords.",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"main": "src/server.ts",
|
||||||
|
"scripts": {
|
||||||
|
"start": "tsx src/server.ts",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.web.json --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"coverage": "vitest run --coverage",
|
||||||
|
"build": "node build.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"fastify": "^4.28.1",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"relay-auth": "file:../relay-auth",
|
||||||
|
"relay-contracts": "file:../relay-contracts",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.9.3",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"@vitest/coverage-v8": "^4.1.9",
|
||||||
|
"esbuild": "^0.28.1",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
|
}
|
||||||
|
}
|
||||||
77
control-panel/public/api.ts
Normal file
77
control-panel/public/api.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Typed fetch client for the panel backend. Same-origin; the session cookie rides automatically
|
||||||
|
* (HttpOnly — JS never reads it). Every call narrows the response and throws `ApiError` on failure
|
||||||
|
* so the UI can react (e.g. bounce to the login screen on 401).
|
||||||
|
*/
|
||||||
|
export interface HostView {
|
||||||
|
readonly hostId: string
|
||||||
|
readonly subdomain: string
|
||||||
|
readonly status: string
|
||||||
|
readonly lastSeen?: string
|
||||||
|
readonly createdAt?: string
|
||||||
|
readonly revokedAt?: string | null
|
||||||
|
readonly notAfter?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PairingArtifacts {
|
||||||
|
readonly code: string
|
||||||
|
readonly expiresAt: string
|
||||||
|
readonly pairCommand: string
|
||||||
|
readonly qrDataUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
readonly status: number
|
||||||
|
constructor(status: number, message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.status = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(method: string, url: string, body?: unknown): Promise<Response> {
|
||||||
|
const init: RequestInit = { method, credentials: 'same-origin', headers: {} }
|
||||||
|
if (body !== undefined) {
|
||||||
|
init.headers = { 'content-type': 'application/json' }
|
||||||
|
init.body = JSON.stringify(body)
|
||||||
|
}
|
||||||
|
return fetch(url, init)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function json<T>(res: Response): Promise<T> {
|
||||||
|
if (!res.ok) throw new ApiError(res.status, `request failed (${res.status})`)
|
||||||
|
return (await res.json()) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSession(): Promise<boolean> {
|
||||||
|
const res = await request('GET', '/api/session')
|
||||||
|
const data = await json<{ authenticated: boolean }>(res)
|
||||||
|
return data.authenticated === true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attempt login. Returns true on success; throws ApiError (status) otherwise so callers can message. */
|
||||||
|
export async function login(password: string): Promise<boolean> {
|
||||||
|
const res = await request('POST', '/login', { password })
|
||||||
|
if (res.ok) return true
|
||||||
|
throw new ApiError(res.status, `login failed (${res.status})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
await request('POST', '/logout')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHosts(): Promise<readonly HostView[]> {
|
||||||
|
const res = await request('GET', '/api/hosts')
|
||||||
|
const data = await json<{ hosts: HostView[] }>(res)
|
||||||
|
return data.hosts ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPairingCode(): Promise<PairingArtifacts> {
|
||||||
|
const res = await request('POST', '/api/pairing-codes')
|
||||||
|
return json<PairingArtifacts>(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeHost(hostId: string): Promise<void> {
|
||||||
|
const res = await request('DELETE', `/api/hosts/${encodeURIComponent(hostId)}`)
|
||||||
|
if (!res.ok && res.status !== 204) throw new ApiError(res.status, `revoke failed (${res.status})`)
|
||||||
|
}
|
||||||
114
control-panel/public/app.ts
Normal file
114
control-panel/public/app.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* SPA bootstrap + state machine. On load: check the session → render login or dashboard. The
|
||||||
|
* dashboard polls the host list on an interval and refreshes after mutations. A 401 from any proxy
|
||||||
|
* call bounces the operator back to the login screen (session expired).
|
||||||
|
*/
|
||||||
|
import { mount } from './dom.js'
|
||||||
|
import * as api from './api.js'
|
||||||
|
import { ApiError } from './api.js'
|
||||||
|
import { renderLogin, renderDashboard, pairingModal, confirmDialog, toast, type DashboardHandlers } from './views.js'
|
||||||
|
import type { HostView } from './api.js'
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 15_000
|
||||||
|
|
||||||
|
function rootEl(): HTMLElement {
|
||||||
|
const root = document.getElementById('app')
|
||||||
|
if (root === null) throw new Error('#app root not found')
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
let pollTimer: number | undefined
|
||||||
|
|
||||||
|
function stopPolling(): void {
|
||||||
|
if (pollTimer !== undefined) {
|
||||||
|
clearInterval(pollTimer)
|
||||||
|
pollTimer = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if an error is an auth failure that should bounce to login. */
|
||||||
|
function isUnauthorized(err: unknown): boolean {
|
||||||
|
return err instanceof ApiError && err.status === 401
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showLogin(): Promise<void> {
|
||||||
|
stopPolling()
|
||||||
|
const root = rootEl()
|
||||||
|
const view = renderLogin(async (password) => {
|
||||||
|
const errorNode = (view as HTMLElement & { errorNode?: HTMLElement }).errorNode
|
||||||
|
try {
|
||||||
|
await api.login(password)
|
||||||
|
await showDashboard()
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof ApiError && err.status === 429 ? 'Too many attempts. Wait and retry.' : err instanceof ApiError && err.status === 503 ? 'Panel not configured (no password set).' : 'Incorrect password.'
|
||||||
|
if (errorNode) errorNode.textContent = msg
|
||||||
|
}
|
||||||
|
})
|
||||||
|
mount(root, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHosts(): Promise<readonly HostView[]> {
|
||||||
|
return api.getHosts()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshDashboard(root: HTMLElement, handlers: DashboardHandlers): Promise<void> {
|
||||||
|
try {
|
||||||
|
const hosts = await loadHosts()
|
||||||
|
mount(root, renderDashboard(hosts, handlers))
|
||||||
|
} catch (err) {
|
||||||
|
if (isUnauthorized(err)) {
|
||||||
|
await showLogin()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast(root, 'Failed to load hosts.', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showDashboard(): Promise<void> {
|
||||||
|
const root = rootEl()
|
||||||
|
|
||||||
|
const handlers: DashboardHandlers = {
|
||||||
|
onRefresh: () => void refreshDashboard(root, handlers),
|
||||||
|
onLogout: async () => {
|
||||||
|
stopPolling()
|
||||||
|
await api.logout()
|
||||||
|
await showLogin()
|
||||||
|
},
|
||||||
|
onNewCode: async () => {
|
||||||
|
try {
|
||||||
|
const artifacts = await api.createPairingCode()
|
||||||
|
document.body.append(pairingModal(artifacts))
|
||||||
|
} catch (err) {
|
||||||
|
if (isUnauthorized(err)) return void showLogin()
|
||||||
|
toast(root, 'Failed to create pairing code.', 'error')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRevoke: async (host) => {
|
||||||
|
const ok = await confirmDialog(`Revoke host "${host.subdomain}"? This removes its tunnel access.`)
|
||||||
|
if (!ok) return
|
||||||
|
try {
|
||||||
|
await api.revokeHost(host.hostId)
|
||||||
|
toast(root, `Revoked ${host.subdomain}.`, 'info')
|
||||||
|
await refreshDashboard(root, handlers)
|
||||||
|
} catch (err) {
|
||||||
|
if (isUnauthorized(err)) return void showLogin()
|
||||||
|
toast(root, 'Failed to revoke host.', 'error')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshDashboard(root, handlers)
|
||||||
|
stopPolling()
|
||||||
|
pollTimer = window.setInterval(() => void refreshDashboard(root, handlers), POLL_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function boot(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const authed = await api.getSession()
|
||||||
|
await (authed ? showDashboard() : showLogin())
|
||||||
|
} catch {
|
||||||
|
await showLogin()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void boot()
|
||||||
60
control-panel/public/dom.ts
Normal file
60
control-panel/public/dom.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Tiny DOM builder. ALL text and attribute values are set via `textContent` / `setAttribute` — this
|
||||||
|
* module NEVER assigns `innerHTML`, so untrusted server/host data (host subdomains, etc.) can never
|
||||||
|
* inject markup. Children passed as strings become text nodes (also safe).
|
||||||
|
*/
|
||||||
|
export type Child = Node | string
|
||||||
|
|
||||||
|
export interface ElProps {
|
||||||
|
class?: string
|
||||||
|
text?: string
|
||||||
|
type?: string
|
||||||
|
name?: string
|
||||||
|
placeholder?: string
|
||||||
|
value?: string
|
||||||
|
disabled?: boolean
|
||||||
|
autocomplete?: string
|
||||||
|
title?: string
|
||||||
|
src?: string
|
||||||
|
alt?: string
|
||||||
|
role?: string
|
||||||
|
ariaLabel?: string
|
||||||
|
onClick?: (e: MouseEvent) => void
|
||||||
|
onSubmit?: (e: SubmitEvent) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function el<K extends keyof HTMLElementTagNameMap>(
|
||||||
|
tag: K,
|
||||||
|
props: ElProps = {},
|
||||||
|
children: Child[] = [],
|
||||||
|
): HTMLElementTagNameMap[K] {
|
||||||
|
const node = document.createElement(tag)
|
||||||
|
if (props.class !== undefined) node.className = props.class
|
||||||
|
if (props.text !== undefined) node.textContent = props.text
|
||||||
|
if (props.type !== undefined) node.setAttribute('type', props.type)
|
||||||
|
if (props.name !== undefined) node.setAttribute('name', props.name)
|
||||||
|
if (props.placeholder !== undefined) node.setAttribute('placeholder', props.placeholder)
|
||||||
|
if (props.value !== undefined) (node as HTMLInputElement).value = props.value
|
||||||
|
if (props.disabled) node.setAttribute('disabled', 'true')
|
||||||
|
if (props.autocomplete !== undefined) node.setAttribute('autocomplete', props.autocomplete)
|
||||||
|
if (props.title !== undefined) node.setAttribute('title', props.title)
|
||||||
|
if (props.src !== undefined) node.setAttribute('src', props.src)
|
||||||
|
if (props.alt !== undefined) node.setAttribute('alt', props.alt)
|
||||||
|
if (props.role !== undefined) node.setAttribute('role', props.role)
|
||||||
|
if (props.ariaLabel !== undefined) node.setAttribute('aria-label', props.ariaLabel)
|
||||||
|
if (props.onClick !== undefined) node.addEventListener('click', props.onClick as EventListener)
|
||||||
|
if (props.onSubmit !== undefined) node.addEventListener('submit', props.onSubmit as EventListener)
|
||||||
|
for (const child of children) node.append(child)
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove all children of a node. */
|
||||||
|
export function clear(node: Element): void {
|
||||||
|
while (node.firstChild) node.removeChild(node.firstChild)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace a node's content with a single new child. */
|
||||||
|
export function mount(root: Element, child: Node): void {
|
||||||
|
clear(root)
|
||||||
|
root.append(child)
|
||||||
|
}
|
||||||
17
control-panel/public/index.html
Normal file
17
control-panel/public/index.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
<title>Tunnel Control Panel</title>
|
||||||
|
<link rel="stylesheet" href="./styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main id="app" class="app">
|
||||||
|
<div class="centered"><p class="muted">Loading…</p></div>
|
||||||
|
</main>
|
||||||
|
<script type="module" src="./app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
134
control-panel/public/styles.css
Normal file
134
control-panel/public/styles.css
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
/* Tunnel Control Panel — themed (light/dark via prefers-color-scheme), responsive. */
|
||||||
|
:root {
|
||||||
|
--bg: #f6f7f9;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-2: #f0f2f5;
|
||||||
|
--text: #1b1f24;
|
||||||
|
--muted: #5b6572;
|
||||||
|
--border: #dfe3e8;
|
||||||
|
--primary: #2563eb;
|
||||||
|
--primary-text: #ffffff;
|
||||||
|
--danger: #dc2626;
|
||||||
|
--ok: #16a34a;
|
||||||
|
--warn: #d97706;
|
||||||
|
--shadow: 0 6px 24px rgba(20, 24, 33, 0.12);
|
||||||
|
--radius: 12px;
|
||||||
|
color-scheme: light dark;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #0f1319;
|
||||||
|
--surface: #171c24;
|
||||||
|
--surface-2: #1f2630;
|
||||||
|
--text: #e7ebf0;
|
||||||
|
--muted: #9aa5b3;
|
||||||
|
--border: #2a323d;
|
||||||
|
--primary: #3b82f6;
|
||||||
|
--danger: #ef4444;
|
||||||
|
--ok: #22c55e;
|
||||||
|
--warn: #f59e0b;
|
||||||
|
--shadow: 0 6px 24px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
.mono { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.small { font-size: 13px; }
|
||||||
|
h1 { font-size: 22px; margin: 0; }
|
||||||
|
h2 { font-size: 17px; margin: 0 0 12px; }
|
||||||
|
|
||||||
|
.app { min-height: 100vh; }
|
||||||
|
.centered { display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 24px; }
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 9px 14px;
|
||||||
|
border-radius: 9px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s ease, border-color 0.12s ease, transform 0.02s ease;
|
||||||
|
}
|
||||||
|
.btn:hover { border-color: var(--primary); }
|
||||||
|
.btn:active { transform: translateY(1px); }
|
||||||
|
.btn.primary { background: var(--primary); border-color: var(--primary); color: var(--primary-text); }
|
||||||
|
.btn.danger { background: transparent; border-color: var(--danger); color: var(--danger); }
|
||||||
|
.btn.danger:hover { background: color-mix(in srgb, var(--danger) 12%, transparent); }
|
||||||
|
.btn.ghost { background: transparent; }
|
||||||
|
.btn.small { padding: 6px 10px; font-size: 13px; }
|
||||||
|
.icon-btn { background: none; border: none; color: var(--muted); font-size: 18px; cursor: pointer; line-height: 1; }
|
||||||
|
|
||||||
|
/* Login */
|
||||||
|
.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 28px;
|
||||||
|
}
|
||||||
|
.login { width: 100%; max-width: 360px; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.field-label { font-size: 13px; color: var(--muted); }
|
||||||
|
.login input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 11px 12px;
|
||||||
|
border-radius: 9px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.login input:focus { outline: 2px solid var(--primary); outline-offset: 1px; }
|
||||||
|
.error { color: var(--danger); font-size: 13px; min-height: 18px; margin: 0; }
|
||||||
|
|
||||||
|
/* Dashboard */
|
||||||
|
.dashboard { max-width: 960px; margin: 0 auto; padding: 24px 20px 48px; }
|
||||||
|
.topbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 20px; }
|
||||||
|
.topbar .actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.dashboard .card { padding: 20px; }
|
||||||
|
|
||||||
|
.table-scroll { overflow-x: auto; }
|
||||||
|
table.hosts { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||||
|
table.hosts th, table.hosts td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
||||||
|
table.hosts th { font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); }
|
||||||
|
table.hosts tr:last-child td { border-bottom: none; }
|
||||||
|
.empty { color: var(--muted); padding: 24px 4px; text-align: center; }
|
||||||
|
|
||||||
|
/* Status pills */
|
||||||
|
.pill { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; border: 1px solid var(--border); }
|
||||||
|
.pill-online, .pill-active { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 45%, transparent); background: color-mix(in srgb, var(--ok) 12%, transparent); }
|
||||||
|
.pill-offline { color: var(--muted); }
|
||||||
|
.pill-revoked, .pill-suspended { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 45%, transparent); background: color-mix(in srgb, var(--danger) 12%, transparent); }
|
||||||
|
.pill-unknown { color: var(--warn); }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.overlay { position: fixed; inset: 0; background: rgba(6, 9, 14, 0.55); display: flex; align-items: center; justify-content: center; padding: 20px; z-index: 50; }
|
||||||
|
.modal { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); width: 100%; max-width: 420px; }
|
||||||
|
.modal-head { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--border); }
|
||||||
|
.modal-body { padding: 20px; display: flex; flex-direction: column; gap: 14px; align-items: center; text-align: center; }
|
||||||
|
.code-big { font-size: 26px; font-weight: 700; letter-spacing: 0.08em; padding: 8px 12px; background: var(--surface-2); border-radius: 9px; }
|
||||||
|
.qr { width: 200px; height: 200px; image-rendering: pixelated; background: #fff; padding: 8px; border-radius: 9px; }
|
||||||
|
.command-row { display: flex; gap: 8px; align-items: center; width: 100%; }
|
||||||
|
.command-row.end { justify-content: flex-end; }
|
||||||
|
.command { flex: 1; text-align: left; background: var(--surface-2); padding: 9px 11px; border-radius: 8px; font-size: 13px; overflow-x: auto; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* Toasts */
|
||||||
|
.toast { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); padding: 10px 16px; border-radius: 9px; box-shadow: var(--shadow); z-index: 60; font-size: 14px; }
|
||||||
|
.toast-info { background: var(--surface); border: 1px solid var(--border); color: var(--text); }
|
||||||
|
.toast-error { background: var(--danger); color: #fff; }
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.topbar { flex-direction: column; align-items: stretch; }
|
||||||
|
.topbar .actions { justify-content: stretch; }
|
||||||
|
.topbar .actions .btn { flex: 1; }
|
||||||
|
}
|
||||||
161
control-panel/public/views.ts
Normal file
161
control-panel/public/views.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* View builders. Every dynamic value (host subdomain/status/dates, pairing code, command) is placed
|
||||||
|
* with `textContent` via the `el` helper — NEVER innerHTML — so host data (treated as untrusted for
|
||||||
|
* XSS even though it comes from an authenticated CP) can't inject markup. The QR is an <img> whose
|
||||||
|
* src is a data: URL our own backend generated with the `qrcode` lib.
|
||||||
|
*/
|
||||||
|
import { el, clear, type Child } from './dom.js'
|
||||||
|
import type { HostView, PairingArtifacts } from './api.js'
|
||||||
|
|
||||||
|
export interface DashboardHandlers {
|
||||||
|
onNewCode: () => void
|
||||||
|
onRevoke: (host: HostView) => void
|
||||||
|
onLogout: () => void
|
||||||
|
onRefresh: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Login card with a password field; `onSubmit(password)` fires on submit. */
|
||||||
|
export function renderLogin(onSubmit: (password: string) => void): HTMLElement {
|
||||||
|
const input = el('input', { type: 'password', name: 'password', placeholder: 'Panel password', autocomplete: 'current-password' })
|
||||||
|
const error = el('p', { class: 'error', role: 'alert' })
|
||||||
|
const form = el(
|
||||||
|
'form',
|
||||||
|
{
|
||||||
|
class: 'card login',
|
||||||
|
onSubmit: (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
error.textContent = ''
|
||||||
|
onSubmit(input.value)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[
|
||||||
|
el('h1', { text: 'Tunnel Control Panel' }),
|
||||||
|
el('label', { text: 'Password', class: 'field-label' }),
|
||||||
|
input,
|
||||||
|
el('button', { type: 'submit', class: 'btn primary', text: 'Sign in' }),
|
||||||
|
error,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
const wrap = el('div', { class: 'centered' }, [form])
|
||||||
|
// Expose the error node so the app can show login failures.
|
||||||
|
;(wrap as HTMLElement & { errorNode?: HTMLElement }).errorNode = error
|
||||||
|
queueMicrotask(() => input.focus())
|
||||||
|
return wrap
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusPill(status: string): HTMLElement {
|
||||||
|
const known = ['online', 'offline', 'revoked', 'suspended', 'active'].includes(status)
|
||||||
|
return el('span', { class: `pill pill-${known ? status : 'unknown'}`, text: status })
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(value: string | undefined | null): string {
|
||||||
|
if (value === undefined || value === null || value === '') return '—'
|
||||||
|
const t = Date.parse(value)
|
||||||
|
return Number.isNaN(t) ? value : new Date(t).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostRow(host: HostView, handlers: DashboardHandlers): HTMLElement {
|
||||||
|
const revoke = el('button', {
|
||||||
|
class: 'btn danger small',
|
||||||
|
text: 'Revoke',
|
||||||
|
title: `Revoke ${host.subdomain}`,
|
||||||
|
onClick: () => handlers.onRevoke(host),
|
||||||
|
})
|
||||||
|
return el('tr', {}, [
|
||||||
|
el('td', { text: host.subdomain, class: 'mono' }),
|
||||||
|
el('td', {}, [statusPill(host.status)]),
|
||||||
|
el('td', { text: fmtDate(host.notAfter), class: 'muted' }),
|
||||||
|
el('td', { text: fmtDate(host.lastSeen), class: 'muted' }),
|
||||||
|
el('td', {}, [revoke]),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostsTable(hosts: readonly HostView[], handlers: DashboardHandlers): HTMLElement {
|
||||||
|
if (hosts.length === 0) {
|
||||||
|
return el('div', { class: 'empty', text: 'No hosts enrolled yet. Create a pairing code to add one.' })
|
||||||
|
}
|
||||||
|
const head = el('thead', {}, [
|
||||||
|
el('tr', {}, [
|
||||||
|
el('th', { text: 'Subdomain' }),
|
||||||
|
el('th', { text: 'Status' }),
|
||||||
|
el('th', { text: 'Cert expiry' }),
|
||||||
|
el('th', { text: 'Last seen' }),
|
||||||
|
el('th', { text: '' }),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
const body = el('tbody', {}, hosts.map((h) => hostRow(h, handlers)))
|
||||||
|
return el('div', { class: 'table-scroll' }, [el('table', { class: 'hosts' }, [head, body])])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full dashboard: header (refresh / new-code / logout) + hosts table. */
|
||||||
|
export function renderDashboard(hosts: readonly HostView[], handlers: DashboardHandlers): HTMLElement {
|
||||||
|
const header = el('header', { class: 'topbar' }, [
|
||||||
|
el('h1', { text: 'Tunnel Control Panel' }),
|
||||||
|
el('div', { class: 'actions' }, [
|
||||||
|
el('button', { class: 'btn', text: 'Refresh', onClick: () => handlers.onRefresh() }),
|
||||||
|
el('button', { class: 'btn primary', text: 'New pairing code', onClick: () => handlers.onNewCode() }),
|
||||||
|
el('button', { class: 'btn ghost', text: 'Log out', onClick: () => handlers.onLogout() }),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
return el('div', { class: 'dashboard' }, [
|
||||||
|
header,
|
||||||
|
el('section', { class: 'card' }, [el('h2', { text: 'Hosts' }), hostsTable(hosts, handlers)]),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generic modal overlay; returns { overlay, close }. Clicking the backdrop or ✕ closes it. */
|
||||||
|
function modal(title: string, children: Child[]): { overlay: HTMLElement; close: () => void } {
|
||||||
|
const close = (): void => overlay.remove()
|
||||||
|
const dialog = el('div', { class: 'modal', role: 'dialog' }, [
|
||||||
|
el('div', { class: 'modal-head' }, [el('h2', { text: title }), el('button', { class: 'icon-btn', text: '✕', ariaLabel: 'Close', onClick: close })]),
|
||||||
|
el('div', { class: 'modal-body' }, children),
|
||||||
|
])
|
||||||
|
const overlay = el('div', { class: 'overlay', onClick: (e) => { if (e.target === overlay) close() } }, [dialog])
|
||||||
|
return { overlay, close }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pairing-code modal: the code, its QR, the copyable command, and the expiry. */
|
||||||
|
export function pairingModal(artifacts: PairingArtifacts): HTMLElement {
|
||||||
|
const command = el('code', { class: 'command', text: artifacts.pairCommand })
|
||||||
|
const copyBtn = el('button', {
|
||||||
|
class: 'btn small',
|
||||||
|
text: 'Copy command',
|
||||||
|
onClick: () => {
|
||||||
|
void navigator.clipboard?.writeText(artifacts.pairCommand).then(
|
||||||
|
() => { copyBtn.textContent = 'Copied!' },
|
||||||
|
() => { copyBtn.textContent = 'Copy failed' },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const { overlay } = modal('Pairing code', [
|
||||||
|
el('p', { class: 'muted', text: 'Run this on the new host, or scan the QR from the phone client. Single-use.' }),
|
||||||
|
el('div', { class: 'code-big mono', text: artifacts.code }),
|
||||||
|
el('img', { class: 'qr', src: artifacts.qrDataUrl, alt: 'Pairing code QR' }),
|
||||||
|
el('div', { class: 'command-row' }, [command, copyBtn]),
|
||||||
|
el('p', { class: 'muted small', text: `Expires: ${fmtDate(artifacts.expiresAt)}` }),
|
||||||
|
])
|
||||||
|
return overlay
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Confirm dialog → resolves true (confirmed) / false (cancelled). */
|
||||||
|
export function confirmDialog(message: string): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const { overlay, close } = modal('Please confirm', [
|
||||||
|
el('p', { text: message }),
|
||||||
|
el('div', { class: 'command-row end' }, [
|
||||||
|
el('button', { class: 'btn ghost', text: 'Cancel', onClick: () => { close(); resolve(false) } }),
|
||||||
|
el('button', { class: 'btn danger', text: 'Revoke', onClick: () => { close(); resolve(true) } }),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
document.body.append(overlay)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A transient toast for errors/success (textContent only). */
|
||||||
|
export function toast(root: HTMLElement, message: string, kind: 'error' | 'info' = 'info'): void {
|
||||||
|
const t = el('div', { class: `toast toast-${kind}`, text: message, role: 'status' })
|
||||||
|
root.append(t)
|
||||||
|
setTimeout(() => t.remove(), 4000)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { clear }
|
||||||
85
control-panel/src/app.ts
Normal file
85
control-panel/src/app.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Fastify app assembly. All collaborators are injected (config, CP client, token minter, static
|
||||||
|
* root, clock) so the whole surface is unit-testable via `app.inject()` with fakes and NO network.
|
||||||
|
* Route order matters: auth + api plugins register BEFORE the static wildcard so specific routes win.
|
||||||
|
*/
|
||||||
|
import Fastify, { type FastifyInstance } from 'fastify'
|
||||||
|
import type { PanelConfig } from './config.js'
|
||||||
|
import type { CpClient } from './cp-client.js'
|
||||||
|
import type { ManageTokenMinter } from './manage-token.js'
|
||||||
|
import type { RateLimiter } from './security/rate-limit.js'
|
||||||
|
import { buildAuthRoutes } from './routes/auth-routes.js'
|
||||||
|
import { buildApiRoutes } from './routes/api-routes.js'
|
||||||
|
import { buildStaticRoutes } from './static.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baseline security response headers applied to EVERY response (via an onRequest hook, so they are
|
||||||
|
* present on 404/401/error paths and on streamed static files too). The CSP is tuned for this
|
||||||
|
* self-contained SPA: it loads /build/app.js (script) and /build/styles.css (stylesheet) same-origin
|
||||||
|
* and renders the pairing QR as a `data:` image — hence `img-src 'self' data:`. `style-src` allows
|
||||||
|
* inline styles defensively; everything else is locked to 'self', framing is forbidden, and
|
||||||
|
* object-src/base-uri are neutralised.
|
||||||
|
*/
|
||||||
|
const SECURITY_HEADERS: Readonly<Record<string, string>> = {
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
'X-Frame-Options': 'DENY',
|
||||||
|
'Referrer-Policy': 'no-referrer',
|
||||||
|
'Content-Security-Policy':
|
||||||
|
"default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppDeps {
|
||||||
|
readonly config: PanelConfig
|
||||||
|
readonly cpClient: CpClient
|
||||||
|
readonly minter: ManageTokenMinter
|
||||||
|
/** Clock (ms) — injectable for tests. */
|
||||||
|
readonly now?: () => number
|
||||||
|
/** Per-IP login limiter — injectable for tests. */
|
||||||
|
readonly rateLimiter?: RateLimiter
|
||||||
|
/** Absolute path to the built SPA (public/build). `null` ⇒ skip static serving (tests). */
|
||||||
|
readonly staticRoot?: string | null
|
||||||
|
/** Server-side error log (no secrets). Defaults to a no-op. */
|
||||||
|
readonly logError?: (message: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildApp(deps: AppDeps): Promise<FastifyInstance> {
|
||||||
|
// Fastify's own logger is disabled: we control what is logged so no secret/token/password leaks.
|
||||||
|
//
|
||||||
|
// trustProxy: the panel sits behind nginx on loopback. The nginx vhost MUST set
|
||||||
|
// `X-Forwarded-For $remote_addr` (a clean, non-client-controllable value — NOT
|
||||||
|
// `$proxy_add_x_forwarded_for`, which would append attacker-supplied hops). Trusting the proxy makes
|
||||||
|
// `req.ip` reflect the REAL client address; without it every request looks like 127.0.0.1 and the
|
||||||
|
// /login rate limiter collapses into a single global bucket → a trivial unauthenticated lockout DoS.
|
||||||
|
const app = Fastify({ logger: false, bodyLimit: 64 * 1024, trustProxy: true })
|
||||||
|
|
||||||
|
// Defense-in-depth: stamp security headers on every response before routing (so 404/401/error
|
||||||
|
// responses and streamed static files all carry them). Registered first, applies to all child plugins.
|
||||||
|
app.addHook('onRequest', async (_req, reply) => {
|
||||||
|
for (const [name, value] of Object.entries(SECURITY_HEADERS)) reply.header(name, value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Conditional spreads keep `exactOptionalPropertyTypes` happy (never pass an explicit `undefined`).
|
||||||
|
const nowPart = deps.now !== undefined ? { now: deps.now } : {}
|
||||||
|
await app.register(
|
||||||
|
buildAuthRoutes({
|
||||||
|
config: deps.config,
|
||||||
|
...nowPart,
|
||||||
|
...(deps.rateLimiter !== undefined ? { rateLimiter: deps.rateLimiter } : {}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await app.register(
|
||||||
|
buildApiRoutes({
|
||||||
|
config: deps.config,
|
||||||
|
cpClient: deps.cpClient,
|
||||||
|
minter: deps.minter,
|
||||||
|
...nowPart,
|
||||||
|
...(deps.logError !== undefined ? { logError: deps.logError } : {}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (deps.staticRoot != null) {
|
||||||
|
await app.register(buildStaticRoutes(deps.staticRoot))
|
||||||
|
}
|
||||||
|
|
||||||
|
return app
|
||||||
|
}
|
||||||
97
control-panel/src/config.ts
Normal file
97
control-panel/src/config.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* Boot configuration — zod-validated at the process boundary, FAIL-CLOSED.
|
||||||
|
*
|
||||||
|
* Every field is read from the environment ONCE at startup and returned as an immutable
|
||||||
|
* `PanelConfig`. Structural problems (missing SESSION_SECRET, a non-numeric port, an empty
|
||||||
|
* OPERATOR_ACCOUNT_ID) throw here so the server never boots half-configured.
|
||||||
|
*
|
||||||
|
* `PANEL_PASSWORD` is intentionally OPTIONAL at this layer: an unset password is a valid (if
|
||||||
|
* useless) deployment state that the /login route turns into a fail-closed 503 — the panel must
|
||||||
|
* still start so an operator can see the error, rather than crash-looping. Every other secret is
|
||||||
|
* required. Secrets are NEVER logged; this module only ever returns them inside the config object.
|
||||||
|
*/
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
/** Default control-plane admin API base (loopback — the CP admin surface must never be public). */
|
||||||
|
export const DEFAULT_CP_URL = 'http://127.0.0.1:8080'
|
||||||
|
/** Default PKCS#8 Ed25519 capability signing key (matches relay-run/mint-manage-token.ts). */
|
||||||
|
export const DEFAULT_CAPABILITY_SIGN_KEY_PATH = '/etc/relay/capability/capability-sign.key.pem'
|
||||||
|
/** Default tunnel zone used to render the `pair` command shown to the operator. */
|
||||||
|
export const DEFAULT_TUNNEL_ZONE = 'terminal.yaojia.wang'
|
||||||
|
/** Default loopback bind port for the panel HTTP server. */
|
||||||
|
export const DEFAULT_PANEL_BIND_PORT = 8090
|
||||||
|
/** SESSION_SECRET must have enough entropy to make cookie forgery infeasible. */
|
||||||
|
export const MIN_SESSION_SECRET_LEN = 16
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True iff `hostname` is a loopback literal: `localhost`, `::1`, or anything in 127.0.0.0/8.
|
||||||
|
* The panel's CP admin client must ONLY ever reach the local control-plane — enforcing this at the
|
||||||
|
* config boundary is anti-SSRF (a misconfigured CP_URL pointing at a remote/internal host is refused).
|
||||||
|
*/
|
||||||
|
export function isLoopbackHost(hostname: string): boolean {
|
||||||
|
const h = hostname.replace(/^\[/, '').replace(/\]$/, '').toLowerCase() // strip IPv6 brackets
|
||||||
|
if (h === 'localhost' || h === '::1') return true
|
||||||
|
const m = /^(\d{1,3})\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.exec(h)
|
||||||
|
return m !== null && Number(m[1]) === 127
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True iff `url` parses AND its host is a loopback literal. Malformed URLs fail closed (false). */
|
||||||
|
function cpUrlHostIsLoopback(url: string): boolean {
|
||||||
|
try {
|
||||||
|
return isLoopbackHost(new URL(url).hostname)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const EnvSchema = z.object({
|
||||||
|
// Optional: unset ⇒ /login returns 503 (fail-closed at the route, not at boot).
|
||||||
|
PANEL_PASSWORD: z.string().min(1).optional(),
|
||||||
|
// Required: HMAC key that signs the session cookie. No default — a predictable secret is a bug.
|
||||||
|
SESSION_SECRET: z.string().min(MIN_SESSION_SECRET_LEN, `SESSION_SECRET must be >= ${MIN_SESSION_SECRET_LEN} chars`),
|
||||||
|
// Must be loopback (127.0.0.0/8, ::1, localhost): the CP admin surface is local-only. Fail closed otherwise.
|
||||||
|
CP_URL: z
|
||||||
|
.string()
|
||||||
|
.url()
|
||||||
|
.refine(cpUrlHostIsLoopback, 'CP_URL host must be loopback (127.0.0.0/8, ::1, or localhost)')
|
||||||
|
.default(DEFAULT_CP_URL),
|
||||||
|
// `aud` of every minted manage token — MUST equal the control-plane's expectedAud (its BASE_DOMAIN).
|
||||||
|
BASE_DOMAIN: z.string().min(1),
|
||||||
|
// `sub`/accountId scoped into every manage token and admin path.
|
||||||
|
OPERATOR_ACCOUNT_ID: z.string().min(1),
|
||||||
|
CAPABILITY_SIGN_KEY_PATH: z.string().min(1).default(DEFAULT_CAPABILITY_SIGN_KEY_PATH),
|
||||||
|
TUNNEL_ZONE: z.string().min(1).default(DEFAULT_TUNNEL_ZONE),
|
||||||
|
PANEL_BIND_PORT: z.coerce.number().int().min(1).max(65535).default(DEFAULT_PANEL_BIND_PORT),
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface PanelConfig {
|
||||||
|
readonly panelPassword: string | undefined
|
||||||
|
readonly sessionSecret: string
|
||||||
|
readonly cpUrl: string
|
||||||
|
readonly baseDomain: string
|
||||||
|
readonly operatorAccountId: string
|
||||||
|
readonly capabilitySignKeyPath: string
|
||||||
|
readonly tunnelZone: string
|
||||||
|
readonly panelBindPort: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loopback-only bind host — the admin panel must never listen on a public interface. */
|
||||||
|
export const PANEL_BIND_HOST = '127.0.0.1'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse + validate the environment into an immutable config. Throws a `ZodError` on any structural
|
||||||
|
* problem (fail-closed). Never logs the values it reads.
|
||||||
|
*/
|
||||||
|
export function loadConfig(env: NodeJS.ProcessEnv): PanelConfig {
|
||||||
|
const parsed = EnvSchema.parse(env)
|
||||||
|
return {
|
||||||
|
panelPassword: parsed.PANEL_PASSWORD,
|
||||||
|
sessionSecret: parsed.SESSION_SECRET,
|
||||||
|
cpUrl: parsed.CP_URL.replace(/\/+$/, ''), // normalise: strip trailing slashes for clean joins
|
||||||
|
baseDomain: parsed.BASE_DOMAIN,
|
||||||
|
operatorAccountId: parsed.OPERATOR_ACCOUNT_ID,
|
||||||
|
capabilitySignKeyPath: parsed.CAPABILITY_SIGN_KEY_PATH,
|
||||||
|
tunnelZone: parsed.TUNNEL_ZONE,
|
||||||
|
panelBindPort: parsed.PANEL_BIND_PORT,
|
||||||
|
}
|
||||||
|
}
|
||||||
127
control-panel/src/cp-client.ts
Normal file
127
control-panel/src/cp-client.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* Control-plane ADMIN API client. Each method takes a freshly-minted `manage` bearer token and calls
|
||||||
|
* the loopback CP admin surface. Responses are Zod-validated at the boundary (the CP is trusted for
|
||||||
|
* auth but its payloads are still external data → validate, never `as`). Non-2xx ⇒ `CpClientError`.
|
||||||
|
*
|
||||||
|
* CP contract (control-plane/src/api/provision.ts, mounted at root, `Authorization: Bearer <token>`):
|
||||||
|
* - GET /accounts/:id/hosts → 200 [HostRecord...] (agentPubkey base64)
|
||||||
|
* - POST /accounts/:id/pairing-codes → 201 { code, expiresAt }
|
||||||
|
* - DELETE /hosts/:hostId → 204
|
||||||
|
*
|
||||||
|
* We expose only a curated host view to the SPA (never leak agentPubkey/enrollFpr).
|
||||||
|
*/
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export interface HostView {
|
||||||
|
readonly hostId: string
|
||||||
|
readonly subdomain: string
|
||||||
|
readonly status: string
|
||||||
|
readonly lastSeen: string | undefined
|
||||||
|
readonly createdAt: string | undefined
|
||||||
|
readonly revokedAt: string | null | undefined
|
||||||
|
/** Cert expiry, if the CP ever includes one (not in today's HostRecord) — surfaced when present. */
|
||||||
|
readonly notAfter: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IssuedPairing {
|
||||||
|
readonly code: string
|
||||||
|
readonly expiresAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CpClient {
|
||||||
|
listHosts(accountId: string, token: string): Promise<readonly HostView[]>
|
||||||
|
createPairingCode(accountId: string, token: string): Promise<IssuedPairing>
|
||||||
|
deleteHost(hostId: string, token: string): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upstream failure — carries an HTTP-ish status for the route layer to map (kept generic; no CP body leaked). */
|
||||||
|
export class CpClientError extends Error {
|
||||||
|
readonly status: number
|
||||||
|
constructor(status: number, message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'CpClientError'
|
||||||
|
this.status = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lenient: require the fields we render; passthrough-tolerate everything else the CP adds/removes.
|
||||||
|
const HostRecordSchema = z
|
||||||
|
.object({
|
||||||
|
hostId: z.string(),
|
||||||
|
subdomain: z.string(),
|
||||||
|
status: z.string(),
|
||||||
|
lastSeen: z.string().optional(),
|
||||||
|
createdAt: z.string().optional(),
|
||||||
|
revokedAt: z.string().nullable().optional(),
|
||||||
|
notAfter: z.string().optional(),
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
|
||||||
|
const HostListSchema = z.array(HostRecordSchema)
|
||||||
|
const IssuedPairingSchema = z.object({ code: z.string().min(1), expiresAt: z.string().min(1) })
|
||||||
|
|
||||||
|
function toHostView(rec: z.infer<typeof HostRecordSchema>): HostView {
|
||||||
|
return {
|
||||||
|
hostId: rec.hostId,
|
||||||
|
subdomain: rec.subdomain,
|
||||||
|
status: rec.status,
|
||||||
|
lastSeen: rec.lastSeen,
|
||||||
|
createdAt: rec.createdAt,
|
||||||
|
revokedAt: rec.revokedAt,
|
||||||
|
notAfter: rec.notAfter,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FetchFn = typeof fetch
|
||||||
|
|
||||||
|
interface CpClientDeps {
|
||||||
|
readonly cpUrl: string
|
||||||
|
readonly fetchFn?: FetchFn
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJson(res: Response): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
return await res.json()
|
||||||
|
} catch {
|
||||||
|
throw new CpClientError(502, 'control-plane returned a non-JSON response')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCpClient(deps: CpClientDeps): CpClient {
|
||||||
|
const doFetch: FetchFn = deps.fetchFn ?? fetch
|
||||||
|
const authHeaders = (token: string): Record<string, string> => ({ authorization: `Bearer ${token}`, accept: 'application/json' })
|
||||||
|
|
||||||
|
const call = async (path: string, init: RequestInit): Promise<Response> => {
|
||||||
|
try {
|
||||||
|
return await doFetch(`${deps.cpUrl}${path}`, init)
|
||||||
|
} catch {
|
||||||
|
// Network/DNS/connection failure — the CP is unreachable.
|
||||||
|
throw new CpClientError(502, 'control-plane unreachable')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async listHosts(accountId, token) {
|
||||||
|
const res = await call(`/accounts/${encodeURIComponent(accountId)}/hosts`, { headers: authHeaders(token) })
|
||||||
|
if (!res.ok) throw new CpClientError(res.status, `list hosts failed (${res.status})`)
|
||||||
|
const parsed = HostListSchema.parse(await readJson(res))
|
||||||
|
return parsed.map(toHostView)
|
||||||
|
},
|
||||||
|
|
||||||
|
async createPairingCode(accountId, token) {
|
||||||
|
const res = await call(`/accounts/${encodeURIComponent(accountId)}/pairing-codes`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...authHeaders(token), 'content-type': 'application/json' },
|
||||||
|
body: '{}',
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new CpClientError(res.status, `issue pairing code failed (${res.status})`)
|
||||||
|
const parsed = IssuedPairingSchema.parse(await readJson(res))
|
||||||
|
return { code: parsed.code, expiresAt: parsed.expiresAt }
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteHost(hostId, token) {
|
||||||
|
const res = await call(`/hosts/${encodeURIComponent(hostId)}`, { method: 'DELETE', headers: authHeaders(token) })
|
||||||
|
if (!res.ok && res.status !== 204) throw new CpClientError(res.status, `revoke host failed (${res.status})`)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
106
control-panel/src/manage-token.ts
Normal file
106
control-panel/src/manage-token.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* In-process `manage` capability-token minter — the in-code equivalent of
|
||||||
|
* relay-run/scripts/mint-manage-token.ts. Every proxied admin call mints a FRESH short-TTL token so
|
||||||
|
* a leaked token's blast radius is ~60s.
|
||||||
|
*
|
||||||
|
* The token is a §4.3 PASETO v4.public capability token signed by the P5 PRIVATE Ed25519 key
|
||||||
|
* (PKCS#8 PEM at CAPABILITY_SIGN_KEY_PATH): `aud = BASE_DOMAIN`, `sub = OPERATOR_ACCOUNT_ID`,
|
||||||
|
* `rights = ['manage']`, `ttl = 60s`. `issueCapabilityToken` mandates a well-formed DPoP `cnf.jkt`,
|
||||||
|
* so we stamp one from a throwaway ephemeral key (the CP admin API verifies signature+aud+rights but
|
||||||
|
* does NOT require a live DPoP proof — see the mint script's header note).
|
||||||
|
*
|
||||||
|
* SECURITY: the signing-key material and the minted token are NEVER logged.
|
||||||
|
*/
|
||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import { issueCapabilityToken } from 'relay-auth'
|
||||||
|
import { generateEd25519KeyPair, exportEd25519PublicRaw } from 'relay-auth/src/crypto/ed25519.js'
|
||||||
|
import { jwkThumbprint } from 'relay-auth/src/crypto/thumbprint.js'
|
||||||
|
|
||||||
|
/** Manage tokens are `manage`-scoped, single-account, 60s TTL. `host` is a placeholder (issue() forbids '*'/''). */
|
||||||
|
const MANAGE_TOKEN_TTL_SEC = 60
|
||||||
|
const MANAGE_HOST_PLACEHOLDER = '_manage_'
|
||||||
|
|
||||||
|
/** Raised when the signing key cannot be loaded/imported. Message is safe (no key material). */
|
||||||
|
export class ManageTokenError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ManageTokenError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManageTokenMinter {
|
||||||
|
/** Mint a fresh manage token for the configured operator account. */
|
||||||
|
mint(): Promise<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManageTokenConfig {
|
||||||
|
readonly capabilitySignKeyPath: string
|
||||||
|
readonly baseDomain: string
|
||||||
|
readonly operatorAccountId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Import a PKCS#8 PEM Ed25519 private key into a non-extractable signing CryptoKey. */
|
||||||
|
async function importSigningKeyFromPem(pemPath: string): Promise<CryptoKey> {
|
||||||
|
let pem: string
|
||||||
|
try {
|
||||||
|
pem = await readFile(pemPath, 'utf8')
|
||||||
|
} catch {
|
||||||
|
throw new ManageTokenError(`capability signing key not readable at ${pemPath}`)
|
||||||
|
}
|
||||||
|
const b64 = pem.replace(/-----[^-]+-----/g, '').replace(/\s+/g, '')
|
||||||
|
if (b64.length === 0) throw new ManageTokenError('capability signing key PEM is empty')
|
||||||
|
let der: Uint8Array<ArrayBuffer>
|
||||||
|
try {
|
||||||
|
// Copy into a fresh ArrayBuffer-backed view: WebCrypto's BufferSource requires Uint8Array<ArrayBuffer>,
|
||||||
|
// which Buffer (ArrayBufferLike) does not satisfy under strict lib types.
|
||||||
|
const raw = Buffer.from(b64, 'base64')
|
||||||
|
der = new Uint8Array(new ArrayBuffer(raw.byteLength))
|
||||||
|
der.set(raw)
|
||||||
|
} catch {
|
||||||
|
throw new ManageTokenError('capability signing key PEM is not valid base64')
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await globalThis.crypto.subtle.importKey('pkcs8', der, { name: 'Ed25519' }, false, ['sign'])
|
||||||
|
} catch {
|
||||||
|
throw new ManageTokenError('capability signing key is not a valid PKCS#8 Ed25519 key')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a minter that lazily loads + memoizes the signing key (so the panel boots even if the key
|
||||||
|
* file is briefly unavailable — a missing key surfaces as an upstream error on the first proxy call,
|
||||||
|
* not a boot crash). Clock is injectable for tests.
|
||||||
|
*/
|
||||||
|
export function createManageTokenMinter(config: ManageTokenConfig, now: () => number = () => Date.now()): ManageTokenMinter {
|
||||||
|
let keyPromise: Promise<CryptoKey> | undefined
|
||||||
|
|
||||||
|
const signingKey = (): Promise<CryptoKey> => {
|
||||||
|
if (keyPromise === undefined) {
|
||||||
|
keyPromise = importSigningKeyFromPem(config.capabilitySignKeyPath).catch((err: unknown) => {
|
||||||
|
keyPromise = undefined // allow a later retry after the operator fixes the key
|
||||||
|
throw err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return keyPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async mint(): Promise<string> {
|
||||||
|
const key = await signingKey()
|
||||||
|
const eph = await generateEd25519KeyPair()
|
||||||
|
const cnfJkt = await jwkThumbprint(await exportEd25519PublicRaw(eph.publicKey))
|
||||||
|
return issueCapabilityToken(
|
||||||
|
{
|
||||||
|
principal: { accountId: config.operatorAccountId } as never, // runtime reads only accountId
|
||||||
|
aud: config.baseDomain,
|
||||||
|
host: MANAGE_HOST_PLACEHOLDER,
|
||||||
|
rights: ['manage'],
|
||||||
|
ttlSeconds: MANAGE_TOKEN_TTL_SEC,
|
||||||
|
cnfJkt,
|
||||||
|
},
|
||||||
|
key,
|
||||||
|
Math.floor(now() / 1000),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
37
control-panel/src/pairing.ts
Normal file
37
control-panel/src/pairing.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Operator-facing pairing artifacts derived from a freshly-issued pairing code:
|
||||||
|
* - the ready-to-run `web-terminal-agent pair <code> --install --zone <zone>` command line, and
|
||||||
|
* - a QR PNG data: URL of the code (rendered with the `qrcode` dep) for phone scanning.
|
||||||
|
* Pure/deterministic given the code + zone (except the QR, which is a stable render of the code).
|
||||||
|
*/
|
||||||
|
import QRCode from 'qrcode'
|
||||||
|
|
||||||
|
/** Build the exact command an operator runs on the new host to enroll it into the tunnel. */
|
||||||
|
export function buildPairCommand(code: string, zone: string): string {
|
||||||
|
return `web-terminal-agent pair ${code} --install --zone ${zone}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render a QR PNG data: URL encoding the pairing code (for scanning on the phone client). */
|
||||||
|
export async function buildQrDataUrl(code: string): Promise<string> {
|
||||||
|
return QRCode.toDataURL(code, { errorCorrectionLevel: 'M', margin: 1, width: 256 })
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PairingArtifacts {
|
||||||
|
readonly code: string
|
||||||
|
readonly expiresAt: string
|
||||||
|
readonly pairCommand: string
|
||||||
|
readonly qrDataUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Combine an issued code with its operator-facing command + QR into the /api/pairing-codes payload. */
|
||||||
|
export async function buildPairingArtifacts(
|
||||||
|
issued: { readonly code: string; readonly expiresAt: string },
|
||||||
|
zone: string,
|
||||||
|
): Promise<PairingArtifacts> {
|
||||||
|
return {
|
||||||
|
code: issued.code,
|
||||||
|
expiresAt: issued.expiresAt,
|
||||||
|
pairCommand: buildPairCommand(issued.code, zone),
|
||||||
|
qrDataUrl: await buildQrDataUrl(issued.code),
|
||||||
|
}
|
||||||
|
}
|
||||||
94
control-panel/src/routes/api-routes.ts
Normal file
94
control-panel/src/routes/api-routes.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Session-gated proxy routes. A `preHandler` rejects any request without a valid session cookie
|
||||||
|
* (401) — nothing downstream runs unauthenticated. For EACH call we mint a FRESH `manage` token and
|
||||||
|
* hand it to the CP admin client:
|
||||||
|
* - GET /api/hosts → CP GET /accounts/{op}/hosts → curated host list
|
||||||
|
* - POST /api/pairing-codes → CP POST /accounts/{op}/pairing-codes → { code, expiresAt, pairCommand, qrDataUrl }
|
||||||
|
* - DELETE /api/hosts/:hostId → CP DELETE /hosts/:hostId → 204
|
||||||
|
*
|
||||||
|
* Upstream/mint failures map to a generic 502 (never leak CP internals or token/key material).
|
||||||
|
*/
|
||||||
|
import { z } from 'zod'
|
||||||
|
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
|
||||||
|
import type { PanelConfig } from '../config.js'
|
||||||
|
import type { CpClient } from '../cp-client.js'
|
||||||
|
import { CpClientError } from '../cp-client.js'
|
||||||
|
import type { ManageTokenMinter } from '../manage-token.js'
|
||||||
|
import { buildPairingArtifacts } from '../pairing.js'
|
||||||
|
import { requestIsAuthed } from '../security/request.js'
|
||||||
|
|
||||||
|
// hostId is a server-issued UUIDv4 — accept only a conservative id charset (defence-in-depth).
|
||||||
|
// A bare `.` or `..` passes the charset but would reshape the outbound CP admin URL
|
||||||
|
// (`${cpUrl}/hosts/${hostId}`) via dot-segment normalization, so reject those two exact values.
|
||||||
|
export const HostIdSchema = z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(128)
|
||||||
|
.regex(/^[A-Za-z0-9._-]+$/)
|
||||||
|
.refine((v) => v !== '.' && v !== '..', { message: 'host id must not be a dot-segment' })
|
||||||
|
|
||||||
|
export interface ApiRoutesDeps {
|
||||||
|
readonly config: PanelConfig
|
||||||
|
readonly cpClient: CpClient
|
||||||
|
readonly minter: ManageTokenMinter
|
||||||
|
readonly now?: () => number
|
||||||
|
/** Server-side logger for upstream failures (no secrets). Defaults to a no-op. */
|
||||||
|
readonly logError?: (message: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapUpstreamError(reply: FastifyReply, err: unknown, log: (m: string) => void): FastifyReply {
|
||||||
|
if (err instanceof CpClientError) {
|
||||||
|
log(`upstream control-plane error: ${err.message}`)
|
||||||
|
return reply.code(502).send({ error: 'upstream_error' })
|
||||||
|
}
|
||||||
|
log(`proxy error: ${err instanceof Error ? err.name : 'unknown'}`)
|
||||||
|
return reply.code(502).send({ error: 'upstream_error' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildApiRoutes(deps: ApiRoutesDeps): FastifyPluginAsync {
|
||||||
|
const now = deps.now ?? (() => Date.now())
|
||||||
|
const log = deps.logError ?? (() => {})
|
||||||
|
const accountId = deps.config.operatorAccountId
|
||||||
|
|
||||||
|
return async (app) => {
|
||||||
|
// Deny-by-default session gate for every route in this plugin.
|
||||||
|
app.addHook('preHandler', async (req: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
if (!requestIsAuthed(req, deps.config.sessionSecret, now())) {
|
||||||
|
await reply.code(401).send({ error: 'unauthenticated' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/hosts', async (_req, reply) => {
|
||||||
|
try {
|
||||||
|
const token = await deps.minter.mint()
|
||||||
|
const hosts = await deps.cpClient.listHosts(accountId, token)
|
||||||
|
return reply.code(200).send({ hosts })
|
||||||
|
} catch (err) {
|
||||||
|
return mapUpstreamError(reply, err, log)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/pairing-codes', async (_req, reply) => {
|
||||||
|
try {
|
||||||
|
const token = await deps.minter.mint()
|
||||||
|
const issued = await deps.cpClient.createPairingCode(accountId, token)
|
||||||
|
const artifacts = await buildPairingArtifacts(issued, deps.config.tunnelZone)
|
||||||
|
return reply.code(201).send(artifacts)
|
||||||
|
} catch (err) {
|
||||||
|
return mapUpstreamError(reply, err, log)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/api/hosts/:hostId', async (req, reply) => {
|
||||||
|
const parsed = HostIdSchema.safeParse((req.params as { hostId?: unknown }).hostId)
|
||||||
|
if (!parsed.success) return reply.code(400).send({ error: 'invalid host id' })
|
||||||
|
try {
|
||||||
|
const token = await deps.minter.mint()
|
||||||
|
await deps.cpClient.deleteHost(parsed.data, token)
|
||||||
|
return reply.code(204).send()
|
||||||
|
} catch (err) {
|
||||||
|
return mapUpstreamError(reply, err, log)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
72
control-panel/src/routes/auth-routes.ts
Normal file
72
control-panel/src/routes/auth-routes.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Auth routes: POST /login, POST /logout, GET /api/session.
|
||||||
|
*
|
||||||
|
* SECURITY:
|
||||||
|
* - /login is RATE-LIMITED per client IP BEFORE any credential work (brute-force throttle → 429).
|
||||||
|
* - FAIL-CLOSED: unset PANEL_PASSWORD ⇒ 503 (never authenticates); wrong password ⇒ 401.
|
||||||
|
* - credential compare is CONSTANT-TIME (compare.ts SHA-256 fixed-length).
|
||||||
|
* - on success, set a signed HttpOnly; SameSite=Strict; Secure-when-https session cookie (12h).
|
||||||
|
* - the password and session token are NEVER logged; input is Zod-validated at the boundary.
|
||||||
|
*/
|
||||||
|
import { z } from 'zod'
|
||||||
|
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
|
||||||
|
import type { PanelConfig } from '../config.js'
|
||||||
|
import { constantTimeEqual } from '../security/compare.js'
|
||||||
|
import { buildClearCookie, buildSetCookie } from '../security/cookies.js'
|
||||||
|
import { createSessionToken, SESSION_TTL_SEC } from '../security/session.js'
|
||||||
|
import { createSlidingWindowLimiter, DEFAULT_LOGIN_RATE_MAX, DEFAULT_LOGIN_RATE_WINDOW_MS, type RateLimiter } from '../security/rate-limit.js'
|
||||||
|
import { isSecureRequest, requestIsAuthed } from '../security/request.js'
|
||||||
|
|
||||||
|
const LoginBodySchema = z.object({ password: z.string().min(1).max(512) }).strict()
|
||||||
|
|
||||||
|
export interface AuthRoutesDeps {
|
||||||
|
readonly config: PanelConfig
|
||||||
|
readonly now?: () => number
|
||||||
|
readonly rateLimiter?: RateLimiter
|
||||||
|
readonly clientKey?: (req: FastifyRequest) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCookie(reply: FastifyReply, value: string): void {
|
||||||
|
reply.header('set-cookie', value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAuthRoutes(deps: AuthRoutesDeps): FastifyPluginAsync {
|
||||||
|
const now = deps.now ?? (() => Date.now())
|
||||||
|
const clientKey = deps.clientKey ?? ((req: FastifyRequest) => req.ip || 'unknown')
|
||||||
|
const limiter = deps.rateLimiter ?? createSlidingWindowLimiter(DEFAULT_LOGIN_RATE_MAX, DEFAULT_LOGIN_RATE_WINDOW_MS, now)
|
||||||
|
|
||||||
|
return async (app) => {
|
||||||
|
app.post('/login', async (req, reply) => {
|
||||||
|
// Throttle FIRST so brute force is limited regardless of outcome.
|
||||||
|
if (!limiter.allow(clientKey(req))) {
|
||||||
|
return reply.code(429).send({ error: 'rate_limited' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = LoginBodySchema.safeParse(req.body)
|
||||||
|
if (!parsed.success) return reply.code(400).send({ error: 'invalid request' })
|
||||||
|
|
||||||
|
// Fail-closed: an unconfigured panel authenticates no one.
|
||||||
|
const configured = deps.config.panelPassword
|
||||||
|
if (typeof configured !== 'string' || configured.length === 0) {
|
||||||
|
return reply.code(503).send({ error: 'login unavailable' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!constantTimeEqual(parsed.data.password, configured)) {
|
||||||
|
return reply.code(401).send({ error: 'rejected' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = createSessionToken(deps.config.sessionSecret, now())
|
||||||
|
setCookie(reply, buildSetCookie({ value: token, maxAgeSec: SESSION_TTL_SEC, secure: isSecureRequest(req) }))
|
||||||
|
return reply.code(200).send({ authenticated: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/logout', async (req, reply) => {
|
||||||
|
setCookie(reply, buildClearCookie({ secure: isSecureRequest(req) }))
|
||||||
|
return reply.code(200).send({ authenticated: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/session', async (req, reply) => {
|
||||||
|
return reply.code(200).send({ authenticated: requestIsAuthed(req, deps.config.sessionSecret, now()) })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
23
control-panel/src/security/compare.ts
Normal file
23
control-panel/src/security/compare.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Constant-time string comparison (SECURITY-CRITICAL — never use `===` for secrets).
|
||||||
|
*
|
||||||
|
* Mirrors src/http/auth.ts in the base app: both inputs are hashed with SHA-256 to a FIXED 32
|
||||||
|
* bytes, then compared with `crypto.timingSafeEqual`. Hashing-to-fixed-length removes the length
|
||||||
|
* side-channel and sidesteps `timingSafeEqual`'s throw-on-length-mismatch. A missing/empty
|
||||||
|
* candidate short-circuits to `false` before the comparator (it is not a secret-compare oracle).
|
||||||
|
*/
|
||||||
|
import { createHash, timingSafeEqual } from 'node:crypto'
|
||||||
|
|
||||||
|
export function constantTimeEqual(a: string | undefined, b: string | undefined): boolean {
|
||||||
|
if (typeof a !== 'string' || typeof b !== 'string') return false
|
||||||
|
if (a.length === 0 || b.length === 0) return false
|
||||||
|
const ha = createHash('sha256').update(a, 'utf8').digest()
|
||||||
|
const hb = createHash('sha256').update(b, 'utf8').digest()
|
||||||
|
return timingSafeEqual(ha, hb) // both are exactly 32 bytes → never throws
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Constant-time byte comparison of two equal-length buffers (false on length mismatch). */
|
||||||
|
export function constantTimeEqualBytes(a: Buffer, b: Buffer): boolean {
|
||||||
|
if (a.length !== b.length) return false
|
||||||
|
return timingSafeEqual(a, b)
|
||||||
|
}
|
||||||
53
control-panel/src/security/cookies.ts
Normal file
53
control-panel/src/security/cookies.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Cookie parse + Set-Cookie serialization — dependency-light (no @fastify/cookie), mirroring the
|
||||||
|
* base app's src/http/auth.ts discipline. Values are returned verbatim (NOT URL-decoded); the
|
||||||
|
* session token uses a cookie-safe charset (base64url + '.') so there is nothing to decode.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The panel session cookie name. HttpOnly (JS can't read it → XSS can't exfiltrate it). */
|
||||||
|
export const SESSION_COOKIE_NAME = 'panel_session'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a raw `Cookie:` header into a name→value map. Malformed pairs (no `=`, empty name) are
|
||||||
|
* ignored; last write wins on duplicate names.
|
||||||
|
*/
|
||||||
|
export function parseCookieHeader(header: string | undefined): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {}
|
||||||
|
if (header === undefined || header === '') return out
|
||||||
|
for (const part of header.split(';')) {
|
||||||
|
const eq = part.indexOf('=')
|
||||||
|
if (eq <= 0) continue
|
||||||
|
const name = part.slice(0, eq).trim()
|
||||||
|
if (name === '') continue
|
||||||
|
out[name] = part.slice(eq + 1).trim()
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SetCookieOptions {
|
||||||
|
readonly value: string
|
||||||
|
readonly maxAgeSec: number
|
||||||
|
readonly secure: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a `Set-Cookie` value. Flags: HttpOnly (no JS read), SameSite=Strict (cross-site pages can't
|
||||||
|
* ride the cookie — CSRF/CSWSH defence), Path=/, Max-Age, and Secure ONLY when `secure` (a Secure
|
||||||
|
* cookie is never sent over http:// — forcing it would break a loopback/http operator session).
|
||||||
|
*/
|
||||||
|
export function buildSetCookie(opts: SetCookieOptions): string {
|
||||||
|
const parts = [
|
||||||
|
`${SESSION_COOKIE_NAME}=${opts.value}`,
|
||||||
|
'Path=/',
|
||||||
|
`Max-Age=${opts.maxAgeSec}`,
|
||||||
|
'HttpOnly',
|
||||||
|
'SameSite=Strict',
|
||||||
|
]
|
||||||
|
if (opts.secure) parts.push('Secure')
|
||||||
|
return parts.join('; ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a `Set-Cookie` that immediately expires the session cookie (logout). */
|
||||||
|
export function buildClearCookie(opts: { secure: boolean }): string {
|
||||||
|
return buildSetCookie({ value: '', maxAgeSec: 0, secure: opts.secure })
|
||||||
|
}
|
||||||
32
control-panel/src/security/rate-limit.ts
Normal file
32
control-panel/src/security/rate-limit.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* In-process sliding-window rate limiter keyed by an arbitrary client bucket (per-IP for /login).
|
||||||
|
* Mirrors the base app's control-plane auth-login limiter: a rejected attempt is NOT recorded, so a
|
||||||
|
* throttled client can't push its own window forward. Pure/injectable clock for tests.
|
||||||
|
*/
|
||||||
|
export interface RateLimiter {
|
||||||
|
/** Returns true and records the hit if under the limit; false (no record) when the window is full. */
|
||||||
|
allow(key: string): boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default per-IP login attempts within the window. */
|
||||||
|
export const DEFAULT_LOGIN_RATE_MAX = 10
|
||||||
|
/** Default login rate window (ms): 15 minutes. */
|
||||||
|
export const DEFAULT_LOGIN_RATE_WINDOW_MS = 15 * 60 * 1000
|
||||||
|
|
||||||
|
export function createSlidingWindowLimiter(max: number, windowMs: number, now: () => number): RateLimiter {
|
||||||
|
const hits = new Map<string, number[]>()
|
||||||
|
return {
|
||||||
|
allow(key: string): boolean {
|
||||||
|
const ts = now()
|
||||||
|
const cutoff = ts - windowMs
|
||||||
|
const kept = (hits.get(key) ?? []).filter((t) => t > cutoff)
|
||||||
|
if (kept.length >= max) {
|
||||||
|
hits.set(key, kept) // persist the pruned window; do NOT record this rejected attempt
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
kept.push(ts)
|
||||||
|
hits.set(key, kept)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
26
control-panel/src/security/request.ts
Normal file
26
control-panel/src/security/request.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Request-scoped security helpers shared by the route plugins: detect HTTPS (drives the Secure
|
||||||
|
* cookie flag) and read/verify the session cookie off an incoming Fastify request.
|
||||||
|
*/
|
||||||
|
import type { FastifyRequest } from 'fastify'
|
||||||
|
import { parseCookieHeader, SESSION_COOKIE_NAME } from './cookies.js'
|
||||||
|
import { verifySessionToken } from './session.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True iff the request arrived over HTTPS — directly (`socket.encrypted`) or via a TLS-terminating
|
||||||
|
* edge that set `x-forwarded-proto: https`. Even though the panel binds loopback, a reverse proxy
|
||||||
|
* may front it; honour XFP so the Secure flag is correct on the tunnel path.
|
||||||
|
*/
|
||||||
|
export function isSecureRequest(req: FastifyRequest): boolean {
|
||||||
|
const xfp = req.headers['x-forwarded-proto']
|
||||||
|
const proto = Array.isArray(xfp) ? xfp[0] : xfp
|
||||||
|
if (typeof proto === 'string' && proto.split(',')[0]?.trim().toLowerCase() === 'https') return true
|
||||||
|
const socket = req.raw.socket as { encrypted?: boolean } | undefined
|
||||||
|
return socket?.encrypted === true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True iff the request carries a valid, unexpired session cookie signed with `sessionSecret`. */
|
||||||
|
export function requestIsAuthed(req: FastifyRequest, sessionSecret: string, nowMs: number): boolean {
|
||||||
|
const cookies = parseCookieHeader(req.headers.cookie)
|
||||||
|
return verifySessionToken(sessionSecret, cookies[SESSION_COOKIE_NAME], nowMs)
|
||||||
|
}
|
||||||
54
control-panel/src/security/session.ts
Normal file
54
control-panel/src/security/session.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Signed session token — an HMAC-SHA256 MAC over a short-TTL expiry claim. The cookie value is
|
||||||
|
* `<expEpochSec>.<macBase64url>`; the MAC covers the expiry so a client cannot extend its own
|
||||||
|
* session. Verification is constant-time and rejects tampering, wrong-secret, and past-expiry
|
||||||
|
* tokens. Self-contained (node:crypto only) — no external signing dependency.
|
||||||
|
*/
|
||||||
|
import { createHmac } from 'node:crypto'
|
||||||
|
import { constantTimeEqualBytes } from './compare.js'
|
||||||
|
|
||||||
|
/** Session lifetime (seconds): 12h — long enough to avoid constant re-auth, short enough to bound replay. */
|
||||||
|
export const SESSION_TTL_SEC = 12 * 60 * 60
|
||||||
|
|
||||||
|
function macFor(secret: string, payload: string): Buffer {
|
||||||
|
return createHmac('sha256', secret).update(payload, 'utf8').digest()
|
||||||
|
}
|
||||||
|
|
||||||
|
function b64url(buf: Buffer): string {
|
||||||
|
return buf.toString('base64url')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint a session token that expires `ttlSec` after `nowMs`. The expiry is embedded and signed.
|
||||||
|
*/
|
||||||
|
export function createSessionToken(secret: string, nowMs: number, ttlSec: number = SESSION_TTL_SEC): string {
|
||||||
|
const expSec = Math.floor(nowMs / 1000) + ttlSec
|
||||||
|
const payload = String(expSec)
|
||||||
|
return `${payload}.${b64url(macFor(secret, payload))}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True iff `token` is a well-formed, correctly-signed, unexpired session token.
|
||||||
|
* Any structural problem, MAC mismatch, or past-expiry ⇒ false (deny-by-default).
|
||||||
|
*/
|
||||||
|
export function verifySessionToken(secret: string, token: string | undefined, nowMs: number): boolean {
|
||||||
|
if (typeof token !== 'string' || token.length === 0) return false
|
||||||
|
const dot = token.indexOf('.')
|
||||||
|
if (dot <= 0 || dot === token.length - 1) return false
|
||||||
|
const payload = token.slice(0, dot)
|
||||||
|
const presentedMacB64 = token.slice(dot + 1)
|
||||||
|
// Expiry must be a positive integer string; reject anything else before touching crypto.
|
||||||
|
if (!/^\d+$/.test(payload)) return false
|
||||||
|
|
||||||
|
let presentedMac: Buffer
|
||||||
|
try {
|
||||||
|
presentedMac = Buffer.from(presentedMacB64, 'base64url')
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const expectedMac = macFor(secret, payload)
|
||||||
|
if (!constantTimeEqualBytes(presentedMac, expectedMac)) return false
|
||||||
|
|
||||||
|
const expSec = Number(payload)
|
||||||
|
return Number.isSafeInteger(expSec) && expSec * 1000 > nowMs
|
||||||
|
}
|
||||||
45
control-panel/src/server.ts
Normal file
45
control-panel/src/server.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Process entrypoint. Loads + validates config (fail-closed), wires the REAL collaborators (CP
|
||||||
|
* client over CP_URL, lazy manage-token minter reading the PKCS#8 capability key), and binds the
|
||||||
|
* app to LOOPBACK ONLY (127.0.0.1) — the admin panel must never listen on a public interface.
|
||||||
|
*
|
||||||
|
* Logging here is deliberately minimal and secret-free: only the bind address/port and startup
|
||||||
|
* errors (never the password, session secret, key material, or any minted token) are printed.
|
||||||
|
*/
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { dirname, resolve } from 'node:path'
|
||||||
|
import { loadConfig, PANEL_BIND_HOST } from './config.js'
|
||||||
|
import { createCpClient } from './cp-client.js'
|
||||||
|
import { createManageTokenMinter } from './manage-token.js'
|
||||||
|
import { buildApp } from './app.js'
|
||||||
|
|
||||||
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||||
|
/** Built SPA lives at control-panel/public/build (sibling of src/). */
|
||||||
|
const STATIC_ROOT = resolve(HERE, '..', 'public', 'build')
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const config = loadConfig(process.env)
|
||||||
|
const cpClient = createCpClient({ cpUrl: config.cpUrl })
|
||||||
|
const minter = createManageTokenMinter({
|
||||||
|
capabilitySignKeyPath: config.capabilitySignKeyPath,
|
||||||
|
baseDomain: config.baseDomain,
|
||||||
|
operatorAccountId: config.operatorAccountId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const app = await buildApp({
|
||||||
|
config,
|
||||||
|
cpClient,
|
||||||
|
minter,
|
||||||
|
staticRoot: STATIC_ROOT,
|
||||||
|
// Structured, secret-free server log for upstream failures.
|
||||||
|
logError: (message: string) => process.stderr.write(`[control-panel] ${message}\n`),
|
||||||
|
})
|
||||||
|
|
||||||
|
await app.listen({ host: PANEL_BIND_HOST, port: config.panelBindPort })
|
||||||
|
process.stdout.write(`[control-panel] listening on http://${PANEL_BIND_HOST}:${config.panelBindPort}\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err: unknown) => {
|
||||||
|
process.stderr.write(`[control-panel] fatal: ${err instanceof Error ? err.message : String(err)}\n`)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user