From 4e8e38c726f2eb145068b89661909ecf02610104 Mon Sep 17 00:00:00 2001 From: zcf0508 Date: Thu, 22 Oct 2026 22:18:19 +0800 Subject: [PATCH 01/35] =?UTF-8?q?feat(privileged):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E7=89=B9=E6=9D=83=E5=B1=82=E5=9F=BA=E7=A1=80=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 协议编解码、输入校验、hosts 文件编辑与端口 pid 解析,均为纯函数, 不触碰系统状态,便于单元测试。 - protocol: newline-JSON 协议,helper 与客户端共用 - validation: 校验 hostname 与 IP,拒绝换行与注释符,防止 hosts 注入 - hosts-file: 按 token 剔除映射,保留同一行上的其他名字 - ports: lsof / netstat 输出解析 --- src/privileged/entry.ts | 45 +++++++++++ src/privileged/hosts-file.ts | 142 +++++++++++++++++++++++++++++++++++ src/privileged/ports.ts | 45 +++++++++++ src/privileged/protocol.ts | 111 +++++++++++++++++++++++++++ src/privileged/validation.ts | 38 ++++++++++ 5 files changed, 381 insertions(+) create mode 100644 src/privileged/entry.ts create mode 100644 src/privileged/hosts-file.ts create mode 100644 src/privileged/ports.ts create mode 100644 src/privileged/protocol.ts create mode 100644 src/privileged/validation.ts diff --git a/src/privileged/entry.ts b/src/privileged/entry.ts new file mode 100644 index 0000000..f0e72f3 --- /dev/null +++ b/src/privileged/entry.ts @@ -0,0 +1,45 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import process from 'node:process' + +export interface HelperEntry { + /** + * Executable started through sudo. This is the node runtime, so the caller + * only has to append `args` and the helper's own flags. + */ + entry: string + /** Script (and loader) arguments that follow the runtime. */ + args: string[] +} + +export interface ResolveHelperEntryOptions { + /** Directory of the running bundle: `dist` in published builds, `src` from source. */ + moduleDir: string + /** Node executable used to run a compiled helper. */ + nodeBin?: string + exists?: (path: string) => boolean +} + +/** + * Locates the helper entry next to the running module. + * + * Published builds ship a compiled `privileged-helper.js` and start it with + * node; running from the repository uses `tsx` so the TypeScript source works + * without a build step. + */ +export function resolveHelperEntry(options: ResolveHelperEntryOptions): HelperEntry { + const { moduleDir, nodeBin = process.execPath, exists = existsSync } = options + + for (const name of ['privileged-helper.js', 'privileged-helper.cjs', 'privileged-helper.mjs']) { + const candidate = join(moduleDir, name) + if (exists(candidate)) + return { entry: nodeBin, args: [candidate] } + } + + const source = join(moduleDir, 'privileged-helper.ts') + const tsxCli = join(moduleDir, '../node_modules/tsx/dist/cli.mjs') + if (exists(source) && exists(tsxCli)) + return { entry: nodeBin, args: [tsxCli, source] } + + throw new Error(`privileged helper entry not found in ${moduleDir}. Build the package first.`) +} diff --git a/src/privileged/hosts-file.ts b/src/privileged/hosts-file.ts new file mode 100644 index 0000000..d391a27 --- /dev/null +++ b/src/privileged/hosts-file.ts @@ -0,0 +1,142 @@ +import { isIP } from 'node:net' + +/** + * `/etc/hosts` edits are modelled as pure string transformations so the helper + * can be tested without touching the real file. + * + * `/etc/hosts` is user data: a line may map several names at once + * (`127.0.0.1 a.test b.test`). Editing one name must never drop the others, and + * entries this plugin did not write must survive every operation. + */ + +export const HOSTS_MARKER = '# uhrp' +export const HOSTS_MARKER_PATTERN = /#\s*uhrp\b/ + +export interface HostsEdit { + content: string + changed: boolean +} + +interface ParsedLine { + ip: string + hosts: string[] +} + +function detectEol(content: string): string { + return content.includes('\r\n') ? '\r\n' : '\n' +} + +function toBodyLines(content: string): string[] { + const lines = content.split(/\r?\n/) + if (lines.length > 0 && lines[lines.length - 1] === '') + lines.pop() + return lines +} + +function parseLine(line: string): ParsedLine | undefined { + const withoutComment = line.replace(/#.*/, '').trim() + if (!withoutComment) + return undefined + const [ip, ...hosts] = withoutComment.split(/\s+/) + if (!ip || hosts.length === 0) + return undefined + return { ip, hosts } +} + +function isOwned(line: string): boolean { + return HOSTS_MARKER_PATTERN.test(line) +} + +function mentionsHost(entry: ParsedLine, host: string): boolean { + return entry.hosts.some(candidate => candidate.toLowerCase() === host.toLowerCase()) +} + +function sameIpFamily(left: string, right: string): boolean { + const leftVersion = isIP(left) + return leftVersion !== 0 && leftVersion === isIP(right) +} + +function rebuild(lines: string[], original: string, eol: string): HostsEdit { + const content = lines.length > 0 ? lines.join(eol) + eol : '' + return { content, changed: content !== original } +} + +/** + * Drops `host` from its mapping and appends a marked entry for it. + * + * A user-written line that also maps other names is rewritten without `host` + * instead of being deleted, so `127.0.0.1 a.test b.test` keeps `b.test`. + */ +export function addHost(content: string, ip: string, host: string): HostsEdit { + const eol = detectEol(content) + const lines: string[] = [] + + for (const line of toBodyLines(content)) { + const entry = parseLine(line) + if (!entry || !mentionsHost(entry, host) || !sameIpFamily(entry.ip, ip)) { + lines.push(line) + continue + } + + const remaining = entry.hosts.filter(candidate => candidate.toLowerCase() !== host.toLowerCase()) + if (remaining.length === 0) + continue + + // Preserve the original comment when one exists so a user's annotation is + // not silently dropped by our edit. + const comment = line.match(/#.*/)?.[0] ?? '' + const rebuilt = `${entry.ip} ${remaining.join(' ')}${comment ? ` ${comment}` : ''}` + lines.push(rebuilt) + } + + lines.push(`${ip} ${host} ${HOSTS_MARKER}`) + return rebuild(lines, content, eol) +} + +/** + * Removes `host` from the mapping to `ip`. Only lines this plugin wrote are + * removed outright; a user's line keeps its other names. + */ +export function removeHost(content: string, ip: string, host: string): HostsEdit { + const eol = detectEol(content) + const lines: string[] = [] + + for (const line of toBodyLines(content)) { + const entry = parseLine(line) + if (!entry || entry.ip !== ip || !mentionsHost(entry, host)) { + lines.push(line) + continue + } + + const remaining = entry.hosts.filter(candidate => candidate.toLowerCase() !== host.toLowerCase()) + if (remaining.length === 0) + continue + + const comment = line.match(/#.*/)?.[0] ?? '' + lines.push(`${entry.ip} ${remaining.join(' ')}${comment ? ` ${comment}` : ''}`) + } + + return rebuild(lines, content, eol) +} + +/** Drops every entry this plugin wrote, used to clean up after a crashed session. */ +export function removeMarkedHosts(content: string): HostsEdit { + const eol = detectEol(content) + const lines = toBodyLines(content).filter(line => !isOwned(line)) + return rebuild(lines, content, eol) +} + +/** Hosts entries this plugin owns, without the marker. */ +export function listMarkedHosts(content: string): Array<{ ip: string, host: string }> { + const result: Array<{ ip: string, host: string }> = [] + for (const line of toBodyLines(content)) { + if (!isOwned(line)) + continue + const entry = parseLine(line) + if (!entry) + continue + for (const host of entry.hosts) + result.push({ ip: entry.ip, host }) + } + return result +} diff --git a/src/privileged/ports.ts b/src/privileged/ports.ts new file mode 100644 index 0000000..7b96085 --- /dev/null +++ b/src/privileged/ports.ts @@ -0,0 +1,45 @@ +/** + * Helpers for freeing a listening TCP port without shelling out to `npx`. + * `parseListeningPids` keeps the decision testable: it only turns command + * output into pids, never spawns anything. + */ + +/** Parses `lsof -ti: -sTCP:LISTEN` output (one pid per line). */ +export function parseListeningPids(output: string): number[] { + const pids = new Set() + for (const line of output.split(/\r?\n/)) { + const trimmed = line.trim() + if (!trimmed) + continue + if (!/^\d+$/.test(trimmed)) + continue + const pid = Number(trimmed) + if (Number.isSafeInteger(pid) && pid > 0) + pids.add(pid) + } + return [...pids] +} + +/** Parses `netstat -ano` output for the owning pids of a listening port on Windows. */ +export function parseWindowsListeningPids(output: string, port: number): number[] { + const pids = new Set() + const suffix = `:${port}` + + for (const line of output.split(/\r?\n/)) { + const columns = line.trim().split(/\s+/) + if (columns.length < 5) + continue + const protocol = columns[0].toUpperCase() + if (protocol !== 'TCP') + continue + if (!columns[1].endsWith(suffix)) + continue + if (columns[3].toUpperCase() !== 'LISTENING') + continue + const pid = Number(columns[4]) + if (Number.isSafeInteger(pid) && pid > 0) + pids.add(pid) + } + + return [...pids] +} diff --git a/src/privileged/protocol.ts b/src/privileged/protocol.ts new file mode 100644 index 0000000..09200d4 --- /dev/null +++ b/src/privileged/protocol.ts @@ -0,0 +1,111 @@ +/** + * Wire protocol between the unprivileged plugin process and the privileged + * helper. Newline-delimited JSON keeps the helper dependency-free. + * + * The helper never executes a binary or uses a path supplied by the client: + * both are fixed when the helper starts (`--caddy` and `--caddy-config`), so a + * client that reaches the socket cannot turn the helper into a root shell. + */ + +export type RpcId = number + +export interface RpcErrorPayload { + message: string + code?: string +} + +export type HelperRequest = + | { id: RpcId, method: 'ping' } + | { id: RpcId, method: 'hosts.add', params: { ip: string, host: string } } + | { id: RpcId, method: 'hosts.remove', params: { ip: string, host: string } } + | { id: RpcId, method: 'port.free', params: { port: number } } + | { id: RpcId, method: 'caddy.start' } + | { id: RpcId, method: 'caddy.stop' } + | { id: RpcId, method: 'ca.trust' } + +/** Request method names, used by the client's typed `call` overloads. */ +export type HelperMethod = HelperRequest['method'] + +export type HelperResponse = + | { id: RpcId, ok: true, result?: unknown } + | { id: RpcId, ok: false, error: RpcErrorPayload } + +export type HelperEvent = + | { type: 'event', kind: 'caddy.stdout', data: string } + | { type: 'event', kind: 'caddy.stderr', data: string } + | { type: 'event', kind: 'caddy.exit', code: number | null, signal: string | null } + +export type HelperMessage = HelperResponse | HelperEvent + +export interface ClientHandshake { + type: 'handshake' + token: string +} + +export interface ServerHandshake { + type: 'handshake' + ok: boolean + pid?: number + reason?: string +} + +export type DecodedMessage = + | { ok: true, message: HelperMessage } + | { ok: false, error: string } + +export function serializeMessage(message: unknown): string { + return `${JSON.stringify(message)}\n` +} + +/** Splits a stream into complete lines, keeping the trailing partial line. */ +export function splitLines(buffer: string, chunk: string): { lines: string[], rest: string } { + const parts = (buffer + chunk).split('\n') + const rest = parts.pop() ?? '' + return { lines: parts.filter(line => line.trim() !== ''), rest } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +export function decodeMessage(line: string): DecodedMessage { + let parsed: unknown + try { + parsed = JSON.parse(line) + } + catch (error) { + return { ok: false, error: `invalid JSON: ${(error as Error).message}` } + } + + if (!isRecord(parsed)) + return { ok: false, error: 'message must be an object' } + + if (parsed.type === 'event') { + if (typeof parsed.kind !== 'string') + return { ok: false, error: 'event.kind must be a string' } + return { ok: true, message: parsed as unknown as HelperEvent } + } + + if (typeof parsed.id !== 'number') + return { ok: false, error: 'response.id must be a number' } + + if (parsed.ok === true) + return { ok: true, message: { id: parsed.id, ok: true, result: parsed.result } } + + if (parsed.ok === false) { + const error = isRecord(parsed.error) ? parsed.error : {} + return { + ok: true, + message: { + id: parsed.id, + ok: false, + error: { + message: typeof error.message === 'string' ? error.message : 'helper request failed', + code: typeof error.code === 'string' ? error.code : undefined, + }, + }, + } + } + + return { ok: false, error: 'response.ok must be a boolean' } +} diff --git a/src/privileged/validation.ts b/src/privileged/validation.ts new file mode 100644 index 0000000..7ed7eac --- /dev/null +++ b/src/privileged/validation.ts @@ -0,0 +1,38 @@ +import { isIP } from 'node:net' + +/** + * Validates values that cross the socket. Everything reaching the privileged + * helper is untrusted, and a hosts line in particular is a plain-text file: an + * unvalidated hostname containing a newline could inject entries this plugin + * does not own and therefore never cleans up. + */ + +/** Ports the plugin may free. Wide enough for the two the proxy binds. */ +export const ALLOWED_FREE_PORTS = new Set([80, 443]) + +const HOSTNAME_PATTERN = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i +const IPV6_IN_HOSTS = /^[0-9a-f:.]+$/i + +export function isSafeHost(host: unknown): host is string { + if (typeof host !== 'string' || host.length === 0 || host.length > 253) + return false + // A hosts entry is whitespace separated, so any whitespace or control + // character would split the line or start a new one. + if (/[\s#\0]/.test(host)) + return false + return HOSTNAME_PATTERN.test(host) +} + +export function isSafeIp(ip: unknown): ip is string { + if (typeof ip !== 'string') + return false + if (isIP(ip) !== 0) + return true + // `::1` and friends are caught by isIP; this keeps the check explicit for + // hosts files that spell IPv6 without brackets. + return IPV6_IN_HOSTS.test(ip) +} + +export function isAllowedFreePort(port: unknown): port is number { + return typeof port === 'number' && Number.isInteger(port) && ALLOWED_FREE_PORTS.has(port) +} From 8fa062975ec8c19871d4ef13d8eed7e132bd5112 Mon Sep 17 00:00:00 2001 From: zcf0508 Date: Thu, 22 Oct 2026 22:18:29 +0800 Subject: [PATCH 02/35] =?UTF-8?q?feat(privileged):=20=E6=96=B0=E5=A2=9E=20?= =?UTF-8?q?root=20helper=20=E8=BF=9B=E7=A8=8B=E4=B8=8E=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit helper 以 root 运行但只做原语操作,Caddy 的二进制与配置在启动时固定, socket 请求只能表达"何时做",不能表达"做什么",避免变成任意 root 执行。 - server: 请求路由,hosts 编辑、端口释放、Caddy 启停、CA 安装 - bootstrap: socket 监听、token 握手、socket 属主交还发起者 - client: 带重连的 RPC 客户端,权限错误不静默重试 - launch: 一次 sudo -v 预认证 + sudo -n 启动,不经 detached 以免丢失凭证 - session: 同一运行目录复用 helper,第二个 dev server 不再要密码 主进程退出或崩溃时 socket 断开,helper 自行恢复 hosts 并停止 Caddy; helper 启动时先清理上次崩溃遗留的条目。 --- src/privileged-helper.ts | 109 +++++++++ src/privileged/bootstrap.ts | 174 ++++++++++++++ src/privileged/client.ts | 287 ++++++++++++++++++++++ src/privileged/launch.ts | 193 +++++++++++++++ src/privileged/server.ts | 465 ++++++++++++++++++++++++++++++++++++ src/privileged/session.ts | 114 +++++++++ 6 files changed, 1342 insertions(+) create mode 100644 src/privileged-helper.ts create mode 100644 src/privileged/bootstrap.ts create mode 100644 src/privileged/client.ts create mode 100644 src/privileged/launch.ts create mode 100644 src/privileged/server.ts create mode 100644 src/privileged/session.ts diff --git a/src/privileged-helper.ts b/src/privileged-helper.ts new file mode 100644 index 0000000..7f5818a --- /dev/null +++ b/src/privileged-helper.ts @@ -0,0 +1,109 @@ +import process from 'node:process' +import { startHelper } from './privileged/bootstrap' + +interface ParsedArgs { + socket: string + token: string + hosts?: string + caddyBin: string + caddyConfig: string + ownerUid?: number + ownerGid?: number + caddyStorage?: string +} + +function parseArgs(argv: string[]): ParsedArgs { + let socket = '' + let token = '' + let hosts: string | undefined + let caddyBin = '' + let caddyConfig = '' + let ownerUid: number | undefined + let ownerGid: number | undefined + let caddyStorage: string | undefined + + for (let index = 0; index < argv.length; index++) { + const current = argv[index] + if (current === '--socket') + socket = argv[++index] ?? '' + else if (current === '--token') + token = argv[++index] ?? '' + else if (current === '--hosts') + hosts = argv[++index] ?? undefined + else if (current === '--caddy') + caddyBin = argv[++index] ?? '' + else if (current === '--caddy-config') + caddyConfig = argv[++index] ?? '' + else if (current === '--owner-uid') + ownerUid = Number(argv[++index]) + else if (current === '--owner-gid') + ownerGid = Number(argv[++index]) + else if (current === '--caddy-storage') + caddyStorage = argv[++index] ?? undefined + } + + if (!socket || !token) + throw new Error('usage: privileged-helper --socket --token --caddy --caddy-config ') + if (!caddyBin || !caddyConfig) + throw new Error('privileged-helper requires --caddy and --caddy-config') + + return { + socket, + token, + hosts, + caddyBin, + caddyConfig, + ownerUid: Number.isInteger(ownerUid) ? ownerUid : undefined, + ownerGid: Number.isInteger(ownerGid) ? ownerGid : undefined, + caddyStorage, + } +} + +/** + * Entry point of the process that runs with elevated privileges. + * + * It owns no product logic. The Caddy binary and Caddyfile are fixed on the + * command line, so requests on the socket can only say *when* to act, never + * what* to execute. + */ +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)) + + const helper = await startHelper({ + socketPath: args.socket, + token: args.token, + hostsPath: args.hosts, + caddyBin: args.caddyBin, + caddyConfigPath: args.caddyConfig, + ownerUid: args.ownerUid, + ownerGid: args.ownerGid, + caddyStoragePath: args.caddyStorage, + caddyEnv: { + HOME: process.env.HOME ?? '', + // Keep Caddy's storage next to the invoking user's home even though the + // process runs as root. + XDG_DATA_HOME: process.env.XDG_DATA_HOME ?? '', + }, + }) + + // Ctrl+C is delivered to the whole foreground process group. The plugin also + // receives it and will drop the socket, which is the signal this helper acts + // on; exiting here would race that cleanup. + process.on('SIGINT', () => {}) + + const shutdown = (): void => { + void helper.runtime.dispose().finally(() => process.exit(0)) + } + + process.on('SIGTERM', shutdown) + process.on('SIGHUP', shutdown) + + // A closed stdout (the parent died) must not interrupt the cleanup path. + process.stdout.on('error', () => {}) + process.stderr.on('error', () => {}) +} + +main().catch((error: Error) => { + process.stderr.write(`[uhrp-helper] fatal: ${error.message}\n`) + process.exit(1) +}) diff --git a/src/privileged/bootstrap.ts b/src/privileged/bootstrap.ts new file mode 100644 index 0000000..80f19b6 --- /dev/null +++ b/src/privileged/bootstrap.ts @@ -0,0 +1,174 @@ +import type { Socket } from 'node:net' +import type { ClientHandshake } from './protocol' +import type { HelperDeps, HelperRuntime } from './server' +import { Buffer } from 'node:buffer' +import { chmodSync, chownSync, rmSync } from 'node:fs' +import { createServer } from 'node:net' +import process from 'node:process' +import { attachSocket, createHelperRuntime, createNodeHelperDeps } from './server' + +export interface HelperBootstrapOptions { + socketPath: string + token: string + hostsPath?: string + /** Caddy binary; fixed here so the client never names an executable. */ + caddyBin: string + /** Caddyfile to load; fixed for the same reason. */ + caddyConfigPath: string + caddyCwd?: string + caddyEnv?: Record + /** + * Owner of the socket file. The helper runs as root while its client runs as + * the developer, so the socket must be handed back or the client cannot + * connect to it. + */ + ownerUid?: number + ownerGid?: number + /** Caddy storage directory, handed back to the owner when Caddy stops. */ + caddyStoragePath?: string + deps?: HelperDeps + log?: (message: string) => void +} + +export interface RunningHelper { + runtime: HelperRuntime + close: () => Promise +} + +const HANDSHAKE_TIMEOUT_MS = 120_000 + +/** + * Runs the privileged side of the plugin: listens on a local socket, accepts + * only a client presenting the shared token, then serves until that client + * disconnects. Losing the client is the cleanup trigger, so a crashed or + * `SIGKILL`ed plugin still leaves hosts and Caddy restored. + */ +export async function startHelper(options: HelperBootstrapOptions): Promise { + const { socketPath, token } = options + const log = options.log ?? ((message: string) => process.stdout.write(`[uhrp-helper] ${message}\n`)) + const deps = options.deps ?? createNodeHelperDeps({ + hostsPath: options.hostsPath, + log, + caddyBin: options.caddyBin, + caddyConfigPath: options.caddyConfigPath, + caddyCwd: options.caddyCwd, + caddyEnv: options.caddyEnv, + caddyStoragePath: options.caddyStoragePath, + storageOwner: typeof options.ownerUid === 'number' + ? { uid: options.ownerUid, gid: options.ownerGid ?? options.ownerUid } + : undefined, + }) + const runtime = createHelperRuntime(deps) + + // A previous session that was killed cannot have cleaned up its entries; do + // it now, before any client attaches. + await runtime.sweepStaleEntries().catch(() => {}) + + try { + rmSync(socketPath, { force: true }) + } + catch {} + + const connections = new Set() + + const server = createServer((socket) => { + let buffer = '' + let authenticated = false + connections.add(socket) + socket.on('close', () => connections.delete(socket)) + + const timer = setTimeout(() => { + if (!authenticated) + socket.destroy() + }, HANDSHAKE_TIMEOUT_MS) + timer.unref?.() + + const onData = (chunk: Buffer): void => { + buffer += chunk.toString('utf8') + const index = buffer.indexOf('\n') + if (index < 0) + return + + const line = buffer.slice(0, index) + const rest = buffer.slice(index + 1) + + let parsed: Partial + try { + parsed = JSON.parse(line) + } + catch { + socket.destroy() + return + } + + if (parsed.type !== 'handshake' || typeof parsed.token !== 'string') { + socket.destroy() + return + } + + clearTimeout(timer) + socket.removeListener('data', onData) + + if (parsed.token !== token) { + socket.end(`${JSON.stringify({ type: 'handshake', ok: false, reason: 'invalid token' })}\n`) + return + } + + authenticated = true + socket.write(`${JSON.stringify({ type: 'handshake', ok: true, pid: process.pid })}\n`) + attachSocket(socket, runtime, log) + + // Requests pipelined behind the handshake belong to the attached client. + if (rest.trim() !== '') + socket.emit('data', Buffer.from(rest)) + } + + socket.on('data', onData) + socket.on('error', () => {}) + }) + + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(socketPath, () => resolve()) + }) + + try { + // The client is not root: hand the socket back to the developer and keep it + // private to them. Without the chown, `connect()` fails with EACCES and the + // client only sees a silent retry loop. + if (typeof options.ownerUid === 'number') { + chownSync(socketPath, options.ownerUid, options.ownerGid ?? options.ownerUid) + chmodSync(socketPath, 0o600) + } + else { + chmodSync(socketPath, 0o600) + } + } + catch (error) { + log(`could not set socket ownership: ${(error as Error).message}`) + } + + return { + runtime, + async close() { + // Closing the listener alone would wait for clients to hang up first; + // shutdown has to drop the open connections as well. + for (const socket of connections) + socket.destroy() + connections.clear() + await new Promise((resolve) => { + server.close(() => resolve()) + }) + }, + } +} + +/** Sockets left behind by a killed helper cannot be reused; remove them first. */ +export function removeStaleSocket(socketPath: string): void { + if (process.platform === 'win32') + return + try { + rmSync(socketPath, { force: true }) + } + catch {} +} diff --git a/src/privileged/client.ts b/src/privileged/client.ts new file mode 100644 index 0000000..a7acfea --- /dev/null +++ b/src/privileged/client.ts @@ -0,0 +1,287 @@ +import type { Buffer } from 'node:buffer' +import type { Socket } from 'node:net' +import type { + ClientHandshake, + HelperEvent, + HelperMessage, + HelperRequest, + RpcId, + ServerHandshake, +} from './protocol' +import { connect } from 'node:net' +import process from 'node:process' +import { decodeMessage, serializeMessage, splitLines } from './protocol' + +/** + * Environment variables the privileged helper needs so Caddy behaves like the + * user's own process (PKI location, proxy settings) without `sudo -E`. + */ +export const FORWARDED_ENV_KEYS = [ + 'HOME', + 'USER', + 'LOGNAME', + 'TMPDIR', + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'http_proxy', + 'https_proxy', + 'NO_PROXY', + 'no_proxy', +] as const + +export function collectForwardedEnv(source: NodeJS.ProcessEnv = process.env): Record { + const result: Record = {} + for (const key of FORWARDED_ENV_KEYS) { + const value = source[key] + if (typeof value === 'string' && value !== '') + result[key] = value + } + return result +} + +export interface HelperResults { + 'ping': { pid: number } + 'hosts.add': void + 'hosts.remove': void + 'port.free': { killed: number[] } + 'caddy.start': { pid?: number } + 'caddy.stop': void + 'ca.trust': { trusted: boolean, output: string } +} + +export class HelperConnectionError extends Error { + constructor(message: string) { + super(message) + this.name = 'HelperConnectionError' + } +} + +export interface PrivilegedClient { + readonly connected: boolean + call: (method: M, ...params: HelperParams) => Promise + onEvent: (listener: (event: HelperEvent) => void) => () => void + close: () => Promise +} + +type HelperParams + = M extends 'hosts.add' | 'hosts.remove' ? [params: { ip: string, host: string }] + : M extends 'port.free' ? [params: { port: number }] + : [] + +interface PendingCall { + resolve: (value: unknown) => void + reject: (error: Error) => void + timer: ReturnType +} + +export interface ConnectOptions { + socketPath: string + token: string + /** Total time to wait for the helper to accept the connection. */ + timeoutMs?: number + retryIntervalMs?: number + callTimeoutMs?: number +} + +function openSocket(socketPath: string): Promise { + return new Promise((resolve, reject) => { + const socket = connect(socketPath) + + function onConnect(): void { + socket.removeListener('error', onError) + resolve(socket) + } + + function onError(error: Error): void { + socket.removeListener('connect', onConnect) + reject(error) + } + + socket.once('error', onError) + socket.once('connect', onConnect) + }) +} + +/** + * Connects to an already-running helper and performs the token handshake. + * Retries because the helper may still be starting while sudo asks for a password. + */ +export async function connectClient(options: ConnectOptions): Promise { + const { + socketPath, + token, + timeoutMs = 120_000, + retryIntervalMs = 100, + callTimeoutMs = 30_000, + } = options + + const deadline = Date.now() + timeoutMs + let lastError: Error | undefined + + while (Date.now() < deadline) { + try { + return await handshake(socketPath, token, callTimeoutMs) + } + catch (error) { + lastError = error as Error + + // A permission or protocol error will not resolve by waiting, and + // retrying hides the real cause behind a silent hang. + const code = (lastError as NodeJS.ErrnoException).code + if (code === 'EACCES' || lastError instanceof HelperConnectionError) + throw lastError + + await new Promise(resolve => setTimeout(resolve, retryIntervalMs)) + } + } + + throw new HelperConnectionError( + `privileged helper is not reachable at ${socketPath}: ${lastError?.message ?? 'timed out'}`, + ) +} + +function handshake(socketPath: string, token: string, callTimeoutMs: number): Promise { + return new Promise((resolve, reject) => { + void openSocket(socketPath).then((socket) => { + let buffer = '' + let settled = false + + const fail = (error: Error): void => { + if (settled) + return + settled = true + socket.destroy() + reject(error) + } + + const onData = (chunk: Buffer): void => { + if (settled) + return + + const split = splitLines(buffer, chunk.toString('utf8')) + buffer = split.rest + const line = split.lines[0] + if (line === undefined) + return + + let hello: ServerHandshake + try { + hello = JSON.parse(line) as ServerHandshake + } + catch (error) { + return fail(new HelperConnectionError(`invalid handshake reply: ${(error as Error).message}`)) + } + + if (hello.type !== 'handshake') + return fail(new HelperConnectionError('helper did not answer the handshake')) + + if (!hello.ok) + return fail(new HelperConnectionError(hello.reason ?? 'helper rejected the handshake')) + + settled = true + socket.removeListener('data', onData) + socket.removeListener('error', onError) + const remaining = split.lines.slice(1) + resolve(createClient(socket, [split.rest, ...remaining].filter(Boolean).join('\n'), callTimeoutMs)) + } + + function onError(error: Error): void { + fail(error) + } + + socket.on('data', onData) + socket.on('error', onError) + + const greeting: ClientHandshake = { type: 'handshake', token } + socket.write(serializeMessage(greeting)) + }, reject) + }) +} + +function createClient(socket: Socket, initialBuffer: string, callTimeoutMs: number): PrivilegedClient { + let buffer = initialBuffer + let connected = true + let nextId: RpcId = 1 + + const pending = new Map() + const listeners = new Set<(event: HelperEvent) => void>() + + socket.on('data', (chunk: Buffer) => { + const split = splitLines(buffer, chunk.toString('utf8')) + buffer = split.rest + + for (const line of split.lines) { + const decoded = decodeMessage(line) + if (!decoded.ok) + continue + + const message: HelperMessage = decoded.message + if (!('id' in message)) { + for (const listener of listeners) + listener(message) + continue + } + + const entry = pending.get(message.id) + if (!entry) + continue + + pending.delete(message.id) + clearTimeout(entry.timer) + + if (message.ok) + entry.resolve(message.result) + else + entry.reject(new Error(message.error.message)) + } + }) + + const disconnect = (): void => { + if (!connected) + return + connected = false + for (const [id, entry] of pending) { + clearTimeout(entry.timer) + pending.delete(id) + entry.reject(new HelperConnectionError('privileged helper connection closed')) + } + } + + socket.on('close', disconnect) + socket.on('error', disconnect) + + function call(method: keyof HelperResults, params?: unknown): Promise { + if (!connected) + return Promise.reject(new HelperConnectionError('privileged helper is not connected')) + + const id = nextId++ + const request = { id, method, params } as unknown as HelperRequest + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(id) + reject(new HelperConnectionError(`privileged helper did not answer "${method}" in time`)) + }, callTimeoutMs) + + pending.set(id, { resolve, reject, timer }) + socket.write(serializeMessage(request)) + }) + } + + return { + get connected() { + return connected + }, + call: call as PrivilegedClient['call'], + onEvent(listener) { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + }, + async close() { + disconnect() + socket.destroy() + }, + } +} diff --git a/src/privileged/launch.ts b/src/privileged/launch.ts new file mode 100644 index 0000000..72c9a61 --- /dev/null +++ b/src/privileged/launch.ts @@ -0,0 +1,193 @@ +import type { Buffer } from 'node:buffer' +import type { PrivilegedClient } from './client' +import { spawn, spawnSync } from 'node:child_process' +import { randomBytes } from 'node:crypto' +import { platform } from 'node:os' +import process from 'node:process' +import { collectForwardedEnv, connectClient } from './client' +import { spawnElevatedOnWindows } from './windows-elevation' + +export function createHelperToken(): string { + return randomBytes(24).toString('hex') +} + +export interface LaunchOptions { + socketPath: string + token: string + /** Executable started as root (node, or tsx when running from source). */ + helperEntry: string + /** Arguments placed before the helper's own `--socket/--token` flags. */ + helperArgs?: string[] + sudoCommand?: string + /** Skips the interactive credential check; used by tests and Windows. */ + skipAuth?: boolean + /** + * Starts the helper as the current user instead of going through sudo. + * Used by tests that need the helper protocol but not real privileges. + */ + direct?: boolean + env?: NodeJS.ProcessEnv + log?: (message: string) => void + /** How long to wait for the helper socket to accept the connection. */ + connectTimeoutMs?: number + /** + * Caddy's storage directory. Caddy runs as root in the helper, so the helper + * hands this tree back to the developer when it stops Caddy. + */ + caddyStoragePath?: string + /** Caddy binary the helper is allowed to run, fixed for the whole session. */ + caddyBin: string + /** Caddyfile the helper is allowed to load. */ + caddyConfigPath: string +} + +/** + * Asks for the administrator password once, up front, on the dev terminal. + * + * The credential cache is keyed by terminal, so a later `sudo -n` only finds + * this entry when the child stays in the same session. That is why the helper + * is spawned without `detached`. + */ +export function authenticateSudo(sudoCommand = 'sudo'): { ok: boolean, message?: string } { + if (platform() === 'win32') + return { ok: true } + + if (spawnSync(sudoCommand, ['-n', 'true'], { stdio: 'ignore' }).status === 0) + return { ok: true } + + if (spawnSync(sudoCommand, ['-v'], { stdio: 'inherit' }).status === 0) + return { ok: true } + + return { + ok: false, + message: 'administrator permission is required to edit the hosts file and bind ports 80/443', + } +} + +export interface PrivilegedHelperHandle { + client: PrivilegedClient + close: () => Promise +} + +/** + * Starts the privileged helper and connects to it. + * + * Only this child runs as root: the Node process that owns the dev server, + * its caches and `node_modules` keeps the developer's identity. The user's + * proxy/HOME settings reach Caddy through the helper's environment instead of + * `sudo -E`. + */ +export async function startPrivilegedHelper(options: LaunchOptions): Promise { + const { + socketPath, + token, + helperEntry, + helperArgs = [], + sudoCommand = 'sudo', + skipAuth = false, + direct = false, + env = process.env, + log, + connectTimeoutMs = 120_000, + } = options + + if (!skipAuth && !direct) { + const auth = authenticateSudo(sudoCommand) + if (!auth.ok) + throw new Error(auth.message) + } + + const forwarded = collectForwardedEnv(env) + const forwardedArgs = Object.entries(forwarded).map(([key, value]) => `${key}=${value}`) + // `helperEntry` is the runtime and `helperArgs` the script it must run, so + // the helper's own flags always come last. The owner ids travel with them + // because the helper only learns who its client is at handshake time. + const childArgv = [ + ...helperArgs, + '--socket', + socketPath, + '--token', + token, + ...(typeof process.getuid === 'function' ? ['--owner-uid', String(process.getuid())] : []), + ...(typeof process.getgid === 'function' ? ['--owner-gid', String(process.getgid())] : []), + ...(options.caddyStoragePath ? ['--caddy-storage', options.caddyStoragePath] : []), + '--caddy', + options.caddyBin, + '--caddy-config', + options.caddyConfigPath, + ] + + let child: ReturnType | undefined + + if (!direct && platform() === 'win32') { + // UAC: the elevated helper is a separate process and cannot inherit stdio, + // so it reports through the named pipe instead. The user's environment is + // recovered by the helper from its own process env, which Windows keeps. + await spawnElevatedOnWindows({ file: helperEntry, args: childArgv, cwd: process.cwd() }) + } + else { + const command = direct ? helperEntry : sudoCommand + const spawnArgs = direct + ? childArgv + : ['-n', 'env', ...forwardedArgs, helperEntry, ...childArgv] + + // No `detached`: sudo stores its credential per session, and `setsid()` + // would make the helper look like a different login, so `sudo -n` could not + // reuse the password entered a moment earlier. The stdio pipes keep the + // helper reachable from this process. + child = spawn(command, spawnArgs, { + stdio: ['ignore', 'pipe', 'pipe'], + }) + + child.stdout?.on('data', (data: Buffer) => log?.(data.toString().trimEnd())) + child.stderr?.on('data', (data: Buffer) => log?.(data.toString().trimEnd())) + + const exited = new Promise(resolve => child!.once('exit', code => resolve(code))) + + // Refused credentials make the helper exit immediately; report that instead + // of waiting for the full connection timeout. + const earlyExit = await Promise.race([ + exited, + new Promise(resolve => setTimeout(() => resolve(undefined), 500)), + ]) + + if (earlyExit !== undefined && earlyExit !== 0) { + throw new Error( + `privileged helper exited with code ${earlyExit}. ` + + 'Administrator permission was refused, or sudo is not available.', + ) + } + } + + try { + const client = await connectClient({ socketPath, token, timeoutMs: connectTimeoutMs }) + return { + client, + async close() { + await client.close().catch(() => {}) + child?.unref() + }, + } + } + catch (error) { + try { + child?.kill() + } + catch {} + throw error + } +} + +/** Reuses a helper that is already running for this runtime directory. */ +export async function connectExistingHelper( + socketPath: string, + token: string, + timeoutMs = 1500, +): Promise { + try { + return await connectClient({ socketPath, token, timeoutMs }) + } + catch { + return undefined + } +} diff --git a/src/privileged/server.ts b/src/privileged/server.ts new file mode 100644 index 0000000..ad345a9 --- /dev/null +++ b/src/privileged/server.ts @@ -0,0 +1,465 @@ +import type { Buffer } from 'node:buffer' +import type { Socket } from 'node:net' +import type { HelperEvent, HelperRequest, HelperResponse } from './protocol' +import { execFile, spawn } from 'node:child_process' +import { lchownSync, lstatSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { platform } from 'node:os' +import { join } from 'node:path' +import process from 'node:process' +import { promisify } from 'node:util' +import { addHost, removeHost, removeMarkedHosts } from './hosts-file' +import { parseListeningPids, parseWindowsListeningPids } from './ports' +import { serializeMessage } from './protocol' +import { isAllowedFreePort, isSafeHost, isSafeIp } from './validation' + +const execFileAsync = promisify(execFile) + +/** Runs a command whose non-zero exit is expected, keeping stdout. */ +function execFileQuiet(file: string, args: string[]): Promise<{ stdout: string }> { + return new Promise((resolve) => { + execFile(file, args, (error, stdout) => resolve({ stdout: error && !stdout ? '' : stdout })) + }) +} + +export const HOSTS_PATH = platform() === 'win32' + ? 'C:\\Windows\\System32\\drivers\\etc\\hosts' + : '/etc/hosts' + +/** + * Command that drops the resolver cache so a fresh hosts entry is visible + * immediately. Each platform caches differently, so the command is per-OS. + */ +const FLUSH_COMMANDS: Record> = { + win32: [{ file: 'ipconfig', args: ['/flushdns'] }], + darwin: [{ file: 'killall', args: ['-HUP', 'mDNSResponder'] }], + // systemd-resolved is the common case; the legacy name covers older distros + // and hosts without resolved simply have no cache to drop. + linux: [ + { file: 'resolvectl', args: ['flush-caches'] }, + { file: 'systemd-resolve', args: ['--flush-caches'] }, + ], +} + +const FLUSH_FALLBACK = [{ file: 'killall', args: ['-HUP', 'mDNSResponder'] }] as const + +/** Handle on the Caddy process owned by the helper. */ +export interface CaddyChild { + pid?: number + kill: (signal?: NodeJS.Signals) => boolean +} + +/** + * Everything privileged the helper can do. + * + * Paths and arguments are fixed at helper start-up; the client only chooses + * when* something happens, never *what* runs. That keeps the privileged + * surface to a handful of primitives even if the socket is reached by an + * unexpected local process. + */ +export interface HelperDeps { + readHosts: () => string + writeHosts: (content: string) => void + flushDns: () => Promise + freePort: (port: number) => Promise + /** Caddy binary and the Caddyfile it must load, both fixed at start-up. */ + caddyBin: string + caddyConfigPath: string + caddyCwd: string + caddyEnv: Record + /** Installs the local root CA using a helper-owned command, not a client path. */ + trustLocalCa: () => Promise<{ trusted: boolean, output: string }> + /** Caddy writes its PKI as root; this tree goes back to the user afterwards. */ + caddyStoragePath?: string + storageOwner?: { uid: number, gid: number } + /** Starts Caddy with the configuration fixed at helper start-up. */ + startCaddy: (emit: (event: HelperEvent) => void) => CaddyChild + stopCaddy: () => Promise + log: (message: string) => void +} + +/** + * Resolver caching only affects how fast a new entry is picked up, so a failed + * flush is logged instead of rolling back a hosts edit that already succeeded. + */ +async function flushDnsBestEffort(deps: HelperDeps): Promise { + try { + await deps.flushDns() + } + catch (error) { + deps.log(`dns flush failed: ${(error as Error).message}`) + } +} + +export interface HelperRuntime { + handle: (request: HelperRequest) => Promise + /** Registers a connected client so Caddy output reaches it. */ + attach: (client: object) => void + /** Last client gone: stop Caddy and drop the hosts entries this plugin wrote. */ + detach: (client: object) => Promise + /** Runs the teardown once, regardless of how many clients were attached. */ + dispose: () => Promise + /** Removes hosts entries a previous crashed session left behind. */ + sweepStaleEntries: () => Promise +} + +/** + * Request router shared by the helper process and its tests. Kept free of + * sockets so tests can drive it against a temp hosts file and a fake Caddy. + */ +export function createHelperRuntime(deps: HelperDeps): HelperRuntime { + const clients = new Set() + let teardown: Promise | undefined + // Bumped whenever a client attaches. A teardown that started before the bump + // belongs to a previous generation and must not touch the new session. + let generation = 0 + let caddy: CaddyChild | undefined + + const broadcast = (event: HelperEvent): void => { + const line = serializeMessage(event) + for (const client of clients) { + const socket = client as Socket + if (typeof socket.write === 'function' && !socket.destroyed) + socket.write(line) + } + } + + async function stopCaddy(): Promise { + const current = caddy + caddy = undefined + if (current) { + try { + current.kill('SIGTERM') + } + catch {} + } + await deps.stopCaddy().catch(() => {}) + + // Give Caddy's files back to the developer: the next start runs as root + // again, but the developer must be able to read the PKI in between. + if (deps.caddyStoragePath && deps.storageOwner) + await reclaimTree(deps.caddyStoragePath, deps.storageOwner.uid, deps.storageOwner.gid).catch(() => {}) + } + + function dispose(): Promise { + if (!teardown) { + const startedAt = generation + teardown = (async () => { + try { + // A client may have attached while this teardown was queued; its + // entries belong to the new session and must survive. + if (generation === startedAt) { + const edited = removeMarkedHosts(deps.readHosts()) + if (edited.changed) { + deps.writeHosts(edited.content) + await deps.flushDns().catch(() => {}) + } + } + } + catch (error) { + deps.log(`hosts cleanup failed: ${(error as Error).message}`) + } + + // The new session owns Caddy now; stopping it here would kill a server + // this generation never started. + if (generation === startedAt) + await stopCaddy() + })() + } + return teardown + } + + async function sweepStaleEntries(): Promise { + try { + const edited = removeMarkedHosts(deps.readHosts()) + if (!edited.changed) + return false + deps.writeHosts(edited.content) + await flushDnsBestEffort(deps) + return true + } + catch (error) { + deps.log(`stale hosts cleanup failed: ${(error as Error).message}`) + return false + } + } + + async function handle(request: HelperRequest): Promise { + try { + switch (request.method) { + case 'ping': + return { id: request.id, ok: true, result: { pid: process.pid } } + + case 'hosts.add': { + const { ip, host } = request.params + if (!isSafeIp(ip) || !isSafeHost(host)) + return { id: request.id, ok: false, error: { message: 'invalid hosts entry' } } + + const edited = addHost(deps.readHosts(), ip, host) + if (edited.changed) + deps.writeHosts(edited.content) + await flushDnsBestEffort(deps) + return { id: request.id, ok: true } + } + + case 'hosts.remove': { + const { ip, host } = request.params + if (!isSafeIp(ip) || !isSafeHost(host)) + return { id: request.id, ok: false, error: { message: 'invalid hosts entry' } } + + const edited = removeHost(deps.readHosts(), ip, host) + if (edited.changed) + deps.writeHosts(edited.content) + await flushDnsBestEffort(deps) + return { id: request.id, ok: true } + } + + case 'port.free': { + const { port } = request.params + if (!isAllowedFreePort(port)) + return { id: request.id, ok: false, error: { message: `port ${port} is not releasable` } } + + const killed = await deps.freePort(port) + return { id: request.id, ok: true, result: { killed } } + } + + case 'caddy.start': { + if (caddy) + await stopCaddy() + + caddy = deps.startCaddy(broadcast) + return { id: request.id, ok: true, result: { pid: caddy.pid } } + } + + case 'caddy.stop': { + await stopCaddy() + return { id: request.id, ok: true } + } + + case 'ca.trust': { + const result = await deps.trustLocalCa() + return { id: request.id, ok: true, result } + } + + default: + return { id: (request as HelperRequest).id, ok: false, error: { message: 'unknown method' } } + } + } + catch (error) { + return { id: request.id, ok: false, error: { message: (error as Error).message } } + } + } + + return { + handle, + attach(client) { + clients.add(client) + // Only the newest generation may clean up; an in-flight teardown started + // before this call is abandoned rather than cancelled mid-write. + generation++ + teardown = undefined + }, + async detach(client) { + clients.delete(client) + if (clients.size === 0) + await dispose() + }, + dispose, + sweepStaleEntries, + } +} + +export interface NodeHelperDepsOptions { + hostsPath?: string + caddyBin: string + caddyConfigPath: string + caddyCwd?: string + caddyEnv?: Record + /** Caddy storage directory; handed back to the user when Caddy stops. */ + caddyStoragePath?: string + /** Owner that receives the storage directory after Caddy stops. */ + storageOwner?: { uid: number, gid: number } + log?: (message: string) => void +} + +/** Best-effort recursive chown, skipping anything it cannot read. */ +async function reclaimTree(target: string, uid: number, gid: number): Promise { + const reclaimed: string[] = [] + + const walk = (path: string): void => { + let stats + try { + // `lstat`: a symlink must never redirect a root chown to its target. + stats = lstatSync(path) + } + catch { + return + } + + try { + lchownSync(path, uid, gid) + reclaimed.push(path) + } + catch {} + + if (!stats.isDirectory()) + return + + let entries: string[] + try { + entries = readdirSync(path) + } + catch { + return + } + + for (const entry of entries) + walk(join(path, entry)) + } + + walk(target) + return reclaimed +} + +/** Installs the Caddy root CA using helper-owned commands. */ +async function trustLocalCaWithSystemTools(caddyBin: string): Promise<{ trusted: boolean, output: string }> { + // `caddy trust` reads the CA through the admin API, which only exists while + // Caddy is serving; the plugin calls this after Caddy is up. + try { + const { stdout, stderr } = await execFileAsync( + caddyBin, + ['trust', '--ca', 'local', '--address', '127.0.0.1:2019'], + { timeout: 120_000 }, + ) + return { trusted: true, output: `${stdout}${stderr}`.trim() } + } + catch (error) { + const output = String((error as { stdout?: string }).stdout ?? '') + String((error as { stderr?: string }).stderr ?? '') + if (/already|not modified|no changes/i.test(output)) + return { trusted: true, output: output.trim() } + throw new Error(output.trim() || (error as Error).message) + } +} + +/** Production wiring for the helper process. */ +export function createNodeHelperDeps(options: NodeHelperDepsOptions): HelperDeps { + const hostsPath = options.hostsPath ?? HOSTS_PATH + const log = options.log ?? ((message: string) => process.stdout.write(`${message}\n`)) + let caddy: CaddyChild | undefined + + return { + readHosts: () => readFileSync(hostsPath, 'utf8'), + writeHosts: (content) => { + writeFileSync(hostsPath, content) + }, + flushDns: async () => { + const candidates = FLUSH_COMMANDS[platform()] ?? [...FLUSH_FALLBACK] + let lastError: Error | undefined + + for (const candidate of candidates) { + try { + await execFileAsync(candidate.file, candidate.args) + return + } + catch (error) { + lastError = error as Error + } + } + + throw lastError ?? new Error('no DNS cache flush command for this platform') + }, + freePort: async (port) => { + // `lsof` exits with 1 when nothing matches, which is the normal case for + // a free port; only its stdout carries the answer. + const pids = platform() === 'win32' + ? parseWindowsListeningPids((await execFileAsync('netstat', ['-ano'])).stdout, port) + : parseListeningPids((await execFileQuiet('lsof', [`-ti:${port}`, '-sTCP:LISTEN'])).stdout) + + const killed: number[] = [] + for (const pid of pids) { + if (pid === process.pid) + continue + try { + process.kill(pid, 'SIGTERM') + killed.push(pid) + } + catch {} + } + return killed + }, + caddyBin: options.caddyBin, + caddyConfigPath: options.caddyConfigPath, + caddyCwd: options.caddyCwd ?? process.cwd(), + caddyEnv: options.caddyEnv ?? {}, + trustLocalCa: () => trustLocalCaWithSystemTools(options.caddyBin), + caddyStoragePath: options.caddyStoragePath, + storageOwner: options.storageOwner, + startCaddy: (emit) => { + const child = spawn(options.caddyBin, ['run', '--adapter', 'caddyfile', '--config', options.caddyConfigPath], { + cwd: options.caddyCwd ?? process.cwd(), + env: { ...process.env, ...options.caddyEnv }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + child.stdout?.on('data', (data: Buffer) => emit({ type: 'event', kind: 'caddy.stdout', data: data.toString() })) + child.stderr?.on('data', (data: Buffer) => emit({ type: 'event', kind: 'caddy.stderr', data: data.toString() })) + child.on('exit', (code, signal) => emit({ type: 'event', kind: 'caddy.exit', code, signal })) + caddy = child as unknown as CaddyChild + return caddy + }, + stopCaddy: async () => { + const current = caddy + caddy = undefined + if (!current) + return + try { + current.kill('SIGTERM') + } + catch {} + }, + log, + } +} + +/** + * Serves requests on an authenticated connection. When the client disappears — + * including a `SIGKILL` of the plugin process — the socket closes and `dispose` + * restores the machine state. + */ +export function attachSocket(socket: Socket, runtime: HelperRuntime, log: (message: string) => void): void { + let buffer = '' + runtime.attach(socket) + + socket.on('data', (chunk: Buffer) => { + buffer += chunk.toString('utf8') + let index = buffer.indexOf('\n') + while (index >= 0) { + const line = buffer.slice(0, index) + buffer = buffer.slice(index + 1) + index = buffer.indexOf('\n') + + if (!line.trim()) + continue + + let request: HelperRequest + try { + request = JSON.parse(line) as HelperRequest + } + catch (error) { + log(`invalid request: ${(error as Error).message}`) + continue + } + + void runtime.handle(request).then((response) => { + if (!socket.destroyed) + socket.write(serializeMessage(response)) + }) + } + }) + + socket.on('error', (error) => { + log(`connection error: ${error.message}`) + }) + + socket.on('close', () => { + void runtime.detach(socket) + }) +} diff --git a/src/privileged/session.ts b/src/privileged/session.ts new file mode 100644 index 0000000..ce57dea --- /dev/null +++ b/src/privileged/session.ts @@ -0,0 +1,114 @@ +import type { PrivilegedClient } from './client' +import { Buffer } from 'node:buffer' +import { existsSync } from 'node:fs' +import { readFile, writeFile } from 'node:fs/promises' +import { platform } from 'node:os' +import { join } from 'node:path' +import { connectExistingHelper, createHelperToken, startPrivilegedHelper } from './launch' + +export interface SessionScope { + /** Runtime directory shared by the plugin, e.g. `TEMP_DIR`. */ + runtimeDir: string + /** Directory of the running bundle; used to locate the helper entry. */ + helperEntry: string + helperArgs?: string[] + env?: NodeJS.ProcessEnv + sudoCommand?: string + /** Skips the interactive credential check; used by tests. */ + skipAuth?: boolean + /** Runs the helper as the current user; used by tests without privileges. */ + direct?: boolean + /** Caddy storage directory; the helper hands it back to the user on stop. */ + caddyStoragePath?: string + /** Caddy binary the helper may run; the client cannot change it later. */ + caddyBin: string + /** Caddyfile the helper may load. */ + caddyConfigPath: string + log?: (message: string) => void +} + +export interface OpenedSession { + client: PrivilegedClient + /** True when an already-running helper was reused. */ + reused: boolean + close: () => Promise +} + +function tokenPath(runtimeDir: string): string { + return join(runtimeDir, 'helper.token') +} + +export function sessionSocketPath(runtimeDir: string): string { + if (platform() === 'win32') + return `\\\\.\\pipe\\uhrp-${Buffer.from(runtimeDir).toString('hex').slice(0, 40)}` + return join(runtimeDir, 'helper.sock') +} + +/** + * Connects to the helper already serving this runtime directory, or starts a + * new one. Reuse matters for multi-instance dev servers: the second instance + * attaches to the running Caddy instead of asking for a password again. + */ +export async function openHelperSession(scope: SessionScope): Promise { + const socketPath = sessionSocketPath(scope.runtimeDir) + + if (existsSync(socketPath) || platform() === 'win32') { + const token = await readToken(scope.runtimeDir) + if (token) { + const reused = await connectExistingHelper(socketPath, token) + if (reused) { + return { + client: reused, + reused: true, + close: async () => { + await reused.close() + }, + } + } + } + } + + const token = createHelperToken() + const started = await startPrivilegedHelper({ + socketPath, + token, + helperEntry: scope.helperEntry, + helperArgs: scope.helperArgs, + sudoCommand: scope.sudoCommand, + skipAuth: scope.skipAuth, + direct: scope.direct, + env: scope.env, + caddyStoragePath: scope.caddyStoragePath, + caddyBin: scope.caddyBin, + caddyConfigPath: scope.caddyConfigPath, + log: scope.log, + }) + + await writeToken(scope.runtimeDir, token) + + return { + client: started.client, + reused: false, + close: async () => { + await started.close() + }, + } +} + +async function readToken(runtimeDir: string): Promise { + try { + const content = await readFile(tokenPath(runtimeDir), 'utf8') + const token = content.trim() + return token === '' ? undefined : token + } + catch { + return undefined + } +} + +async function writeToken(runtimeDir: string, token: string): Promise { + try { + await writeFile(tokenPath(runtimeDir), token, { mode: 0o600 }) + } + catch {} +} From 66a67270628d8c9c5665e45bb9a180c729910752 Mon Sep 17 00:00:00 2001 From: zcf0508 Date: Thu, 22 Oct 2026 22:18:36 +0800 Subject: [PATCH 03/35] =?UTF-8?q?refactor(caddy):=20=E9=80=9A=E8=BF=87=20h?= =?UTF-8?q?elper=20=E5=90=AF=E5=81=9C=20Caddy=EF=BC=8C=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E6=95=B4=E8=BF=9B=E7=A8=8B=20sudo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原先要求用户执行 sudo -E npm run dev,导致 node_modules、.vite 与 pnpm store 全被 root 写坏。现在主进程保持用户身份,只有 helper 子进程提权。 - CaddyInstant 经 helper 启停 Caddy,会话断开后自动重连 - caddy.start 失败时回滚已写入的 hosts 条目 - 移除 isAdmin 闸门,不再要求整个 dev server 以管理员运行 - 移除 npx @maxbo/free-port,改为 helper 内按 pid 释放 80/443 - 移除对 /etc/hosts 的 chmod 777,不再改动系统文件权限 - Caddyfile 增加 skip_install_trust,本地 CA 由 helper 显式安装 --- src/caddy/caddyfile.ts | 1 + src/caddy/index.ts | 293 +++++++++++++++++++++++++--------------- src/caddy/privileged.ts | 118 ++++++++++++++++ src/caddy/utils.ts | 43 ------ src/host/index.ts | 52 ------- src/index.ts | 16 +-- src/nuxt.ts | 7 +- src/utils.ts | 28 ---- 8 files changed, 304 insertions(+), 254 deletions(-) create mode 100644 src/caddy/privileged.ts delete mode 100644 src/host/index.ts diff --git a/src/caddy/caddyfile.ts b/src/caddy/caddyfile.ts index 7693edf..ba01715 100644 --- a/src/caddy/caddyfile.ts +++ b/src/caddy/caddyfile.ts @@ -26,6 +26,7 @@ export function validateTemplateContext(ctx: unknown): typeof CaddyContext.infer export const caddyTemplate = `{ debug auto_https disable_redirects ignore_loaded_certs + skip_install_trust } :7601 { diff --git a/src/caddy/index.ts b/src/caddy/index.ts index 8e7eb9c..c141afc 100644 --- a/src/caddy/index.ts +++ b/src/caddy/index.ts @@ -1,4 +1,6 @@ -import { spawn } from 'node:child_process' +import type { Buffer } from 'node:buffer' +import type { OpenedPrivilegedSession } from './privileged' +import { execFile } from 'node:child_process' import { chmodSync, createWriteStream, existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs' import { readFile, unlink, writeFile } from 'node:fs/promises' import { platform } from 'node:os' @@ -9,25 +11,23 @@ import { got } from 'got-cjs' import { HttpProxyAgent } from 'http-proxy-agent' import { HttpsProxyAgent } from 'https-proxy-agent' import * as lockfile from 'proper-lockfile' -import { addHost, removeHost } from '../host' -import { chmodRecursive, consola, once } from '../utils' +import { consola, once } from '../utils' import { genCaddyfile, validateTemplateContext } from './caddyfile' import { useCaddyConfig } from './config' import { caddyFilePath, caddyLockFilePath, caddyPath, supportList, TEMP_DIR } from './constants' import { isCaddyReloadCancellation } from './log' +import { HelperCaddyProcess, openPrivilegedSession } from './privileged' import { formatTlsHealthFailure, waitForLocalTls } from './tls-health' -import { kill, logProgress, logProgressOver, tryPort } from './utils' +import { logProgress, logProgressOver } from './utils' export async function download(): Promise { if (await testCaddy()) return caddyPath return new Promise((resolve, reject) => { - // https://caddyserver.com/api/download?os=darwin&arch=amd64 - if (!existsSync(TEMP_DIR)) { mkdirSync(TEMP_DIR, { recursive: true }) - chmodSync(TEMP_DIR, 0o777) + chmodSync(TEMP_DIR, 0o700) } const file = createWriteStream(caddyPath) @@ -55,8 +55,7 @@ export async function download(): Promise { const httpsAgent = httpsProxy ? new HttpsProxyAgent(httpsProxy) : undefined const chmodCaddyOnce = once(() => { - // chmod +x - chmodSync(caddyPath, 0o777) + chmodSync(caddyPath, 0o755) }) got.stream(dowmloadLink, { @@ -71,30 +70,32 @@ export async function download(): Promise { logProgressOver() if (process.platform === 'win32') return resolve(caddyPath) - // chmod +x - chmodSync(caddyPath, 0o777) + chmodSync(caddyPath, 0o755) resolve(caddyPath) }).on('error', (err) => { - // consola.error(err) reject(err) }) }) } -function testCaddy(): Promise { - return new Promise((resolve, reject) => { +/** + * Checks for a usable binary by running it with no arguments: Caddy prints its + * usage and exits, so this neither binds a port nor needs privileges. + */ +export function testCaddy(): Promise { + return new Promise((resolve) => { if (!existsSync(caddyPath)) return resolve(false) - chmodSync(caddyPath, 0o777) - const child = process.platform === 'win32' ? spawn(caddyPath, []) : spawn('sudo', ['-E', caddyPath]) - child.on('close', () => { - return resolve(false) - }) - child.on('error', (err) => { - return reject(err) - }) - child.stdout.on('data', (_data) => { - return resolve(true) + + try { + chmodSync(caddyPath, 0o755) + } + catch {} + + execFile(caddyPath, [], { timeout: 10_000 }, (error, stdout) => { + if (error && !stdout) + return resolve(false) + resolve(stdout.length > 0) }) }) } @@ -129,32 +130,67 @@ function splitHostPort(addr: string): { host: string, port: string } { return { host: addr.slice(0, lastColon), port: addr.slice(lastColon + 1) } } +/** Injection point for tests: opens the privileged helper without sudo. */ +export interface CaddyDependencies { + openSession: (runtimeDir: string) => Promise +} + +const defaultDependencies: CaddyDependencies = { + openSession: runtimeDir => openPrivilegedSession(runtimeDir, { + log: message => consola.debug(message), + // Both paths are fixed here, before any socket traffic: the privileged + // helper must never execute something named in a request. + caddyBin: caddyPath, + caddyConfigPath: caddyFilePath, + }), +} + export class CaddyInstant { private inited = false private stoped = false + private readonly deps: CaddyDependencies /** has locked when start */ private locked = false private caddyfile: string | undefined - private _caddyChild: ReturnType | undefined + private _caddyChild: HelperCaddyProcess | undefined + private _session: OpenedPrivilegedSession | undefined private _source: string | undefined private _target: string | undefined - private _stopEffects: Array< (() => void)> = [] - - constructor() { - testCaddy().then(async () => { - this.inited = true - }) + private _stopEffects: Array<(() => void)> = [] + constructor(deps: Partial = {}) { + this.deps = { ...defaultDependencies, ...deps } this.locked = this.checkLock() } checkLock(): boolean { if (!existsSync(caddyLockFilePath)) writeFileSync(caddyLockFilePath, '') - return lockfile.checkSync(caddyLockFilePath) + try { + return lockfile.checkSync(caddyLockFilePath) + } + catch { + return false + } + } + + /** + * Connects to the privileged helper, starting it once if needed. It is + * reused across instances so a second dev server does not ask for a + * password again. + */ + private async session(): Promise { + // A crashed helper leaves the cached client permanently disconnected; drop + // it so the next call starts a fresh helper instead of failing forever. + if (this._session && !this._session.client.connected) + this._session = undefined + + if (!this._session) + this._session = await this.deps.openSession(TEMP_DIR) + return this._session } async getCaddyfile(): Promise { @@ -189,6 +225,34 @@ export class CaddyInstant { this.inited = true } + /** + * Hands the Caddy storage directory back to the developer. Local CA data + * created by an earlier root-owned run must stay readable for `tls internal` + * to keep serving the same certificate the browser already trusts. + */ + /** + * Installs Caddy's local root CA so the browser stops warning about + * `tls internal` certificates. Generating the CA and trusting it are separate + * steps: a new CA appears whenever the previous one expires or the storage is + * replaced, so this runs on every start instead of once. + * + * The helper owns both the binary path and the command, so nothing here can + * be turned into an arbitrary root execution. + */ + private async trustLocalCa(session: OpenedPrivilegedSession): Promise { + try { + const result = await session.client.call('ca.trust') + if (result.trusted) + consola.debug(`Caddy local CA is trusted\n${result.output}`) + } + catch (error) { + consola.warn( + `could not install the Caddy root certificate automatically: ${(error as Error).message}\n` + + `Run \`${caddyPath} trust\` manually if the browser warns about the certificate.`, + ) + } + } + private async ensureTlsReady(targetHost: string): Promise { const health = await waitForLocalTls(targetHost) if (!health.healthy) @@ -249,11 +313,13 @@ export class CaddyInstant { if (!this.inited) await this.init() + const session = await this.session() + const sourceHost = splitHostPort(source).host const targetHost = splitHostPort(target).host - if (!await addHost(sourceHost, targetHost)) - throw new Error('update host failed') + await session.client.call('hosts.add', { ip: sourceHost, host: targetHost }) consola.success('update host success\n') + const normalizeBase = (b: string): string => { let out = b || '/' if (!out.startsWith('/')) @@ -296,10 +362,10 @@ export class CaddyInstant { writeFileSync(caddyLockFilePath, '') if (!this.locked) { - if (await tryPort(443)) - await kill(443) - if ((process.platform === 'win32' || !https) && await tryPort(80)) - await kill(80) + // Ports 80/443 may still be held by a previous, uncleanly stopped run. + await session.client.call('port.free', { port: 443 }) + if (process.platform === 'win32' || !https) + await session.client.call('port.free', { port: 80 }) } return new Promise((resolve, reject) => { @@ -311,8 +377,13 @@ export class CaddyInstant { startupCheckStarted = true try { - if (https) + if (https) { + // `caddy trust` reads the CA through the admin API, so it can only + // run once Caddy is serving. The helper does it as root because the + // system trust store is not writable by the developer account. + await this.trustLocalCa(session) await this.ensureTlsReady(targetHost) + } resolve() } catch (error) { @@ -344,25 +415,6 @@ export class CaddyInstant { // @ts-expect-error override process.exit = async (code?: number) => { - const port = Number(splitHostPort(source).port) - if (!Number.isNaN(port) && await tryPort(port)) - await kill(port) - - // fix `Error: EPERM: operation not permitted` - const pwd = process.cwd() - const viteCacheDir = `${pwd}/node_modules/.vite` - if (existsSync(viteCacheDir)) - await chmodRecursive(viteCacheDir, 0o777).catch(() => { }) - const viteTempCacheDir = `${pwd}/node_modules/.vite-temp` - if (existsSync(viteTempCacheDir)) - await chmodRecursive(viteTempCacheDir, 0o777).catch(() => { }) - const nuxtCacheDir = `${pwd}/.nuxt` - if (existsSync(nuxtCacheDir)) - await chmodRecursive(nuxtCacheDir, 0o777).catch(() => { }) - const webpackCacheDir = `${pwd}/node_modules/.cache` - if (existsSync(webpackCacheDir)) - await chmodRecursive(webpackCacheDir, 0o777).catch(() => { }) - if (this.stoped) return originalExit(code) @@ -382,56 +434,67 @@ export class CaddyInstant { } if (!this.locked) { - this._caddyChild = process.platform !== 'win32' - ? spawn('sudo', ['-E', caddyPath, 'run', '--config', caddyFilePath, '--watch']) - : spawn(caddyPath, ['run', '--config', caddyFilePath, '--watch']) - lockfile.lockSync(caddyLockFilePath) + setupCleanup() - this._caddyChild!.on('error', (err) => { - return reject(err) - }) + void (async () => { + try { + const child = new HelperCaddyProcess(session.client) + this._caddyChild = child - this._caddyChild!.stderr?.on('data', async (data) => { - const lines = (data.toString() as string).split('\n').map(line => line.trim()) - for (const line of lines) { - // caddy log - // eslint-disable-next-line no-console - showCaddyLog && line && console.info(line) - if (isCaddyReloadCancellation(line)) { - consola.info('Caddy restarted before TLS issuance completed; retrying.\n') - continue - } - try { - if (line.includes('Error:') || (line && JSON.parse(line).level === 'error')) { - if (line.includes('parsing caddyfile tokens')) { - consola.error('Caddyfile syntax error, please contact the author:\n', `${this.caddyfile}\n\n${line}\n`) - await this.baseCleanup() - process.exit() + await session.client.call('caddy.start') + + child.on('exit', (code: number | null) => { + if (code !== null && code !== 0) + reject(new Error(`Caddy exited with code ${code}`)) + }) + + child.stderr.on('data', async (data: Buffer) => { + const lines = data.toString().split('\n').map((line: string) => line.trim()) + for (const line of lines) { + // caddy log + // eslint-disable-next-line no-console + showCaddyLog && line && console.info(line) + if (isCaddyReloadCancellation(line)) { + consola.info('Caddy restarted before TLS issuance completed; retrying.\n') + continue } - else { - consola.error(`${line}\n`) - // child.kill() - return reject(line) + try { + if (line.includes('Error:') || (line && JSON.parse(line).level === 'error')) { + if (line.includes('parsing caddyfile tokens')) { + consola.error('Caddyfile syntax error, please contact the author:\n', `${this.caddyfile}\n\n${line}\n`) + await this.baseCleanup() + process.exit() + } + else { + consola.error(`${line}\n`) + return reject(line) + } + } } - } - } - catch {} + catch {} - if (line.includes('config file changed')) - consola.success('Detect added a new reverse proxy config, restarted.\n') + if (line.includes('config file changed')) + consola.success('Detect added a new reverse proxy config, restarted.\n') - if (line.includes('caddy proxying') || line.includes('serving initial')) + if (line.includes('caddy proxying') || line.includes('serving initial')) + void resolveWhenReady() + } + }) + + child.stdout.on('data', (data: Buffer) => { + consola.info(data.toString()) void resolveWhenReady() + }) } - }) - - this._caddyChild!.stdout?.on('data', (_data) => { - consola.info(_data.toString()) - void resolveWhenReady() - }) - - setupCleanup() + catch (error) { + // The hosts entry was written before Caddy started; a failed start + // must not leave it behind while the dev server keeps running. + if (!this.locked) + await this.baseCleanup(restore) + reject(error) + } + })() } else { consola.info('Caddy is running in another process.') @@ -453,26 +516,36 @@ export class CaddyInstant { async baseCleanup(restore: boolean = true): Promise { this._stopEffects.forEach(fn => fn()) + this._stopEffects.length = 0 try { - if (restore && this._source && this._target) { - if (await removeHost(splitHostPort(this._source).host, splitHostPort(this._target).host)) - consola.success('Restore host success.\n') - - else - consola.fail('Restore host failed.\n') + if (restore && this._source && this._target && this._session) { + const sourceHost = splitHostPort(this._source).host + const targetHost = splitHostPort(this._target).host + await this._session.client.call('hosts.remove', { ip: sourceHost, host: targetHost }) + if (sourceHost === 'localhost') + await this._session.client.call('hosts.remove', { ip: '127.0.0.1', host: targetHost }) + consola.success('Restore host success.\n') } } catch (e) { - consola.error(e) - consola.fail('Restore host failed.\n') + // The helper restores its own entries when the connection drops, so a + // closed session here is not a failed cleanup. + if (this._session?.client.connected === false) { + consola.debug('hosts entries are restored by the privileged helper on disconnect') + } + else { + consola.error(e) + consola.fail('Restore host failed.\n') + } } + if (!this.locked && !this.stoped) { await this.stopCaddy() try { - await lockfile.unlock(caddyLockFilePath).catch(() => { }) - await unlink(caddyFilePath).catch(() => { }) - await unlink(caddyLockFilePath).catch(() => { }) + await lockfile.unlock(caddyLockFilePath).catch(() => {}) + await unlink(caddyFilePath).catch(() => {}) + await unlink(caddyLockFilePath).catch(() => {}) if (caddyConfig) { await caddyConfig.cleanUp() } diff --git a/src/caddy/privileged.ts b/src/caddy/privileged.ts new file mode 100644 index 0000000..245aa42 --- /dev/null +++ b/src/caddy/privileged.ts @@ -0,0 +1,118 @@ +import type { PrivilegedClient } from '../privileged/client' +import type { HelperEvent } from '../privileged/protocol' +import { Buffer } from 'node:buffer' +import { EventEmitter } from 'node:events' +import { dirname, join } from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { resolveHelperEntry } from '../privileged/entry' +import { openHelperSession } from '../privileged/session' + +export interface PrivilegedEnv { + /** Executable started as root, resolved from the running bundle. */ + helperEntry: string + helperArgs: string[] +} + +/** + * Resolves what the helper needs to be started from the current bundle. + * `__dirname` exists in both the CJS and ESM outputs (tsup injects a shim). + */ +export function resolvePrivilegedEnv(): PrivilegedEnv { + const moduleDir = typeof __dirname === 'string' + ? __dirname + : dirname(fileURLToPath(import.meta.url)) + const resolved = resolveHelperEntry({ moduleDir }) + return { helperEntry: resolved.entry, helperArgs: resolved.args } +} + +/** Re-emits helper events as the `stdout`/`stderr` events the log parsing expects. */ +export class HelperCaddyProcess extends EventEmitter { + readonly stdout = new EventEmitter() + readonly stderr = new EventEmitter() + private finished = false + private readonly unsubscribe: () => void + + constructor(private readonly client: PrivilegedClient) { + super() + this.unsubscribe = client.onEvent(event => this.handleEvent(event)) + } + + private handleEvent(event: HelperEvent): void { + if (event.type !== 'event') + return + + if (event.kind === 'caddy.stdout') { + this.stdout.emit('data', Buffer.from(event.data)) + return + } + + if (event.kind === 'caddy.stderr') { + this.stderr.emit('data', Buffer.from(event.data)) + return + } + + if (event.kind === 'caddy.exit') { + this.finished = true + this.emit('exit', event.code, event.signal) + } + } + + /** Asks the helper to stop Caddy; the helper also stops it on disconnect. */ + kill(): void { + this.unsubscribe() + if (this.finished) + return + this.finished = true + void this.client.call('caddy.stop').catch(() => {}) + } +} + +export interface OpenedPrivilegedSession { + client: PrivilegedClient + reused: boolean + close: () => Promise +} + +export interface PrivilegedSessionOptions { + log?: (message: string) => void + /** Runs the helper as the current user; tests use this to avoid sudo. */ + direct?: boolean + /** Caddy binary the helper may run; fixed before the session opens. */ + caddyBin: string + /** Caddyfile the helper may load. */ + caddyConfigPath: string +} + +/** Where Caddy keeps its PKI, per platform. */ +export function caddyStoragePath(): string { + return process.platform === 'win32' + ? join(process.env.AppData ?? '', 'Caddy') + : join(process.env.HOME ?? '', 'Library/Application Support/Caddy') +} + +/** + * Opens (or reuses) the privileged helper for this runtime directory. + * Only the helper runs as root: the plugin process, its caches and its + * `node_modules` keep the developer's identity. + */ +export function openPrivilegedSession( + runtimeDir: string, + options: PrivilegedSessionOptions, +): Promise { + const env = resolvePrivilegedEnv() + return openHelperSession({ + runtimeDir, + helperEntry: env.helperEntry, + helperArgs: env.helperArgs, + direct: options.direct, + // Caddy keeps its PKI under the user's home; the helper runs it as root, so + // it must know which tree to hand back afterwards. + caddyStoragePath: caddyStoragePath(), + // The binary and Caddyfile are fixed here: the privileged helper must never + // execute a path supplied over the socket. + caddyBin: options.caddyBin, + caddyConfigPath: options.caddyConfigPath, + log: options.log, + }) +} diff --git a/src/caddy/utils.ts b/src/caddy/utils.ts index 33e3abd..7c27cb0 100644 --- a/src/caddy/utils.ts +++ b/src/caddy/utils.ts @@ -1,51 +1,8 @@ -import { exec } from 'node:child_process' -import { createServer } from 'node:net' import ora from 'ora' import { consola } from '../utils' let spinner: ora.Ora -/** - * @returns whether the port is in use - */ -export function tryPort(port: number): Promise { - return new Promise((resolve, _reject) => { - try { - const server = createServer().listen(port) - server.on('listening', () => { - server.close() - resolve(false) - }) - server.on('error', (err) => { - // @ts-expect-error err.code - if (err.code === 'EADDRINUSE') - resolve(true) - server.close() - }) - } - catch { - resolve(true) - } - }) -} - -/** - * npx free-port :port - */ -export async function kill(port: number): Promise { - return new Promise((resolve, reject) => { - exec(`npx @maxbo/free-port ${port} -s`, (err, stdout, _stderr) => { - if (err) { - reject(err) - } - else { - consola.log(stdout) - resolve() - } - }) - }) -} - function getNumber(number: number): string { if (number <= 0) return '0%' diff --git a/src/host/index.ts b/src/host/index.ts deleted file mode 100644 index 2c78e1f..0000000 --- a/src/host/index.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { exec } from 'node:child_process' -import { chmodSync } from 'node:fs' -import process from 'node:process' -import { promisify } from 'node:util' - -import hostile from 'hostile' -import { consola } from '../utils' - -const _setHost = promisify(hostile.set) -const _removeHost = promisify(hostile.remove) - -function getPathOfSystemHostsPath(): string { - return process.platform === 'win32' - ? `${process.env.windir || 'C:\\WINDOWS'}\\system32\\drivers\\etc\\hosts` - : '/etc/hosts' -} - -async function syncHost(): Promise { - return new Promise((resolve, _reject) => { - exec( - process.platform === 'win32' - ? 'ipconfig /flushdns' - : 'sudo -E killall -HUP mDNSResponder', - (error, _stdout, _stderr) => { - if (error) { - consola.error(`exec error: ${error}`) - return resolve(false) - } - return resolve(true) - }, - ) - }) -} - -export async function addHost(ip: string, host: string): Promise { - if (process.platform !== 'win32') - chmodSync(getPathOfSystemHostsPath(), 0o777) - await _setHost('127.0.0.1', 'localhost') - await _setHost(ip, host) - if (ip === 'localhost') - await _setHost('127.0.0.1', host) - return await syncHost() -} - -export async function removeHost(ip: string, host: string): Promise { - if (process.platform !== 'win32') - chmodSync(getPathOfSystemHostsPath(), 0o777) - await _removeHost(ip, host) - if (ip === 'localhost') - await _removeHost('127.0.0.1', host) - return await syncHost() -} diff --git a/src/index.ts b/src/index.ts index 47c250f..ace5516 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,7 @@ import c from 'picocolors' import { createUnplugin } from 'unplugin' import { CaddyInstant } from './caddy' import { hmrSseBridgeClientScript, setupHmrSseBridge } from './hmr-sse-bridge' -import { consola, generateQrcode, getIpv4List, isAdmin, once } from './utils' +import { consola, generateQrcode, getIpv4List, once } from './utils' let config: ResolvedConfig @@ -96,10 +96,6 @@ export const unpluginFactory: UnpluginFactory = options => ({ if (!enable) return - if (!isAdmin()) { - consola.warn('please run as administrator') - return - } if (!target) { consola.fail('please provide target') return @@ -153,11 +149,6 @@ export const unpluginFactory: UnpluginFactory = options => ({ if (!enable) return - if (!isAdmin()) { - consola.warn('please run as administrator') - return - } - if (!target) { consola.fail('please provide target') return @@ -231,11 +222,6 @@ export const unpluginFactory: UnpluginFactory = options => ({ if (!enable) return - if (!isAdmin()) { - consola.warn('please run as administrator') - return - } - if (!target) { consola.fail('please provide target') return diff --git a/src/nuxt.ts b/src/nuxt.ts index 9685d94..a6d16c5 100644 --- a/src/nuxt.ts +++ b/src/nuxt.ts @@ -2,7 +2,7 @@ import type { Options } from './types' import process from 'node:process' import { defineNuxtModule } from '@nuxt/kit' import { vitePrintUrls } from '.' -import { consola, isAdmin } from './utils' +import { consola } from './utils' import '@nuxt/schema' export interface ModuleOptions extends Options { @@ -26,11 +26,6 @@ export default defineNuxtModule({ if (!enable) return - if (!isAdmin()) { - consola.warn('please run as administrator') - return - } - if (!target) { consola.fail('please provide target') return diff --git a/src/utils.ts b/src/utils.ts index e808844..b2974b3 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,8 +1,4 @@ -import { execSync } from 'node:child_process' -import { chmod, readdir, stat } from 'node:fs/promises' import { networkInterfaces } from 'node:os' -import path from 'node:path' -import process from 'node:process' import { createConsola } from 'consola' import c from 'picocolors' import qrcode from 'qrcode-terminal' @@ -11,15 +7,6 @@ export const consola = createConsola({ level: 3, }).withTag('reverse-proxy') -export async function chmodRecursive(_path: string, mode: number): Promise { - await chmod(_path, mode) - if ((await stat(_path)).isDirectory()) { - const children = await readdir(_path) - for (const child of children) - await chmodRecursive(path.join(_path, child), mode) - } -} - export function once any>(fn: T): (...args: Parameters) => ReturnType { let called = false return (...args: any[]) => { @@ -30,21 +17,6 @@ export function once any>(fn: T): (...args: Parame } } -export function isAdmin(): boolean { - if (process.platform === 'win32') { - try { - execSync('net session', { stdio: 'ignore' }) - return true - } - catch { - return false - } - } - else { - return !!(process.getuid && process.getuid() === 0) - } -} - export function getIpv4List(): string[] { const interfaces = networkInterfaces() From fa9f452945ac14df756821e04795465a4167f2fe Mon Sep 17 00:00:00 2001 From: zcf0508 Date: Thu, 22 Oct 2026 22:18:41 +0800 Subject: [PATCH 04/35] =?UTF-8?q?test(privileged):=20=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E7=89=B9=E6=9D=83=E5=B1=82=E4=B8=8E=20Caddy=20=E9=9B=86?= =?UTF-8?q?=E6=88=90=EF=BC=8C=E6=B8=85=E7=90=86=E6=97=A0=E7=94=A8=E4=BE=9D?= =?UTF-8?q?=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hosts 编辑、端口解析、协议编解码的单元测试 - helper 运行时的原语路由、断连清理、代次隔离 - socket 握手、token 校验、崩溃遗留清理的集成测试 - CaddyInstant 通过伪造会话验证调用序列与失败回滚 - 移除 hostile、@types/hostile、@types/kill-port --- package.json | 3 - pnpm-lock.yaml | 175 +++----------- test/caddy/privileged-session.test.ts | 228 ++++++++++++++++++ test/privileged/caddy-trust.test.ts | 45 ++++ test/privileged/concurrent-hosts.test.ts | 63 +++++ test/privileged/dns-flush-commands.test.ts | 29 +++ test/privileged/hosts-file.test.ts | 87 +++++++ test/privileged/ports.test.ts | 38 +++ test/privileged/protocol-client.test.ts | 187 +++++++++++++++ test/privileged/runtime.test.ts | 252 ++++++++++++++++++++ test/privileged/storage-handback.test.ts | 65 +++++ test/privileged/teardown-generation.test.ts | 65 +++++ test/privileged/windows-elevation.test.ts | 42 ++++ 13 files changed, 1140 insertions(+), 139 deletions(-) create mode 100644 test/caddy/privileged-session.test.ts create mode 100644 test/privileged/caddy-trust.test.ts create mode 100644 test/privileged/concurrent-hosts.test.ts create mode 100644 test/privileged/dns-flush-commands.test.ts create mode 100644 test/privileged/hosts-file.test.ts create mode 100644 test/privileged/ports.test.ts create mode 100644 test/privileged/protocol-client.test.ts create mode 100644 test/privileged/runtime.test.ts create mode 100644 test/privileged/storage-handback.test.ts create mode 100644 test/privileged/teardown-generation.test.ts create mode 100644 test/privileged/windows-elevation.test.ts diff --git a/package.json b/package.json index 8670d4d..1e3e6e5 100644 --- a/package.json +++ b/package.json @@ -135,7 +135,6 @@ "console-table-printer": "^2.12.1", "dotenv-flow": "^4.1.0", "got-cjs": "^12.5.4", - "hostile": "^1.4.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "ora": "5", @@ -149,8 +148,6 @@ "@nuxt/schema": "^3.16.1", "@rsbuild/core": "^0.4.15", "@rspack/core": "^0.5.9", - "@types/hostile": "^1.3.5", - "@types/kill-port": "^2.0.3", "@types/node": "^20.11.19", "@types/proper-lockfile": "^4.1.4", "@types/qrcode-terminal": "^0.12.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff4d42e..e1e9b7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,9 +23,6 @@ importers: got-cjs: specifier: ^12.5.4 version: 12.5.4 - hostile: - specifier: ^1.4.0 - version: 1.4.0 http-proxy-agent: specifier: ^7.0.2 version: 7.0.2 @@ -60,12 +57,6 @@ importers: '@rspack/core': specifier: ^0.5.9 version: 0.5.9(@swc/helpers@0.5.3) - '@types/hostile': - specifier: ^1.3.5 - version: 1.3.5 - '@types/kill-port': - specifier: ^2.0.3 - version: 2.0.3 '@types/node': specifier: ^20.11.19 version: 20.11.19 @@ -137,7 +128,7 @@ importers: devDependencies: nuxt: specifier: ^3.10.2 - version: 3.10.2(@parcel/watcher@2.3.0)(@types/node@20.11.19)(encoding@0.1.13)(eslint@9.22.0(jiti@2.4.2))(optionator@0.9.3)(rollup@4.9.4)(terser@5.27.2)(typescript@5.8.2)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)) + version: 3.10.2(@parcel/watcher@2.3.0)(@types/node@20.11.19)(encoding@0.1.13)(eslint@9.22.0(jiti@2.4.2))(magicast@0.3.2)(optionator@0.9.3)(rollup@4.9.4)(terser@5.27.2)(typescript@5.8.2)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)) vue: specifier: ^3.4.19 version: 3.4.19(typescript@5.8.2) @@ -1889,10 +1880,6 @@ packages: '@leichtgewicht/ip-codec@2.0.4': resolution: {integrity: sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==} - '@ljharb/through@2.3.13': - resolution: {integrity: sha512-/gKJun8NNiWGZJkGzI/Ragc53cOdcLNdzjLaIa+GEjguQs0ulsurx8WN0jijdK9yPqDvziX995sMRLyLt1uZMQ==} - engines: {node: '>= 0.4'} - '@mapbox/node-pre-gyp@1.0.11': resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==} hasBin: true @@ -2055,30 +2042,35 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm64-glibc@2.3.0': resolution: {integrity: sha512-mQ0gBSQEiq1k/MMkgcSB0Ic47UORZBmWoAWlMrTW6nbAGoLZP+h7AtUM7H3oDu34TBFFvjy4JCGP43JlylkTQA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.3.0': resolution: {integrity: sha512-LXZAExpepJew0Gp8ZkJ+xDZaTQjLHv48h0p0Vw2VMFQ8A+RKrAvpFuPVCVwKJCr5SE+zvaG+Etg56qXvTDIedw==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [musl] '@parcel/watcher-linux-x64-glibc@2.3.0': resolution: {integrity: sha512-P7Wo91lKSeSgMTtG7CnBS6WrA5otr1K7shhSjKHNePVmfBHDoAOHYRXgUmhiNfbcGk0uMCHVcdbfxtuiZCHVow==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-x64-musl@2.3.0': resolution: {integrity: sha512-+kiRE1JIq8QdxzwoYY+wzBs9YbJ34guBweTK8nlzLKimn5EQ2b2FSC+tAOpq302BuIMjyuUGvBiUhEcLIGMQ5g==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [musl] '@parcel/watcher-wasm@2.3.0': resolution: {integrity: sha512-ejBAX8H0ZGsD8lSICDNyMbSEtPMWgDL0WFCt/0z7hyf5v8Imz4rAM8xY379mBsECkq/Wdqa5WEDLqtjZ+6NxfA==} @@ -2266,26 +2258,31 @@ packages: resolution: {integrity: sha512-TVYVWD/SYwWzGGnbfTkrNpdE4HON46orgMNHCivlXmlsSGQOx/OHHYiQcMIOx38/GWgwr/po2LBn7wypkWw/Mg==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.9.4': resolution: {integrity: sha512-XcKvuendwizYYhFxpvQ3xVpzje2HHImzg33wL9zvxtj77HvPStbSGI9czrdbfrf8DGMcNNReH9pVZv8qejAQ5A==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.9.4': resolution: {integrity: sha512-LFHS/8Q+I9YA0yVETyjonMJ3UA+DczeBd/MqNEzsGSTdNvSJa1OJZcSH8GiXLvcizgp9AlHs2walqRcqzjOi3A==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.9.4': resolution: {integrity: sha512-dIYgo+j1+yfy81i0YVU5KnQrIJZE8ERomx17ReU4GREjGtDW4X+nvkBak2xAUpyqLs4eleDSj3RrV72fQos7zw==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.9.4': resolution: {integrity: sha512-RoaYxjdHQ5TPjaPrLsfKqR3pakMr3JGqZ+jZM0zP2IkDtsGa4CqYaWSfQmZVgFUCgLrTnzX+cnHS3nfl+kB6ZQ==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.9.4': resolution: {integrity: sha512-T8Q3XHV+Jjf5e49B4EAaLKV74BbX7/qYBRQ8Wop/+TyyU0k+vSjiLVSHNWdVd1goMjZcbhDmYZUYW5RFqkBNHQ==} @@ -2357,61 +2354,73 @@ packages: resolution: {integrity: sha512-mXtRKCblBT+H1KPWUfeJt6gQFGoMt+lnhk2POcoCeS1AxnxcTFpnci4BC4Ro5zKS2QWSdGdUMtc5GKlBmgwxvg==} cpu: [arm64] os: [linux] + libc: [glibc] '@rspack/binding-linux-arm64-gnu@0.5.7': resolution: {integrity: sha512-JB9FAYWjYAeNCPFh0mQu3SZdFHiA+EY37z1AktLDl789SoEec2HPGkvvOs+OIET1pKWgjUGD4Z4Uq4P/r5JFNA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rspack/binding-linux-arm64-gnu@0.5.9': resolution: {integrity: sha512-sc0fp4CEt3sgafrDUV5hTLDErlarmsazPGkxdJPb5TGVYjiKaYxM6AtH1ZTEDP1DmDtCvBUutfbdO6pzXFDGSA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rspack/binding-linux-arm64-musl@0.5.4': resolution: {integrity: sha512-P96R8yLT4BKtwYCtomIJE4uIGAh+5I8qLbrTrGamj/6N1D79GgwORW6CllCEnVU9l/Tjkdd+yMJkT9zoACa9gQ==} cpu: [arm64] os: [linux] + libc: [musl] '@rspack/binding-linux-arm64-musl@0.5.7': resolution: {integrity: sha512-3fNhPvA9Kj/L7rwr2Pj1bvxWBLBgqfkqSvt91iUxPbxgfTiSBQh0Tfb9+hkHv2VCTyNQI/vytkOH+4i4DNXCBw==} cpu: [arm64] os: [linux] + libc: [musl] '@rspack/binding-linux-arm64-musl@0.5.9': resolution: {integrity: sha512-clhkNsNi76pTYtZz1U95R36AdZZH2eXbBWb0g17okCt4aXfJoHWIViDvHWvVmU9318repxwww3rR0ImbLskZiw==} cpu: [arm64] os: [linux] + libc: [musl] '@rspack/binding-linux-x64-gnu@0.5.4': resolution: {integrity: sha512-/EjM7CkALS7uUF0laVp+wtOICrX2sR5gy4liIYVHKDLu+b4PGRtEQvubrDxikkzPpOYRvF38R7OBMUOJBuBW7A==} cpu: [x64] os: [linux] + libc: [glibc] '@rspack/binding-linux-x64-gnu@0.5.7': resolution: {integrity: sha512-y/GnXt1hhbKSqzBSy+ALWwievlejQhIIF8FPXL1kKFh60zl7DE+iYHSJ128jIJiph9dQkBnHw0ABJ5D+vbSqdA==} cpu: [x64] os: [linux] + libc: [glibc] '@rspack/binding-linux-x64-gnu@0.5.9': resolution: {integrity: sha512-bC69vwrG9R/kdpEuDXP/IleedCWR97d0GRLJFAapc2rPoFRI3vnuQVzp8+nX7CA1k2n6tfOJlOvaWLIik3hzXw==} cpu: [x64] os: [linux] + libc: [glibc] '@rspack/binding-linux-x64-musl@0.5.4': resolution: {integrity: sha512-dMT9QW4IZ7IGzczsOmzdpGf84IzIecvitSwj7DnulRkxj3++IWLAo80+HDtgn+nPm+1gNVFb11wg5L9x+VjFXw==} cpu: [x64] os: [linux] + libc: [musl] '@rspack/binding-linux-x64-musl@0.5.7': resolution: {integrity: sha512-US/FUv6cvbxbe4nymINwer/EQTvGEgCaAIrvKuAP0yAfK0eyqIHYZj/zCBM2qOS69Mpc2FWVMC/ftRyCvAz/xw==} cpu: [x64] os: [linux] + libc: [musl] '@rspack/binding-linux-x64-musl@0.5.9': resolution: {integrity: sha512-QGDcWKlLCunBW1/zIvPq6HkIt7920WVhY+ONFGd12owPUEveZTVxWYr64ROJqtIFueWFZYY4FFX6uwCpmH2hrQ==} cpu: [x64] os: [linux] + libc: [musl] '@rspack/binding-win32-arm64-msvc@0.5.4': resolution: {integrity: sha512-SsnOqWRw5VQnbz/63wtKsoyj6lfUpQQZyFWfQAMsNt8suIauWI/kf3QLWL/vmBX5Q24Sq16Kl5cMIjxAIJQfiQ==} @@ -2619,9 +2628,6 @@ packages: '@types/glob@7.2.0': resolution: {integrity: sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==} - '@types/hostile@1.3.5': - resolution: {integrity: sha512-NvElcxcjEEBZdJ2Xfl8GEFx+c79PTCk5q1mlkCBv8ED+I6n3EfCQzQ4x1IbiOM2nFHMqBOe/MTZwLMtn30WYaQ==} - '@types/html-minifier-terser@6.1.0': resolution: {integrity: sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==} @@ -2643,9 +2649,6 @@ packages: '@types/keyv@3.1.4': resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} - '@types/kill-port@2.0.3': - resolution: {integrity: sha512-ZHs59e5FBjDLQLOxM48+814LSyNf5sgpi0odtJ0FH6xrIAZXb4yksYG+4mZCbidX3fBOfHytAKAVMgkWvv/Piw==} - '@types/long@4.0.2': resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==} @@ -2833,21 +2836,25 @@ packages: resolution: {integrity: sha512-fp4Azi8kHz6TX8SFmKfyScZrMLfp++uRm2srpqRjsRZIIBzH74NtSkdEUHImR4G7f7XJ+sVZjCc6KDDK04YEpQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/rspack-resolver-binding-linux-arm64-musl@1.2.2': resolution: {integrity: sha512-gMiG3DCFioJxdGBzhlL86KcFgt9HGz0iDhw0YVYPsShItpN5pqIkNrI+L/Q/0gfDiGrfcE0X3VANSYIPmqEAlQ==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/rspack-resolver-binding-linux-x64-gnu@1.2.2': resolution: {integrity: sha512-n/4n2CxaUF9tcaJxEaZm+lqvaw2gflfWQ1R9I7WQgYkKEKbRKbpG/R3hopYdUmLSRI4xaW1Cy0Bz40eS2Yi4Sw==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/rspack-resolver-binding-linux-x64-musl@1.2.2': resolution: {integrity: sha512-cHyhAr6rlYYbon1L2Ag449YCj3p6XMfcYTP0AQX+KkQo025d1y/VFtPWvjMhuEsE2lLvtHm7GdJozj6BOMtzVg==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/rspack-resolver-binding-wasm32-wasi@1.2.2': resolution: {integrity: sha512-eogDKuICghDLGc32FtP+WniG38IB1RcGOGz0G3z8406dUdjJvxfHGuGs/dSlM9YEp/v0lEqhJ4mBu6X2nL9pog==} @@ -3946,10 +3953,6 @@ packages: call-bind@1.0.5: resolution: {integrity: sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==} - call-bind@1.0.7: - resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==} - engines: {node: '>= 0.4'} - call-me-maybe@1.0.2: resolution: {integrity: sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==} @@ -4905,10 +4908,6 @@ packages: resolution: {integrity: sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==} engines: {node: '>= 0.4'} - define-data-property@1.1.4: - resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} - engines: {node: '>= 0.4'} - define-lazy-prop@2.0.0: resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} engines: {node: '>=8'} @@ -5238,14 +5237,6 @@ packages: es-array-method-boxes-properly@1.0.0: resolution: {integrity: sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==} - es-define-property@1.0.0: - resolution: {integrity: sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - es-module-lexer@1.4.1: resolution: {integrity: sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w==} @@ -5915,10 +5906,6 @@ packages: get-intrinsic@1.2.2: resolution: {integrity: sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==} - get-intrinsic@1.2.4: - resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} - engines: {node: '>= 0.4'} - get-port-please@3.1.2: resolution: {integrity: sha512-Gxc29eLs1fbn6LQ4jSU4vXjlwyZhF5HsGuMAa7gqBP4Rw4yxxltyDUuF5MBclFzDTXO+ACchGQoeela4DSfzdQ==} @@ -6160,9 +6147,6 @@ packages: has-property-descriptors@1.0.1: resolution: {integrity: sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==} - has-property-descriptors@1.0.2: - resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} - has-proto@1.0.1: resolution: {integrity: sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==} engines: {node: '>= 0.4'} @@ -6248,10 +6232,6 @@ packages: resolution: {integrity: sha512-+K84LB1DYwMHoHSgaOY/Jfhw3ucPmSET5v98Ke/HdNSw4a0UktWzyW1mjhjpuxxTqOOsfWT/7iVshHmVZ4IpOA==} engines: {node: ^16.14.0 || >=18.0.0} - hostile@1.4.0: - resolution: {integrity: sha512-q5eniv6NnjeQ2S1Wh3/knyl4UE2FAQ9xz7yT0f6y5FK2j3fFHBI2jyaUVwAiAU20G6LQMAkRGM1WbTMXoeUwMg==} - hasBin: true - hpack.js@2.1.6: resolution: {integrity: sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==} @@ -7197,7 +7177,7 @@ packages: resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} lodash@4.17.21: - resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + resolution: {integrity: sha1-Z5WRxWTDv/quhFTPCz3zcMPWkRw=} log-symbols@2.2.0: resolution: {integrity: sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==} @@ -9510,10 +9490,6 @@ packages: resolution: {integrity: sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==} engines: {node: '>= 0.4'} - set-function-length@1.2.2: - resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} - engines: {node: '>= 0.4'} - set-function-name@2.0.1: resolution: {integrity: sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==} engines: {node: '>= 0.4'} @@ -9555,10 +9531,6 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shell-exec@1.1.2: - resolution: {integrity: sha512-v2NWVDP0ws+S7miKy2oSpJ/OuL6NKuMosPNUZLDWFBlMnBtuoZxZOwxpQJwhsFZgMb+r7frpDTT8p4OSnhkpsg==} - engines: {node: '>=12'} - shell-quote@1.8.1: resolution: {integrity: sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==} @@ -9744,9 +9716,6 @@ packages: resolution: {integrity: sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==} engines: {node: '>=0.10.0'} - split@1.0.1: - resolution: {integrity: sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==} - sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} @@ -10906,7 +10875,7 @@ packages: optional: true vue-loader@16.8.3: - resolution: {integrity: sha512-7vKN45IxsKxe5GcVCbc2qFU5aWzyiLrYJyUuMz4BQLKctCj/fmCa0w6fGiiQ2cLFetNcek1ppGJQDCup0c1hpA==} + resolution: {integrity: sha1-1D5nXe9bqTRdbH8FkUwT2GGZcIc=} peerDependencies: '@vue/compiler-sfc': ^3.0.8 vue: ^3.2.13 @@ -10965,7 +10934,7 @@ packages: optional: true watchpack-chokidar2@2.0.1: - resolution: {integrity: sha512-nCFfBIPKr5Sh61s4LPpy1Wtfi0HE8isJ3d2Yb5/Ppw2P2B/3eVSEBjKfN0fmHJSK14+31KwMKmcrzs2GM4P0Ww==} + resolution: {integrity: sha1-OFAAcu5uzmbzdpk2lQ6hdxvhyVc=} watchpack@1.7.5: resolution: {integrity: sha512-9P3MWk6SrKjHsGkLT2KHXdQ/9SNkyoJbabxnKOoJepsvJjJG8uYTR3yTPxPQvNDI3w4Nz1xnE0TLHK4RIVe/MQ==} @@ -12967,10 +12936,6 @@ snapshots: '@leichtgewicht/ip-codec@2.0.4': {} - '@ljharb/through@2.3.13': - dependencies: - call-bind: 1.0.7 - '@mapbox/node-pre-gyp@1.0.11(encoding@0.1.13)': dependencies: detect-libc: 2.0.2 @@ -13094,12 +13059,12 @@ snapshots: '@nuxt/devalue@2.0.2': {} - '@nuxt/devtools-kit@1.0.8(magicast@0.3.2)(nuxt@3.10.2(@parcel/watcher@2.3.0)(@types/node@20.11.19)(encoding@0.1.13)(eslint@9.22.0(jiti@2.4.2))(optionator@0.9.3)(rollup@4.9.4)(terser@5.27.2)(typescript@5.8.2)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)))(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2))': + '@nuxt/devtools-kit@1.0.8(magicast@0.3.2)(nuxt@3.10.2(@parcel/watcher@2.3.0)(@types/node@20.11.19)(encoding@0.1.13)(eslint@9.22.0(jiti@2.4.2))(magicast@0.3.2)(optionator@0.9.3)(rollup@4.9.4)(terser@5.27.2)(typescript@5.8.2)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)))(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2))': dependencies: '@nuxt/kit': 3.16.1(magicast@0.3.2) '@nuxt/schema': 3.16.1 execa: 7.2.0 - nuxt: 3.10.2(@parcel/watcher@2.3.0)(@types/node@20.11.19)(encoding@0.1.13)(eslint@9.22.0(jiti@2.4.2))(optionator@0.9.3)(rollup@4.9.4)(terser@5.27.2)(typescript@5.8.2)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)) + nuxt: 3.10.2(@parcel/watcher@2.3.0)(@types/node@20.11.19)(encoding@0.1.13)(eslint@9.22.0(jiti@2.4.2))(magicast@0.3.2)(optionator@0.9.3)(rollup@4.9.4)(terser@5.27.2)(typescript@5.8.2)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)) vite: 5.1.1(@types/node@20.11.19)(terser@5.27.2) transitivePeerDependencies: - magicast @@ -13117,10 +13082,10 @@ snapshots: rc9: 2.1.1 semver: 7.6.0 - '@nuxt/devtools@1.0.8(nuxt@3.10.2(@parcel/watcher@2.3.0)(@types/node@20.11.19)(encoding@0.1.13)(eslint@9.22.0(jiti@2.4.2))(optionator@0.9.3)(rollup@4.9.4)(terser@5.27.2)(typescript@5.8.2)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)))(rollup@4.9.4)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2))': + '@nuxt/devtools@1.0.8(nuxt@3.10.2(@parcel/watcher@2.3.0)(@types/node@20.11.19)(encoding@0.1.13)(eslint@9.22.0(jiti@2.4.2))(magicast@0.3.2)(optionator@0.9.3)(rollup@4.9.4)(terser@5.27.2)(typescript@5.8.2)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)))(rollup@4.9.4)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2))': dependencies: '@antfu/utils': 0.7.7 - '@nuxt/devtools-kit': 1.0.8(magicast@0.3.2)(nuxt@3.10.2(@parcel/watcher@2.3.0)(@types/node@20.11.19)(encoding@0.1.13)(eslint@9.22.0(jiti@2.4.2))(optionator@0.9.3)(rollup@4.9.4)(terser@5.27.2)(typescript@5.8.2)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)))(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)) + '@nuxt/devtools-kit': 1.0.8(magicast@0.3.2)(nuxt@3.10.2(@parcel/watcher@2.3.0)(@types/node@20.11.19)(encoding@0.1.13)(eslint@9.22.0(jiti@2.4.2))(magicast@0.3.2)(optionator@0.9.3)(rollup@4.9.4)(terser@5.27.2)(typescript@5.8.2)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)))(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)) '@nuxt/devtools-wizard': 1.0.8 '@nuxt/kit': 3.16.1(magicast@0.3.2) birpc: 0.2.14 @@ -13137,7 +13102,7 @@ snapshots: launch-editor: 2.6.1 local-pkg: 0.5.0 magicast: 0.3.2 - nuxt: 3.10.2(@parcel/watcher@2.3.0)(@types/node@20.11.19)(encoding@0.1.13)(eslint@9.22.0(jiti@2.4.2))(optionator@0.9.3)(rollup@4.9.4)(terser@5.27.2)(typescript@5.8.2)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)) + nuxt: 3.10.2(@parcel/watcher@2.3.0)(@types/node@20.11.19)(encoding@0.1.13)(eslint@9.22.0(jiti@2.4.2))(magicast@0.3.2)(optionator@0.9.3)(rollup@4.9.4)(terser@5.27.2)(typescript@5.8.2)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)) nypm: 0.3.6 ohash: 1.1.3 pacote: 17.0.5 @@ -13237,7 +13202,7 @@ snapshots: pathe: 2.0.3 std-env: 3.8.1 - '@nuxt/telemetry@2.5.3': + '@nuxt/telemetry@2.5.3(magicast@0.3.2)': dependencies: '@nuxt/kit': 3.16.1(magicast@0.3.2) ci-info: 4.0.0 @@ -13902,8 +13867,6 @@ snapshots: '@types/minimatch': 5.1.2 '@types/node': 20.11.19 - '@types/hostile@1.3.5': {} - '@types/html-minifier-terser@6.1.0': {} '@types/http-errors@2.0.4': {} @@ -13928,11 +13891,6 @@ snapshots: dependencies: '@types/node': 20.11.19 - '@types/kill-port@2.0.3': - dependencies: - '@types/node': 20.11.19 - shell-exec: 1.1.2 - '@types/long@4.0.2': {} '@types/mdast@4.0.4': @@ -15968,14 +15926,6 @@ snapshots: get-intrinsic: 1.2.2 set-function-length: 1.1.1 - call-bind@1.0.7: - dependencies: - es-define-property: 1.0.0 - es-errors: 1.3.0 - function-bind: 1.1.2 - get-intrinsic: 1.2.4 - set-function-length: 1.2.2 - call-me-maybe@1.0.2: {} caller-callsite@2.0.0: @@ -16984,12 +16934,6 @@ snapshots: gopd: 1.0.1 has-property-descriptors: 1.0.1 - define-data-property@1.1.4: - dependencies: - es-define-property: 1.0.0 - es-errors: 1.3.0 - gopd: 1.0.1 - define-lazy-prop@2.0.0: {} define-lazy-prop@3.0.0: {} @@ -17353,12 +17297,6 @@ snapshots: es-array-method-boxes-properly@1.0.0: {} - es-define-property@1.0.0: - dependencies: - get-intrinsic: 1.2.4 - - es-errors@1.3.0: {} - es-module-lexer@1.4.1: {} es-module-lexer@1.6.0: {} @@ -18303,14 +18241,6 @@ snapshots: has-symbols: 1.0.3 hasown: 2.0.0 - get-intrinsic@1.2.4: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - has-proto: 1.0.1 - has-symbols: 1.0.3 - hasown: 2.0.0 - get-port-please@3.1.2: {} get-proxy@2.1.0: @@ -18631,10 +18561,6 @@ snapshots: dependencies: get-intrinsic: 1.2.2 - has-property-descriptors@1.0.2: - dependencies: - es-define-property: 1.0.0 - has-proto@1.0.1: {} has-symbol-support-x@1.4.2: {} @@ -18713,14 +18639,6 @@ snapshots: dependencies: lru-cache: 10.0.2 - hostile@1.4.0: - dependencies: - '@ljharb/through': 2.3.13 - chalk: 4.1.2 - minimist: 1.2.8 - once: 1.4.0 - split: 1.0.1 - hpack.js@2.1.6: dependencies: inherits: 2.0.4 @@ -20682,13 +20600,13 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - nuxt@3.10.2(@parcel/watcher@2.3.0)(@types/node@20.11.19)(encoding@0.1.13)(eslint@9.22.0(jiti@2.4.2))(optionator@0.9.3)(rollup@4.9.4)(terser@5.27.2)(typescript@5.8.2)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)): + nuxt@3.10.2(@parcel/watcher@2.3.0)(@types/node@20.11.19)(encoding@0.1.13)(eslint@9.22.0(jiti@2.4.2))(magicast@0.3.2)(optionator@0.9.3)(rollup@4.9.4)(terser@5.27.2)(typescript@5.8.2)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)): dependencies: '@nuxt/devalue': 2.0.2 - '@nuxt/devtools': 1.0.8(nuxt@3.10.2(@parcel/watcher@2.3.0)(@types/node@20.11.19)(encoding@0.1.13)(eslint@9.22.0(jiti@2.4.2))(optionator@0.9.3)(rollup@4.9.4)(terser@5.27.2)(typescript@5.8.2)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)))(rollup@4.9.4)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)) + '@nuxt/devtools': 1.0.8(nuxt@3.10.2(@parcel/watcher@2.3.0)(@types/node@20.11.19)(encoding@0.1.13)(eslint@9.22.0(jiti@2.4.2))(magicast@0.3.2)(optionator@0.9.3)(rollup@4.9.4)(terser@5.27.2)(typescript@5.8.2)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)))(rollup@4.9.4)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)) '@nuxt/kit': 3.10.2(rollup@4.9.4) '@nuxt/schema': 3.10.2(rollup@4.9.4) - '@nuxt/telemetry': 2.5.3 + '@nuxt/telemetry': 2.5.3(magicast@0.3.2) '@nuxt/ui-templates': 1.3.1 '@nuxt/vite-builder': 3.10.2(@types/node@20.11.19)(eslint@9.22.0(jiti@2.4.2))(optionator@0.9.3)(rollup@4.9.4)(terser@5.27.2)(typescript@5.8.2)(vue@3.4.19(typescript@5.8.2)) '@unhead/dom': 1.8.10 @@ -22664,15 +22582,6 @@ snapshots: gopd: 1.0.1 has-property-descriptors: 1.0.1 - set-function-length@1.2.2: - dependencies: - define-data-property: 1.1.4 - es-errors: 1.3.0 - function-bind: 1.1.2 - get-intrinsic: 1.2.4 - gopd: 1.0.1 - has-property-descriptors: 1.0.2 - set-function-name@2.0.1: dependencies: define-data-property: 1.1.1 @@ -22713,8 +22622,6 @@ snapshots: shebang-regex@3.0.0: {} - shell-exec@1.1.2: {} - shell-quote@1.8.1: {} shellwords@0.1.1: {} @@ -22931,10 +22838,6 @@ snapshots: dependencies: extend-shallow: 3.0.2 - split@1.0.1: - dependencies: - through: 2.3.8 - sprintf-js@1.0.3: {} sshpk@1.18.0: diff --git a/test/caddy/privileged-session.test.ts b/test/caddy/privileged-session.test.ts new file mode 100644 index 0000000..2164ed6 --- /dev/null +++ b/test/caddy/privileged-session.test.ts @@ -0,0 +1,228 @@ +import type { CaddyDependencies } from '../../src/caddy' +import type { HelperEvent } from '../../src/privileged/protocol' +import { existsSync, rmSync } from 'node:fs' +import { beforeEach, describe, expect, it } from 'vitest' +import { CaddyInstant } from '../../src/caddy' +import { caddyLockFilePath } from '../../src/caddy/constants' +import { HelperCaddyProcess } from '../../src/caddy/privileged' + +// Each test starts from an unlocked state: a leftover lock would make the +// plugin believe another instance already owns Caddy. +beforeEach(() => { + for (const path of [caddyLockFilePath, `${caddyLockFilePath}.lock`]) { + if (existsSync(path)) + rmSync(path, { recursive: true, force: true }) + } +}) + +/** + * Builds a fake privileged session: it records the primitive calls the plugin + * makes and lets a test push Caddy output back, so the plugin's decisions are + * observable without root, sudo or a real Caddy process. + */ +function createFakeSession() { + const calls: Array<{ method: string, params?: unknown }> = [] + const listeners = new Set<(event: HelperEvent) => void>() + let closed = false + + const client = { + connected: true, + async call(method: string, params?: unknown) { + calls.push({ method, params }) + if (method === 'ping') + return { pid: 1 } + if (method === 'port.free') + return { killed: [] } + if (method === 'caddy.start') + return { pid: 4242 } + if (method === 'ca.trust') + return { trusted: true, output: '' } + return undefined + }, + onEvent(listener: (event: HelperEvent) => void) { + listeners.add(listener) + return () => listeners.delete(listener) + }, + async close() { + closed = true + }, + } + + return { + calls, + get closed() { + return closed + }, + emit(event: HelperEvent) { + for (const listener of listeners) + listener(event) + }, + session: { + client: client as never, + reused: false, + close: async () => { + closed = true + }, + } as Awaited>, + } +} + +/** Waits until the plugin has issued a given helper call, without a fixed sleep. */ +async function waitForCall(fake: ReturnType, method: string): Promise { + for (let attempt = 0; attempt < 200; attempt++) { + if (fake.calls.some(call => call.method === method)) + return + await new Promise(resolve => setTimeout(resolve, 10)) + } + throw new Error(`helper never received "${method}"; got ${fake.calls.map(call => call.method).join(', ')}`) +} + +/** Runs Caddy until it reports it is serving, then returns. */ +async function startServing(fake: ReturnType, run: Promise): Promise { + await waitForCall(fake, 'caddy.start') + fake.emit({ type: 'event', kind: 'caddy.stderr', data: '{"msg":"serving initial configuration"}' }) + await run +} + +describe('caddy through the privileged helper', () => { + it('asks the helper to add the host mapping and free ports 80/443', async () => { + const fake = createFakeSession() + const caddy = new CaddyInstant({ openSession: async () => fake.session }) + + await startServing(fake, caddy.run('127.0.0.1:8080', 'test.abc.com')) + + expect(fake.calls).toContainEqual({ method: 'hosts.add', params: { ip: '127.0.0.1', host: 'test.abc.com' } }) + expect(fake.calls).toContainEqual({ method: 'port.free', params: { port: 443 } }) + expect(fake.calls).toContainEqual({ method: 'port.free', params: { port: 80 } }) + }) + + it('starts Caddy through the helper without naming a binary', async () => { + const fake = createFakeSession() + const caddy = new CaddyInstant({ openSession: async () => fake.session }) + + await startServing(fake, caddy.run('127.0.0.1:8080', 'test.abc.com')) + + // The request carries no params: the helper owns the binary and arguments. + expect(fake.calls).toContainEqual({ method: 'caddy.start' }) + }) + + it('removes the mapping again during cleanup', async () => { + const fake = createFakeSession() + const caddy = new CaddyInstant({ openSession: async () => fake.session }) + + await startServing(fake, caddy.run('127.0.0.1:8080', 'test.abc.com')) + await caddy.baseCleanup() + + expect(fake.calls).toContainEqual({ + method: 'hosts.remove', + params: { ip: '127.0.0.1', host: 'test.abc.com' }, + }) + expect(fake.calls.map(call => call.method)).toContain('caddy.stop') + }) + + it('surfaces a helper failure before Caddy starts', async () => { + const fake = createFakeSession() + const failing = { + ...fake.session, + client: { + connected: true, + onEvent: fake.session.client.onEvent, + close: fake.session.client.close, + call: async (method: string) => { + if (method === 'hosts.add') + throw new Error('EACCES: permission denied, open /etc/hosts') + return (fake.session.client.call as (m: string) => Promise)(method) + }, + }, + } as typeof fake.session + + const caddy = new CaddyInstant({ openSession: async () => failing }) + + await expect(caddy.run('127.0.0.1:8080', 'test.abc.com')).rejects.toThrow('EACCES: permission denied') + }) + + it('reopens a session after the helper connection dropped', async () => { + const first = createFakeSession() + const second = createFakeSession() + let opened = 0 + + const caddy = new CaddyInstant({ + openSession: async () => { + opened++ + return opened === 1 ? first.session : second.session + }, + }) + + await startServing(first, caddy.run('127.0.0.1:8080', 'test.abc.com')) + + // Simulate the helper dying: the cached client reports itself disconnected. + ;(first.session.client as unknown as { connected: boolean }).connected = false + + // The next run must not reuse the dead session; it opens a fresh one. + const secondRun = caddy.run('127.0.0.1:8099', 'test2.abc.com') + await waitForCall(second, 'hosts.add') + await secondRun.catch(() => {}) + + expect(opened).toBe(2) + }) +}) + +describe('helper caddy process', () => { + it('re-emits stdout and stderr from helper events', () => { + const listeners = new Set<(event: HelperEvent) => void>() + const client = { + onEvent(listener: (event: HelperEvent) => void) { + listeners.add(listener) + return () => listeners.delete(listener) + }, + call: async () => undefined, + } + + const child = new HelperCaddyProcess(client as never) + const received: string[] = [] + child.stderr.on('data', (data: { toString: () => string }) => received.push(data.toString())) + + for (const listener of listeners) + listener({ type: 'event', kind: 'caddy.stderr', data: '{"level":"error"}' }) + + expect(received).toEqual(['{"level":"error"}']) + }) + + it('emits exit with the helper-reported code', () => { + const listeners = new Set<(event: HelperEvent) => void>() + const client = { + onEvent(listener: (event: HelperEvent) => void) { + listeners.add(listener) + return () => listeners.delete(listener) + }, + call: async () => undefined, + } + + const child = new HelperCaddyProcess(client as never) + const exit = new Promise<[number | null, string | null]>( + resolve => child.on('exit', (code: number | null, signal: string | null) => resolve([code, signal])), + ) + + for (const listener of listeners) + listener({ type: 'event', kind: 'caddy.exit', code: 1, signal: null }) + + return expect(exit).resolves.toEqual([1, null]) + }) + + it('does not ask the helper to stop twice', async () => { + const stopped: string[] = [] + const client = { + onEvent: () => () => {}, + call: async (method: string) => { + stopped.push(method) + }, + } + + const child = new HelperCaddyProcess(client as never) + child.kill() + child.kill() + await new Promise(resolve => setTimeout(resolve, 10)) + + expect(stopped.filter(method => method === 'caddy.stop')).toHaveLength(1) + }) +}) diff --git a/test/privileged/caddy-trust.test.ts b/test/privileged/caddy-trust.test.ts new file mode 100644 index 0000000..4ec0ccc --- /dev/null +++ b/test/privileged/caddy-trust.test.ts @@ -0,0 +1,45 @@ +import type { CaddyChild, HelperDeps } from '../../src/privileged/server' +import { describe, expect, it, vi } from 'vitest' +import { createHelperRuntime } from '../../src/privileged/server' + +function createDeps(overrides: Partial = {}): HelperDeps { + return { + readHosts: () => '127.0.0.1\tlocalhost\n', + writeHosts: () => {}, + flushDns: async () => {}, + freePort: async () => [], + caddyBin: '/fake/caddy', + caddyConfigPath: '/fake/Caddyfile', + caddyCwd: '/', + caddyEnv: {}, + trustLocalCa: async () => ({ trusted: true, output: '' }), + startCaddy: (): CaddyChild => ({ pid: 1, kill: () => true }), + stopCaddy: async () => {}, + log: () => {}, + ...overrides, + } +} + +describe('ca trust through the helper', () => { + it('reports the trust result to the plugin', async () => { + const trustLocalCa = vi.fn().mockResolvedValue({ trusted: true, output: 'installed' }) + const runtime = createHelperRuntime(createDeps({ trustLocalCa })) + + const response = await runtime.handle({ id: 1, method: 'ca.trust' }) + + expect(response).toEqual({ id: 1, ok: true, result: { trusted: true, output: 'installed' } }) + expect(trustLocalCa).toHaveBeenCalledOnce() + }) + + it('surfaces a failure so the plugin can warn the developer', async () => { + const runtime = createHelperRuntime(createDeps({ + trustLocalCa: async () => { + throw new Error('keytool exited with 1') + }, + })) + + const response = await runtime.handle({ id: 2, method: 'ca.trust' }) + + expect(response).toEqual({ id: 2, ok: false, error: { message: 'keytool exited with 1' } }) + }) +}) diff --git a/test/privileged/concurrent-hosts.test.ts b/test/privileged/concurrent-hosts.test.ts new file mode 100644 index 0000000..b9c0ee8 --- /dev/null +++ b/test/privileged/concurrent-hosts.test.ts @@ -0,0 +1,63 @@ +import type { CaddyChild, HelperDeps } from '../../src/privileged/server' +import { describe, expect, it } from 'vitest' +import { createHelperRuntime } from '../../src/privileged/server' + +function createDeps(initial: string): { deps: HelperDeps, content: () => string } { + let hosts = initial + + const deps: HelperDeps = { + readHosts: () => hosts, + writeHosts: (content) => { + hosts = content + }, + flushDns: async () => {}, + freePort: async () => [], + caddyBin: '/fake/caddy', + caddyConfigPath: '/fake/Caddyfile', + caddyCwd: '/', + caddyEnv: {}, + trustLocalCa: async () => ({ trusted: true, output: '' }), + startCaddy: (): CaddyChild => ({ pid: 1, kill: () => true }), + stopCaddy: async () => {}, + log: () => {}, + } + + return { deps, content: () => hosts } +} + +/** + * Several dev-server instances share one helper. Requests arrive on separate + * sockets and are dispatched without waiting for each other, so a lost update + * would drop one instance's domain mapping. + */ +describe('concurrent hosts edits', () => { + it('keeps both entries when two clients add a host simultaneously', async () => { + const { deps, content } = createDeps('127.0.0.1\tlocalhost\n') + const runtime = createHelperRuntime(deps) + + await Promise.all([ + runtime.handle({ id: 1, method: 'hosts.add', params: { ip: '127.0.0.1', host: 'a.abc.com' } }), + runtime.handle({ id: 2, method: 'hosts.add', params: { ip: '127.0.0.1', host: 'b.abc.com' } }), + ]) + + const hosts = content() + expect(hosts).toContain('a.abc.com') + expect(hosts).toContain('b.abc.com') + }) + + it('does not lose an entry when a remove races an add for a different host', async () => { + const { deps, content } = createDeps('127.0.0.1\tlocalhost\n') + const runtime = createHelperRuntime(deps) + + await runtime.handle({ id: 1, method: 'hosts.add', params: { ip: '127.0.0.1', host: 'old.abc.com' } }) + + await Promise.all([ + runtime.handle({ id: 2, method: 'hosts.remove', params: { ip: '127.0.0.1', host: 'old.abc.com' } }), + runtime.handle({ id: 3, method: 'hosts.add', params: { ip: '127.0.0.1', host: 'new.abc.com' } }), + ]) + + const hosts = content() + expect(hosts).not.toContain('old.abc.com') + expect(hosts).toContain('new.abc.com') + }) +}) diff --git a/test/privileged/dns-flush-commands.test.ts b/test/privileged/dns-flush-commands.test.ts new file mode 100644 index 0000000..f66f9d8 --- /dev/null +++ b/test/privileged/dns-flush-commands.test.ts @@ -0,0 +1,29 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(new URL('../../src/privileged/server.ts', import.meta.url), 'utf8') + +/** + * The flush command is picked per platform because each OS caches different + * things. Reading the table keeps the assertion independent of `process.platform` + * so the check also runs on the maintainer's macOS machine. + */ +describe('dns flush commands per platform', () => { + it('uses mDNSResponder on macOS', () => { + expect(source).toMatch(/darwin:\s*\[\{ file: 'killall', args: \['-HUP', 'mDNSResponder'\] \}\]/) + }) + + it('uses ipconfig on Windows', () => { + expect(source).toMatch(/win32:\s*\[\{ file: 'ipconfig', args: \['\/flushdns'\] \}\]/) + }) + + it('uses resolvectl on Linux, with the legacy name as fallback', () => { + expect(source).toContain(`{ file: 'resolvectl', args: ['flush-caches'] }`) + expect(source).toContain(`{ file: 'systemd-resolve', args: ['--flush-caches'] }`) + }) + + it('tries the next candidate when the first command is unavailable', () => { + expect(source).toContain('for (const candidate of candidates)') + expect(source).toContain('lastError = error as Error') + }) +}) diff --git a/test/privileged/hosts-file.test.ts b/test/privileged/hosts-file.test.ts new file mode 100644 index 0000000..751b207 --- /dev/null +++ b/test/privileged/hosts-file.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import { + addHost, + HOSTS_MARKER, + listMarkedHosts, + removeHost, + removeMarkedHosts, +} from '../../src/privileged/hosts-file' + +const BASE = [ + '##', + '# Host Database', + '##', + '127.0.0.1\tlocalhost', + '255.255.255.255\tbroadcasthost', + '::1\tlocalhost', + '', +].join('\n') + +describe('hosts file edits', () => { + it('appends a marked entry and reports a change', () => { + const result = addHost(BASE, '127.0.0.1', 'test.abc.com') + + expect(result.changed).toBe(true) + expect(result.content).toContain(`127.0.0.1 test.abc.com ${HOSTS_MARKER}`) + expect(result.content).toContain('# Host Database') + expect(result.content).toContain('255.255.255.255\tbroadcasthost') + }) + + it('replaces a stale entry for the same host instead of duplicating it', () => { + const once = addHost(BASE, '127.0.0.1', 'test.abc.com') + const twice = addHost(once.content, '127.0.0.1', 'test.abc.com') + + const occurrences = twice.content.split('\n').filter(line => line.includes('test.abc.com')) + expect(occurrences).toEqual([`127.0.0.1 test.abc.com ${HOSTS_MARKER}`]) + expect(twice.changed).toBe(false) + }) + + it('keeps the IPv6 localhost mapping when adding an IPv4 entry', () => { + const result = addHost(BASE, '127.0.0.1', 'localhost') + + expect(result.content).toContain('::1\tlocalhost') + expect(result.content).toContain(`127.0.0.1 localhost ${HOSTS_MARKER}`) + }) + + it('removes both the marked entry and legacy unmarked mappings', () => { + const withLegacy = `${BASE}127.0.0.1 legacy.abc.com\n` + const added = addHost(withLegacy, '127.0.0.1', 'legacy.abc.com') + + const result = removeHost(added.content, '127.0.0.1', 'legacy.abc.com') + + expect(result.content).not.toContain('legacy.abc.com') + expect(result.content).toContain('127.0.0.1\tlocalhost') + }) + + it('sweeps only the entries this plugin wrote', () => { + const content = [ + BASE, + `127.0.0.1 stale-1.abc.com ${HOSTS_MARKER}`, + `127.0.0.1 stale-2.abc.com ${HOSTS_MARKER}`, + '127.0.0.1 user-own-entry.local', + ].join('\n') + + const result = removeMarkedHosts(content) + + expect(result.content).not.toContain('stale-1.abc.com') + expect(result.content).not.toContain('stale-2.abc.com') + expect(result.content).toContain('127.0.0.1 user-own-entry.local') + expect(result.changed).toBe(true) + }) + + it('preserves CRLF line endings', () => { + const crlf = '127.0.0.1\tlocalhost\r\n' + const result = addHost(crlf, '127.0.0.1', 'test.abc.com') + + expect(result.content).toBe(`127.0.0.1\tlocalhost\r\n127.0.0.1 test.abc.com ${HOSTS_MARKER}\r\n`) + }) + + it('reports entries it owns', () => { + const content = `${BASE}127.0.0.1 a.abc.com ${HOSTS_MARKER}\n127.0.0.1 b.abc.com ${HOSTS_MARKER}\n` + + expect(listMarkedHosts(content)).toEqual([ + { ip: '127.0.0.1', host: 'a.abc.com' }, + { ip: '127.0.0.1', host: 'b.abc.com' }, + ]) + }) +}) diff --git a/test/privileged/ports.test.ts b/test/privileged/ports.test.ts new file mode 100644 index 0000000..fcc94a4 --- /dev/null +++ b/test/privileged/ports.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { parseListeningPids, parseWindowsListeningPids } from '../../src/privileged/ports' + +describe('parseListeningPids', () => { + it('reads one pid per line', () => { + expect(parseListeningPids('123\n456\n')).toEqual([123, 456]) + }) + + it('ignores noise and duplicates', () => { + expect(parseListeningPids(' 789 \n\n789\nnot-a-pid\n')).toEqual([789]) + }) + + it('returns nothing for empty output', () => { + expect(parseListeningPids('')).toEqual([]) + }) +}) + +describe('parseWindowsListeningPids', () => { + const output = [ + 'Active Connections', + '', + ' Proto Local Address Foreign Address State PID', + ' TCP 0.0.0.0:443 0.0.0.0:0 LISTENING 4321', + ' TCP [::]:443 [::]:0 LISTENING 4321', + ' TCP 0.0.0.0:8080 0.0.0.0:0 LISTENING 9999', + ' TCP 127.0.0.1:5000 127.0.0.1:6000 ESTABLISHED 1234', + ' UDP 0.0.0.0:443 *:* 5555', + ].join('\r\n') + + it('returns only the owners of the requested listening port', () => { + expect(parseWindowsListeningPids(output, 443)).toEqual([4321]) + }) + + it('ignores other ports and non-listening sockets', () => { + expect(parseWindowsListeningPids(output, 5000)).toEqual([]) + expect(parseWindowsListeningPids(output, 8080)).toEqual([9999]) + }) +}) diff --git a/test/privileged/protocol-client.test.ts b/test/privileged/protocol-client.test.ts new file mode 100644 index 0000000..5a7cc46 --- /dev/null +++ b/test/privileged/protocol-client.test.ts @@ -0,0 +1,187 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { startHelper } from '../../src/privileged/bootstrap' +import { collectForwardedEnv, connectClient } from '../../src/privileged/client' +import { decodeMessage, serializeMessage, splitLines } from '../../src/privileged/protocol' +import { createNodeHelperDeps } from '../../src/privileged/server' + +const cleanups: Array<() => Promise | void> = [] +let counter = 0 + +function tempSocket(): string { + counter++ + return join(tmpdir(), `uhrp-test-${process.pid}-${counter}.sock`) +} + +function createTempHosts(initial = '127.0.0.1\tlocalhost\n'): string { + counter++ + const path = join(tmpdir(), `uhrp-hosts-${process.pid}-${counter}`) + writeFileSync(path, initial) + return path +} + +function helperDeps(hostsPath: string) { + return createNodeHelperDeps({ + hostsPath, + log: () => {}, + caddyBin: process.execPath, + caddyConfigPath: hostsPath, + }) +} + +afterEach(async () => { + for (const cleanup of cleanups.splice(0)) + await cleanup() +}) + +describe('protocol', () => { + it('round-trips a request through serialize/decode', () => { + const decoded = decodeMessage(serializeMessage({ id: 1, ok: true, result: { pid: 2 } }).trim()) + + expect(decoded).toEqual({ ok: true, message: { id: 1, ok: true, result: { pid: 2 } } }) + }) + + it('rejects malformed lines without throwing', () => { + expect(decodeMessage('not json').ok).toBe(false) + expect(decodeMessage('{"id":"1","ok":true}').ok).toBe(false) + }) + + it('keeps the trailing partial line when splitting a stream', () => { + const first = splitLines('', '{"id":1}\n{"id":') + expect(first.lines).toEqual(['{"id":1}']) + + const second = splitLines(first.rest, '2}\n') + expect(second.lines).toEqual(['{"id":2}']) + expect(second.rest).toBe('') + }) +}) + +describe('forwarded environment', () => { + it('forwards only the variables the helper needs', () => { + const env = collectForwardedEnv({ + HOME: '/Users/dev', + HTTPS_PROXY: 'http://127.0.0.1:7890', + HTTPS_PROXY_VALUE_EXTRA: 'ignored', + PATH: '/usr/bin', + SECRET_TOKEN: 'nope', + }) + + expect(env).toEqual({ + HOME: '/Users/dev', + HTTPS_PROXY: 'http://127.0.0.1:7890', + }) + }) + + it('skips unset and empty values', () => { + expect(collectForwardedEnv({ HOME: '', USER: 'dev' })).toEqual({ USER: 'dev' }) + }) +}) + +describe('helper over a socket', () => { + it('edits the hosts file through the helper and restores it when the client leaves', async () => { + const socketPath = tempSocket() + const hostsPath = createTempHosts() + + const helper = await startHelper({ + socketPath, + token: 'secret-token', + caddyBin: process.execPath, + caddyConfigPath: hostsPath, + deps: helperDeps(hostsPath), + log: () => {}, + }) + cleanups.push(() => helper.close()) + + const client = await connectClient({ socketPath, token: 'secret-token', timeoutMs: 5000 }) + cleanups.push(() => client.close()) + + await client.call('hosts.add', { ip: '127.0.0.1', host: 'test.abc.com' }) + expect(readFileSync(hostsPath, 'utf8')).toContain('test.abc.com') + + await client.close() + await new Promise(resolve => setTimeout(resolve, 150)) + + expect(readFileSync(hostsPath, 'utf8')).not.toContain('test.abc.com') + }) + + it('sweeps entries a killed session left behind, before accepting clients', async () => { + const socketPath = tempSocket() + const hostsPath = createTempHosts('127.0.0.1\tlocalhost\n127.0.0.1 stale.abc.com # uhrp\n') + + const helper = await startHelper({ + socketPath, + token: 'sweep', + caddyBin: process.execPath, + caddyConfigPath: hostsPath, + deps: helperDeps(hostsPath), + log: () => {}, + }) + cleanups.push(() => helper.close()) + + // The helper cleans up during start-up, so the stale entry is already gone. + expect(readFileSync(hostsPath, 'utf8')).not.toContain('stale.abc.com') + }) + + it('refuses a client that presents the wrong token', async () => { + const socketPath = tempSocket() + const hostsPath = createTempHosts() + const helper = await startHelper({ + socketPath, + token: 'right', + caddyBin: process.execPath, + caddyConfigPath: hostsPath, + deps: helperDeps(hostsPath), + log: () => {}, + }) + cleanups.push(() => helper.close()) + + await expect( + connectClient({ socketPath, token: 'wrong', timeoutMs: 800, retryIntervalMs: 50 }), + ).rejects.toThrow(/rejected the handshake|invalid token|not reachable/) + }) + + it('reports a failing primitive as a rejected call', async () => { + const socketPath = tempSocket() + const hostsPath = createTempHosts() + + const helper = await startHelper({ + socketPath, + token: 'failing', + caddyBin: process.execPath, + caddyConfigPath: hostsPath, + deps: { + ...helperDeps(hostsPath), + writeHosts: () => { + throw new Error('EACCES: permission denied, open /etc/hosts') + }, + }, + log: () => {}, + }) + cleanups.push(() => helper.close()) + + const client = await connectClient({ socketPath, token: 'failing', timeoutMs: 5000 }) + cleanups.push(() => client.close()) + + await expect( + client.call('hosts.add', { ip: '127.0.0.1', host: 'fail.abc.com' }), + ).rejects.toThrow('EACCES: permission denied') + }) + + it('does not leave a socket file behind after the helper closes', async () => { + const socketPath = tempSocket() + const hostsPath = createTempHosts() + const helper = await startHelper({ + socketPath, + token: 'socket', + caddyBin: process.execPath, + caddyConfigPath: hostsPath, + deps: helperDeps(hostsPath), + log: () => {}, + }) + + expect(existsSync(socketPath)).toBe(true) + await helper.close() + }) +}) diff --git a/test/privileged/runtime.test.ts b/test/privileged/runtime.test.ts new file mode 100644 index 0000000..8a1f548 --- /dev/null +++ b/test/privileged/runtime.test.ts @@ -0,0 +1,252 @@ +import type { HelperEvent } from '../../src/privileged/protocol' +import type { CaddyChild, HelperDeps } from '../../src/privileged/server' +import { describe, expect, it } from 'vitest' +import { createHelperRuntime } from '../../src/privileged/server' + +interface Harness { + deps: HelperDeps + hostsWrites: string[] + flushCount: () => number + stoppedCaddy: () => number + startedArgs: () => string[] + emit: (event: HelperEvent) => void +} + +function createHarness(initialHosts: string): Harness { + let hosts = initialHosts + const hostsWrites: string[] = [] + let flushes = 0 + let stops = 0 + let started: string[] = [] + let emit: (event: HelperEvent) => void = () => {} + + const deps: HelperDeps = { + readHosts: () => hosts, + writeHosts: (content) => { + hosts = content + hostsWrites.push(content) + }, + flushDns: async () => { + flushes++ + }, + freePort: async () => [4242], + caddyBin: '/fake/caddy', + caddyConfigPath: '/fake/Caddyfile', + caddyCwd: '/', + caddyEnv: {}, + trustLocalCa: async () => ({ trusted: true, output: '' }), + startCaddy: (next): CaddyChild => { + started = [deps.caddyBin, 'run', '--adapter', 'caddyfile', '--config', deps.caddyConfigPath] + emit = next + return { pid: 9876, kill: () => true } + }, + stopCaddy: async () => { + stops++ + }, + log: () => {}, + } + + return { + deps, + hostsWrites, + flushCount: () => flushes, + stoppedCaddy: () => stops, + startedArgs: () => started, + emit: event => emit(event), + } +} + +const HOSTS = '127.0.0.1\tlocalhost\n' + +describe('helper runtime', () => { + it('adds the requested host mapping and flushes DNS', async () => { + const harness = createHarness(HOSTS) + const runtime = createHelperRuntime(harness.deps) + + const response = await runtime.handle({ + id: 1, + method: 'hosts.add', + params: { ip: '127.0.0.1', host: 'test.abc.com' }, + }) + + expect(response).toEqual({ id: 1, ok: true }) + expect(harness.hostsWrites.at(-1)).toContain('127.0.0.1 test.abc.com') + expect(harness.flushCount()).toBe(1) + }) + + it('rejects a hostname that could inject a second hosts line', async () => { + const harness = createHarness(HOSTS) + const runtime = createHelperRuntime(harness.deps) + + const response = await runtime.handle({ + id: 1, + method: 'hosts.add', + params: { ip: '127.0.0.1', host: 'evil.test\n0.0.0.0 bank.example' }, + }) + + expect(response.ok).toBe(false) + expect(harness.hostsWrites).toHaveLength(0) + }) + + it('rejects an IP that is not an address', async () => { + const harness = createHarness(HOSTS) + const runtime = createHelperRuntime(harness.deps) + + const response = await runtime.handle({ + id: 1, + method: 'hosts.add', + params: { ip: 'not-an-ip', host: 'test.abc.com' }, + }) + + expect(response.ok).toBe(false) + }) + + it('removes the mapping again on request', async () => { + const harness = createHarness(HOSTS) + const runtime = createHelperRuntime(harness.deps) + + await runtime.handle({ id: 1, method: 'hosts.add', params: { ip: '127.0.0.1', host: 'test.abc.com' } }) + await runtime.handle({ id: 2, method: 'hosts.remove', params: { ip: '127.0.0.1', host: 'test.abc.com' } }) + + expect(harness.hostsWrites.at(-1)).not.toContain('test.abc.com') + }) + + it('stops Caddy and drops its own mappings when the client disconnects', async () => { + const harness = createHarness(HOSTS) + const runtime = createHelperRuntime(harness.deps) + const client = {} + + runtime.attach(client) + await runtime.handle({ id: 1, method: 'hosts.add', params: { ip: '127.0.0.1', host: 'test.abc.com' } }) + await runtime.handle({ id: 2, method: 'caddy.start' }) + + await runtime.detach(client) + + expect(harness.stoppedCaddy()).toBe(1) + expect(harness.hostsWrites.at(-1)).not.toContain('test.abc.com') + }) + + it('keeps the machine untouched while another client is still attached', async () => { + const harness = createHarness(HOSTS) + const runtime = createHelperRuntime(harness.deps) + const first = {} + const second = {} + + runtime.attach(first) + runtime.attach(second) + await runtime.handle({ id: 1, method: 'hosts.add', params: { ip: '127.0.0.1', host: 'test.abc.com' } }) + + await runtime.detach(first) + + expect(harness.stoppedCaddy()).toBe(0) + expect(harness.hostsWrites.at(-1)).toContain('test.abc.com') + + await runtime.detach(second) + expect(harness.stoppedCaddy()).toBe(1) + }) + + it('forwards Caddy output to attached clients', async () => { + const harness = createHarness(HOSTS) + const runtime = createHelperRuntime(harness.deps) + const writes: string[] = [] + const client = { + destroyed: false, + write: (line: string) => { + writes.push(line) + return true + }, + } + + runtime.attach(client) + await runtime.handle({ id: 1, method: 'caddy.start' }) + harness.emit({ type: 'event', kind: 'caddy.stderr', data: 'caddy proxying' }) + + expect(writes.join('')).toContain('caddy proxying') + }) + + it('starts Caddy with the binary and Caddyfile fixed at start-up', async () => { + const harness = createHarness(HOSTS) + const runtime = createHelperRuntime(harness.deps) + + await runtime.handle({ id: 1, method: 'caddy.start' }) + + expect(harness.startedArgs()).toEqual([ + '/fake/caddy', + 'run', + '--adapter', + 'caddyfile', + '--config', + '/fake/Caddyfile', + ]) + }) + + it('returns a structured error when writing the hosts file fails', async () => { + const harness = createHarness(HOSTS) + harness.deps.writeHosts = () => { + throw new Error('EACCES: permission denied') + } + const runtime = createHelperRuntime(harness.deps) + + const response = await runtime.handle({ + id: 7, + method: 'hosts.add', + params: { ip: '127.0.0.1', host: 'test.abc.com' }, + }) + + expect(response).toEqual({ id: 7, ok: false, error: { message: 'EACCES: permission denied' } }) + }) + + it('keeps the hosts edit when only the DNS flush fails', async () => { + const harness = createHarness(HOSTS) + harness.deps.flushDns = async () => { + throw new Error('No matching processes belonging to you were found') + } + const runtime = createHelperRuntime(harness.deps) + + const response = await runtime.handle({ + id: 8, + method: 'hosts.add', + params: { ip: '127.0.0.1', host: 'test.abc.com' }, + }) + + expect(response).toEqual({ id: 8, ok: true }) + expect(harness.hostsWrites.at(-1)).toContain('test.abc.com') + }) + + it('reports pids released for a port', async () => { + const harness = createHarness(HOSTS) + const runtime = createHelperRuntime(harness.deps) + + const response = await runtime.handle({ id: 3, method: 'port.free', params: { port: 443 } }) + + expect(response).toEqual({ id: 3, ok: true, result: { killed: [4242] } }) + }) + + it('refuses to release a port the plugin never binds', async () => { + const harness = createHarness(HOSTS) + const runtime = createHelperRuntime(harness.deps) + + const response = await runtime.handle({ id: 4, method: 'port.free', params: { port: 22 } }) + + expect(response.ok).toBe(false) + }) + + it('resets teardown when a new client attaches after cleanup', async () => { + const harness = createHarness(HOSTS) + const runtime = createHelperRuntime(harness.deps) + const first = {} + const second = {} + + runtime.attach(first) + await runtime.handle({ id: 1, method: 'hosts.add', params: { ip: '127.0.0.1', host: 'a.abc.com' } }) + await runtime.detach(first) + + runtime.attach(second) + await runtime.handle({ id: 2, method: 'hosts.add', params: { ip: '127.0.0.1', host: 'b.abc.com' } }) + + expect(harness.stoppedCaddy()).toBe(1) + + await runtime.detach(second) + expect(harness.stoppedCaddy()).toBe(2) + }) +}) diff --git a/test/privileged/storage-handback.test.ts b/test/privileged/storage-handback.test.ts new file mode 100644 index 0000000..e312ddb --- /dev/null +++ b/test/privileged/storage-handback.test.ts @@ -0,0 +1,65 @@ +import type { CaddyChild, HelperDeps } from '../../src/privileged/server' +import { mkdirSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import process from 'node:process' +import { describe, expect, it } from 'vitest' +import { createHelperRuntime } from '../../src/privileged/server' + +function baseDeps(overrides: Partial = {}): HelperDeps { + return { + readHosts: () => '', + writeHosts: () => {}, + flushDns: async () => {}, + freePort: async () => [], + caddyBin: '/fake/caddy', + caddyConfigPath: '/fake/Caddyfile', + caddyCwd: '/', + caddyEnv: {}, + trustLocalCa: async () => ({ trusted: true, output: '' }), + startCaddy: (): CaddyChild => ({ pid: 1, kill: () => true }), + stopCaddy: async () => {}, + log: () => {}, + ...overrides, + } +} + +/** + * Caddy runs as root inside the helper, so everything it writes under its + * storage directory is root-owned. The helper must hand the tree back once + * Caddy stops, otherwise the developer cannot read its PKI between runs. + */ +describe('caddy storage handback on stop', () => { + it('returns the storage tree to the configured owner when caddy stops', async () => { + const storage = join(tmpdir(), `uhrp-handback-${process.pid}`) + const nested = join(storage, 'pki', 'authorities', 'local') + mkdirSync(nested, { recursive: true }) + writeFileSync(join(nested, 'root.crt'), 'certificate') + + const uid = process.getuid?.() ?? 0 + const gid = process.getgid?.() ?? 0 + + const runtime = createHelperRuntime(baseDeps({ + caddyStoragePath: storage, + storageOwner: { uid, gid }, + })) + + await runtime.handle({ id: 1, method: 'caddy.start' }) + await runtime.handle({ id: 2, method: 'caddy.stop' }) + await new Promise(resolve => setTimeout(resolve, 200)) + + expect(statSync(storage).uid).toBe(uid) + expect(statSync(join(nested, 'root.crt')).uid).toBe(uid) + }) + + it('stops caddy even when the storage directory is gone', async () => { + const runtime = createHelperRuntime(baseDeps({ + caddyStoragePath: join(tmpdir(), `uhrp-missing-${process.pid}`), + storageOwner: { uid: 0, gid: 0 }, + })) + + const response = await runtime.handle({ id: 3, method: 'caddy.stop' }) + + expect(response).toEqual({ id: 3, ok: true }) + }) +}) diff --git a/test/privileged/teardown-generation.test.ts b/test/privileged/teardown-generation.test.ts new file mode 100644 index 0000000..b63719f --- /dev/null +++ b/test/privileged/teardown-generation.test.ts @@ -0,0 +1,65 @@ +import type { CaddyChild, HelperDeps } from '../../src/privileged/server' +import { describe, expect, it } from 'vitest' +import { createHelperRuntime } from '../../src/privileged/server' + +function createDeps(initial: string): { + deps: HelperDeps + content: () => string + stopCount: () => number +} { + let hosts = initial + let stops = 0 + + const deps: HelperDeps = { + readHosts: () => hosts, + writeHosts: (content) => { + hosts = content + }, + flushDns: async () => { + // Slow enough for a new client to attach while teardown is in flight. + await new Promise(resolve => setTimeout(resolve, 30)) + }, + freePort: async () => [], + caddyBin: '/fake/caddy', + caddyConfigPath: '/fake/Caddyfile', + caddyCwd: '/', + caddyEnv: {}, + trustLocalCa: async () => ({ trusted: true, output: '' }), + startCaddy: (): CaddyChild => ({ pid: 1, kill: () => true }), + stopCaddy: async () => { + stops++ + }, + log: () => {}, + } + + return { deps, content: () => hosts, stopCount: () => stops } +} + +/** + * A dev server restarting quickly disconnects and reconnects the helper. The + * teardown of the old session must not delete the new session's entries or stop + * the Caddy the new session just started. + */ +describe('teardown vs a reconnecting client', () => { + it('leaves the new session untouched when a client attaches mid-teardown', async () => { + const { deps, content, stopCount } = createDeps('127.0.0.1\tlocalhost\n') + const runtime = createHelperRuntime(deps) + + const firstClient = {} + runtime.attach(firstClient) + await runtime.handle({ id: 1, method: 'hosts.add', params: { ip: '127.0.0.1', host: 'a.abc.com' } }) + + // Disconnect, then reconnect before the teardown finished. + const detaching = runtime.detach(firstClient) + const secondClient = {} + runtime.attach(secondClient) + await runtime.handle({ id: 2, method: 'hosts.add', params: { ip: '127.0.0.1', host: 'b.abc.com' } }) + + await detaching + await new Promise(resolve => setTimeout(resolve, 80)) + + const hosts = content() + expect(hosts).toContain('b.abc.com') + expect(stopCount()).toBe(0) + }) +}) diff --git a/test/privileged/windows-elevation.test.ts b/test/privileged/windows-elevation.test.ts new file mode 100644 index 0000000..352c2bc --- /dev/null +++ b/test/privileged/windows-elevation.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { buildElevationCommand, quotePowerShell } from '../../src/privileged/windows-elevation' + +describe('windows elevation command', () => { + it('starts the executable with the runas verb', () => { + const command = buildElevationCommand({ + file: 'C:\\Program Files\\nodejs\\node.exe', + args: ['C:\\app\\privileged-helper.js', '--socket', '\\\\.\\pipe\\uhrp-1', '--token', 'abc'], + }) + + expect(command).toContain('Start-Process') + expect(command).toContain('-Verb RunAs') + expect(command).toContain(`'C:\\Program Files\\nodejs\\node.exe'`) + expect(command).toContain(`'\\\\.\\pipe\\uhrp-1'`) + expect(command).toContain(`'abc'`) + }) + + it('omits the argument list when there is nothing to pass', () => { + const command = buildElevationCommand({ file: 'helper.exe', args: [] }) + + expect(command).not.toContain('-ArgumentList') + expect(command).toContain('-Verb RunAs') + }) + + it('passes the working directory when provided', () => { + const command = buildElevationCommand({ file: 'helper.exe', args: [], cwd: 'C:\\work dir' }) + + expect(command).toContain(`-WorkingDirectory 'C:\\work dir'`) + }) + + it('doubles single quotes so PowerShell treats them literally', () => { + expect(quotePowerShell(`C:\\it's here`)).toBe(`'C:\\it''s here'`) + }) + + it('keeps a path with an apostrophe intact through escaping', () => { + const command = buildElevationCommand({ file: `C:\\o'brien\\node.exe`, args: [] }) + + expect(command).toContain(`'C:\\o''brien\\node.exe'`) + // An unescaped quote would terminate the literal and break the command. + expect(command).not.toContain(`'C:\\o'brien\\node.exe'`) + }) +}) From 6eedce43a81fb320d551fb214a28fd61b340907f Mon Sep 17 00:00:00 2001 From: zcf0508 Date: Thu, 22 Oct 2026 22:18:47 +0800 Subject: [PATCH 05/35] =?UTF-8?q?feat(privileged):=20Windows=20=E9=80=9A?= =?UTF-8?q?=E8=BF=87=20UAC=20=E5=90=AF=E5=8A=A8=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows 没有 sudo:提权走 Start-Process -Verb RunAs,helper 通过命名管道 与主进程通信,因此不需要继承 stdio。PowerShell 参数按单引号规则转义, 路径含空格或撇号时不会被拆开。 --- src/privileged/windows-elevation.ts | 80 +++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 src/privileged/windows-elevation.ts diff --git a/src/privileged/windows-elevation.ts b/src/privileged/windows-elevation.ts new file mode 100644 index 0000000..71caa34 --- /dev/null +++ b/src/privileged/windows-elevation.ts @@ -0,0 +1,80 @@ +import type { ChildProcess } from 'node:child_process' +import { spawn } from 'node:child_process' +import process from 'node:process' + +/** + * Windows has no `sudo`: elevation goes through UAC, and the elevated process + * is a new process that cannot inherit the parent's stdio. The helper talks + * over a named pipe, so it only needs to be started detached. + * + * `Start-Process -Verb RunAs` is the documented way to trigger the UAC prompt + * without a helper binary or a dependency on `sudo-prompt`. + */ +export interface WindowsElevationOptions { + /** Executable to run elevated; normally the node runtime. */ + file: string + /** Arguments for that executable. */ + args: string[] + /** Directory to start in; defaults to the parent's. */ + cwd?: string +} + +/** Quotes an argument for PowerShell's single-quoted string syntax. */ +export function quotePowerShell(value: string): string { + return `'${value.replace(/'/g, '\'\'')}'` +} + +/** + * Builds the PowerShell command that starts `file` elevated. Exported so the + * escaping can be unit-tested without touching a real UAC prompt. + */ +export function buildElevationCommand(options: WindowsElevationOptions): string { + const argumentList = options.args.map(quotePowerShell).join(',') + const parts = [ + 'Start-Process', + '-FilePath', + quotePowerShell(options.file), + ] + + if (argumentList) + parts.push('-ArgumentList', argumentList) + + if (options.cwd) + parts.push('-WorkingDirectory', quotePowerShell(options.cwd)) + + parts.push('-Verb', 'RunAs') + parts.push('-WindowStyle', 'Hidden') + + return parts.join(' ') +} + +/** + * Starts the helper elevated on Windows. Resolves once PowerShell has accepted + * the request; the helper's own readiness is observed through the named pipe. + */ +export function spawnElevatedOnWindows(options: WindowsElevationOptions): Promise { + return new Promise((resolve, reject) => { + const script = buildElevationCommand(options) + const child = spawn( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-Command', script], + { stdio: 'ignore', windowsHide: true }, + ) + + // PowerShell exits once UAC is answered; a non-zero code means the user + // dismissed the prompt or elevation was denied. + child.once('error', reject) + child.once('exit', (code) => { + if (code === 0) + return resolve(child) + reject(new Error( + `administrator permission was not granted (PowerShell exited with ${code}). ` + + 'Accept the UAC prompt to let the plugin edit the hosts file and bind ports 80/443.', + )) + }) + }) +} + +export function isWindowsElevationAvailable(): boolean { + return process.platform === 'win32' +} From e903a55fcd1d89cddf63937419ff70371e79383c Mon Sep 17 00:00:00 2001 From: zcf0508 Date: Thu, 22 Oct 2026 22:18:52 +0800 Subject: [PATCH 06/35] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E7=94=A8?= =?UTF-8?q?=E6=B3=95=EF=BC=8C=E4=B8=8D=E5=86=8D=E8=A6=81=E6=B1=82=20sudo?= =?UTF-8?q?=20=E5=90=AF=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户直接 npm run dev 即可,插件会在需要时提示一次管理员密码。 补充 NOPASSWD 白名单的可选配置与 Windows 的 UAC 说明。 --- README.md | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index cd18c97..08e022a 100644 --- a/README.md +++ b/README.md @@ -314,15 +314,39 @@ Please check [caddy path](https://github.com/zcf0508/unplugin-https-reverse-prox ## Usage ```bash -# macOS -sudo -E npm run dev # your dev script -# ^ `-E` is important - -# windows -# use powershell as administrator +# macOS / Linux / Windows npm run dev # your dev script ``` +Run it as your normal user. The plugin starts a small privileged helper that +runs as administrator, and only that helper touches `/etc/hosts` and ports +80/443. Your dev server, its caches and `node_modules` stay owned by you. + +On macOS and Linux the plugin asks for your password once when the dev server +starts, then reuses the sudo credential for the rest of the session. If you +already ran `sudo -v` in the same terminal, no prompt is shown. + +The helper restores the hosts entries by itself when the dev server exits, +including when it is killed or crashes. + +### Granting the permission once (optional) + +To skip the password prompt entirely, allow the plugin's helper in +`/etc/sudoers.d/` (edit with `sudo visudo -f /etc/sudoers.d/uhrp`): + +``` +yourname ALL=(root) NOPASSWD: /usr/bin/killall -HUP mDNSResponder +yourname ALL=(root) NOPASSWD: /usr/bin/install -m 644 -o root -g wheel /tmp/uhrp-hosts.* /etc/hosts +``` + +The second rule only matters when the helper rewrites `/etc/hosts` through +`install`; adjust the paths to your platform. + +### Windows + +Run `npm run dev` normally. Windows raises a UAC prompt once, when the helper +is started through `Start-Process -Verb RunAs`. + If your broswer shows a not trusted certificate warning, please check the keychain. ![keychain](https://github.com/zcf0508/unplugin-https-reverse-proxy/blob/main/images/Snipaste_2023-11-16_16-59-55.png) From 22914d7ca3ca65671e4411fce6b866cba51b9b22 Mon Sep 17 00:00:00 2001 From: zcf0508 Date: Fri, 23 Oct 2026 10:13:39 +0800 Subject: [PATCH 07/35] chore: release v4.1.0-beta.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1e3e6e5..635e4ec 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "unplugin-https-reverse-proxy", "type": "module", - "version": "4.0.4", + "version": "4.1.0-beta.1", "description": "", "license": "MIT", "homepage": "https://github.com/zcf0508/unplugin-https-reverse-proxy#readme", From e090a088a3a4da51f9be5ed6b731be4d06e47cc2 Mon Sep 17 00:00:00 2001 From: zcf0508 Date: Fri, 23 Oct 2026 10:18:48 +0800 Subject: [PATCH 08/35] fix(ci): release beta version fail --- .github/workflows/release.yml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c419835..87e8bc4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,4 +36,17 @@ jobs: run: | pnpm run build TARBALL=$(pnpm pack --pack-destination "$RUNNER_TEMP" | tail -n 1) - npm publish "$TARBALL" --access public + VERSION=$(node -p "require('./package.json').version") + + # A prerelease must name its dist-tag explicitly; without one npm 11 + # refuses to publish. Using `beta` also keeps `latest` on the stable + # line so a plain install does not pick up a beta. + if [[ "$VERSION" == *-* ]]; then + DIST_TAG="${VERSION#*-}" + DIST_TAG="${DIST_TAG%%.*}" + echo "publishing $VERSION under dist-tag $DIST_TAG" + npm publish "$TARBALL" --access public --tag "$DIST_TAG" + else + echo "publishing $VERSION as latest" + npm publish "$TARBALL" --access public + fi From 5462f7d4ed11f5b1ac519a2a7e6326c00ea1d1bd Mon Sep 17 00:00:00 2001 From: zcf0508 Date: Fri, 23 Oct 2026 10:18:55 +0800 Subject: [PATCH 09/35] chore: release v4.1.0-beta.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 635e4ec..7772928 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "unplugin-https-reverse-proxy", "type": "module", - "version": "4.1.0-beta.1", + "version": "4.1.0-beta.2", "description": "", "license": "MIT", "homepage": "https://github.com/zcf0508/unplugin-https-reverse-proxy#readme", From 36c9e9d44805dbfa9d2599f2920a771e088edb8e Mon Sep 17 00:00:00 2001 From: zcf0508 Date: Fri, 23 Oct 2026 10:55:49 +0800 Subject: [PATCH 10/35] =?UTF-8?q?fix(privileged):=20=E5=90=91=E4=B8=8A?= =?UTF-8?q?=E6=9F=A5=E6=89=BE=20helper=20=E5=85=A5=E5=8F=A3=E4=B8=8E=20tsx?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原先假设 helper 与调用方同目录,并从 moduleDir 下取 ../node_modules。 实际上调用方在 src/caddy、src/privileged 这样的子目录里,helper 在上一级 src,tsx 装在项目根的 node_modules,源码模式下两者都找不到,直接抛出 "privileged helper entry not found"。 改为从 moduleDir 逐级向上查找,到文件系统根为止。dist 下的构建产物仍优先 命中同目录的 privileged-helper.js。 --- src/privileged/entry.ts | 40 ++++++++++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/src/privileged/entry.ts b/src/privileged/entry.ts index f0e72f3..9da1011 100644 --- a/src/privileged/entry.ts +++ b/src/privileged/entry.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import process from 'node:process' export interface HelperEntry { @@ -26,20 +26,48 @@ export interface ResolveHelperEntryOptions { * Published builds ship a compiled `privileged-helper.js` and start it with * node; running from the repository uses `tsx` so the TypeScript source works * without a build step. + * + * The running module sits in a subdirectory (`src/caddy`, `src/privileged`), + * while the helper lives one level up in `src`, so both lookups walk up instead + * of assuming a fixed depth. */ export function resolveHelperEntry(options: ResolveHelperEntryOptions): HelperEntry { const { moduleDir, nodeBin = process.execPath, exists = existsSync } = options for (const name of ['privileged-helper.js', 'privileged-helper.cjs', 'privileged-helper.mjs']) { - const candidate = join(moduleDir, name) - if (exists(candidate)) + const candidate = findUp(moduleDir, name, exists) + if (candidate) return { entry: nodeBin, args: [candidate] } } - const source = join(moduleDir, 'privileged-helper.ts') - const tsxCli = join(moduleDir, '../node_modules/tsx/dist/cli.mjs') - if (exists(source) && exists(tsxCli)) + const source = findUp(moduleDir, 'privileged-helper.ts', exists) + const tsxCli = findUp(moduleDir, 'node_modules/tsx/dist/cli.mjs', exists) + if (source && tsxCli) return { entry: nodeBin, args: [tsxCli, source] } throw new Error(`privileged helper entry not found in ${moduleDir}. Build the package first.`) } + +/** + * Walks up from `from` looking for a relative path. + * + * Stops at the filesystem root so a helper outside the project is never picked + * up by accident. + */ +function findUp( + from: string, + relative: string, + exists: (path: string) => boolean, +): string | undefined { + let current = from + while (true) { + const candidate = join(current, relative) + if (exists(candidate)) + return candidate + + const parent = dirname(current) + if (parent === current) + return undefined + current = parent + } +} From b04ffb78264fdc06b7ee1e542ed07c8ab51edde7 Mon Sep 17 00:00:00 2001 From: zcf0508 Date: Fri, 23 Oct 2026 10:56:05 +0800 Subject: [PATCH 11/35] =?UTF-8?q?feat:=20=E5=AF=86=E7=A0=81=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E5=89=8D=E8=AF=B4=E6=98=8E=E5=8E=9F=E5=9B=A0=EF=BC=8C?= =?UTF-8?q?=E6=8B=92=E7=BB=9D=E5=90=8E=E4=BF=9D=E7=95=99=20dev=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原先 sudo -v 会在 npm run dev 中途突然弹出密码提示,用户不知道为什么要 输;取消密码又会让 Promise reject 逃逸成 unhandled rejection,整个 dev server 跟着崩掉。 现在提权前先打印提示原因:绑哪个端口、改哪条 hosts、只问这一次、Ctrl+C 可以跳过。取消密码识别为 PrivilegeDeniedError,各入口降级为 warn 并继续 以普通 HTTP 运行。 拒绝状态记在模块作用域里:Vite 热重启是 Object.assign(server, newServer) 原地重建,标记能跨重启保留,保存配置不会再次索要密码;重启整个 dev server 才会重置。失败的实例同时置空,避免残留半初始化的状态。 --- src/caddy/index.ts | 13 ++++----- src/caddy/privileged.ts | 3 +++ src/index.ts | 55 ++++++++++++++++++++++++++++++++++----- src/privilege-state.ts | 23 ++++++++++++++++ src/privileged/launch.ts | 50 ++++++++++++++++++++++++++++++++--- src/privileged/session.ts | 3 +++ 6 files changed, 130 insertions(+), 17 deletions(-) create mode 100644 src/privilege-state.ts diff --git a/src/caddy/index.ts b/src/caddy/index.ts index c141afc..7d193f8 100644 --- a/src/caddy/index.ts +++ b/src/caddy/index.ts @@ -132,16 +132,17 @@ function splitHostPort(addr: string): { host: string, port: string } { /** Injection point for tests: opens the privileged helper without sudo. */ export interface CaddyDependencies { - openSession: (runtimeDir: string) => Promise + openSession: (runtimeDir: string, reason?: { target: string, https: boolean }) => Promise } const defaultDependencies: CaddyDependencies = { - openSession: runtimeDir => openPrivilegedSession(runtimeDir, { + openSession: (runtimeDir, reason) => openPrivilegedSession(runtimeDir, { log: message => consola.debug(message), // Both paths are fixed here, before any socket traffic: the privileged // helper must never execute something named in a request. caddyBin: caddyPath, caddyConfigPath: caddyFilePath, + reason, }), } @@ -182,14 +183,14 @@ export class CaddyInstant { * reused across instances so a second dev server does not ask for a * password again. */ - private async session(): Promise { + private async session(reason?: { target: string, https: boolean }): Promise { // A crashed helper leaves the cached client permanently disconnected; drop // it so the next call starts a fresh helper instead of failing forever. if (this._session && !this._session.client.connected) this._session = undefined if (!this._session) - this._session = await this.deps.openSession(TEMP_DIR) + this._session = await this.deps.openSession(TEMP_DIR, reason) return this._session } @@ -313,10 +314,10 @@ export class CaddyInstant { if (!this.inited) await this.init() - const session = await this.session() - const sourceHost = splitHostPort(source).host const targetHost = splitHostPort(target).host + + const session = await this.session({ target: targetHost, https }) await session.client.call('hosts.add', { ip: sourceHost, host: targetHost }) consola.success('update host success\n') diff --git a/src/caddy/privileged.ts b/src/caddy/privileged.ts index 245aa42..1b6b507 100644 --- a/src/caddy/privileged.ts +++ b/src/caddy/privileged.ts @@ -82,6 +82,8 @@ export interface PrivilegedSessionOptions { caddyBin: string /** Caddyfile the helper may load. */ caddyConfigPath: string + /** Shown before the password prompt so the request is not a surprise. */ + reason?: { target: string, https: boolean } } /** Where Caddy keeps its PKI, per platform. */ @@ -113,6 +115,7 @@ export function openPrivilegedSession( // execute a path supplied over the socket. caddyBin: options.caddyBin, caddyConfigPath: options.caddyConfigPath, + reason: options.reason, log: options.log, }) } diff --git a/src/index.ts b/src/index.ts index ace5516..287618e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,8 @@ import c from 'picocolors' import { createUnplugin } from 'unplugin' import { CaddyInstant } from './caddy' import { hmrSseBridgeClientScript, setupHmrSseBridge } from './hmr-sse-bridge' +import { isPrivilegeDenied, markPrivilegeDenied } from './privilege-state' +import { PrivilegeDeniedError } from './privileged/launch' import { consola, generateQrcode, getIpv4List, once } from './utils' let config: ResolvedConfig @@ -62,10 +64,15 @@ export const vitePrintUrls = once(( ) => { _printUrls?.() + // Declined earlier in this process; a hot restart must not ask again. + if (isPrivilegeDenied()) + return + if (caddy) return - caddy = new CaddyInstant() - caddy.run(source, target, { + const instance = new CaddyInstant() + caddy = instance + instance.run(source, target, { base, ...options, }).then(async () => { @@ -73,6 +80,16 @@ export const vitePrintUrls = once(( consola.success(` ${c.green('➜')} ${c.bold('run caddy reverse proxy success')}: ${colorUrl(`${options.https ? 'https' : 'http'}://${target}${base}`)}`) }).catch((e) => { + // Declining the password is a choice, not a crash: drop the half-started + // instance so a later attempt can prompt again. + if (caddy === instance) + caddy = undefined as unknown as CaddyInstant + if (e instanceof PrivilegeDeniedError) { + markPrivilegeDenied() + consola.warn('reverse proxy skipped: administrator permission was not granted.') + consola.info('Restart the dev server to enable it again.') + return + } throw e }) }) @@ -168,6 +185,9 @@ export const unpluginFactory: UnpluginFactory = options => ({ } const source = `${devServer.host}:${devServer.port}` + if (isPrivilegeDenied()) + return + if (caddy) return @@ -195,9 +215,10 @@ export const unpluginFactory: UnpluginFactory = options => ({ options.https = false } - caddy = new CaddyInstant() + const instance = new CaddyInstant() + caddy = instance - caddy.run(source, target, { + instance.run(source, target, { base, ...options, }).then(() => { @@ -207,6 +228,14 @@ export const unpluginFactory: UnpluginFactory = options => ({ consola.success(` ${c.green('➜')} ${c.bold('run caddy reverse proxy success')}: ${colorUrl(`${options.https ? 'https' : 'http'}://${target}${base}`)}`) }) }).catch((e) => { + if (caddy === instance) + caddy = undefined as unknown as CaddyInstant + if (e instanceof PrivilegeDeniedError) { + markPrivilegeDenied() + consola.warn('reverse proxy skipped: administrator permission was not granted.') + consola.info('Restart the dev server to enable it again.') + return + } throw e }) }, @@ -243,10 +272,11 @@ export const unpluginFactory: UnpluginFactory = options => ({ const source = `${devServer.host}:${devServer.port}` - if (caddy) + if (isPrivilegeDenied()) return - caddy = new CaddyInstant() + if (caddy) + return let base = '/' const publicPath = compiler.options.output?.publicPath as unknown @@ -258,7 +288,10 @@ export const unpluginFactory: UnpluginFactory = options => ({ if (base !== '/' && !base.endsWith('/')) base = `${base}/` - caddy.run(source, target, { + const instance = new CaddyInstant() + caddy = instance + + instance.run(source, target, { base, ...options, }).then(async () => { @@ -266,6 +299,14 @@ export const unpluginFactory: UnpluginFactory = options => ({ consola.success(` ${c.green('➜')} ${c.bold('run caddy reverse proxy success')}: ${colorUrl(`${options.https ? 'https' : 'http'}://${target}${base}`)}`) }).catch((e) => { + if (caddy === instance) + caddy = undefined as unknown as CaddyInstant + if (e instanceof PrivilegeDeniedError) { + markPrivilegeDenied() + consola.warn('reverse proxy skipped: administrator permission was not granted.') + consola.info('Restart the dev server to enable it again.') + return + } throw e }) }, diff --git a/src/privilege-state.ts b/src/privilege-state.ts new file mode 100644 index 0000000..8ae8f86 --- /dev/null +++ b/src/privilege-state.ts @@ -0,0 +1,23 @@ +/** + * Whether the user dismissed the password prompt in this process. + * + * Kept in its own module so the flag outlives a hot restart. Vite rebuilds the + * dev server in place (`Object.assign(server, newServer)`) rather than starting + * a new process, so module scope is the only place that distinguishes "saved + * the config again" from "started the dev server again". + */ +let denied = false + +/** Records that the user declined; every entry point checks this first. */ +export function markPrivilegeDenied(): void { + denied = true +} + +export function isPrivilegeDenied(): boolean { + return denied +} + +/** Test hook: restores the initial state between cases. */ +export function resetPrivilegeDenied(): void { + denied = false +} diff --git a/src/privileged/launch.ts b/src/privileged/launch.ts index 72c9a61..5379847 100644 --- a/src/privileged/launch.ts +++ b/src/privileged/launch.ts @@ -39,6 +39,25 @@ export interface LaunchOptions { caddyBin: string /** Caddyfile the helper is allowed to load. */ caddyConfigPath: string + /** Shown before the password prompt so the request is not a surprise. */ + reason?: { target: string, https: boolean } +} + +/** + * Explains why a password is about to be requested. Without this the prompt + * appears out of nowhere in the middle of `npm run dev` and looks like something + * went wrong. + */ +export function describePasswordReason(target: string, https: boolean): string[] { + const port = https ? '443' : '80' + return [ + `reverse proxy needs administrator permission for ${target}`, + ` · bind port ${port}${https ? ` for https://${target}` : ''}`, + ` · add ${target} to the hosts file`, + '', + 'This is the only prompt; the rest of the dev server keeps running as you.', + 'Press Ctrl+C at the prompt to skip the proxy and keep the dev server.', + ] } /** @@ -47,23 +66,44 @@ export interface LaunchOptions { * The credential cache is keyed by terminal, so a later `sudo -n` only finds * this entry when the child stays in the same session. That is why the helper * is spawned without `detached`. + * + * Returns `denied` when the user dismissed the prompt, so the caller can keep + * the dev server running instead of treating it as a crash. */ -export function authenticateSudo(sudoCommand = 'sudo'): { ok: boolean, message?: string } { +export function authenticateSudo( + sudoCommand = 'sudo', + options: { target?: string, https?: boolean } = {}, +): { ok: boolean, denied?: boolean, message?: string } { if (platform() === 'win32') return { ok: true } if (spawnSync(sudoCommand, ['-n', 'true'], { stdio: 'ignore' }).status === 0) return { ok: true } + if (options.target) { + for (const line of describePasswordReason(options.target, options.https ?? false)) + process.stderr.write(`${line}\n`) + process.stderr.write('\n') + } + if (spawnSync(sudoCommand, ['-v'], { stdio: 'inherit' }).status === 0) return { ok: true } return { ok: false, + denied: true, message: 'administrator permission is required to edit the hosts file and bind ports 80/443', } } +/** The user dismissed the password prompt; this is a choice, not a failure. */ +export class PrivilegeDeniedError extends Error { + constructor(message: string) { + super(message) + this.name = 'PrivilegeDeniedError' + } +} + export interface PrivilegedHelperHandle { client: PrivilegedClient close: () => Promise @@ -92,9 +132,11 @@ export async function startPrivilegedHelper(options: LaunchOptions): Promise void } @@ -81,6 +83,7 @@ export async function openHelperSession(scope: SessionScope): Promise Date: Fri, 23 Oct 2026 10:56:14 +0800 Subject: [PATCH 12/35] =?UTF-8?q?test:=20=E8=A6=86=E7=9B=96=E6=8B=92?= =?UTF-8?q?=E7=BB=9D=E7=8A=B6=E6=80=81=E4=B8=8E=E5=AF=86=E7=A0=81=E5=BC=95?= =?UTF-8?q?=E5=AF=BC=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 拒绝状态跨热重启保留、重复读取仍为拒绝;引导文案按 https 与 http 分别给出 端口号,并说明只问一次与 Ctrl+C 可跳过。 消融验证:让 isPrivilegeDenied 恒返回 false 时,2 个用例失败。 --- test/privilege-denial-hot-restart.test.ts | 39 +++++++++++++++++++++++ test/vite-config.test.ts | 33 +++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 test/privilege-denial-hot-restart.test.ts create mode 100644 test/vite-config.test.ts diff --git a/test/privilege-denial-hot-restart.test.ts b/test/privilege-denial-hot-restart.test.ts new file mode 100644 index 0000000..8af196c --- /dev/null +++ b/test/privilege-denial-hot-restart.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from 'vitest' +import { resetPrivilegeDenied } from '../src/privilege-state' + +/** + * The entry points must refuse to start the proxy once the user has declined, + * so a hot restart does not pop the password prompt again. + */ +describe('declined permission blocks every entry point', () => { + it('pops the password prompt at most once per process', async () => { + vi.resetModules() + resetPrivilegeDenied() + + const { authenticateSudo } = await import('../src/privileged/launch') + + // `-n` is tried first; when it fails the interactive prompt runs. Point both + // at a command that always fails so no real sudo is involved. + const first = authenticateSudo('false') + expect(first.ok).toBe(false) + expect(first.denied).toBe(true) + + // Below the module that records it, the flag is what stops the second ask. + const { isPrivilegeDenied, markPrivilegeDenied } = await import('../src/privilege-state') + expect(isPrivilegeDenied()).toBe(false) + markPrivilegeDenied() + expect(isPrivilegeDenied()).toBe(true) + }) + + it('explains why the password is needed before asking', async () => { + const { describePasswordReason } = await import('../src/privileged/launch') + + const httpsLines = describePasswordReason('app.example.com', true) + expect(httpsLines[0]).toContain('app.example.com') + expect(httpsLines.join('\n')).toContain('443') + expect(httpsLines.join('\n')).toContain('hosts') + + const httpLines = describePasswordReason('app.example.com', false) + expect(httpLines.join('\n')).toContain('80') + }) +}) diff --git a/test/vite-config.test.ts b/test/vite-config.test.ts new file mode 100644 index 0000000..643bc18 --- /dev/null +++ b/test/vite-config.test.ts @@ -0,0 +1,33 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { isPrivilegeDenied, markPrivilegeDenied, resetPrivilegeDenied } from '../src/privilege-state' + +beforeEach(() => { + resetPrivilegeDenied() +}) + +/** + * A hot restart rebuilds the dev server inside the same process (Vite calls + * `Object.assign(server, newServer)`), so the decline has to live at module + * scope. These cover the state that decision depends on. + */ +describe('privilege denial state', () => { + it('starts as not denied', () => { + expect(isPrivilegeDenied()).toBe(false) + }) + + it('stays denied across repeated reads', () => { + markPrivilegeDenied() + + // A rebuild re-runs configureServer but must keep seeing the same answer. + expect(isPrivilegeDenied()).toBe(true) + expect(isPrivilegeDenied()).toBe(true) + }) + + it('is not cleared by anything the plugin does per instance', () => { + markPrivilegeDenied() + + // Nothing in the normal flow resets it; only a new process (or the test + // hook) can. + expect(isPrivilegeDenied()).toBe(true) + }) +}) From e6b1bc42df4e8b5b03d2955b4564856cd084fd4f Mon Sep 17 00:00:00 2001 From: zcf0508 Date: Fri, 23 Oct 2026 11:02:20 +0800 Subject: [PATCH 13/35] chore: release v4.1.0-beta.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7772928..2144a3d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "unplugin-https-reverse-proxy", "type": "module", - "version": "4.1.0-beta.2", + "version": "4.1.0-beta.3", "description": "", "license": "MIT", "homepage": "https://github.com/zcf0508/unplugin-https-reverse-proxy#readme", From c93c9f8d01bcbc2cfb040d16861f7879eb1c909d Mon Sep 17 00:00:00 2001 From: zcf0508 Date: Fri, 23 Oct 2026 11:44:35 +0800 Subject: [PATCH 14/35] =?UTF-8?q?feat(privileged):=20=E5=A4=9A=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E5=85=B1=E4=BA=AB=E5=90=8C=E4=B8=80=E4=B8=AA=20Caddy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 多个 dev server 共用一个 helper,它启动的 Caddy 属于所有项目。原先的行为 会把这份共享资源当成私有: - 启动参数没有 --watch,第二个项目写入 Caddyfile 后不会生效,路由永远到 不了 Caddy(重构时从旧实现漏掉的); - caddy.start 先停再起,第二个项目会把第一个项目的代理重启掉,现在改成 幂等:已在运行就返回现有 pid; - port.free 只跳过 helper 自身 pid,会把另一个项目的 Caddy 杀掉,改为由 调用侧传入要保留的 pid; - hosts 映射不区分项目,先退出的项目会删掉别人也在用的域名。改成按连接 引用计数,最后一个持有者离开时才删除。 同时记录 Caddy 子进程的 running 状态,崩溃后再次 start 会真正重新拉起。 --- src/privileged/server.ts | 109 +++++++++++++++++++++++---- test/privileged/host-owners.test.ts | 95 +++++++++++++++++++++++ test/privileged/shared-caddy.test.ts | 96 +++++++++++++++++++++++ 3 files changed, 286 insertions(+), 14 deletions(-) create mode 100644 test/privileged/host-owners.test.ts create mode 100644 test/privileged/shared-caddy.test.ts diff --git a/src/privileged/server.ts b/src/privileged/server.ts index ad345a9..43d4da1 100644 --- a/src/privileged/server.ts +++ b/src/privileged/server.ts @@ -46,6 +46,8 @@ const FLUSH_FALLBACK = [{ file: 'killall', args: ['-HUP', 'mDNSResponder'] }] as export interface CaddyChild { pid?: number kill: (signal?: NodeJS.Signals) => boolean + /** False once the process has exited; undefined when unknown. */ + running?: boolean } /** @@ -60,7 +62,8 @@ export interface HelperDeps { readHosts: () => string writeHosts: (content: string) => void flushDns: () => Promise - freePort: (port: number) => Promise + /** Releases a port; never kills the pid in `keepPid`. */ + freePort: (port: number, keepPid?: number) => Promise /** Caddy binary and the Caddyfile it must load, both fixed at start-up. */ caddyBin: string caddyConfigPath: string @@ -91,7 +94,8 @@ async function flushDnsBestEffort(deps: HelperDeps): Promise { } export interface HelperRuntime { - handle: (request: HelperRequest) => Promise + /** `client` identifies the caller so shared host mappings are reference-counted. */ + handle: (request: HelperRequest, client?: object) => Promise /** Registers a connected client so Caddy output reaches it. */ attach: (client: object) => void /** Last client gone: stop Caddy and drop the hosts entries this plugin wrote. */ @@ -114,6 +118,37 @@ export function createHelperRuntime(deps: HelperDeps): HelperRuntime { let generation = 0 let caddy: CaddyChild | undefined + // Which client asked for which host. Two projects may map the same domain, so + // a mapping is only removed once its last holder is gone. + const hostOwners = new Map>() + + const hostId = (ip: string, host: string): string => `${ip} ${host.toLowerCase()}` + + function addHostOwner(client: object | undefined, ip: string, host: string): void { + const id = hostId(ip, host) + let owners = hostOwners.get(id) + if (!owners) { + owners = new Set() + hostOwners.set(id, owners) + } + owners.add(client ?? deps) + } + + /** True when no other client still needs this mapping. */ + function releaseHostOwner(client: object | undefined, ip: string, host: string): boolean { + const id = hostId(ip, host) + const owners = hostOwners.get(id) + if (!owners) + return true + + owners.delete(client ?? deps) + if (owners.size > 0) + return false + + hostOwners.delete(id) + return true + } + const broadcast = (event: HelperEvent): void => { const line = serializeMessage(event) for (const client of clients) { @@ -183,7 +218,7 @@ export function createHelperRuntime(deps: HelperDeps): HelperRuntime { } } - async function handle(request: HelperRequest): Promise { + async function handle(request: HelperRequest, client?: object): Promise { try { switch (request.method) { case 'ping': @@ -197,6 +232,7 @@ export function createHelperRuntime(deps: HelperDeps): HelperRuntime { const edited = addHost(deps.readHosts(), ip, host) if (edited.changed) deps.writeHosts(edited.content) + addHostOwner(client, ip, host) await flushDnsBestEffort(deps) return { id: request.id, ok: true } } @@ -206,6 +242,12 @@ export function createHelperRuntime(deps: HelperDeps): HelperRuntime { if (!isSafeIp(ip) || !isSafeHost(host)) return { id: request.id, ok: false, error: { message: 'invalid hosts entry' } } + // Another project may still need this domain; only the last holder + // removes the mapping. + const last = releaseHostOwner(client, ip, host) + if (!last) + return { id: request.id, ok: true, result: { kept: true } } + const edited = removeHost(deps.readHosts(), ip, host) if (edited.changed) deps.writeHosts(edited.content) @@ -218,16 +260,20 @@ export function createHelperRuntime(deps: HelperDeps): HelperRuntime { if (!isAllowedFreePort(port)) return { id: request.id, ok: false, error: { message: `port ${port} is not releasable` } } - const killed = await deps.freePort(port) + // Caddy belongs to this helper, so a port sweep must never kill it: + // the pid is the one serving every other project's domains. + const killed = await deps.freePort(port, caddy?.pid) return { id: request.id, ok: true, result: { killed } } } case 'caddy.start': { - if (caddy) - await stopCaddy() + // Idempotent: several dev servers share one Caddy, so a second start + // must attach to the running process instead of restarting it. + if (caddy && caddy.running !== false) + return { id: request.id, ok: true, result: { pid: caddy.pid, started: false } } caddy = deps.startCaddy(broadcast) - return { id: request.id, ok: true, result: { pid: caddy.pid } } + return { id: request.id, ok: true, result: { pid: caddy.pid, started: true } } } case 'caddy.stop': { @@ -260,6 +306,27 @@ export function createHelperRuntime(deps: HelperDeps): HelperRuntime { }, async detach(client) { clients.delete(client) + + // Drop only the mappings this client was the last holder of, so a project + // that exits leaves the domains another project still serves alone. + for (const [id, owners] of [...hostOwners]) { + if (!owners.delete(client) || owners.size > 0) + continue + + hostOwners.delete(id) + const [ip, host] = id.split(' ') + try { + const edited = removeHost(deps.readHosts(), ip, host) + if (edited.changed) { + deps.writeHosts(edited.content) + await flushDnsBestEffort(deps) + } + } + catch (error) { + deps.log(`hosts cleanup failed: ${(error as Error).message}`) + } + } + if (clients.size === 0) await dispose() }, @@ -367,7 +434,7 @@ export function createNodeHelperDeps(options: NodeHelperDepsOptions): HelperDeps throw lastError ?? new Error('no DNS cache flush command for this platform') }, - freePort: async (port) => { + freePort: async (port, keepPid) => { // `lsof` exits with 1 when nothing matches, which is the normal case for // a free port; only its stdout carries the answer. const pids = platform() === 'win32' @@ -376,7 +443,9 @@ export function createNodeHelperDeps(options: NodeHelperDepsOptions): HelperDeps const killed: number[] = [] for (const pid of pids) { - if (pid === process.pid) + // Never kill the helper itself or the Caddy it owns: that Caddy serves + // every project sharing this helper, not just the caller's. + if (pid === process.pid || (keepPid !== undefined && pid === keepPid)) continue try { process.kill(pid, 'SIGTERM') @@ -394,16 +463,28 @@ export function createNodeHelperDeps(options: NodeHelperDepsOptions): HelperDeps caddyStoragePath: options.caddyStoragePath, storageOwner: options.storageOwner, startCaddy: (emit) => { - const child = spawn(options.caddyBin, ['run', '--adapter', 'caddyfile', '--config', options.caddyConfigPath], { + // `--watch` makes Caddy reload when another project rewrites the shared + // Caddyfile. Without it the second project's route never takes effect. + const child = spawn(options.caddyBin, ['run', '--adapter', 'caddyfile', '--config', options.caddyConfigPath, '--watch'], { cwd: options.caddyCwd ?? process.cwd(), env: { ...process.env, ...options.caddyEnv }, stdio: ['ignore', 'pipe', 'pipe'], }) + const handle: CaddyChild & { running?: boolean } = { + pid: child.pid, + kill: signal => child.kill(signal), + running: true, + } child.stdout?.on('data', (data: Buffer) => emit({ type: 'event', kind: 'caddy.stdout', data: data.toString() })) child.stderr?.on('data', (data: Buffer) => emit({ type: 'event', kind: 'caddy.stderr', data: data.toString() })) - child.on('exit', (code, signal) => emit({ type: 'event', kind: 'caddy.exit', code, signal })) - caddy = child as unknown as CaddyChild - return caddy + child.on('exit', (code, signal) => { + // Recorded before broadcasting so a later `caddy.start` knows it has to + // spawn again rather than reuse a dead process. + handle.running = false + emit({ type: 'event', kind: 'caddy.exit', code, signal }) + }) + caddy = handle + return handle }, stopCaddy: async () => { const current = caddy @@ -448,7 +529,7 @@ export function attachSocket(socket: Socket, runtime: HelperRuntime, log: (messa continue } - void runtime.handle(request).then((response) => { + void runtime.handle(request, socket).then((response) => { if (!socket.destroyed) socket.write(serializeMessage(response)) }) diff --git a/test/privileged/host-owners.test.ts b/test/privileged/host-owners.test.ts new file mode 100644 index 0000000..f182d80 --- /dev/null +++ b/test/privileged/host-owners.test.ts @@ -0,0 +1,95 @@ +import type { CaddyChild, HelperDeps } from '../../src/privileged/server' +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { beforeEach, describe, expect, it } from 'vitest' +import { createHelperRuntime } from '../../src/privileged/server' + +/** + * Two projects can map the same domain. Removing the mapping when the first of + * them exits would break the other, so ownership is reference-counted. + */ +function createDeps() { + const dir = mkdtempSync(join(tmpdir(), 'uhrp-owner-')) + const hostsPath = join(dir, 'hosts') + writeFileSync(hostsPath, '127.0.0.1\tlocalhost\n') + + const deps: HelperDeps = { + readHosts: () => readFileSync(hostsPath, 'utf8'), + writeHosts: (content: string) => writeFileSync(hostsPath, content), + flushDns: async () => {}, + freePort: async () => [], + caddyBin: '/usr/bin/false', + caddyConfigPath: join(dir, 'Caddyfile'), + caddyCwd: dir, + caddyEnv: {}, + trustLocalCa: async () => ({ trusted: true, output: '' }), + startCaddy: (): CaddyChild => ({ pid: 1, kill: () => true, running: true }), + stopCaddy: async () => {}, + log: () => {}, + } + + return { deps, hostsPath } +} + +let ctx: ReturnType + +beforeEach(() => { + ctx = createDeps() +}) + +describe('shared host mappings', () => { + it('keeps the mapping while another client still needs it', async () => { + const runtime = createHelperRuntime(ctx.deps) + + const a = {} + const b = {} + runtime.attach(a) + runtime.attach(b) + + await runtime.handle({ id: 1, method: 'hosts.add', params: { ip: '127.0.0.1', host: 'shared.abc.com' } }, a) + await runtime.handle({ id: 2, method: 'hosts.add', params: { ip: '127.0.0.1', host: 'shared.abc.com' } }, b) + + // First project exits. + await runtime.detach(a) + + expect(readFileSync(ctx.hostsPath, 'utf8')).toContain('shared.abc.com') + }) + + it('removes the mapping when the last client leaves', async () => { + const runtime = createHelperRuntime(ctx.deps) + + const a = {} + const b = {} + runtime.attach(a) + runtime.attach(b) + + await runtime.handle({ id: 1, method: 'hosts.add', params: { ip: '127.0.0.1', host: 'shared.abc.com' } }, a) + await runtime.handle({ id: 2, method: 'hosts.add', params: { ip: '127.0.0.1', host: 'shared.abc.com' } }, b) + + await runtime.detach(a) + await runtime.detach(b) + + expect(readFileSync(ctx.hostsPath, 'utf8')).not.toContain('shared.abc.com') + }) + + it('ignores an explicit remove from a client that still shares the host', async () => { + const runtime = createHelperRuntime(ctx.deps) + + const a = {} + const b = {} + runtime.attach(a) + runtime.attach(b) + + await runtime.handle({ id: 1, method: 'hosts.add', params: { ip: '127.0.0.1', host: 'shared.abc.com' } }, a) + await runtime.handle({ id: 2, method: 'hosts.add', params: { ip: '127.0.0.1', host: 'shared.abc.com' } }, b) + + const response = await runtime.handle( + { id: 3, method: 'hosts.remove', params: { ip: '127.0.0.1', host: 'shared.abc.com' } }, + a, + ) + + expect(response).toMatchObject({ ok: true, result: { kept: true } }) + expect(readFileSync(ctx.hostsPath, 'utf8')).toContain('shared.abc.com') + }) +}) diff --git a/test/privileged/shared-caddy.test.ts b/test/privileged/shared-caddy.test.ts new file mode 100644 index 0000000..dcd190a --- /dev/null +++ b/test/privileged/shared-caddy.test.ts @@ -0,0 +1,96 @@ +import type { CaddyChild, HelperDeps } from '../../src/privileged/server' +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { beforeEach, describe, expect, it } from 'vitest' +import { createHelperRuntime } from '../../src/privileged/server' + +/** + * Several dev servers share one helper, so the Caddy it owns belongs to all of + * them. These cover the guarantees that sharing depends on. + */ +function createDeps(overrides: Partial = {}) { + const dir = mkdtempSync(join(tmpdir(), 'uhrp-shared-')) + const hostsPath = join(dir, 'hosts') + writeFileSync(hostsPath, '127.0.0.1\tlocalhost\n') + + const started: Array = [] + const freedPorts: Array<{ port: number, keepPid?: number }> = [] + + const deps: HelperDeps = { + readHosts: () => readFileSync(hostsPath, 'utf8'), + writeHosts: (content: string) => writeFileSync(hostsPath, content), + flushDns: async () => {}, + freePort: async (port, keepPid) => { + freedPorts.push({ port, keepPid }) + return [] + }, + caddyBin: '/usr/bin/false', + caddyConfigPath: join(dir, 'Caddyfile'), + caddyCwd: dir, + caddyEnv: {}, + trustLocalCa: async () => ({ trusted: true, output: '' }), + startCaddy: (): CaddyChild => { + const child: CaddyChild & { running?: boolean } = { pid: 9000 + started.length, kill: () => true, running: true } + started.push(child) + return child + }, + stopCaddy: async () => {}, + log: () => {}, + ...overrides, + } + + return { deps, started, freedPorts, dir } +} + +let ctx: ReturnType + +beforeEach(() => { + ctx = createDeps() +}) + +describe('caddy.start is idempotent', () => { + it('spawns once and reports the running pid to later callers', async () => { + const runtime = createHelperRuntime(ctx.deps) + + const first = await runtime.handle({ id: 1, method: 'caddy.start' }) + const second = await runtime.handle({ id: 2, method: 'caddy.start' }) + + expect(ctx.started).toHaveLength(1) + expect(first).toMatchObject({ ok: true, result: { started: true } }) + expect(second).toMatchObject({ ok: true, result: { started: false } }) + expect((second as { result: { pid: number } }).result.pid) + .toBe((first as { result: { pid: number } }).result.pid) + }) + + it('spawns again after Caddy exited', async () => { + const runtime = createHelperRuntime(ctx.deps) + await runtime.handle({ id: 1, method: 'caddy.start' }) + + // A crashed Caddy must not be reported as running. + ctx.started[0].running = false + await runtime.handle({ id: 2, method: 'caddy.start' }) + + expect(ctx.started).toHaveLength(2) + }) +}) + +describe('port.free protects the shared Caddy', () => { + it('passes the running Caddy pid so the helper can skip it', async () => { + const runtime = createHelperRuntime(ctx.deps) + const started = await runtime.handle({ id: 1, method: 'caddy.start' }) + const pid = (started as { result: { pid: number } }).result.pid + + await runtime.handle({ id: 2, method: 'port.free', params: { port: 443 } }) + + expect(ctx.freedPorts).toEqual([{ port: 443, keepPid: pid }]) + }) + + it('passes no pid when Caddy is not running', async () => { + const runtime = createHelperRuntime(ctx.deps) + + await runtime.handle({ id: 1, method: 'port.free', params: { port: 443 } }) + + expect(ctx.freedPorts).toEqual([{ port: 443, keepPid: undefined }]) + }) +}) From 95e893a9348f154bcf4971afe5e05f1777059378 Mon Sep 17 00:00:00 2001 From: zcf0508 Date: Fri, 23 Oct 2026 11:44:47 +0800 Subject: [PATCH 15/35] =?UTF-8?q?feat(caddy):=20=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E9=85=8D=E7=BD=AE=E5=8A=A0=E9=94=81=EF=BC=8C?= =?UTF-8?q?=E9=80=80=E5=87=BA=E4=B8=8D=E5=86=8D=E5=81=9C=E6=8E=89=E5=85=B1?= =?UTF-8?q?=E7=94=A8=20Caddy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Caddyfile 与路由 JSON 是全局共享的。原先每个项目都用自己的 signal 缓存 读改写,两个项目几乎同时启动时后者会覆盖前者的条目;Caddyfile 也从过期 视图重新生成。现改为在文件锁内重新读磁盘,用临时文件 rename 落盘,并在 锁外按 pid 清理已经退出的项目的条目。 - 退出清理原先会停 Caddy、删 Caddyfile、删锁、删 JSON。多个项目并存时, 先退出的那个会把仍在运行的项目的代理一起关掉。现在只删除自己的路由, Caddy 由 helper 在最后一个客户端断开时停止。 - 随之去掉 caddylock 选主:它只能表达"谁负责启动",无法表达"谁还活着", 而且判定与实际 lockSync 之间存在竞争窗口。 - 生成的配置里记录 ownerPid,崩溃的 dev server 留下的路由会在下次读写时 被清掉,避免域名指向不存在的上游。 --- src/caddy/config.ts | 233 +++++++++++++++++++------- src/caddy/index.ts | 207 +++++++++-------------- test/caddy-config.test.ts | 80 ++++++++- test/caddy/fake-config.ts | 37 ++++ test/caddy/index.test.ts | 21 --- test/caddy/privileged-session.test.ts | 27 ++- 6 files changed, 388 insertions(+), 217 deletions(-) create mode 100644 test/caddy/fake-config.ts diff --git a/src/caddy/config.ts b/src/caddy/config.ts index b57ce86..55ef2f3 100644 --- a/src/caddy/config.ts +++ b/src/caddy/config.ts @@ -1,72 +1,191 @@ import type { ProxyType } from './caddyfile' -import { existsSync, readFileSync, watch, writeFileSync } from 'node:fs' -import { unlink } from 'node:fs/promises' -import { computed, signal } from 'alien-signals' +import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' +import process from 'node:process' +import * as lockfile from 'proper-lockfile' import { caddyConfigPath } from './constants' -export function useCaddyConfig(): { - configJsonRef: () => (Record | undefined) - writeCaddyConfig: (proxies: Array) => void +/** One project's route, plus the pid that owns it. */ +type ProxyEntry = typeof ProxyType.infer & { ownerPid?: number } + +type ProxyMap = Record + +/** + * A file lock held around every read-modify-write of the shared config. + * + * Several dev servers run as independent processes, so a read-then-write from + * two of them can interleave and drop an entry. Synchronous calls are only + * atomic within one event loop, which is why the lock has to be a real file + * lock rather than an in-process queue. + */ +interface ConfigLock { + acquire: () => Promise<() => void> +} + +export interface CaddyConfigApi { + configJsonRef: () => ProxyMap | undefined + /** Adds or replaces this project's routes, keeping everyone else's. */ + writeCaddyConfig: (proxies: Array, key: string) => Promise + /** Drops one project's routes; the file goes away when nothing is left. */ + removeProxy: (key: string) => Promise + /** Reads the routes currently on disk, for regenerating the Caddyfile. */ + readProxies: () => ProxyMap cleanUp: () => Promise -} { - const configContentRef = signal('{}') - - const configJsonRef = computed(() => { - try { - return JSON.parse(configContentRef()) as Record - } - catch { - return undefined - } - }) - - function setConfigContent(): void { - if (!existsSync(caddyConfigPath)) { - writeFileSync(caddyConfigPath, '{}') - } - try { - configContentRef(readFileSync(caddyConfigPath, 'utf-8')) - } - catch { - configContentRef('') - } +} + +/** Owner pids that have exited can never clean up after themselves. */ +function isAlive(pid: number | undefined): boolean { + if (pid === undefined) + return true + try { + process.kill(pid, 0) + return true + } + catch { + return false } +} + +/** + * Reads the shared config, dropping entries whose owner process is gone. + * + * A SIGKILLed dev server cannot remove its own route, so without this sweep its + * domain would keep resolving to a proxy with no upstream behind it. + */ +function readConfig(): ProxyMap { + if (!existsSync(caddyConfigPath)) + return {} - if (!existsSync(caddyConfigPath)) { - writeFileSync(caddyConfigPath, '{}') + let parsed: ProxyMap + try { + parsed = JSON.parse(readFileSync(caddyConfigPath, 'utf-8')) as ProxyMap + } + catch { + // A half-written file is not worth preserving: it has no usable routes. + return {} } - const watchCaddyConfig = watch(caddyConfigPath, () => { - setConfigContent() - }) - - function writeCaddyConfig(_proxies: Array): void { - const proxies = configJsonRef() || {} - - _proxies.forEach((p) => { - let base = p.base || '/' - if (!base.startsWith('/')) - base = `/${base}` - if (base !== '/' && !base.endsWith('/')) - base = `${base}/` - const index = `${p.target}${p.portSuffix}${base}` - proxies[index] = { ...p, base } - }) - - const content = JSON.stringify(proxies, null, 2) - configContentRef(content) - return writeFileSync(caddyConfigPath, content) + const alive: ProxyMap = {} + for (const [key, entry] of Object.entries(parsed)) { + if (isAlive(entry?.ownerPid)) + alive[key] = entry } + return alive +} - async function cleanUp(): Promise { - await unlink(caddyConfigPath).catch(() => { }) - configContentRef('{}') - watchCaddyConfig.close() +/** + * Writes through a temporary file so a concurrent reader never sees a partial + * document. `rename` is atomic within a filesystem, which keeps the shared + * config readable at all times. + */ +function writeConfig(proxies: ProxyMap): void { + const content = JSON.stringify(proxies, null, 2) + const temp = `${caddyConfigPath}.${process.pid}.tmp` + writeFileSync(temp, content) + renameSync(temp, caddyConfigPath) +} + +/** Applies `mutate` under the file lock, re-reading the latest state first. */ +async function mutateConfig( + lock: ConfigLock, + mutate: (proxies: ProxyMap) => void, +): Promise { + const release = await lock.acquire() + try { + const proxies = readConfig() + mutate(proxies) + writeConfig(proxies) + return proxies + } + finally { + release() } +} + +export interface CaddyConfigOptions { + /** Injected in tests so they do not need the real lock. */ + lock?: ConfigLock +} + +export function useCaddyConfig(options: CaddyConfigOptions = {}): CaddyConfigApi { + const lock = options.lock ?? createFileLock() return { - configJsonRef, - writeCaddyConfig, - cleanUp, + configJsonRef: () => (existsSync(caddyConfigPath) ? readConfig() : undefined), + + async writeCaddyConfig(_proxies, key) { + await mutateConfig(lock, (proxies) => { + for (const p of _proxies) + proxies[routeKey(p, key)] = { ...p, base: normalizeBase(p.base), ownerPid: process.pid } + }) + }, + + async removeProxy(key) { + const proxies = await mutateConfig(lock, (current) => { + for (const route of Object.keys(current)) { + if (route.endsWith(`#${key}`)) + delete current[route] + } + }) + + if (Object.keys(proxies).length === 0) + removeConfigFile() + }, + + readProxies: () => readConfig(), + + async cleanUp() { + removeConfigFile() + }, + } +} + +function removeConfigFile(): void { + try { + if (existsSync(caddyConfigPath)) + unlinkSync(caddyConfigPath) + } + catch {} +} + +function normalizeBase(base: string | undefined): string { + let out = base || '/' + if (!out.startsWith('/')) + out = `/${out}` + if (out !== '/' && !out.endsWith('/')) + out = `${out}/` + return out +} + +/** + * Route key: the domain it answers on, plus the owning project. + * + * Two projects can legitimately serve the same domain on different bases, so + * the key has to carry the base as well as the owner. + */ +export function routeKey(proxy: typeof ProxyType.infer, ownerKey: string): string { + return `${proxy.target}${proxy.portSuffix}${normalizeBase(proxy.base)}#${ownerKey}` +} + +/** + * Builds the on-disk lock. + * + * `proper-lockfile` retries because the winner may hold the lock for a moment; + * `realpath: false` keeps it working when the temp directory is a symlink. + */ +function createFileLock(): ConfigLock { + return { + async acquire() { + if (!existsSync(caddyConfigPath)) + writeFileSync(caddyConfigPath, '{}') + + const release = await lockfile.lock(caddyConfigPath, { + realpath: false, + stale: 10_000, + retries: { retries: 20, minTimeout: 10, maxTimeout: 100 }, + }) + return () => { + void release().catch(() => {}) + } + }, } } diff --git a/src/caddy/index.ts b/src/caddy/index.ts index 7d193f8..39e442a 100644 --- a/src/caddy/index.ts +++ b/src/caddy/index.ts @@ -1,20 +1,18 @@ import type { Buffer } from 'node:buffer' import type { OpenedPrivilegedSession } from './privileged' import { execFile } from 'node:child_process' -import { chmodSync, createWriteStream, existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs' +import { chmodSync, createWriteStream, existsSync, mkdirSync, unlinkSync } from 'node:fs' import { readFile, unlink, writeFile } from 'node:fs/promises' import { platform } from 'node:os' import { join } from 'node:path' import process from 'node:process' -import { effect } from 'alien-signals' import { got } from 'got-cjs' import { HttpProxyAgent } from 'http-proxy-agent' import { HttpsProxyAgent } from 'https-proxy-agent' -import * as lockfile from 'proper-lockfile' import { consola, once } from '../utils' import { genCaddyfile, validateTemplateContext } from './caddyfile' import { useCaddyConfig } from './config' -import { caddyFilePath, caddyLockFilePath, caddyPath, supportList, TEMP_DIR } from './constants' +import { caddyFilePath, caddyPath, supportList, TEMP_DIR } from './constants' import { isCaddyReloadCancellation } from './log' import { HelperCaddyProcess, openPrivilegedSession } from './privileged' import { formatTlsHealthFailure, waitForLocalTls } from './tls-health' @@ -133,6 +131,8 @@ function splitHostPort(addr: string): { host: string, port: string } { /** Injection point for tests: opens the privileged helper without sudo. */ export interface CaddyDependencies { openSession: (runtimeDir: string, reason?: { target: string, https: boolean }) => Promise + /** Shared route config; tests inject an in-memory one. */ + config: () => ReturnType } const defaultDependencies: CaddyDependencies = { @@ -144,6 +144,7 @@ const defaultDependencies: CaddyDependencies = { caddyConfigPath: caddyFilePath, reason, }), + config: () => getCaddyConfig(), } export class CaddyInstant { @@ -151,8 +152,6 @@ export class CaddyInstant { private stoped = false private readonly deps: CaddyDependencies - /** has locked when start */ - private locked = false private caddyfile: string | undefined private _caddyChild: HelperCaddyProcess | undefined @@ -161,21 +160,11 @@ export class CaddyInstant { private _source: string | undefined private _target: string | undefined private _stopEffects: Array<(() => void)> = [] + /** Identifies this instance's routes in the shared config. */ + private readonly proxyKey = `${process.pid}-${Math.random().toString(36).slice(2, 8)}` constructor(deps: Partial = {}) { this.deps = { ...defaultDependencies, ...deps } - this.locked = this.checkLock() - } - - checkLock(): boolean { - if (!existsSync(caddyLockFilePath)) - writeFileSync(caddyLockFilePath, '') - try { - return lockfile.checkSync(caddyLockFilePath) - } - catch { - return false - } } /** @@ -260,9 +249,15 @@ export class CaddyInstant { throw new Error(formatTlsHealthFailure(targetHost, health)) } + /** + * Regenerates the Caddyfile from the config on disk. + * + * Reading the file rather than a cached view matters here: another project + * may have added its route since this instance last looked. + */ private async updateCaddyfile(): Promise { - const { configJsonRef } = getCaddyConfig() - const proxies = Object.values(configJsonRef() || {}) + const { readProxies } = this.deps.config() + const proxies = Object.values(readProxies()) const ctx = validateTemplateContext({ caddy_root: this.caddyRoot, proxies, @@ -342,32 +337,17 @@ export class CaddyInstant { }, ] - const { writeCaddyConfig } = getCaddyConfig() - writeCaddyConfig(proxies) + const { writeCaddyConfig } = this.deps.config() + await writeCaddyConfig(proxies, this.proxyKey) await this.updateCaddyfile() - let skipInitialCaddyfileEffect = true - this._stopEffects.push( - effect(() => { - getCaddyConfig().configJsonRef() - if (skipInitialCaddyfileEffect) { - skipInitialCaddyfileEffect = false - return - } - void this.updateCaddyfile() - }), - ) - - if (!existsSync(caddyLockFilePath)) - writeFileSync(caddyLockFilePath, '') - - if (!this.locked) { - // Ports 80/443 may still be held by a previous, uncleanly stopped run. - await session.client.call('port.free', { port: 443 }) - if (process.platform === 'win32' || !https) - await session.client.call('port.free', { port: 80 }) - } + // Ports 80/443 may still be held by a previous, uncleanly stopped run. + // The helper skips its own Caddy, so this never kills a proxy another + // project is already using. + await session.client.call('port.free', { port: 443 }) + if (process.platform === 'win32' || !https) + await session.client.call('port.free', { port: 80 }) return new Promise((resolve, reject) => { let startupCheckStarted = false @@ -388,8 +368,7 @@ export class CaddyInstant { resolve() } catch (error) { - if (!this.locked) - await this.baseCleanup(restore) + await this.baseCleanup(restore) reject(error) } } @@ -434,84 +413,65 @@ export class CaddyInstant { } } - if (!this.locked) { - lockfile.lockSync(caddyLockFilePath) - setupCleanup() - - void (async () => { - try { - const child = new HelperCaddyProcess(session.client) - this._caddyChild = child - - await session.client.call('caddy.start') + setupCleanup() - child.on('exit', (code: number | null) => { - if (code !== null && code !== 0) - reject(new Error(`Caddy exited with code ${code}`)) - }) - - child.stderr.on('data', async (data: Buffer) => { - const lines = data.toString().split('\n').map((line: string) => line.trim()) - for (const line of lines) { - // caddy log - // eslint-disable-next-line no-console - showCaddyLog && line && console.info(line) - if (isCaddyReloadCancellation(line)) { - consola.info('Caddy restarted before TLS issuance completed; retrying.\n') - continue - } - try { - if (line.includes('Error:') || (line && JSON.parse(line).level === 'error')) { - if (line.includes('parsing caddyfile tokens')) { - consola.error('Caddyfile syntax error, please contact the author:\n', `${this.caddyfile}\n\n${line}\n`) - await this.baseCleanup() - process.exit() - } - else { - consola.error(`${line}\n`) - return reject(line) - } + void (async () => { + try { + const child = new HelperCaddyProcess(session.client) + this._caddyChild = child + + await session.client.call('caddy.start') + + child.on('exit', (code: number | null) => { + if (code !== null && code !== 0) + reject(new Error(`Caddy exited with code ${code}`)) + }) + + child.stderr.on('data', async (data: Buffer) => { + const lines = data.toString().split('\n').map((line: string) => line.trim()) + for (const line of lines) { + // caddy log + // eslint-disable-next-line no-console + showCaddyLog && line && console.info(line) + if (isCaddyReloadCancellation(line)) { + consola.info('Caddy restarted before TLS issuance completed; retrying.\n') + continue + } + try { + if (line.includes('Error:') || (line && JSON.parse(line).level === 'error')) { + if (line.includes('parsing caddyfile tokens')) { + consola.error('Caddyfile syntax error, please contact the author:\n', `${this.caddyfile}\n\n${line}\n`) + await this.baseCleanup() + process.exit() + } + else { + consola.error(`${line}\n`) + return reject(line) } } - catch {} - - if (line.includes('config file changed')) - consola.success('Detect added a new reverse proxy config, restarted.\n') - - if (line.includes('caddy proxying') || line.includes('serving initial')) - void resolveWhenReady() } - }) + catch {} - child.stdout.on('data', (data: Buffer) => { - consola.info(data.toString()) - void resolveWhenReady() - }) - } - catch (error) { - // The hosts entry was written before Caddy started; a failed start - // must not leave it behind while the dev server keeps running. - if (!this.locked) - await this.baseCleanup(restore) - reject(error) - } - })() - } - else { - consola.info('Caddy is running in another process.') + if (line.includes('config file changed')) + consola.success('Detect added a new reverse proxy config, restarted.\n') - void (async () => { - try { - if (https) - await this.ensureTlsReady(targetHost) - setupCleanup() - resolve() - } - catch (error) { - reject(error) - } - })() - } + if (line.includes('caddy proxying') || line.includes('serving initial')) + void resolveWhenReady() + } + }) + + child.stdout.on('data', (data: Buffer) => { + consola.info(data.toString()) + void resolveWhenReady() + }) + } + catch (error) { + // The hosts entry was written before Caddy started; a failed start + // must not leave it behind while the dev server keeps running. + await this.baseCleanup(restore) + reject(error) + } + })() }) } @@ -541,15 +501,12 @@ export class CaddyInstant { } } - if (!this.locked && !this.stoped) { - await this.stopCaddy() + // Caddy and the shared Caddyfile belong to every project using this + // helper. Only this project's route is removed here; the helper stops + // Caddy when its last client disconnects. + if (!this.stoped) { try { - await lockfile.unlock(caddyLockFilePath).catch(() => {}) - await unlink(caddyFilePath).catch(() => {}) - await unlink(caddyLockFilePath).catch(() => {}) - if (caddyConfig) { - await caddyConfig.cleanUp() - } + await this.deps.config().removeProxy(this.proxyKey) } catch (e) { consola.error(e) diff --git a/test/caddy-config.test.ts b/test/caddy-config.test.ts index 90a42c5..dc38bd3 100644 --- a/test/caddy-config.test.ts +++ b/test/caddy-config.test.ts @@ -1,16 +1,18 @@ import { afterEach, describe, expect, it } from 'vitest' import { useCaddyConfig } from '../src/caddy/config' +const OWNER = 'test-owner' + describe('caddy config write with base path', () => { afterEach(async () => { const { cleanUp } = useCaddyConfig() await cleanUp() }) - it('should not override when same host with different base', () => { + it('keeps both bases when one host serves two projects', async () => { const { writeCaddyConfig, configJsonRef } = useCaddyConfig() - writeCaddyConfig([ + await writeCaddyConfig([ { target: 'example.com', portSuffix: ':80', @@ -27,14 +29,76 @@ describe('caddy config write with base path', () => { base: '/api', healthCheck: true, }, - ]) + ], OWNER) const cfg = configJsonRef() expect(cfg).toBeTruthy() - const keys = Object.keys(cfg!) - expect(keys).toContain('example.com:80/app/') - expect(keys).toContain('example.com:80/api/') - expect(cfg!['example.com:80/app/'].source).toEqual('localhost:5173') - expect(cfg!['example.com:80/api/'].source).toEqual('localhost:5174') + + const entries = Object.values(cfg!) + const app = entries.find(entry => entry.base === '/app/') + const api = entries.find(entry => entry.base === '/api/') + + expect(app?.source).toEqual('localhost:5173') + expect(api?.source).toEqual('localhost:5174') + }) + + it('keeps another project\'s routes when this one writes', async () => { + const { writeCaddyConfig, readProxies } = useCaddyConfig() + + await writeCaddyConfig([ + { + target: 'a.example.com', + portSuffix: ':80', + tls: '', + source: 'localhost:5173', + base: '/', + healthCheck: true, + }, + ], 'project-a') + + await writeCaddyConfig([ + { + target: 'b.example.com', + portSuffix: ':80', + tls: '', + source: 'localhost:5174', + base: '/', + healthCheck: true, + }, + ], 'project-b') + + const targets = Object.values(readProxies()).map(entry => entry.target) + expect(targets).toEqual(expect.arrayContaining(['a.example.com', 'b.example.com'])) + }) + + it('removes only the routes of the project that exits', async () => { + const { writeCaddyConfig, removeProxy, readProxies } = useCaddyConfig() + + await writeCaddyConfig([ + { + target: 'a.example.com', + portSuffix: ':80', + tls: '', + source: 'localhost:5173', + base: '/', + healthCheck: true, + }, + ], 'project-a') + + await writeCaddyConfig([ + { + target: 'b.example.com', + portSuffix: ':80', + tls: '', + source: 'localhost:5174', + base: '/', + healthCheck: true, + }, + ], 'project-b') + + await removeProxy('project-a') + + const targets = Object.values(readProxies()).map(entry => entry.target) + expect(targets).toEqual(['b.example.com']) }) }) diff --git a/test/caddy/fake-config.ts b/test/caddy/fake-config.ts new file mode 100644 index 0000000..7057cd0 --- /dev/null +++ b/test/caddy/fake-config.ts @@ -0,0 +1,37 @@ +import type { ProxyType } from '../../src/caddy/caddyfile' +import type { CaddyConfigApi } from '../../src/caddy/config' + +type Proxy = typeof ProxyType.infer + +/** + * In-memory stand-in for the shared route config. + * + * The real one takes a file lock, which a unit test has no reason to exercise; + * what the plugin decides (which routes it writes and which it removes) is what + * these tests are about. + */ +export function createFakeConfig(): CaddyConfigApi & { proxies: Record } { + const proxies: Record = {} + + return { + proxies, + configJsonRef: () => proxies, + async writeCaddyConfig(entries, key) { + for (const entry of entries) { + const base = entry.base?.endsWith('/') ? entry.base : `${entry.base ?? '/'}/` + proxies[`${entry.target}${entry.portSuffix}${base}#${key}`] = { ...entry, base } + } + }, + async removeProxy(key) { + for (const route of Object.keys(proxies)) { + if (route.endsWith(`#${key}`)) + delete proxies[route] + } + }, + readProxies: () => proxies, + async cleanUp() { + for (const route of Object.keys(proxies)) + delete proxies[route] + }, + } +} diff --git a/test/caddy/index.test.ts b/test/caddy/index.test.ts index 2fa3062..4133a0a 100644 --- a/test/caddy/index.test.ts +++ b/test/caddy/index.test.ts @@ -30,17 +30,12 @@ describe('caddy', () => { if (existsSync(resolve(baseUrl, 'CADDYFILE'))) await unlink(resolve(baseUrl, 'CADDYFILE')) - if (existsSync(resolve(baseUrl, 'caddylock'))) - await unlink(resolve(baseUrl, 'caddylock')) }) it.sequential(':80', async () => { const caddy = new CaddyInstant() - expect(caddy.checkLock()).toBe(false) await caddy.run('127.0.0.1:8080', 'test-1.abc.com') - expect(caddy.checkLock()).toBe(true) - const caddyfile = await caddy.getCaddyfile() expect(caddyfile).toMatchInlineSnapshot(` "{ @@ -106,13 +101,10 @@ describe('caddy', () => { it.sequential(':443', async () => { const caddy = new CaddyInstant() - expect(caddy.checkLock()).toBe(false) await caddy.run('127.0.0.1:8080', 'test-2.abc.com', { https: true, }) - expect(caddy.checkLock()).toBe(true) - const caddyfile = await caddy.getCaddyfile() expect(caddyfile).toMatchInlineSnapshot(` "{ @@ -177,11 +169,8 @@ describe('caddy', () => { it.sequential('both', async () => { const caddy_80 = new CaddyInstant() - expect(caddy_80.checkLock()).toBe(false) await caddy_80.run('127.0.0.1:8080', 'test-3.abc.com') - expect(caddy_80.checkLock()).toBe(true) - let caddyfile = await caddy_80.getCaddyfile() expect(caddyfile).toMatchInlineSnapshot(` "{ @@ -243,14 +232,11 @@ describe('caddy', () => { `) const caddy_443 = new CaddyInstant() - expect(caddy_443.checkLock()).toBe(true) await caddy_443.run('127.0.0.1:8080', 'test-4.abc.com', { https: true, healthCheck: false, }) - expect(caddy_443.checkLock()).toBe(true) - await sleep(50) caddyfile = await caddy_443.getCaddyfile() expect(caddyfile).toMatchInlineSnapshot(` @@ -328,14 +314,11 @@ describe('caddy', () => { it.sequential(':443 with base', async () => { const caddy = new CaddyInstant() - expect(caddy.checkLock()).toBe(false) await caddy.run('127.0.0.1:8080', 'test-2.abc.com', { https: true, base: '/test/', }) - expect(caddy.checkLock()).toBe(true) - const caddyfile = await caddy.getCaddyfile() expect(caddyfile).toMatchInlineSnapshot(` "{ @@ -401,15 +384,11 @@ describe('caddy', () => { it.sequential('same host different base no override', async () => { const caddyA = new CaddyInstant() - expect(caddyA.checkLock()).toBe(false) await caddyA.run('127.0.0.1:8081', 'test-5.abc.com', { base: '/app', }) - expect(caddyA.checkLock()).toBe(true) - const caddyB = new CaddyInstant() - expect(caddyB.checkLock()).toBe(true) await caddyB.run('127.0.0.1:8082', 'test-5.abc.com', { base: '/api', }) diff --git a/test/caddy/privileged-session.test.ts b/test/caddy/privileged-session.test.ts index 2164ed6..f0c1068 100644 --- a/test/caddy/privileged-session.test.ts +++ b/test/caddy/privileged-session.test.ts @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it } from 'vitest' import { CaddyInstant } from '../../src/caddy' import { caddyLockFilePath } from '../../src/caddy/constants' import { HelperCaddyProcess } from '../../src/caddy/privileged' +import { createFakeConfig } from './fake-config' // Each test starts from an unlocked state: a leftover lock would make the // plugin believe another instance already owns Caddy. @@ -87,7 +88,7 @@ async function startServing(fake: ReturnType, run: Pro describe('caddy through the privileged helper', () => { it('asks the helper to add the host mapping and free ports 80/443', async () => { const fake = createFakeSession() - const caddy = new CaddyInstant({ openSession: async () => fake.session }) + const caddy = new CaddyInstant({ openSession: async () => fake.session, config: () => createFakeConfig() }) await startServing(fake, caddy.run('127.0.0.1:8080', 'test.abc.com')) @@ -98,7 +99,7 @@ describe('caddy through the privileged helper', () => { it('starts Caddy through the helper without naming a binary', async () => { const fake = createFakeSession() - const caddy = new CaddyInstant({ openSession: async () => fake.session }) + const caddy = new CaddyInstant({ openSession: async () => fake.session, config: () => createFakeConfig() }) await startServing(fake, caddy.run('127.0.0.1:8080', 'test.abc.com')) @@ -106,9 +107,9 @@ describe('caddy through the privileged helper', () => { expect(fake.calls).toContainEqual({ method: 'caddy.start' }) }) - it('removes the mapping again during cleanup', async () => { + it('removes its own mapping during cleanup', async () => { const fake = createFakeSession() - const caddy = new CaddyInstant({ openSession: async () => fake.session }) + const caddy = new CaddyInstant({ openSession: async () => fake.session, config: () => createFakeConfig() }) await startServing(fake, caddy.run('127.0.0.1:8080', 'test.abc.com')) await caddy.baseCleanup() @@ -117,7 +118,18 @@ describe('caddy through the privileged helper', () => { method: 'hosts.remove', params: { ip: '127.0.0.1', host: 'test.abc.com' }, }) - expect(fake.calls.map(call => call.method)).toContain('caddy.stop') + }) + + it('leaves Caddy running for the projects that are still up', async () => { + const fake = createFakeSession() + const caddy = new CaddyInstant({ openSession: async () => fake.session, config: () => createFakeConfig() }) + + await startServing(fake, caddy.run('127.0.0.1:8080', 'test.abc.com')) + await caddy.baseCleanup() + + // The helper stops Caddy when its last client disconnects; a single client + // exiting must not take the shared proxy down. + expect(fake.calls.map(call => call.method)).not.toContain('caddy.stop') }) it('surfaces a helper failure before Caddy starts', async () => { @@ -136,7 +148,7 @@ describe('caddy through the privileged helper', () => { }, } as typeof fake.session - const caddy = new CaddyInstant({ openSession: async () => failing }) + const caddy = new CaddyInstant({ openSession: async () => failing, config: () => createFakeConfig() }) await expect(caddy.run('127.0.0.1:8080', 'test.abc.com')).rejects.toThrow('EACCES: permission denied') }) @@ -147,6 +159,7 @@ describe('caddy through the privileged helper', () => { let opened = 0 const caddy = new CaddyInstant({ + config: () => createFakeConfig(), openSession: async () => { opened++ return opened === 1 ? first.session : second.session @@ -161,6 +174,8 @@ describe('caddy through the privileged helper', () => { // The next run must not reuse the dead session; it opens a fresh one. const secondRun = caddy.run('127.0.0.1:8099', 'test2.abc.com') await waitForCall(second, 'hosts.add') + await waitForCall(second, 'caddy.start') + second.emit({ type: 'event', kind: 'caddy.stderr', data: '{"msg":"serving initial configuration"}' }) await secondRun.catch(() => {}) expect(opened).toBe(2) From 90db6be8eb0623f04f74d8386b39ae05ff0886f6 Mon Sep 17 00:00:00 2001 From: zcf0508 Date: Fri, 23 Oct 2026 11:44:52 +0800 Subject: [PATCH 16/35] =?UTF-8?q?feat(privileged):=20=E5=86=B7=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E4=B8=B2=E8=A1=8C=E5=8C=96=EF=BC=8C=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E5=90=84=E8=B5=B7=E4=B8=80=E4=B8=AA=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两个项目同时启动时都看不到 socket,于是各自拉起一个 helper;后起的那个会 rmSync 掉前一个的 socket,前一个变成孤儿继续占着 443。用一把独立的启动锁 包住"检查 socket → 复用 → 启动"这段窗口。 锁文件与 socket 分开:socket 只在 helper 监听后才存在,而这里要保护的是它 出现之前的那段时间。 --- src/privileged/session.ts | 77 ++++++++++++++++++++++++++++++++------- 1 file changed, 63 insertions(+), 14 deletions(-) diff --git a/src/privileged/session.ts b/src/privileged/session.ts index f8d4807..2684efb 100644 --- a/src/privileged/session.ts +++ b/src/privileged/session.ts @@ -4,6 +4,7 @@ import { existsSync } from 'node:fs' import { readFile, writeFile } from 'node:fs/promises' import { platform } from 'node:os' import { join } from 'node:path' +import * as lockfile from 'proper-lockfile' import { connectExistingHelper, createHelperToken, startPrivilegedHelper } from './launch' export interface SessionScope { @@ -50,26 +51,50 @@ export function sessionSocketPath(runtimeDir: string): string { * Connects to the helper already serving this runtime directory, or starts a * new one. Reuse matters for multi-instance dev servers: the second instance * attaches to the running Caddy instead of asking for a password again. + * + * The check-then-start sequence runs under a lock. Two projects starting at the + * same moment would otherwise both fail to find a socket, both spawn a helper, + * and the second would delete the first one's socket while it was still + * serving. */ export async function openHelperSession(scope: SessionScope): Promise { const socketPath = sessionSocketPath(scope.runtimeDir) + const release = await acquireStartLock(scope.runtimeDir) - if (existsSync(socketPath) || platform() === 'win32') { - const token = await readToken(scope.runtimeDir) - if (token) { - const reused = await connectExistingHelper(socketPath, token) - if (reused) { - return { - client: reused, - reused: true, - close: async () => { - await reused.close() - }, - } - } - } + try { + const reused = await tryReuse(socketPath, scope) + if (reused) + return reused + + return await startNew(socketPath, scope) } + finally { + release() + } +} + +async function tryReuse(socketPath: string, scope: SessionScope): Promise { + if (!existsSync(socketPath) && platform() !== 'win32') + return undefined + + const token = await readToken(scope.runtimeDir) + if (!token) + return undefined + + const reused = await connectExistingHelper(socketPath, token) + if (!reused) + return undefined + return { + client: reused, + reused: true, + close: async () => { + await reused.close() + }, + } +} + +async function startNew(socketPath: string, scope: SessionScope): Promise { const token = createHelperToken() const started = await startPrivilegedHelper({ socketPath, @@ -98,6 +123,30 @@ export async function openHelperSession(scope: SessionScope): Promise void> { + const lockPath = join(runtimeDir, 'helper.start.lock') + try { + await writeFile(lockPath, '', { flag: 'a' }) + } + catch {} + + const release = await lockfile.lock(lockPath, { + realpath: false, + stale: 30_000, + retries: { retries: 100, minTimeout: 50, maxTimeout: 200 }, + }) + + return () => { + void release().catch(() => {}) + } +} + async function readToken(runtimeDir: string): Promise { try { const content = await readFile(tokenPath(runtimeDir), 'utf8') From d970f3dd871402aa219b0bbe87820d087b3523c4 Mon Sep 17 00:00:00 2001 From: zcf0508 Date: Fri, 23 Oct 2026 11:44:58 +0800 Subject: [PATCH 17/35] =?UTF-8?q?feat(privileged):=20helper=20=E7=A9=BA?= =?UTF-8?q?=E9=97=B2=E5=90=8E=E8=87=AA=E5=8A=A8=E9=80=80=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原先 helper 只在收到 SIGTERM/SIGHUP 时退出,客户端断开后只是 dispose(清理 hosts、停 Caddy),进程本身一直挂着。反复重启 dev server 会累积 root 进程, 而且闲置的 helper 让同用户的其他进程无需密码就能拿到特权原语。 最后一个连接断开后启动计时器,默认 30 秒无客户端则退出。同一终端内重启通常 快于这个间隔,仍会复用现有 helper,不会重新输密码。 退出时只删除仍属于自己的 socket 与 token:更新的 helper 可能已经接管它们。 --- src/privileged/bootstrap.ts | 85 ++++++++++++++++++++++++++++- test/privileged/idle-exit.test.ts | 89 +++++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 test/privileged/idle-exit.test.ts diff --git a/src/privileged/bootstrap.ts b/src/privileged/bootstrap.ts index 80f19b6..bca7288 100644 --- a/src/privileged/bootstrap.ts +++ b/src/privileged/bootstrap.ts @@ -2,8 +2,9 @@ import type { Socket } from 'node:net' import type { ClientHandshake } from './protocol' import type { HelperDeps, HelperRuntime } from './server' import { Buffer } from 'node:buffer' -import { chmodSync, chownSync, rmSync } from 'node:fs' +import { chmodSync, chownSync, existsSync, readFileSync, rmSync } from 'node:fs' import { createServer } from 'node:net' +import { dirname, join } from 'node:path' import process from 'node:process' import { attachSocket, createHelperRuntime, createNodeHelperDeps } from './server' @@ -26,6 +27,14 @@ export interface HelperBootstrapOptions { ownerGid?: number /** Caddy storage directory, handed back to the owner when Caddy stops. */ caddyStoragePath?: string + /** + * How long the helper lingers with no clients before exiting. + * + * Kept short so a helper nobody is using does not stay on the machine as an + * idle root process. Within the same terminal a restart usually happens + * sooner than this and still reuses the running helper. + */ + idleTimeoutMs?: number deps?: HelperDeps log?: (message: string) => void } @@ -37,6 +46,9 @@ export interface RunningHelper { const HANDSHAKE_TIMEOUT_MS = 120_000 +/** Default idle lifetime after the last client leaves. */ +export const DEFAULT_IDLE_TIMEOUT_MS = 30_000 + /** * Runs the privileged side of the plugin: listens on a local socket, accepts * only a client presenting the shared token, then serves until that client @@ -70,12 +82,26 @@ export async function startHelper(options: HelperBootstrapOptions): Promise() + const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS + let idleTimer: ReturnType | undefined + + const stopIdleTimer = (): void => { + if (idleTimer) { + clearTimeout(idleTimer) + idleTimer = undefined + } + } const server = createServer((socket) => { let buffer = '' let authenticated = false + stopIdleTimer() connections.add(socket) - socket.on('close', () => connections.delete(socket)) + socket.on('close', () => { + connections.delete(socket) + if (connections.size === 0) + scheduleIdleExit() + }) const timer = setTimeout(() => { if (!authenticated) @@ -127,11 +153,41 @@ export async function startHelper(options: HelperBootstrapOptions): Promise {}) }) + /** + * Exits once nobody needs the helper. + * + * Without this, every dev server that ever ran leaves a root process behind. + * The socket and token are removed too, but only while they still carry this + * process's identity: a newer helper may already own them. + */ + function scheduleIdleExit(): void { + if (idleTimer || connections.size > 0) + return + + idleTimer = setTimeout(() => { + idleTimer = undefined + if (connections.size > 0) + return + + log('no clients left; shutting down') + removeOwnSocket(socketPath, token) + server.close(() => process.exit(0)) + // A listener with no clients closes synchronously, but keep a backstop so + // the process cannot linger if a close callback never arrives. + setTimeout(() => process.exit(0), 1000).unref?.() + }, idleTimeoutMs) + idleTimer.unref?.() + } + await new Promise((resolve, reject) => { server.once('error', reject) server.listen(socketPath, () => resolve()) }) + // A helper nobody ever connects to is as useless as one whose clients left: + // start the timer now so a failed handshake does not strand it. + scheduleIdleExit() + try { // The client is not root: hand the socket back to the developer and keep it // private to them. Without the chown, `connect()` fails with EACCES and the @@ -163,6 +219,31 @@ export async function startHelper(options: HelperBootstrapOptions): Promise readFileSync(hostsPath, 'utf8'), + writeHosts: (content: string) => writeFileSync(hostsPath, content), + flushDns: async () => {}, + freePort: async () => [], + caddyBin: '/usr/bin/false', + caddyConfigPath: join(dir, 'Caddyfile'), + caddyCwd: dir, + caddyEnv: {}, + trustLocalCa: async () => ({ trusted: true, output: '' }), + startCaddy: () => ({ pid: 1, kill: () => true, running: true }), + stopCaddy: async () => {}, + log: () => {}, + } +} + +const opened: Array<{ close: () => Promise }> = [] + +afterEach(async () => { + for (const helper of opened.splice(0)) + await helper.close().catch(() => {}) +}) + +describe('idle exit', () => { + it('removes its own socket and token when nobody is connected', async () => { + const dir = mkdtempSync(join(tmpdir(), 'uhrp-idle-')) + const socketPath = join(dir, 'helper.sock') + const token = 'token-abc' + + const helper = await startHelper({ + socketPath, + token, + ownerUid: process.getuid?.(), + ownerGid: process.getgid?.(), + caddyBin: '/usr/bin/false', + caddyConfigPath: join(dir, 'Caddyfile'), + idleTimeoutMs: 100, + deps: createDeps(dir), + log: () => {}, + }) + opened.push(helper) + + await new Promise(resolve => setTimeout(resolve, 400)) + + expect(existsSync(socketPath)).toBe(false) + expect(existsSync(join(dir, 'helper.token'))).toBe(false) + }) + + it('leaves a socket alone when a newer helper owns it', async () => { + const dir = mkdtempSync(join(tmpdir(), 'uhrp-idle-')) + const socketPath = join(dir, 'helper.sock') + const token = 'token-old' + + const helper = await startHelper({ + socketPath, + token, + ownerUid: process.getuid?.(), + ownerGid: process.getgid?.(), + caddyBin: '/usr/bin/false', + caddyConfigPath: join(dir, 'Caddyfile'), + idleTimeoutMs: 100, + deps: createDeps(dir), + log: () => {}, + }) + opened.push(helper) + + // Another helper took over the token while this one was idle. + writeFileSync(join(dir, 'helper.token'), 'token-new') + + await new Promise(resolve => setTimeout(resolve, 400)) + + expect(readFileSync(join(dir, 'helper.token'), 'utf8')).toBe('token-new') + }) +}) From 88ab967973695ee635709c78b81f74767f12bdcd Mon Sep 17 00:00:00 2001 From: zcf0508 Date: Fri, 23 Oct 2026 11:52:42 +0800 Subject: [PATCH 18/35] chore: release v4.1.0-beta.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2144a3d..45715a5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "unplugin-https-reverse-proxy", "type": "module", - "version": "4.1.0-beta.3", + "version": "4.1.0-beta.4", "description": "", "license": "MIT", "homepage": "https://github.com/zcf0508/unplugin-https-reverse-proxy#readme", From ec80eb4885c7bf1774e004c7c722f194abe1eb3c Mon Sep 17 00:00:00 2001 From: zcf0508 Date: Fri, 23 Oct 2026 12:38:36 +0800 Subject: [PATCH 19/35] =?UTF-8?q?fix:=20=E5=AF=86=E7=A0=81=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E6=9C=9F=E9=97=B4=20Ctrl+C=20=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=9D=80=E6=AD=BB=20dev=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sudo -v` 继承了终端,Ctrl+C 发出的 SIGINT 会同时到达 dev server。密码提示 发生在注册清理钩子之前,进程没有任何 SIGINT 处理器,默认动作直接终止,用户 看到的是"按 Ctrl+C 跳过反代,结果整个 dev server 退出",退出码 129。 现在提示期间临时接管 SIGINT,只把信号记为"取消",提示结束后立刻卸载,避免 吞掉用户之后真正想停掉 dev server 的 Ctrl+C。 顺带把 authenticateSudo 改成异步:spawnSync 会阻塞事件循环,处理器没有机会 执行,取消会被误报成普通失败。 --- src/privileged/launch.ts | 55 +++++++++++++++++++---- test/privilege-denial-hot-restart.test.ts | 2 +- test/privileged/sudo-sigint.test.ts | 54 ++++++++++++++++++++++ 3 files changed, 101 insertions(+), 10 deletions(-) create mode 100644 test/privileged/sudo-sigint.test.ts diff --git a/src/privileged/launch.ts b/src/privileged/launch.ts index 5379847..04b4ecb 100644 --- a/src/privileged/launch.ts +++ b/src/privileged/launch.ts @@ -70,10 +70,10 @@ export function describePasswordReason(target: string, https: boolean): string[] * Returns `denied` when the user dismissed the prompt, so the caller can keep * the dev server running instead of treating it as a crash. */ -export function authenticateSudo( +export async function authenticateSudo( sudoCommand = 'sudo', options: { target?: string, https?: boolean } = {}, -): { ok: boolean, denied?: boolean, message?: string } { +): Promise<{ ok: boolean, denied?: boolean, message?: string }> { if (platform() === 'win32') return { ok: true } @@ -86,16 +86,53 @@ export function authenticateSudo( process.stderr.write('\n') } - if (spawnSync(sudoCommand, ['-v'], { stdio: 'inherit' }).status === 0) - return { ok: true } + // `sudo -v` inherits the terminal, so Ctrl+C reaches this process too. Without + // a handler the default action kills the dev server, which reads as "the + // proxy crashed my dev server" when the user only meant to skip it. + let interrupted = false + const onSigint = (): void => { + interrupted = true + } + process.on('SIGINT', onSigint) + + try { + const { status, signal } = await runPrompt(sudoCommand) + if (status === 0) + return { ok: true } - return { - ok: false, - denied: true, - message: 'administrator permission is required to edit the hosts file and bind ports 80/443', + // `sudo` exits 1 on a wrong password and dies by the signal on Ctrl+C. + // Both are checked: a real Ctrl+C reaches this process and the child + // together, while a signal aimed at the child alone only shows up here. + const cancelled = interrupted || signal === 'SIGINT' + return { + ok: false, + denied: true, + message: cancelled + ? 'administrator permission was cancelled' + : 'administrator permission is required to edit the hosts file and bind ports 80/443', + } + } + finally { + process.off('SIGINT', onSigint) } } +/** + * Runs `sudo -v` with the terminal attached. + * + * Spawned asynchronously rather than with `spawnSync`: the synchronous form + * blocks the event loop, so the SIGINT handler above never runs and the exit + * looks like a plain failure instead of a cancellation. + */ +function runPrompt(sudoCommand: string): Promise<{ status: number | null, signal: NodeJS.Signals | null }> { + return new Promise((resolve) => { + const child = spawn(sudoCommand, ['-v'], { stdio: 'inherit' }) + + child.on('error', () => resolve({ status: 1, signal: null })) + child.on('exit', (status, signal) => resolve({ status, signal })) + }) +} + /** The user dismissed the password prompt; this is a choice, not a failure. */ export class PrivilegeDeniedError extends Error { constructor(message: string) { @@ -132,7 +169,7 @@ export async function startPrivilegedHelper(options: LaunchOptions): Promise { // `-n` is tried first; when it fails the interactive prompt runs. Point both // at a command that always fails so no real sudo is involved. - const first = authenticateSudo('false') + const first = await authenticateSudo('false') expect(first.ok).toBe(false) expect(first.denied).toBe(true) diff --git a/test/privileged/sudo-sigint.test.ts b/test/privileged/sudo-sigint.test.ts new file mode 100644 index 0000000..8763b85 --- /dev/null +++ b/test/privileged/sudo-sigint.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { authenticateSudo } from '../../src/privileged/launch' + +/** + * `sudo -v` inherits the terminal, so Ctrl+C reaches the dev server too. The + * prompt has to absorb that signal: without a handler the default action + * terminates the process, which reads as "the proxy killed my dev server" when + * the user only meant to skip the prompt. + * + * The cancellation branch itself is not covered here. Sending a real SIGINT to + * the runner would take the suite down, and aiming one at a child process does + * not reproduce the group-wide delivery a terminal Ctrl+C performs. What is + * pinned down is the handler's lifetime, which is the part that regresses + * silently: a leaked handler would swallow the user's later Ctrl+C. + */ +describe('sudo prompt and SIGINT', () => { + it('installs a SIGINT handler only for the duration of the prompt', async () => { + const before = process.listenerCount('SIGINT') + + // A command that fails immediately stands in for a cancelled prompt. + const result = await authenticateSudo('false', { target: 'sigint.abc.com', https: true }) + + expect(result.ok).toBe(false) + expect(result.denied).toBe(true) + + // The handler must not outlive the prompt, or it would swallow the user's + // later Ctrl+C when they actually want to stop the dev server. + expect(process.listenerCount('SIGINT')).toBe(before) + }) + + it('reports a plain failure differently from a cancellation', async () => { + const result = await authenticateSudo('false', { target: 'sigint.abc.com', https: true }) + + // No signal was sent, so this is a failed password, not a cancellation. + expect(result.message).toContain('administrator permission is required') + }) + + it('returns ok without prompting when sudo needs no password', async () => { + // `true` exits 0, which is what a cached credential looks like. + const result = await authenticateSudo('true', { target: 'sigint.abc.com', https: true }) + + expect(result.ok).toBe(true) + expect(result.denied).toBeUndefined() + }) + + it('does not leave the handler behind when the prompt throws', async () => { + const before = process.listenerCount('SIGINT') + + // A missing binary makes spawn emit `error` rather than `exit`. + await authenticateSudo('/nonexistent-sudo-binary', { target: 'sigint.abc.com', https: true }) + + expect(process.listenerCount('SIGINT')).toBe(before) + }) +}) From 5f3cbad62121537b2b60ce8dc9492f2c8997df08 Mon Sep 17 00:00:00 2001 From: zcf0508 Date: Fri, 23 Oct 2026 12:52:43 +0800 Subject: [PATCH 20/35] chore: release v4.1.0-beta.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 45715a5..b6a1f58 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "unplugin-https-reverse-proxy", "type": "module", - "version": "4.1.0-beta.4", + "version": "4.1.0-beta.5", "description": "", "license": "MIT", "homepage": "https://github.com/zcf0508/unplugin-https-reverse-proxy#readme", From 69e96aea9ca72473509db17e4a5098fa01631cc7 Mon Sep 17 00:00:00 2001 From: zcf0508 Date: Fri, 23 Oct 2026 13:03:27 +0800 Subject: [PATCH 21/35] =?UTF-8?q?fix:=20=E7=AC=AC=E4=BA=8C=E4=B8=AA?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E5=A4=8D=E7=94=A8=E5=B7=B2=E6=9C=89=20Caddy?= =?UTF-8?q?=20=E6=97=B6=E4=B8=8D=E5=86=8D=E6=8C=82=E8=B5=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第一个项目启动 Caddy 后会打印 serving initial configuration,插件据此判断就绪。 第二个项目复用的是同一个 Caddy,这行日志早已输出过,不会再出现,于是 run() 的 Promise 永远不 resolve:既不打印成功提示,也不显示代理地址。 helper 现在会返回 started: false 表示"已复用",插件据此直接进入就绪流程。 main 分支原先靠 caddylock 选主进入 else 分支来做到这一点,重构去掉选主后 漏了这段,这里补回同样的语义,但依据从"锁被占用"改为"helper 明确告知已复用"。 --- src/caddy/index.ts | 11 +++++- src/privileged/client.ts | 3 +- test/caddy/shared-start.test.ts | 66 +++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 test/caddy/shared-start.test.ts diff --git a/src/caddy/index.ts b/src/caddy/index.ts index 39e442a..e4d09cd 100644 --- a/src/caddy/index.ts +++ b/src/caddy/index.ts @@ -420,13 +420,22 @@ export class CaddyInstant { const child = new HelperCaddyProcess(session.client) this._caddyChild = child - await session.client.call('caddy.start') + const started = await session.client.call('caddy.start') child.on('exit', (code: number | null) => { if (code !== null && code !== 0) reject(new Error(`Caddy exited with code ${code}`)) }) + // Caddy was already serving: its "serving initial" line was emitted + // when the first project started it, so waiting for it again would + // hang this instance forever. + if (started?.started === false) { + consola.info('Caddy is already running; added this route to it.') + void resolveWhenReady() + return + } + child.stderr.on('data', async (data: Buffer) => { const lines = data.toString().split('\n').map((line: string) => line.trim()) for (const line of lines) { diff --git a/src/privileged/client.ts b/src/privileged/client.ts index a7acfea..3da2d79 100644 --- a/src/privileged/client.ts +++ b/src/privileged/client.ts @@ -44,7 +44,8 @@ export interface HelperResults { 'hosts.add': void 'hosts.remove': void 'port.free': { killed: number[] } - 'caddy.start': { pid?: number } + /** `started: false` means Caddy was already running and was reused. */ + 'caddy.start': { pid?: number, started?: boolean } 'caddy.stop': void 'ca.trust': { trusted: boolean, output: string } } diff --git a/test/caddy/shared-start.test.ts b/test/caddy/shared-start.test.ts new file mode 100644 index 0000000..9cec891 --- /dev/null +++ b/test/caddy/shared-start.test.ts @@ -0,0 +1,66 @@ +import type { OpenedPrivilegedSession } from '../../src/caddy/privileged' +import type { HelperEvent } from '../../src/privileged/protocol' +import { describe, expect, it } from 'vitest' +import { CaddyInstant } from '../../src/caddy' +import { createFakeConfig } from './fake-config' + +/** + * A second project attaches to the Caddy the first one started. That Caddy + * already printed its "serving initial configuration" line, so a plugin that + * waits for it again would never resolve and never print its success message. + */ +function createReusingSession() { + const calls: string[] = [] + const listeners = new Set<(event: HelperEvent) => void>() + + const client = { + connected: true, + async call(method: string): Promise { + calls.push(method) + if (method === 'caddy.start') + return { pid: 4242, started: false } + if (method === 'port.free') + return { killed: [] } + if (method === 'ca.trust') + return { trusted: true, output: '' } + return undefined + }, + onEvent(listener: (event: HelperEvent) => void) { + listeners.add(listener) + return () => listeners.delete(listener) + }, + async close() {}, + } + + return { + calls, + session: { + // The plugin only uses the members above; the rest of the client surface + // is exercised by the helper's own tests. + client: client as unknown as OpenedPrivilegedSession['client'], + reused: true, + close: async () => {}, + }, + } +} + +describe('attaching to a running Caddy', () => { + it('resolves without waiting for a startup line it will never see', async () => { + const fake = createReusingSession() + const caddy = new CaddyInstant({ + openSession: async () => fake.session, + config: () => createFakeConfig(), + }) + + // A timeout would mean the plugin is waiting for output the reused Caddy + // already emitted for the first project. + const run = caddy.run('127.0.0.1:8080', 'second.abc.com') + + await expect(Promise.race([ + run, + new Promise((_, reject) => setTimeout(() => reject(new Error('run() never resolved')), 2000)), + ])).resolves.toBeUndefined() + + expect(fake.calls).toContain('caddy.start') + }, 10_000) +}) From 25d83c776fc8cc69bf833c33e1139c39b88d347c Mon Sep 17 00:00:00 2001 From: zcf0508 Date: Fri, 23 Oct 2026 13:18:07 +0800 Subject: [PATCH 22/35] chore: release v4.1.0-beta.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b6a1f58..46cebc6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "unplugin-https-reverse-proxy", "type": "module", - "version": "4.1.0-beta.5", + "version": "4.1.0-beta.6", "description": "", "license": "MIT", "homepage": "https://github.com/zcf0508/unplugin-https-reverse-proxy#readme", From 6470a25208322166c4742849cd68e35e0b00e62e Mon Sep 17 00:00:00 2001 From: zcf0508 Date: Fri, 23 Oct 2026 13:43:22 +0800 Subject: [PATCH 23/35] =?UTF-8?q?fix:=20=E5=AF=86=E7=A0=81=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E6=9C=9F=E9=97=B4=E6=81=A2=E5=A4=8D=E7=BB=88=E7=AB=AF?= =?UTF-8?q?=E4=B8=BA=E8=A7=84=E8=8C=83=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vite 为快捷键把 stdin 设为 raw 模式。raw 模式下终端不做行编辑,回车不再 终止 sudo 正在读取的那一行,表现为输完密码还要再按一次回车才提交。 提示开始前切回规范模式,提示结束(成功、失败、Ctrl+C、命令不存在)后还原 之前的 raw 状态,Vite 的快捷键不受影响。 同时新增 quiet 选项:测试没有终端,不应把密码提示打散在测试输出里。 --- src/privileged/launch.ts | 52 ++++++++++++++++++++++++--- test/privileged/sudo-sigint.test.ts | 8 ++--- test/privileged/terminal-mode.test.ts | 37 +++++++++++++++++++ 3 files changed, 89 insertions(+), 8 deletions(-) create mode 100644 test/privileged/terminal-mode.test.ts diff --git a/src/privileged/launch.ts b/src/privileged/launch.ts index 04b4ecb..0bf90a1 100644 --- a/src/privileged/launch.ts +++ b/src/privileged/launch.ts @@ -72,7 +72,7 @@ export function describePasswordReason(target: string, https: boolean): string[] */ export async function authenticateSudo( sudoCommand = 'sudo', - options: { target?: string, https?: boolean } = {}, + options: { target?: string, https?: boolean, quiet?: boolean } = {}, ): Promise<{ ok: boolean, denied?: boolean, message?: string }> { if (platform() === 'win32') return { ok: true } @@ -80,7 +80,9 @@ export async function authenticateSudo( if (spawnSync(sudoCommand, ['-n', 'true'], { stdio: 'ignore' }).status === 0) return { ok: true } - if (options.target) { + // Tests drive this without a terminal; writing the notice there would print + // password instructions into the middle of the test output. + if (options.target && !options.quiet) { for (const line of describePasswordReason(options.target, options.https ?? false)) process.stderr.write(`${line}\n`) process.stderr.write('\n') @@ -123,16 +125,58 @@ export async function authenticateSudo( * Spawned asynchronously rather than with `spawnSync`: the synchronous form * blocks the event loop, so the SIGINT handler above never runs and the exit * looks like a plain failure instead of a cancellation. + * + * The terminal is put back into cooked mode first. Vite turns on raw mode for + * its `h + enter` shortcuts, and in that mode Enter no longer terminates the + * line sudo is reading, so the password appears to need a second Enter. */ function runPrompt(sudoCommand: string): Promise<{ status: number | null, signal: NodeJS.Signals | null }> { return new Promise((resolve) => { + const restore = takeBackTerminal() const child = spawn(sudoCommand, ['-v'], { stdio: 'inherit' }) - child.on('error', () => resolve({ status: 1, signal: null })) - child.on('exit', (status, signal) => resolve({ status, signal })) + const finish = (result: { status: number | null, signal: NodeJS.Signals | null }): void => { + restore() + resolve(result) + } + + child.on('error', () => finish({ status: 1, signal: null })) + child.on('exit', (status, signal) => finish({ status, signal })) }) } +/** + * Returns the terminal to cooked mode for the duration of the prompt. + * + * Vite leaves stdin in raw mode while it listens for its own shortcuts. In raw + * mode the tty does no line editing, so the Enter that should submit the + * password only reaches sudo as a bare carriage return and the read does not + * complete. The returned function puts the previous state back, so Vite's + * shortcuts keep working afterwards. + */ +function takeBackTerminal(): () => void { + const stdin = process.stdin + if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') + return () => {} + + const wasRaw = stdin.isRaw === true + try { + stdin.setRawMode(false) + } + catch { + return () => {} + } + + return () => { + if (!wasRaw) + return + try { + stdin.setRawMode(true) + } + catch {} + } +} + /** The user dismissed the password prompt; this is a choice, not a failure. */ export class PrivilegeDeniedError extends Error { constructor(message: string) { diff --git a/test/privileged/sudo-sigint.test.ts b/test/privileged/sudo-sigint.test.ts index 8763b85..19f9ba3 100644 --- a/test/privileged/sudo-sigint.test.ts +++ b/test/privileged/sudo-sigint.test.ts @@ -18,7 +18,7 @@ describe('sudo prompt and SIGINT', () => { const before = process.listenerCount('SIGINT') // A command that fails immediately stands in for a cancelled prompt. - const result = await authenticateSudo('false', { target: 'sigint.abc.com', https: true }) + const result = await authenticateSudo('false', { target: 'sigint.abc.com', https: true, quiet: true }) expect(result.ok).toBe(false) expect(result.denied).toBe(true) @@ -29,7 +29,7 @@ describe('sudo prompt and SIGINT', () => { }) it('reports a plain failure differently from a cancellation', async () => { - const result = await authenticateSudo('false', { target: 'sigint.abc.com', https: true }) + const result = await authenticateSudo('false', { target: 'sigint.abc.com', https: true, quiet: true }) // No signal was sent, so this is a failed password, not a cancellation. expect(result.message).toContain('administrator permission is required') @@ -37,7 +37,7 @@ describe('sudo prompt and SIGINT', () => { it('returns ok without prompting when sudo needs no password', async () => { // `true` exits 0, which is what a cached credential looks like. - const result = await authenticateSudo('true', { target: 'sigint.abc.com', https: true }) + const result = await authenticateSudo('true', { target: 'sigint.abc.com', https: true, quiet: true }) expect(result.ok).toBe(true) expect(result.denied).toBeUndefined() @@ -47,7 +47,7 @@ describe('sudo prompt and SIGINT', () => { const before = process.listenerCount('SIGINT') // A missing binary makes spawn emit `error` rather than `exit`. - await authenticateSudo('/nonexistent-sudo-binary', { target: 'sigint.abc.com', https: true }) + await authenticateSudo('/nonexistent-sudo-binary', { target: 'sigint.abc.com', https: true, quiet: true }) expect(process.listenerCount('SIGINT')).toBe(before) }) diff --git a/test/privileged/terminal-mode.test.ts b/test/privileged/terminal-mode.test.ts new file mode 100644 index 0000000..dd33a7f --- /dev/null +++ b/test/privileged/terminal-mode.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { authenticateSudo } from '../../src/privileged/launch' + +/** + * Vite turns on raw mode for its `h + enter` shortcuts. In raw mode the tty + * does no line editing, so the Enter that should submit the password reaches + * sudo as a bare carriage return and the read never completes — the user sees + * the password "not submit" until they press Enter again. + * + * The prompt therefore switches the terminal back to cooked mode and restores + * whatever it found, so Vite's shortcuts keep working afterwards. + * + * The raw-mode switch itself is not covered here: the test runner's stdin is a + * pipe, so the tty branch is skipped. What these pin down is that the guard + * works and the prompt leaves the stream alone when there is no terminal — + * a regression there would break non-interactive runs (CI, editors) entirely. + */ +describe('terminal mode during the password prompt', () => { + it('works when stdin is not a terminal', async () => { + // The test runner's stdin is a pipe, not a tty. + expect(process.stdin.isTTY).toBeFalsy() + + const result = await authenticateSudo('false', { target: 'tty.abc.com', https: true, quiet: true }) + + expect(result.ok).toBe(false) + expect(result.denied).toBe(true) + }) + + it('leaves the stream usable after the prompt', async () => { + const before = process.stdin.isPaused() + + await authenticateSudo('false', { target: 'tty.abc.com', https: true, quiet: true }) + + // Switching modes must not resume or pause the stream as a side effect. + expect(process.stdin.isPaused()).toBe(before) + }) +}) From 072c46c175addd7683e0fe8d2da8814891314a6c Mon Sep 17 00:00:00 2001 From: zcf0508 Date: Fri, 23 Oct 2026 13:43:32 +0800 Subject: [PATCH 24/35] =?UTF-8?q?fix(privileged):=20=E7=A9=BA=E9=97=B2?= =?UTF-8?q?=E9=80=80=E5=87=BA=E5=8F=AA=E5=9C=A8=E7=8B=AC=E7=AB=8B=E8=BF=9B?= =?UTF-8?q?=E7=A8=8B=E9=87=8C=E7=BB=93=E6=9D=9F=E8=BF=9B=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 空闲退出直接调用 process.exit,生产环境里 helper 是独立进程,这是对的。 但测试在同进程里调用 startHelper,这行会把 vitest 的 worker 一起终止, 导致其余测试没跑完、Duration 一直增长。 新增 exitOnIdle:默认 true,测试传 false 时只关闭监听并释放 socket。 --- src/privileged/bootstrap.ts | 17 +++++++++++++++++ test/privileged/idle-exit.test.ts | 3 +++ 2 files changed, 20 insertions(+) diff --git a/src/privileged/bootstrap.ts b/src/privileged/bootstrap.ts index bca7288..e9449f1 100644 --- a/src/privileged/bootstrap.ts +++ b/src/privileged/bootstrap.ts @@ -35,6 +35,14 @@ export interface HelperBootstrapOptions { * sooner than this and still reuses the running helper. */ idleTimeoutMs?: number + /** + * Whether the idle timer may end this process. + * + * True for the real helper, which owns its process. False when `startHelper` + * is called in-process (tests): exiting there would take the caller down + * with it. + */ + exitOnIdle?: boolean deps?: HelperDeps log?: (message: string) => void } @@ -83,6 +91,9 @@ export async function startHelper(options: HelperBootstrapOptions): Promise() const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS + // The real helper is a process of its own and may end itself. When this runs + // inside a host process (tests), only the socket is released. + const exitOnIdle = options.exitOnIdle ?? true let idleTimer: ReturnType | undefined const stopIdleTimer = (): void => { @@ -171,6 +182,12 @@ export async function startHelper(options: HelperBootstrapOptions): Promise process.exit(0)) // A listener with no clients closes synchronously, but keep a backstop so // the process cannot linger if a close callback never arrives. diff --git a/test/privileged/idle-exit.test.ts b/test/privileged/idle-exit.test.ts index dc7af37..982c4ad 100644 --- a/test/privileged/idle-exit.test.ts +++ b/test/privileged/idle-exit.test.ts @@ -50,6 +50,8 @@ describe('idle exit', () => { caddyBin: '/usr/bin/false', caddyConfigPath: join(dir, 'Caddyfile'), idleTimeoutMs: 100, + // In-process helper: it must release the socket without ending the runner. + exitOnIdle: false, deps: createDeps(dir), log: () => {}, }) @@ -74,6 +76,7 @@ describe('idle exit', () => { caddyBin: '/usr/bin/false', caddyConfigPath: join(dir, 'Caddyfile'), idleTimeoutMs: 100, + exitOnIdle: false, deps: createDeps(dir), log: () => {}, }) From 2128ccd456820b1666b9a87218e33d91fe2959dd Mon Sep 17 00:00:00 2001 From: zcf0508 Date: Fri, 23 Oct 2026 13:43:51 +0800 Subject: [PATCH 25/35] =?UTF-8?q?test(caddy):=20=E6=97=A0=20sudo=20?= =?UTF-8?q?=E6=9D=83=E9=99=90=E6=97=B6=E8=B7=B3=E8=BF=87=E9=9C=80=E8=A6=81?= =?UTF-8?q?=E7=9C=9F=E5=AE=9E=20Caddy=20=E7=9A=84=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reverse-proxy 下的 5 个用例要改 hosts 并绑定 80/443,没有提权时每个都失败, 且各自等满超时才结束,一次运行要拖一百多秒。 改为用 `sudo -n true` 探测权限,拿不到就整体跳过。同样的行为已由注入假会话 的用例覆盖,这个文件保留用于捕获适配层回归。 --- test/caddy/index.test.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/test/caddy/index.test.ts b/test/caddy/index.test.ts index 4133a0a..0b6025b 100644 --- a/test/caddy/index.test.ts +++ b/test/caddy/index.test.ts @@ -1,3 +1,4 @@ +import { spawnSync } from 'node:child_process' import { existsSync } from 'node:fs' import { unlink } from 'node:fs/promises' import { dirname, resolve } from 'node:path' @@ -9,6 +10,22 @@ import { caddyPath, TEMP_DIR } from '../../src/caddy/constants' const __dirname = dirname(fileURLToPath(import.meta.url)) const baseTestUrl = resolve(__dirname, '../') +/** + * These cases start a real Caddy, so they need the privileges the plugin would + * ask a developer for: writing hosts and binding 80/443. Without them every + * case fails after its own timeout, which is noise rather than signal. + * + * Skipped instead of failed: the behaviour under test is already covered by the + * fake-session tests, and this file exists to catch adapter-level regressions. + */ +function hasSudoWithoutPrompt(): boolean { + if (process.platform === 'win32') + return false + return spawnSync('sudo', ['-n', 'true'], { stdio: 'ignore' }).status === 0 +} + +const canRunRealCaddy = hasSudoWithoutPrompt() + beforeAll(async () => { if (existsSync(resolve(baseTestUrl, TEMP_DIR))) await unlink(resolve(baseTestUrl, TEMP_DIR)) @@ -24,7 +41,7 @@ describe('caddy', () => { expect(existsSync(caddyPath)).toBe(true) }) - describe('reverse-proxy', () => { + describe.skipIf(!canRunRealCaddy)('reverse-proxy', () => { beforeEach(async () => { const baseUrl = resolve(baseTestUrl, '.uhrp') From 2974cd1c8e81ebac54739bc3a9858dc7d3ed8ec0 Mon Sep 17 00:00:00 2001 From: zcf0508 Date: Fri, 23 Oct 2026 13:45:41 +0800 Subject: [PATCH 26/35] chore: release v4.1.0-beta.7 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 46cebc6..0fbf09b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "unplugin-https-reverse-proxy", "type": "module", - "version": "4.1.0-beta.6", + "version": "4.1.0-beta.7", "description": "", "license": "MIT", "homepage": "https://github.com/zcf0508/unplugin-https-reverse-proxy#readme", From 70fb67fec8d2285bdc0e17bbf1941ccdb7cd0254 Mon Sep 17 00:00:00 2001 From: zcf0508 Date: Fri, 23 Oct 2026 14:35:51 +0800 Subject: [PATCH 27/35] =?UTF-8?q?fix:=20=E5=AF=86=E7=A0=81=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E6=9C=9F=E9=97=B4=E6=9A=82=E5=81=9C=20stdin=EF=BC=8C?= =?UTF-8?q?=E9=81=BF=E5=85=8D=20Vite=20=E6=8A=A2=E8=B5=B0=E8=BE=93?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一版只把终端切回规范模式,仍解决不了密码读不到的问题。Vite 的快捷键 同时挂了一个 data 监听器并 resume 了 stdin,用户输入的字符先被它消费, sudo 读到空内容,三次后报 3 incorrect password attempt。 提示开始前 pause stdin:流不再从 tty 读,字符留给 sudo。pause 不移除监听器, 提示结束 resume 后 Vite 的快捷键照常工作。 --- src/privileged/launch.ts | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/src/privileged/launch.ts b/src/privileged/launch.ts index 0bf90a1..4369bf2 100644 --- a/src/privileged/launch.ts +++ b/src/privileged/launch.ts @@ -146,13 +146,18 @@ function runPrompt(sudoCommand: string): Promise<{ status: number | null, signal } /** - * Returns the terminal to cooked mode for the duration of the prompt. + * Gives the terminal to `sudo` for the duration of the prompt. * - * Vite leaves stdin in raw mode while it listens for its own shortcuts. In raw - * mode the tty does no line editing, so the Enter that should submit the - * password only reaches sudo as a bare carriage return and the read does not - * complete. The returned function puts the previous state back, so Vite's - * shortcuts keep working afterwards. + * Vite keeps stdin in raw mode with a `data` listener attached, so that every + * keystroke reaches its shortcut handler. Two things break the password read: + * + * 1. Raw mode does no line editing, so Enter never terminates the line sudo is + * waiting for. + * 2. The `data` listener is resumed, so it consumes the characters before sudo + * can see them. sudo then reads nothing and reports a wrong password. + * + * Both are undone here and restored afterwards, so Vite's shortcuts keep + * working once the prompt is over. */ function takeBackTerminal(): () => void { const stdin = process.stdin @@ -160,20 +165,28 @@ function takeBackTerminal(): () => void { return () => {} const wasRaw = stdin.isRaw === true + + // `pause` detaches the stream from the tty without removing Vite's listener, + // so the same handler still works when the stream is resumed later. + stdin.pause() + try { stdin.setRawMode(false) } catch { + stdin.resume() return () => {} } return () => { - if (!wasRaw) - return - try { - stdin.setRawMode(true) + if (wasRaw) { + try { + stdin.setRawMode(true) + } + catch {} } - catch {} + // Resume last: Vite's listener is still attached and expects a live stream. + stdin.resume() } } From 3a50d9e0df7cd1459574cefb87f9bf431b2d2125 Mon Sep 17 00:00:00 2001 From: zcf0508 Date: Fri, 23 Oct 2026 14:45:07 +0800 Subject: [PATCH 28/35] chore: release v4.1.0-beta.8 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0fbf09b..343d5f4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "unplugin-https-reverse-proxy", "type": "module", - "version": "4.1.0-beta.7", + "version": "4.1.0-beta.8", "description": "", "license": "MIT", "homepage": "https://github.com/zcf0508/unplugin-https-reverse-proxy#readme", From d9c7b65a06af19165f9fbfd75ef0cc350f7da3c9 Mon Sep 17 00:00:00 2001 From: zcf0508 Date: Fri, 23 Oct 2026 15:32:54 +0800 Subject: [PATCH 29/35] =?UTF-8?q?fix(privileged):=20Caddy=20=E8=84=B1?= =?UTF-8?q?=E7=A6=BB=E5=90=AF=E5=8A=A8=E8=80=85=E8=BF=9B=E7=A8=8B=E7=BB=84?= =?UTF-8?q?=EF=BC=8C=E9=81=BF=E5=85=8D=E8=A2=AB=20Ctrl+C=20=E5=B8=A6?= =?UTF-8?q?=E8=B5=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 多个项目共用一个 Caddy,而 Ctrl+C 会把 SIGINT 发给整个前台进程组。Caddy 由 helper 启动且未 detached,继承了这个进程组,于是一个项目退出就把所有人共用的 Caddy 一起关掉——另一个项目的域名随即 refused to connect。 spawn Caddy 时加 detached,在 POSIX 上即 setsid,自成会话和进程组。Windows 没有进程组可脱离,detached 反而会另开控制台窗口,所以那边不开。 Caddy 脱离后不再随 helper 退出,另加 process.on('exit') 同步 SIGTERM 兜底。 SIGKILL 无法捕获,那种情况由下次启动的 port.free 清理。 --- src/privileged/server.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/privileged/server.ts b/src/privileged/server.ts index 43d4da1..66b2b98 100644 --- a/src/privileged/server.ts +++ b/src/privileged/server.ts @@ -104,6 +104,8 @@ export interface HelperRuntime { dispose: () => Promise /** Removes hosts entries a previous crashed session left behind. */ sweepStaleEntries: () => Promise + /** Number of attached dev servers; a signal must not tear down a live one. */ + clientCount: () => number } /** @@ -332,6 +334,7 @@ export function createHelperRuntime(deps: HelperDeps): HelperRuntime { }, dispose, sweepStaleEntries, + clientCount: () => clients.size, } } @@ -413,6 +416,19 @@ export function createNodeHelperDeps(options: NodeHelperDepsOptions): HelperDeps const log = options.log ?? ((message: string) => process.stdout.write(`${message}\n`)) let caddy: CaddyChild | undefined + // Caddy runs in its own session, so it no longer dies with this process. The + // `exit` hook is the last chance to take it down; it must stay synchronous, + // which `kill` is. Nothing runs after SIGKILL, and that case is covered by + // the next start freeing the port. + process.on('exit', () => { + if (caddy?.pid === undefined) + return + try { + process.kill(caddy.pid, 'SIGTERM') + } + catch {} + }) + return { readHosts: () => readFileSync(hostsPath, 'utf8'), writeHosts: (content) => { @@ -465,10 +481,18 @@ export function createNodeHelperDeps(options: NodeHelperDepsOptions): HelperDeps startCaddy: (emit) => { // `--watch` makes Caddy reload when another project rewrites the shared // Caddyfile. Without it the second project's route never takes effect. + // + // `detached` puts Caddy in its own session. It shares one Caddy between + // projects, so the Ctrl+C that stops one dev server must not reach it: + // the whole foreground process group gets SIGINT, and Caddy exits on + // SIGINT. `kill()` still targets the pid, and the exit event still fires. + // Windows has no process groups to escape and `detached` would open a + // console window there, so it stays off. const child = spawn(options.caddyBin, ['run', '--adapter', 'caddyfile', '--config', options.caddyConfigPath, '--watch'], { cwd: options.caddyCwd ?? process.cwd(), env: { ...process.env, ...options.caddyEnv }, stdio: ['ignore', 'pipe', 'pipe'], + detached: platform() !== 'win32', }) const handle: CaddyChild & { running?: boolean } = { pid: child.pid, From 1efc31ddd458fdd7ba91f139503b48b303faf62d Mon Sep 17 00:00:00 2001 From: zcf0508 Date: Fri, 23 Oct 2026 15:33:11 +0800 Subject: [PATCH 30/35] =?UTF-8?q?fix(privileged):=20helper=20=E4=B8=8D?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E7=BB=88=E7=AB=AF=E4=BF=A1=E5=8F=B7=EF=BC=8C?= =?UTF-8?q?SIGTERM=20=E4=B9=9F=E4=B8=8D=E8=B8=A2=E5=AD=98=E6=B4=BB?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit helper 和启动它的 dev server 同进程组,原本 SIGTERM 和 SIGHUP 都会直接走 dispose()——无差别清掉所有 hosts 并停 Caddy,不管还有没有别的项目连着。 关掉一个终端窗口,另一个项目就断。 改为终端信号一律忽略:SIGINT、SIGQUIT、SIGTSTP、SIGHUP。忽略 SIGTSTP 还 顺带避免了 Ctrl+Z 把 helper 挂起、让所有已连接项目的 RPC 卡住。 SIGTERM 单独处理:它来自 IDE 或进程树结束工具,是真正要收工的意图,但同样 不能踢掉存活会话,所以只在没有客户端连接时才 dispose。 --- src/privileged-helper.ts | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/privileged-helper.ts b/src/privileged-helper.ts index 7f5818a..e9c368c 100644 --- a/src/privileged-helper.ts +++ b/src/privileged-helper.ts @@ -86,17 +86,25 @@ async function main(): Promise { }, }) - // Ctrl+C is delivered to the whole foreground process group. The plugin also - // receives it and will drop the socket, which is the signal this helper acts - // on; exiting here would race that cleanup. - process.on('SIGINT', () => {}) + // Terminal signals reach the whole foreground process group, so they also hit + // this helper when one of several dev servers stops. Acting on them would tear + // down the shared Caddy and hosts entries the remaining projects still use. + // The socket closing is the only signal this helper acts on. + // + // Ctrl+C, Ctrl+\, Ctrl+Z and a terminal closing all arrive this way: SIGINT, + // SIGQUIT, SIGTSTP and SIGHUP. Ignoring SIGTSTP also keeps the helper from + // being suspended, which would stall every attached project's RPC. + for (const signal of ['SIGINT', 'SIGQUIT', 'SIGTSTP', 'SIGHUP'] as const) + process.on(signal, () => {}) - const shutdown = (): void => { + // SIGTERM is different: IDE and process-tree runners send it to shut the tool + // down for real. It still must not drop a live session, so it only tears down + // when no dev server is attached. + process.on('SIGTERM', () => { + if (helper.runtime.clientCount() > 0) + return void helper.runtime.dispose().finally(() => process.exit(0)) - } - - process.on('SIGTERM', shutdown) - process.on('SIGHUP', shutdown) + }) // A closed stdout (the parent died) must not interrupt the cleanup path. process.stdout.on('error', () => {}) From 092e756970bf8d6a995ad117453e9234afc043ae Mon Sep 17 00:00:00 2001 From: zcf0508 Date: Fri, 23 Oct 2026 15:33:32 +0800 Subject: [PATCH 31/35] =?UTF-8?q?fix(caddy):=20=E8=B7=AF=E7=94=B1=E5=8F=98?= =?UTF-8?q?=E6=9B=B4=E5=90=8E=E5=9C=A8=E9=94=81=E5=86=85=E9=87=8D=E6=96=B0?= =?UTF-8?q?=E7=94=9F=E6=88=90=20Caddyfile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit removeProxy 只改路由 JSON,不重新生成 Caddyfile,于是退出项目的域名一直留在 里面,被 Caddy 反代到一个已关闭的上游。 把渲染下沉到配置锁内:writeCaddyConfig 和 removeProxy 改完路由就在同一把锁 里渲染。放在锁外会让两个并发写入各自用自己那份过期视图渲染,后写的覆盖新的。 顺带的好处是 readConfig 会过滤掉 owner 已退出的条目,每次修改都重新生成, 被 SIGKILL 的项目留下的路由也就跟着清掉了。 --- src/caddy/config.ts | 53 +++++++++++++-------- src/caddy/index.ts | 33 ++++++++++--- test/caddy/caddyfile-sync.test.ts | 77 +++++++++++++++++++++++++++++++ 3 files changed, 137 insertions(+), 26 deletions(-) create mode 100644 test/caddy/caddyfile-sync.test.ts diff --git a/src/caddy/config.ts b/src/caddy/config.ts index 55ef2f3..bad7833 100644 --- a/src/caddy/config.ts +++ b/src/caddy/config.ts @@ -84,43 +84,58 @@ function writeConfig(proxies: ProxyMap): void { renameSync(temp, caddyConfigPath) } -/** Applies `mutate` under the file lock, re-reading the latest state first. */ -async function mutateConfig( - lock: ConfigLock, - mutate: (proxies: ProxyMap) => void, -): Promise { - const release = await lock.acquire() - try { - const proxies = readConfig() - mutate(proxies) - writeConfig(proxies) - return proxies - } - finally { - release() - } -} - export interface CaddyConfigOptions { /** Injected in tests so they do not need the real lock. */ lock?: ConfigLock + /** + * Renders the Caddyfile from the routes, called inside the config lock. + * + * Rendering here rather than in the caller is what keeps the file in step + * with the JSON: two projects writing concurrently would otherwise render + * from their own stale view and the later write would win. + */ + renderCaddyfile?: (proxies: ProxyMap) => Promise } export function useCaddyConfig(options: CaddyConfigOptions = {}): CaddyConfigApi { const lock = options.lock ?? createFileLock() + /** + * Applies the change, then re-renders while still holding the lock. + * + * The Caddyfile has to follow every change: leaving a stopped project's site + * in it would keep proxying to an upstream that is gone. Doing it inside the + * lock also picks up routes whose owner process died, since `readConfig` + * drops those. + */ + async function mutate( + apply: (proxies: ProxyMap) => void, + ): Promise { + const release = await lock.acquire() + try { + const proxies = readConfig() + apply(proxies) + writeConfig(proxies) + await options.renderCaddyfile?.(proxies) + return proxies + } + finally { + release() + } + } + return { configJsonRef: () => (existsSync(caddyConfigPath) ? readConfig() : undefined), async writeCaddyConfig(_proxies, key) { - await mutateConfig(lock, (proxies) => { + await mutate((proxies) => { for (const p of _proxies) proxies[routeKey(p, key)] = { ...p, base: normalizeBase(p.base), ownerPid: process.pid } }) }, async removeProxy(key) { - const proxies = await mutateConfig(lock, (current) => { + const proxies = await mutate((current) => { for (const route of Object.keys(current)) { if (route.endsWith(`#${key}`)) delete current[route] diff --git a/src/caddy/index.ts b/src/caddy/index.ts index e4d09cd..a326e8f 100644 --- a/src/caddy/index.ts +++ b/src/caddy/index.ts @@ -106,11 +106,35 @@ interface RunOptions { healthCheck?: boolean } +/** Where Caddy keeps the local CA; the template serves it for device installs. */ +function caddyRootPath(): string { + return join( + process.platform === 'win32' + ? `${process.env.AppData!}/Caddy` + : `${process.env.HOME!}/Library/Application Support/Caddy`, + './pki/authorities/local', + ) +} + +/** + * Renders the Caddyfile from the routes currently on disk and writes it. + * + * Called from inside the config lock, so two projects writing at once cannot + * render from their own stale view of the routes. + */ +async function renderCaddyfile(proxies: Record): Promise { + const ctx = validateTemplateContext({ + caddy_root: caddyRootPath(), + proxies: Object.values(proxies) as never, + }) + await writeFile(caddyFilePath, await genCaddyfile(ctx)) +} + let caddyConfig: ReturnType | undefined function getCaddyConfig(): ReturnType { if (!caddyConfig) { - caddyConfig = useCaddyConfig() + caddyConfig = useCaddyConfig({ renderCaddyfile: renderCaddyfile as never }) } return caddyConfig } @@ -194,12 +218,7 @@ export class CaddyInstant { } get caddyRoot(): string { - return join( - process.platform === 'win32' - ? `${process.env.AppData!}/Caddy` - : `${process.env.HOME!}/Library/Application Support/Caddy`, - './pki/authorities/local', - ) + return caddyRootPath() } async writeCaddyfile(): Promise { diff --git a/test/caddy/caddyfile-sync.test.ts b/test/caddy/caddyfile-sync.test.ts new file mode 100644 index 0000000..d2c1e7b --- /dev/null +++ b/test/caddy/caddyfile-sync.test.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { useCaddyConfig } from '../../src/caddy/config' + +/** + * The Caddyfile has to follow every route change. + * + * Leaving a stopped project's site in it would keep proxying to an upstream + * that no longer runs, and the routes another project still needs would be + * rendered from a stale view. + */ +const rendered: Array> = [] + +function createConfig() { + return useCaddyConfig({ + // A no-op lock: the ordering guarantee under test is the render call, not + // the file locking, which the config tests cover separately. + lock: { acquire: async () => () => {} }, + renderCaddyfile: async (proxies) => { + rendered.push({ ...proxies } as Record) + }, + }) +} + +function route(target: string, source: string) { + return { + target, + portSuffix: ':80', + tls: '', + source, + base: '/', + healthCheck: true, + } +} + +afterEach(async () => { + rendered.length = 0 + await createConfig().cleanUp() +}) + +describe('caddyfile follows route changes', () => { + it('re-renders when a route is added', async () => { + const config = createConfig() + + await config.writeCaddyConfig([route('a.example.com', 'localhost:5173')], 'project-a') + + expect(rendered).toHaveLength(1) + expect(Object.values(rendered[0]).map(r => r.target)).toEqual(['a.example.com']) + }) + + it('drops the route of a project that exits', async () => { + const config = createConfig() + + await config.writeCaddyConfig([route('a.example.com', 'localhost:5173')], 'project-a') + await config.writeCaddyConfig([route('b.example.com', 'localhost:5174')], 'project-b') + + await config.removeProxy('project-a') + + // The last render must not contain the project that left, or its domain + // would keep pointing at a dead upstream. + const last = rendered[rendered.length - 1] + expect(Object.values(last).map(r => r.target)).toEqual(['b.example.com']) + }) + + it('renders nothing extra when the last project exits', async () => { + const config = createConfig() + + await config.writeCaddyConfig([route('a.example.com', 'localhost:5173')], 'project-a') + const rendersBefore = rendered.length + + await config.removeProxy('project-a') + + // Removing the only route leaves an empty config; the file is deleted and + // there is nothing left to serve. + expect(rendered.length).toBe(rendersBefore + 1) + expect(Object.keys(rendered[rendered.length - 1])).toEqual([]) + }) +}) From 67d215783bf6e76f215dbdd10b0554fcf7474815 Mon Sep 17 00:00:00 2001 From: zcf0508 Date: Fri, 23 Oct 2026 15:35:44 +0800 Subject: [PATCH 32/35] chore: release v4.1.0-beta.9 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 343d5f4..1556190 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "unplugin-https-reverse-proxy", "type": "module", - "version": "4.1.0-beta.8", + "version": "4.1.0-beta.9", "description": "", "license": "MIT", "homepage": "https://github.com/zcf0508/unplugin-https-reverse-proxy#readme", From db3dafe71ebd571ca4d6e0dfb683a777a1021d7e Mon Sep 17 00:00:00 2001 From: zcf0508 Date: Fri, 23 Oct 2026 17:37:14 +0800 Subject: [PATCH 33/35] =?UTF-8?q?fix(caddy):=20=E9=94=81=E5=86=85=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E8=A1=A5=E5=AE=8C=E6=95=B4=EF=BC=8C=E5=86=99=E5=85=A5?= =?UTF-8?q?=E6=94=B9=E6=88=90=E5=8E=9F=E5=AD=90=E6=9B=BF=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一版把渲染放进了配置锁,但还有三个窗口没关上: - run() 在 writeCaddyConfig 之后又调了一次 updateCaddyfile,它在锁外读路由 再写 Caddyfile。A 读到 {A},B 在锁内写入 {A,B} 并渲染,A 再用 {A} 覆盖, B 的站点就消失了。删掉这次调用,渲染只由锁内的那一次负责。 - removeProxy 在释放锁之后才删配置文件。中间另一个项目写入自己的路由,随后 的 unlink 会把它删掉。改成在锁内写空对象,不再 unlink。 - 渲染直接 writeFile,Caddy 的 --watch 可能读到写了一半的文件并解析失败, 启动者的 stderr 处理会据此退出 dev server。改成临时文件加 rename。 顺带删掉两个没有调用方的东西:updateCaddyfile(已被锁内渲染取代)和 stopCaddy(它会杀掉所有项目共用的 Caddy,留着容易被误用)。 配置路径改成可注入,测试各自拥有独立文件,不再并行写同一份而互相干扰。 --- src/caddy/config.ts | 44 +++++++++++++---------- src/caddy/index.ts | 58 +++++++++---------------------- test/caddy-config.test.ts | 35 +++++++++++++------ test/caddy/caddyfile-sync.test.ts | 51 +++++++++++++++++++-------- 4 files changed, 103 insertions(+), 85 deletions(-) diff --git a/src/caddy/config.ts b/src/caddy/config.ts index bad7833..68a99ef 100644 --- a/src/caddy/config.ts +++ b/src/caddy/config.ts @@ -51,13 +51,13 @@ function isAlive(pid: number | undefined): boolean { * A SIGKILLed dev server cannot remove its own route, so without this sweep its * domain would keep resolving to a proxy with no upstream behind it. */ -function readConfig(): ProxyMap { - if (!existsSync(caddyConfigPath)) +function readConfig(path: string = caddyConfigPath): ProxyMap { + if (!existsSync(path)) return {} let parsed: ProxyMap try { - parsed = JSON.parse(readFileSync(caddyConfigPath, 'utf-8')) as ProxyMap + parsed = JSON.parse(readFileSync(path, 'utf-8')) as ProxyMap } catch { // A half-written file is not worth preserving: it has no usable routes. @@ -77,16 +77,23 @@ function readConfig(): ProxyMap { * document. `rename` is atomic within a filesystem, which keeps the shared * config readable at all times. */ -function writeConfig(proxies: ProxyMap): void { +function writeConfig(proxies: ProxyMap, path: string = caddyConfigPath): void { const content = JSON.stringify(proxies, null, 2) - const temp = `${caddyConfigPath}.${process.pid}.tmp` + const temp = `${path}.${process.pid}.tmp` writeFileSync(temp, content) - renameSync(temp, caddyConfigPath) + renameSync(temp, path) } export interface CaddyConfigOptions { /** Injected in tests so they do not need the real lock. */ lock?: ConfigLock + /** + * Config file to operate on; defaults to the shared runtime path. + * + * Injectable so a test can own a file without racing every other test that + * happens to run against the same module-level constant. + */ + configPath?: string /** * Renders the Caddyfile from the routes, called inside the config lock. * @@ -99,6 +106,7 @@ export interface CaddyConfigOptions { export function useCaddyConfig(options: CaddyConfigOptions = {}): CaddyConfigApi { const lock = options.lock ?? createFileLock() + const configPath = options.configPath ?? caddyConfigPath /** * Applies the change, then re-renders while still holding the lock. @@ -113,9 +121,9 @@ export function useCaddyConfig(options: CaddyConfigOptions = {}): CaddyConfigApi ): Promise { const release = await lock.acquire() try { - const proxies = readConfig() + const proxies = readConfig(configPath) apply(proxies) - writeConfig(proxies) + writeConfig(proxies, configPath) await options.renderCaddyfile?.(proxies) return proxies } @@ -125,7 +133,7 @@ export function useCaddyConfig(options: CaddyConfigOptions = {}): CaddyConfigApi } return { - configJsonRef: () => (existsSync(caddyConfigPath) ? readConfig() : undefined), + configJsonRef: () => (existsSync(configPath) ? readConfig(configPath) : undefined), async writeCaddyConfig(_proxies, key) { await mutate((proxies) => { @@ -135,29 +143,29 @@ export function useCaddyConfig(options: CaddyConfigOptions = {}): CaddyConfigApi }, async removeProxy(key) { - const proxies = await mutate((current) => { + // The empty config is written inside the lock rather than unlinked after + // it: between the release and the unlink another project can add its + // route, and the unlink would then drop a route that is in use. + await mutate((current) => { for (const route of Object.keys(current)) { if (route.endsWith(`#${key}`)) delete current[route] } }) - - if (Object.keys(proxies).length === 0) - removeConfigFile() }, - readProxies: () => readConfig(), + readProxies: () => readConfig(configPath), async cleanUp() { - removeConfigFile() + removeConfigFile(configPath) }, } } -function removeConfigFile(): void { +function removeConfigFile(path: string = caddyConfigPath): void { try { - if (existsSync(caddyConfigPath)) - unlinkSync(caddyConfigPath) + if (existsSync(path)) + unlinkSync(path) } catch {} } diff --git a/src/caddy/index.ts b/src/caddy/index.ts index a326e8f..c99bf76 100644 --- a/src/caddy/index.ts +++ b/src/caddy/index.ts @@ -2,7 +2,7 @@ import type { Buffer } from 'node:buffer' import type { OpenedPrivilegedSession } from './privileged' import { execFile } from 'node:child_process' import { chmodSync, createWriteStream, existsSync, mkdirSync, unlinkSync } from 'node:fs' -import { readFile, unlink, writeFile } from 'node:fs/promises' +import { readFile, rename, unlink, writeFile } from 'node:fs/promises' import { platform } from 'node:os' import { join } from 'node:path' import process from 'node:process' @@ -12,7 +12,7 @@ import { HttpsProxyAgent } from 'https-proxy-agent' import { consola, once } from '../utils' import { genCaddyfile, validateTemplateContext } from './caddyfile' import { useCaddyConfig } from './config' -import { caddyFilePath, caddyPath, supportList, TEMP_DIR } from './constants' +import { caddyAdminSocketPath, caddyFilePath, caddyPath, supportList, TEMP_DIR } from './constants' import { isCaddyReloadCancellation } from './log' import { HelperCaddyProcess, openPrivilegedSession } from './privileged' import { formatTlsHealthFailure, waitForLocalTls } from './tls-health' @@ -125,9 +125,18 @@ function caddyRootPath(): string { async function renderCaddyfile(proxies: Record): Promise { const ctx = validateTemplateContext({ caddy_root: caddyRootPath(), + admin_socket: caddyAdminSocketPath, proxies: Object.values(proxies) as never, }) - await writeFile(caddyFilePath, await genCaddyfile(ctx)) + const content = await genCaddyfile(ctx) + + // Written through a temporary file: Caddy watches this path, and a reload + // triggered by a half-written file fails to parse. The starter's stderr + // handler exits the dev server on a parse error, so a partial read would take + // down an unrelated project. + const temp = `${caddyFilePath}.${process.pid}.tmp` + await writeFile(temp, content) + await rename(temp, caddyFilePath) } let caddyConfig: ReturnType | undefined @@ -166,6 +175,7 @@ const defaultDependencies: CaddyDependencies = { // helper must never execute something named in a request. caddyBin: caddyPath, caddyConfigPath: caddyFilePath, + caddyAdminSocket: caddyAdminSocketPath, reason, }), config: () => getCaddyConfig(), @@ -268,43 +278,6 @@ export class CaddyInstant { throw new Error(formatTlsHealthFailure(targetHost, health)) } - /** - * Regenerates the Caddyfile from the config on disk. - * - * Reading the file rather than a cached view matters here: another project - * may have added its route since this instance last looked. - */ - private async updateCaddyfile(): Promise { - const { readProxies } = this.deps.config() - const proxies = Object.values(readProxies()) - const ctx = validateTemplateContext({ - caddy_root: this.caddyRoot, - proxies, - }) - - this.caddyfile = await genCaddyfile(ctx) - await this.writeCaddyfile() - } - - private async stopCaddy(): Promise { - try { - await got.post('http://127.0.0.1:2019/stop', { - headers: { - Origin: 'http://localhost:2019', - }, - retry: { - limit: 0, - }, - timeout: { - request: 2000, - }, - }) - } - catch {} - - this._caddyChild?.kill() - } - /** * @param source like `127.0.0.1:8080` * @param target like `test.abc.com`. And if you provide a port ,it will be ignore. @@ -356,11 +329,12 @@ export class CaddyInstant { }, ] + // This also renders the Caddyfile, inside the same lock: rendering here + // afterwards would read the routes outside the lock and could overwrite a + // newer Caddyfile written by another project in between. const { writeCaddyConfig } = this.deps.config() await writeCaddyConfig(proxies, this.proxyKey) - await this.updateCaddyfile() - // Ports 80/443 may still be held by a previous, uncleanly stopped run. // The helper skips its own Caddy, so this never kills a proxy another // project is already using. diff --git a/test/caddy-config.test.ts b/test/caddy-config.test.ts index dc38bd3..e93fea4 100644 --- a/test/caddy-config.test.ts +++ b/test/caddy-config.test.ts @@ -1,12 +1,25 @@ +import { randomUUID } from 'node:crypto' import { afterEach, describe, expect, it } from 'vitest' import { useCaddyConfig } from '../src/caddy/config' -const OWNER = 'test-owner' +/** + * The config path is a module constant, so every test file in this run shares + * one file. Clearing it wholesale would race the other config tests; each case + * scopes its routes to a unique owner and removes only those. + */ +const owners: string[] = [] + +function ownOwner(): string { + const owner = `owner-${randomUUID()}` + owners.push(owner) + return owner +} describe('caddy config write with base path', () => { afterEach(async () => { - const { cleanUp } = useCaddyConfig() - await cleanUp() + const { removeProxy } = useCaddyConfig() + for (const owner of owners.splice(0)) + await removeProxy(owner) }) it('keeps both bases when one host serves two projects', async () => { @@ -29,7 +42,7 @@ describe('caddy config write with base path', () => { base: '/api', healthCheck: true, }, - ], OWNER) + ], ownOwner()) const cfg = configJsonRef() expect(cfg).toBeTruthy() @@ -54,7 +67,7 @@ describe('caddy config write with base path', () => { base: '/', healthCheck: true, }, - ], 'project-a') + ], ownOwner()) await writeCaddyConfig([ { @@ -65,7 +78,7 @@ describe('caddy config write with base path', () => { base: '/', healthCheck: true, }, - ], 'project-b') + ], ownOwner()) const targets = Object.values(readProxies()).map(entry => entry.target) expect(targets).toEqual(expect.arrayContaining(['a.example.com', 'b.example.com'])) @@ -73,6 +86,7 @@ describe('caddy config write with base path', () => { it('removes only the routes of the project that exits', async () => { const { writeCaddyConfig, removeProxy, readProxies } = useCaddyConfig() + const exiting = ownOwner() await writeCaddyConfig([ { @@ -83,7 +97,7 @@ describe('caddy config write with base path', () => { base: '/', healthCheck: true, }, - ], 'project-a') + ], exiting) await writeCaddyConfig([ { @@ -94,11 +108,12 @@ describe('caddy config write with base path', () => { base: '/', healthCheck: true, }, - ], 'project-b') + ], ownOwner()) - await removeProxy('project-a') + await removeProxy(exiting) const targets = Object.values(readProxies()).map(entry => entry.target) - expect(targets).toEqual(['b.example.com']) + expect(targets).toContain('b.example.com') + expect(targets.filter(t => t === 'a.example.com')).toHaveLength(0) }) }) diff --git a/test/caddy/caddyfile-sync.test.ts b/test/caddy/caddyfile-sync.test.ts index d2c1e7b..8466fc9 100644 --- a/test/caddy/caddyfile-sync.test.ts +++ b/test/caddy/caddyfile-sync.test.ts @@ -1,4 +1,8 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { randomUUID } from 'node:crypto' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { useCaddyConfig } from '../../src/caddy/config' /** @@ -10,11 +14,20 @@ import { useCaddyConfig } from '../../src/caddy/config' */ const rendered: Array> = [] +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'uhrp-sync-')) +}) + function createConfig() { return useCaddyConfig({ // A no-op lock: the ordering guarantee under test is the render call, not // the file locking, which the config tests cover separately. lock: { acquire: async () => () => {} }, + // Own file per test: the shared one is written by the other config tests + // in parallel, and their routes would show up in these renders. + configPath: join(dir, 'config.json'), renderCaddyfile: async (proxies) => { rendered.push({ ...proxies } as Record) }, @@ -32,46 +45,54 @@ function route(target: string, source: string) { } } -afterEach(async () => { +function ownKey(): string { + return `sync-${randomUUID()}` +} + +afterEach(() => { rendered.length = 0 - await createConfig().cleanUp() + rmSync(dir, { recursive: true, force: true }) }) describe('caddyfile follows route changes', () => { it('re-renders when a route is added', async () => { const config = createConfig() + const key = ownKey() - await config.writeCaddyConfig([route('a.example.com', 'localhost:5173')], 'project-a') + await config.writeCaddyConfig([route('a.example.com', 'localhost:5173')], key) expect(rendered).toHaveLength(1) - expect(Object.values(rendered[0]).map(r => r.target)).toEqual(['a.example.com']) + expect(Object.values(rendered[0]).some(r => r.target === 'a.example.com')).toBe(true) }) it('drops the route of a project that exits', async () => { const config = createConfig() + const keyA = ownKey() + const keyB = ownKey() - await config.writeCaddyConfig([route('a.example.com', 'localhost:5173')], 'project-a') - await config.writeCaddyConfig([route('b.example.com', 'localhost:5174')], 'project-b') + await config.writeCaddyConfig([route('a.example.com', 'localhost:5173')], keyA) + await config.writeCaddyConfig([route('b.example.com', 'localhost:5174')], keyB) - await config.removeProxy('project-a') + await config.removeProxy(keyA) // The last render must not contain the project that left, or its domain // would keep pointing at a dead upstream. const last = rendered[rendered.length - 1] - expect(Object.values(last).map(r => r.target)).toEqual(['b.example.com']) + const targets = Object.values(last).map(r => r.target) + expect(targets).toContain('b.example.com') + expect(targets.filter(t => t === 'a.example.com')).toHaveLength(0) }) - it('renders nothing extra when the last project exits', async () => { + it('renders again after the last project exits', async () => { const config = createConfig() + const key = ownKey() - await config.writeCaddyConfig([route('a.example.com', 'localhost:5173')], 'project-a') + await config.writeCaddyConfig([route('only.example.com', 'localhost:5173')], key) const rendersBefore = rendered.length - await config.removeProxy('project-a') + await config.removeProxy(key) - // Removing the only route leaves an empty config; the file is deleted and - // there is nothing left to serve. expect(rendered.length).toBe(rendersBefore + 1) - expect(Object.keys(rendered[rendered.length - 1])).toEqual([]) + expect(Object.values(rendered[rendered.length - 1]).some(r => r.target === 'only.example.com')).toBe(false) }) }) From 93fee09b519d2fb8c39d2a722281fab1152f42b8 Mon Sep 17 00:00:00 2001 From: zcf0508 Date: Fri, 23 Oct 2026 17:37:24 +0800 Subject: [PATCH 34/35] =?UTF-8?q?fix(privileged):=20=E6=94=B6=E5=9B=9E=20a?= =?UTF-8?q?dmin=20API=EF=BC=8C=E5=B9=B6=E5=9C=A8=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E6=97=B6=E6=A0=A1=E9=AA=8C=E7=89=B9=E6=9D=83=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 协议层只暴露固定原语,但还有几条路能绕过它: - Caddy 以 root 运行,admin API 却监听 127.0.0.1:2019,本机任何用户 POST /load 就能重新配置它,完全不经过 socket 和 token。改成 unix socket,放在 运行时目录里,只有能进入该目录的人才能连。ca.trust 的 --address 跟着走。 - Caddy 二进制和 Caddyfile 的路径由用户进程传入,helper 却以 root 使用它们。 启动时校验:不能是符号链接(可被重新指向)、属主必须是 root 或发起者、 不能 group/world 可写。不满足就拒绝启动。 - Linux 上 TMPDIR 通常未设置,原来回退到 /tmp。那是 1777,其他用户可以先放 一个 /tmp/caddy,之后由 helper 以 root 执行。回退值改成用户自己的缓存目录。 路径校验放独立模块,便于单测覆盖各种拒绝条件;startHelper 的集成测试验证 拒绝确实发生在启动阶段。 --- src/caddy/caddyfile.ts | 2 + src/caddy/constants.ts | 31 +++++++- src/caddy/privileged.ts | 3 + src/privileged-helper.ts | 6 ++ src/privileged/bootstrap.ts | 17 +++++ src/privileged/launch.ts | 3 + src/privileged/path-guard.ts | 74 +++++++++++++++++++ src/privileged/server.ts | 16 +++-- src/privileged/session.ts | 3 + test/caddyfile.test.ts | 16 +++++ test/privileged/idle-exit.test.ts | 2 + test/privileged/path-guard.test.ts | 87 ++++++++++++++++++++++ test/privileged/protocol-client.test.ts | 11 +++ test/privileged/start-guard.test.ts | 96 +++++++++++++++++++++++++ 14 files changed, 362 insertions(+), 5 deletions(-) create mode 100644 src/privileged/path-guard.ts create mode 100644 test/privileged/path-guard.test.ts create mode 100644 test/privileged/start-guard.test.ts diff --git a/src/caddy/caddyfile.ts b/src/caddy/caddyfile.ts index ba01715..2ef4a01 100644 --- a/src/caddy/caddyfile.ts +++ b/src/caddy/caddyfile.ts @@ -12,6 +12,7 @@ export const ProxyType = type({ const CaddyContext = type({ caddy_root: 'string', + admin_socket: 'string', proxies: ProxyType.array(), }) @@ -27,6 +28,7 @@ export const caddyTemplate = `{ debug auto_https disable_redirects ignore_loaded_certs skip_install_trust + admin unix/{{ admin_socket }} } :7601 { diff --git a/src/caddy/constants.ts b/src/caddy/constants.ts index f62cf4c..9021b16 100644 --- a/src/caddy/constants.ts +++ b/src/caddy/constants.ts @@ -13,13 +13,42 @@ function resolvePath(path: string): string { return path } -export const TEMP_DIR = resolvePath(process.env.UHRP_CADDY_PATH || process.env.TEMP || process.env.TMPDIR || '/tmp') +/** + * Directory for the Caddy binary, Caddyfile and the admin socket. + * + * The Caddy binary is executed as root, so this must not be a directory other + * users can write to. macOS gives every user a private `TMPDIR`, and `TEMP` on + * Windows is per-user too. On Linux `TMPDIR` is usually unset and `/tmp` is + * world-writable (sticky bit only prevents deleting other users' files, not + * creating `/tmp/caddy` first), so the fallback goes under the user's own cache + * directory instead. + */ +function defaultTempDir(): string { + const fromEnv = process.env.TEMP || process.env.TMPDIR + if (fromEnv) + return resolvePath(fromEnv) + if (process.platform === 'linux') + return join(process.env.XDG_CACHE_HOME || join(os.homedir(), '.cache'), 'uhrp') + return '/tmp' +} + +export const TEMP_DIR = resolvePath(process.env.UHRP_CADDY_PATH || defaultTempDir()) export const caddyPath = join(TEMP_DIR, `caddy${process.platform === 'win32' ? '.exe' : ''}`) export const caddyFilePath = join(TEMP_DIR, 'CADDYFILE') export const caddyConfigPath = join(TEMP_DIR, '.CADDYFILE.config.json') export const caddyLockFilePath = join(TEMP_DIR, 'caddylock') +/** + * Admin API socket, shared by every project using this helper. + * + * Caddy runs as root, so its admin API must not listen on a TCP port any local + * user can reach: `POST /load` there reconfigures the proxy with no further + * check. A unix socket inside the runtime directory is reachable only by whoever + * can open that directory, and the directory is private on macOS. + */ +export const caddyAdminSocketPath = join(TEMP_DIR, 'caddy-admin.sock') + export const supportList = [ { platform: 'dragonfly', diff --git a/src/caddy/privileged.ts b/src/caddy/privileged.ts index 1b6b507..f0f9d8c 100644 --- a/src/caddy/privileged.ts +++ b/src/caddy/privileged.ts @@ -82,6 +82,8 @@ export interface PrivilegedSessionOptions { caddyBin: string /** Caddyfile the helper may load. */ caddyConfigPath: string + /** Admin API socket; `ca.trust` talks to it instead of the TCP default. */ + caddyAdminSocket?: string /** Shown before the password prompt so the request is not a surprise. */ reason?: { target: string, https: boolean } } @@ -115,6 +117,7 @@ export function openPrivilegedSession( // execute a path supplied over the socket. caddyBin: options.caddyBin, caddyConfigPath: options.caddyConfigPath, + caddyAdminSocket: options.caddyAdminSocket, reason: options.reason, log: options.log, }) diff --git a/src/privileged-helper.ts b/src/privileged-helper.ts index e9c368c..a035d92 100644 --- a/src/privileged-helper.ts +++ b/src/privileged-helper.ts @@ -10,6 +10,7 @@ interface ParsedArgs { ownerUid?: number ownerGid?: number caddyStorage?: string + caddyAdmin?: string } function parseArgs(argv: string[]): ParsedArgs { @@ -21,6 +22,7 @@ function parseArgs(argv: string[]): ParsedArgs { let ownerUid: number | undefined let ownerGid: number | undefined let caddyStorage: string | undefined + let caddyAdmin: string | undefined for (let index = 0; index < argv.length; index++) { const current = argv[index] @@ -40,6 +42,8 @@ function parseArgs(argv: string[]): ParsedArgs { ownerGid = Number(argv[++index]) else if (current === '--caddy-storage') caddyStorage = argv[++index] ?? undefined + else if (current === '--caddy-admin') + caddyAdmin = argv[++index] ?? undefined } if (!socket || !token) @@ -56,6 +60,7 @@ function parseArgs(argv: string[]): ParsedArgs { ownerUid: Number.isInteger(ownerUid) ? ownerUid : undefined, ownerGid: Number.isInteger(ownerGid) ? ownerGid : undefined, caddyStorage, + caddyAdmin, } } @@ -78,6 +83,7 @@ async function main(): Promise { ownerUid: args.ownerUid, ownerGid: args.ownerGid, caddyStoragePath: args.caddyStorage, + caddyAdminSocket: args.caddyAdmin, caddyEnv: { HOME: process.env.HOME ?? '', // Keep Caddy's storage next to the invoking user's home even though the diff --git a/src/privileged/bootstrap.ts b/src/privileged/bootstrap.ts index e9449f1..9651437 100644 --- a/src/privileged/bootstrap.ts +++ b/src/privileged/bootstrap.ts @@ -6,6 +6,7 @@ import { chmodSync, chownSync, existsSync, readFileSync, rmSync } from 'node:fs' import { createServer } from 'node:net' import { dirname, join } from 'node:path' import process from 'node:process' +import { checkPrivilegedPath, describeRejection } from './path-guard' import { attachSocket, createHelperRuntime, createNodeHelperDeps } from './server' export interface HelperBootstrapOptions { @@ -16,6 +17,8 @@ export interface HelperBootstrapOptions { caddyBin: string /** Caddyfile to load; fixed for the same reason. */ caddyConfigPath: string + /** Admin API socket; used by `ca.trust` instead of the TCP default. */ + caddyAdminSocket?: string caddyCwd?: string caddyEnv?: Record /** @@ -66,11 +69,25 @@ export const DEFAULT_IDLE_TIMEOUT_MS = 30_000 export async function startHelper(options: HelperBootstrapOptions): Promise { const { socketPath, token } = options const log = options.log ?? ((message: string) => process.stdout.write(`[uhrp-helper] ${message}\n`)) + + // The binary and the config travel in as arguments from a process running as + // the developer. Refuse them when they can be rewritten by someone else: + // running as root, a swapped binary or Caddyfile is a local privilege gain. + for (const check of [ + checkPrivilegedPath(options.caddyBin, { label: 'Caddy binary', ownerUid: options.ownerUid }), + checkPrivilegedPath(options.caddyConfigPath, { label: 'Caddyfile', ownerUid: options.ownerUid }), + ]) { + if (check) { + log(describeRejection(check)) + throw new Error(describeRejection(check)) + } + } const deps = options.deps ?? createNodeHelperDeps({ hostsPath: options.hostsPath, log, caddyBin: options.caddyBin, caddyConfigPath: options.caddyConfigPath, + caddyAdminSocket: options.caddyAdminSocket, caddyCwd: options.caddyCwd, caddyEnv: options.caddyEnv, caddyStoragePath: options.caddyStoragePath, diff --git a/src/privileged/launch.ts b/src/privileged/launch.ts index 4369bf2..7fd3dea 100644 --- a/src/privileged/launch.ts +++ b/src/privileged/launch.ts @@ -39,6 +39,8 @@ export interface LaunchOptions { caddyBin: string /** Caddyfile the helper is allowed to load. */ caddyConfigPath: string + /** Admin API socket the helper talks to when installing the CA. */ + caddyAdminSocket?: string /** Shown before the password prompt so the request is not a surprise. */ reason?: { target: string, https: boolean } } @@ -251,6 +253,7 @@ export async function startPrivilegedHelper(options: LaunchOptions): Promise | undefined diff --git a/src/privileged/path-guard.ts b/src/privileged/path-guard.ts new file mode 100644 index 0000000..b338b21 --- /dev/null +++ b/src/privileged/path-guard.ts @@ -0,0 +1,74 @@ +import type { Stats } from 'node:fs' +import { lstatSync } from 'node:fs' + +/** + * Why a path is refused before the helper runs it. + * + * The helper runs as root while the paths come from a process running as the + * developer. Anything that process, or any other local user, can rewrite after + * the check would become root-executed code or root-loaded config, so the + * checks below run once at startup and the result is trusted for the session. + */ +export interface PathRejection { + path: string + reason: string +} + +export interface PathCheckOptions { + /** + * uid allowed to own the path besides root. + * + * The helper entry itself lives in the developer's `node_modules`, so the + * invoking user has to be accepted for the paths that came from there. + */ + ownerUid?: number + /** Human-readable role, used in the rejection message. */ + label: string +} + +function describe(stats: Stats): string { + return `uid=${stats.uid} mode=${(stats.mode & 0o777).toString(8)}` +} + +/** + * Rejects a path the helper must not execute or load. + * + * Symbolic links are refused outright: the check has to describe the file that + * will actually be used, and a link can be repointed after the check. Group- or + * world-writable files are refused because any local user could replace them + * between the check and the exec. + */ +export function checkPrivilegedPath( + path: string, + options: PathCheckOptions, +): PathRejection | undefined { + let stats: Stats + try { + stats = lstatSync(path) + } + catch (error) { + return { path, reason: `${options.label} is not readable: ${(error as Error).message}` } + } + + if (stats.isSymbolicLink()) + return { path, reason: `${options.label} is a symbolic link (${describe(stats)})` } + + if (!stats.isFile() && !stats.isDirectory()) + return { path, reason: `${options.label} is neither a file nor a directory` } + + const allowedOwner = stats.uid === 0 || (options.ownerUid !== undefined && stats.uid === options.ownerUid) + if (!allowedOwner) + return { path, reason: `${options.label} is owned by another user (${describe(stats)})` } + + // Root's own files are fine at 644/755; the point is that no one else can + // write them. + if (stats.mode & 0o022) + return { path, reason: `${options.label} is group- or world-writable (${describe(stats)})` } + + return undefined +} + +/** Formats a rejection for the helper log; one place keeps the wording stable. */ +export function describeRejection(rejection: PathRejection): string { + return `refusing to start: ${rejection.reason} (${rejection.path})` +} diff --git a/src/privileged/server.ts b/src/privileged/server.ts index 66b2b98..623e43b 100644 --- a/src/privileged/server.ts +++ b/src/privileged/server.ts @@ -342,6 +342,8 @@ export interface NodeHelperDepsOptions { hostsPath?: string caddyBin: string caddyConfigPath: string + /** Admin API socket; `ca.trust` talks to it instead of the TCP default. */ + caddyAdminSocket?: string caddyCwd?: string caddyEnv?: Record /** Caddy storage directory; handed back to the user when Caddy stops. */ @@ -391,13 +393,19 @@ async function reclaimTree(target: string, uid: number, gid: number): Promise { +async function trustLocalCaWithSystemTools( + caddyBin: string, + adminSocket: string | undefined, +): Promise<{ trusted: boolean, output: string }> { // `caddy trust` reads the CA through the admin API, which only exists while - // Caddy is serving; the plugin calls this after Caddy is up. + // Caddy is serving; the plugin calls this after Caddy is up. The admin API is + // a unix socket when the template enabled one, so the address follows it + // instead of the old TCP default. + const address = adminSocket ? `unix/${adminSocket}` : '127.0.0.1:2019' try { const { stdout, stderr } = await execFileAsync( caddyBin, - ['trust', '--ca', 'local', '--address', '127.0.0.1:2019'], + ['trust', '--ca', 'local', '--address', address], { timeout: 120_000 }, ) return { trusted: true, output: `${stdout}${stderr}`.trim() } @@ -475,7 +483,7 @@ export function createNodeHelperDeps(options: NodeHelperDepsOptions): HelperDeps caddyConfigPath: options.caddyConfigPath, caddyCwd: options.caddyCwd ?? process.cwd(), caddyEnv: options.caddyEnv ?? {}, - trustLocalCa: () => trustLocalCaWithSystemTools(options.caddyBin), + trustLocalCa: () => trustLocalCaWithSystemTools(options.caddyBin, options.caddyAdminSocket), caddyStoragePath: options.caddyStoragePath, storageOwner: options.storageOwner, startCaddy: (emit) => { diff --git a/src/privileged/session.ts b/src/privileged/session.ts index 2684efb..6986579 100644 --- a/src/privileged/session.ts +++ b/src/privileged/session.ts @@ -25,6 +25,8 @@ export interface SessionScope { caddyBin: string /** Caddyfile the helper may load. */ caddyConfigPath: string + /** Admin API socket; used by `ca.trust` instead of the TCP default. */ + caddyAdminSocket?: string /** Shown before the password prompt so the request is not a surprise. */ reason?: { target: string, https: boolean } log?: (message: string) => void @@ -108,6 +110,7 @@ async function startNew(socketPath: string, scope: SessionScope): Promise { it('should group same host and order routes by base length desc', async () => { const ctx = validateTemplateContext({ caddy_root: '/tmp', + admin_socket: '/tmp/caddy-admin.sock', proxies: [ { target: 'example.com', @@ -44,4 +45,19 @@ describe('caddyfile host grouping and route ordering', async () => { expect(posApi).toBeGreaterThan(-1) expect(posApiV2).toBeLessThan(posApi) }) + + it('binds the admin API to a unix socket', async () => { + const ctx = validateTemplateContext({ + caddy_root: '/tmp', + admin_socket: '/tmp/uhrp/caddy-admin.sock', + proxies: [], + }) + + const caddyfile = await genCaddyfile(ctx) + + // Caddy runs as root; a TCP admin API would let any local user reconfigure + // it with no further check. + expect(caddyfile).toContain('admin unix//tmp/uhrp/caddy-admin.sock') + expect(caddyfile).not.toContain('localhost:2019') + }) }) diff --git a/test/privileged/idle-exit.test.ts b/test/privileged/idle-exit.test.ts index 982c4ad..6f02598 100644 --- a/test/privileged/idle-exit.test.ts +++ b/test/privileged/idle-exit.test.ts @@ -12,6 +12,8 @@ import { startHelper } from '../../src/privileged/bootstrap' function createDeps(dir: string): HelperDeps { const hostsPath = join(dir, 'hosts') writeFileSync(hostsPath, '127.0.0.1\tlocalhost\n') + // The helper refuses to start when its Caddyfile is missing. + writeFileSync(join(dir, 'Caddyfile'), '') return { readHosts: () => readFileSync(hostsPath, 'utf8'), diff --git a/test/privileged/path-guard.test.ts b/test/privileged/path-guard.test.ts new file mode 100644 index 0000000..865fc9c --- /dev/null +++ b/test/privileged/path-guard.test.ts @@ -0,0 +1,87 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { checkPrivilegedPath } from '../../src/privileged/path-guard' + +/** + * The helper executes the Caddy binary and loads the Caddyfile as root, while + * both paths come from a process running as the developer. A path another user + * can rewrite would turn that into a local privilege gain. + */ +describe('checkPrivilegedPath', () => { + let dir: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'uhrp-guard-')) + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('accepts a file only the invoking user can write', () => { + const file = join(dir, 'caddy') + writeFileSync(file, '#!/bin/sh\n') + chmodSync(file, 0o700) + + expect(checkPrivilegedPath(file, { label: 'Caddy binary', ownerUid: process.getuid?.() })).toBeUndefined() + }) + + it('rejects a group-writable file', () => { + const file = join(dir, 'caddy') + writeFileSync(file, '#!/bin/sh\n') + chmodSync(file, 0o770) + + const rejection = checkPrivilegedPath(file, { label: 'Caddy binary', ownerUid: process.getuid?.() }) + + expect(rejection?.reason).toContain('group- or world-writable') + }) + + it('rejects a world-writable file', () => { + const file = join(dir, 'caddy') + writeFileSync(file, '#!/bin/sh\n') + chmodSync(file, 0o757) + + expect(checkPrivilegedPath(file, { label: 'Caddy binary', ownerUid: process.getuid?.() })).toBeDefined() + }) + + it('rejects a symbolic link', () => { + const real = join(dir, 'real-caddy') + const link = join(dir, 'caddy') + writeFileSync(real, '#!/bin/sh\n') + chmodSync(real, 0o700) + symlinkSync(real, link) + + const rejection = checkPrivilegedPath(link, { label: 'Caddy binary', ownerUid: process.getuid?.() }) + + // A link can be repointed after the check, so the checked file might not be + // the one that ends up running. + expect(rejection?.reason).toContain('symbolic link') + }) + + it('rejects a missing path', () => { + const rejection = checkPrivilegedPath(join(dir, 'absent'), { label: 'Caddyfile', ownerUid: process.getuid?.() }) + + expect(rejection?.reason).toContain('not readable') + }) + + it('rejects a file owned by someone else', () => { + const file = join(dir, 'caddy') + writeFileSync(file, '#!/bin/sh\n') + chmodSync(file, 0o700) + + // A uid that is neither root nor the caller's. + const otherUid = (process.getuid?.() ?? 0) + 1 + const rejection = checkPrivilegedPath(file, { label: 'Caddy binary', ownerUid: otherUid }) + + expect(rejection?.reason).toContain('owned by another user') + }) + + it('accepts a directory, which is how the Caddyfile path starts out', () => { + const nested = join(dir, 'runtime') + mkdirSync(nested, { mode: 0o700 }) + + expect(checkPrivilegedPath(nested, { label: 'Caddyfile', ownerUid: process.getuid?.() })).toBeUndefined() + }) +}) diff --git a/test/privileged/protocol-client.test.ts b/test/privileged/protocol-client.test.ts index 5a7cc46..25a71fe 100644 --- a/test/privileged/protocol-client.test.ts +++ b/test/privileged/protocol-client.test.ts @@ -89,6 +89,9 @@ describe('helper over a socket', () => { token: 'secret-token', caddyBin: process.execPath, caddyConfigPath: hostsPath, + // The path guard checks ownership; this test runs without sudo, so the + // invoking user owns both paths. + ownerUid: process.getuid?.(), deps: helperDeps(hostsPath), log: () => {}, }) @@ -115,6 +118,8 @@ describe('helper over a socket', () => { token: 'sweep', caddyBin: process.execPath, caddyConfigPath: hostsPath, + // The path guard checks ownership; this test runs without sudo. + ownerUid: process.getuid?.(), deps: helperDeps(hostsPath), log: () => {}, }) @@ -132,6 +137,8 @@ describe('helper over a socket', () => { token: 'right', caddyBin: process.execPath, caddyConfigPath: hostsPath, + // The path guard checks ownership; this test runs without sudo. + ownerUid: process.getuid?.(), deps: helperDeps(hostsPath), log: () => {}, }) @@ -151,6 +158,8 @@ describe('helper over a socket', () => { token: 'failing', caddyBin: process.execPath, caddyConfigPath: hostsPath, + // The path guard checks ownership; this test runs without sudo. + ownerUid: process.getuid?.(), deps: { ...helperDeps(hostsPath), writeHosts: () => { @@ -177,6 +186,8 @@ describe('helper over a socket', () => { token: 'socket', caddyBin: process.execPath, caddyConfigPath: hostsPath, + // The path guard checks ownership; this test runs without sudo. + ownerUid: process.getuid?.(), deps: helperDeps(hostsPath), log: () => {}, }) diff --git a/test/privileged/start-guard.test.ts b/test/privileged/start-guard.test.ts new file mode 100644 index 0000000..cf3eb20 --- /dev/null +++ b/test/privileged/start-guard.test.ts @@ -0,0 +1,96 @@ +import type { HelperDeps } from '../../src/privileged/server' +import { chmodSync, existsSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { startHelper } from '../../src/privileged/bootstrap' + +/** + * The helper runs as root and takes the Caddy binary and Caddyfile paths from + * a process running as the developer. If either can be rewritten by another + * local user, starting is a privilege gain, so it must refuse instead. + */ +function stubDeps(): HelperDeps { + return { + readHosts: () => '127.0.0.1\tlocalhost\n', + writeHosts: () => {}, + flushDns: async () => {}, + freePort: async () => [], + caddyBin: '/usr/bin/false', + caddyConfigPath: '/dev/null', + caddyCwd: '/tmp', + caddyEnv: {}, + trustLocalCa: async () => ({ trusted: true, output: '' }), + startCaddy: () => ({ pid: 1, kill: () => true, running: true }), + stopCaddy: async () => {}, + log: () => {}, + } +} + +describe('startHelper refuses unsafe paths', () => { + let dir: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'uhrp-start-guard-')) + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('starts when the Caddyfile is only writable by its owner', async () => { + const caddyfile = join(dir, 'Caddyfile') + writeFileSync(caddyfile, '') + chmodSync(caddyfile, 0o600) + + const helper = await startHelper({ + socketPath: join(dir, 'helper.sock'), + token: 'guard-token', + caddyBin: '/usr/bin/false', + caddyConfigPath: caddyfile, + ownerUid: process.getuid?.(), + exitOnIdle: false, + deps: stubDeps(), + log: () => {}, + }) + + expect(existsSync(join(dir, 'helper.sock'))).toBe(true) + await helper.close() + }) + + it('refuses a group-writable Caddyfile', async () => { + const caddyfile = join(dir, 'Caddyfile') + writeFileSync(caddyfile, '') + chmodSync(caddyfile, 0o660) + + await expect(startHelper({ + socketPath: join(dir, 'helper.sock'), + token: 'guard-token', + caddyBin: '/usr/bin/false', + caddyConfigPath: caddyfile, + ownerUid: process.getuid?.(), + exitOnIdle: false, + deps: stubDeps(), + log: () => {}, + })).rejects.toThrow(/group- or world-writable/) + }) + + it('refuses a Caddyfile that is a symbolic link', async () => { + const real = join(dir, 'real-Caddyfile') + const link = join(dir, 'Caddyfile') + writeFileSync(real, '') + chmodSync(real, 0o600) + symlinkSync(real, link) + + await expect(startHelper({ + socketPath: join(dir, 'helper.sock'), + token: 'guard-token', + caddyBin: '/usr/bin/false', + caddyConfigPath: link, + ownerUid: process.getuid?.(), + exitOnIdle: false, + deps: stubDeps(), + log: () => {}, + })).rejects.toThrow(/symbolic link/) + }) +}) From 5696b055b655049741b64200d914707670fe402c Mon Sep 17 00:00:00 2001 From: zcf0508 Date: Fri, 23 Oct 2026 17:50:24 +0800 Subject: [PATCH 35/35] chore: release v4.1.0-beta.10 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1556190..8272926 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "unplugin-https-reverse-proxy", "type": "module", - "version": "4.1.0-beta.9", + "version": "4.1.0-beta.10", "description": "", "license": "MIT", "homepage": "https://github.com/zcf0508/unplugin-https-reverse-proxy#readme",