@bakers-build/addon-ports-cloudflare-quick 0.0.0-stage → 0.1.0-next.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 keytec GmbH
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
- # Temporary Holding Version
1
+ # @bakers-build/addon-ports-cloudflare-quick
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ bakers.build ports transport: one cloudflared Quick Tunnel process per route, with no fixed name/URL/uptime guarantee. Implements @bakers-build/addon-ports's PortsTransport contract.
4
+
5
+ Part of the bakers.build CLI. Install the meta package `@bakers-build/bakers` to get the `bakers` CLI with all addons. Source: https://git.key-tec.de/bakers-build/bakers-build (bakers-cli/).
@@ -0,0 +1,22 @@
1
+ import type { PortsTransport } from '@bakers-build/addon-ports/transport';
2
+ import { type QuickDeps } from './quick.js';
3
+ export { AmbiguousOwnershipError, buildArgs, configPathFor, generationFromConfigPath, identityEquals, isOwnedArgv, logPathFor, type ManagedProcessIdentity, originEquals, parseLiveUrl, type QuickDeps, type QuickOrigin, type QuickRouteData, reconcileQuick, routeDirFor, sanitizeEnv, } from './quick.js';
4
+ /**
5
+ * Real I/O deps for the Quick transport. `routeDir` scanning here walks
6
+ * the WHOLE `/proc` table, which is fine at the process counts this addon
7
+ * deals with (a handful of routes per machine) — a targeted index would be
8
+ * the upgrade if that stops being true.
9
+ * ponytail: full /proc scan per reconcile call, index by routeDir if this
10
+ * ever needs to run against hundreds of routes.
11
+ */
12
+ /** Exported for a real-process regression test (R4-M1/I2 ownership
13
+ * filtering); not part of the package's public contract. */
14
+ export declare function createNodeDeps(): QuickDeps;
15
+ /**
16
+ * Default export factory consumed by `@bakers-build/addon-ports`'s
17
+ * `loadPortsTransport`. No `zone` — Quick has no fixed `<ticket>-<key>.
18
+ * <zone>` naming, unlike Named Cloudflare/frp.
19
+ */
20
+ export default function createTransport(settings: unknown, context: {
21
+ readonly home: string;
22
+ }): Promise<PortsTransport>;
@@ -0,0 +1,245 @@
1
+ // BB-681 Task 4 — Quick Tunnel as a `PortsTransport`. See `quick.ts` for
2
+ // the pure reconcile logic; this file only wires real I/O (spawn, `/proc`,
3
+ // fs, clock) into `QuickDeps`, exactly as `addons/ports-cloudflare/src/
4
+ // index.ts` wires real `fetch` into `CloudflareDeps`.
5
+ import { execFile as execFileCallback, spawn as nodeSpawn, } from 'node:child_process';
6
+ import { mkdir as fsMkdir, open as fsOpen, readdir as fsReaddir, readFile as fsReadFile, readlink as fsReadlink, } from 'node:fs/promises';
7
+ import { promisify } from 'node:util';
8
+ import { isOwnedArgv, reconcileQuick, } from './quick.js';
9
+ export { AmbiguousOwnershipError, buildArgs, configPathFor, generationFromConfigPath, identityEquals, isOwnedArgv, logPathFor, originEquals, parseLiveUrl, reconcileQuick, routeDirFor, sanitizeEnv, } from './quick.js';
10
+ const execFile = promisify(execFileCallback);
11
+ const EXEC_TIMEOUT_MS = 5_000;
12
+ /** The `@bakers-build/addon-ports` entry's `with.transport.with` for this transport — empty:
13
+ * `cloudflared` is always resolved from `PATH` (BB-681 review I6/over-
14
+ * engineering pass: no binary-path setting, per spec). */
15
+ function validateSettings(settings) {
16
+ if (settings === undefined || settings === null)
17
+ return;
18
+ if (typeof settings !== 'object' || Object.keys(settings).length > 0) {
19
+ throw new Error("the @bakers-build/addon-ports entry's with.transport.with for 'cloudflare-quick' must be empty — cloudflared is resolved from PATH");
20
+ }
21
+ }
22
+ const DATA_FIELDS = [
23
+ 'pid',
24
+ 'exe',
25
+ 'argv',
26
+ 'uid',
27
+ 'startTime',
28
+ 'bootId',
29
+ 'routeDir',
30
+ 'generation',
31
+ 'origin',
32
+ ];
33
+ /** Allowlisted provider state for this transport — thrown, never silently
34
+ * coerced, on anything outside this exact shape. */
35
+ function validateData(data) {
36
+ if (typeof data !== 'object' || data === null) {
37
+ throw new Error('cloudflare-quick transport data must be an object');
38
+ }
39
+ const obj = data;
40
+ const unknownKeys = Object.keys(obj).filter((k) => !DATA_FIELDS.includes(k));
41
+ if (unknownKeys.length > 0) {
42
+ throw new Error(`cloudflare-quick transport data has unknown field(s): ${unknownKeys.join(', ')}`);
43
+ }
44
+ }
45
+ async function readProcIdentity(pid) {
46
+ try {
47
+ const [exeLink, cmdlineRaw, statusRaw, statRaw] = await Promise.all([
48
+ fsReadlink(`/proc/${pid}/exe`).catch(() => ''),
49
+ fsReadFile(`/proc/${pid}/cmdline`, 'utf8'),
50
+ fsReadFile(`/proc/${pid}/status`, 'utf8'),
51
+ fsReadFile(`/proc/${pid}/stat`, 'utf8'),
52
+ ]);
53
+ const argv = cmdlineRaw.split('\0').filter((s) => s.length > 0);
54
+ const uidMatch = /^Uid:\s+(\d+)/m.exec(statusRaw);
55
+ const uid = uidMatch?.[1] ? Number(uidMatch[1]) : -1;
56
+ const afterComm = statRaw.slice(statRaw.lastIndexOf(')') + 2);
57
+ const startTime = Number(afterComm.split(' ')[19]);
58
+ return {
59
+ pid,
60
+ exe: exeLink.replace(/ \(deleted\)$/, ''),
61
+ argv,
62
+ uid,
63
+ startTime: Number.isFinite(startTime) ? startTime : -1,
64
+ bootId: await readBootId(),
65
+ };
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
71
+ let cachedBootId = null;
72
+ async function readBootId() {
73
+ if (cachedBootId)
74
+ return cachedBootId;
75
+ try {
76
+ const stat = await fsReadFile('/proc/stat', 'utf8');
77
+ const match = /^btime (\d+)/m.exec(stat);
78
+ cachedBootId = match?.[1] ?? 'unknown';
79
+ }
80
+ catch {
81
+ cachedBootId = 'unknown';
82
+ }
83
+ return cachedBootId;
84
+ }
85
+ async function listProcPids() {
86
+ const { readdir } = await import('node:fs/promises');
87
+ try {
88
+ const entries = await readdir('/proc');
89
+ return entries
90
+ .map((e) => Number(e))
91
+ .filter((n) => Number.isInteger(n) && n > 0);
92
+ }
93
+ catch {
94
+ return [];
95
+ }
96
+ }
97
+ async function findCloudflaredBin() {
98
+ try {
99
+ const { stdout } = await execFile('which', ['cloudflared'], {
100
+ timeout: EXEC_TIMEOUT_MS,
101
+ });
102
+ const path = stdout.trim();
103
+ return path || null;
104
+ }
105
+ catch {
106
+ return null;
107
+ }
108
+ }
109
+ /**
110
+ * Real I/O deps for the Quick transport. `routeDir` scanning here walks
111
+ * the WHOLE `/proc` table, which is fine at the process counts this addon
112
+ * deals with (a handful of routes per machine) — a targeted index would be
113
+ * the upgrade if that stops being true.
114
+ * ponytail: full /proc scan per reconcile call, index by routeDir if this
115
+ * ever needs to run against hundreds of routes.
116
+ */
117
+ /** Exported for a real-process regression test (R4-M1/I2 ownership
118
+ * filtering); not part of the package's public contract. */
119
+ export function createNodeDeps() {
120
+ return {
121
+ spawn(bin, args, opts) {
122
+ const child = nodeSpawn(bin, args, {
123
+ cwd: opts.cwd,
124
+ env: opts.env,
125
+ detached: true,
126
+ stdio: 'ignore',
127
+ });
128
+ child.unref();
129
+ return { pid: child.pid ?? -1 };
130
+ },
131
+ findBinary: findCloudflaredBin,
132
+ now: () => Date.now(),
133
+ sleep: (ms, signal) => new Promise((resolve) => {
134
+ if (signal?.aborted)
135
+ return resolve();
136
+ const timer = setTimeout(resolve, ms);
137
+ signal?.addEventListener('abort', () => {
138
+ clearTimeout(timer);
139
+ resolve();
140
+ }, { once: true });
141
+ }),
142
+ readFile: async (path) => {
143
+ try {
144
+ // ponytail: reads the whole file each poll; bound by rotating per
145
+ // generation (never unbounded growth across restarts). A tail-only
146
+ // reader is the upgrade if a single generation's log gets huge.
147
+ return await fsReadFile(path, 'utf8');
148
+ }
149
+ catch {
150
+ return null;
151
+ }
152
+ },
153
+ writeFile: async (path, data) => {
154
+ const fh = await fsOpen(path, 'w');
155
+ try {
156
+ await fh.writeFile(data);
157
+ }
158
+ finally {
159
+ await fh.close();
160
+ }
161
+ },
162
+ mkdir: async (path) => {
163
+ await fsMkdir(path, { recursive: true });
164
+ },
165
+ listOwnedProcesses: async (dir) => {
166
+ const pids = await listProcPids();
167
+ const owned = [];
168
+ const ownUid = process.getuid?.();
169
+ for (const pid of pids) {
170
+ const identity = await readProcIdentity(pid);
171
+ if (!identity || !isOwnedArgv(identity.argv, dir))
172
+ continue;
173
+ // BB-681 review R4 (I2 remainder): the `--config <dir>/…` argv
174
+ // marker alone is a string any process could set — spoofable by
175
+ // design, never sufficient to adopt or kill something. Require it
176
+ // ALSO be a `cloudflared` executable run under our own uid before
177
+ // trusting it as a candidate; otherwise a lookalike process (wrong
178
+ // user, or not cloudflared at all) could be adopted as ours or,
179
+ // worse, killed as a mismatched orphan.
180
+ if (ownUid !== undefined && identity.uid !== ownUid)
181
+ continue;
182
+ if (!identity.exe.toLowerCase().includes('cloudflared'))
183
+ continue;
184
+ owned.push(identity);
185
+ }
186
+ return owned;
187
+ },
188
+ listGenerations: async (dir) => {
189
+ try {
190
+ const entries = await fsReaddir(dir, { withFileTypes: true });
191
+ return entries
192
+ .filter((e) => e.isDirectory())
193
+ .map((e) => /^gen-(\d+)$/.exec(e.name)?.[1])
194
+ .filter((n) => n !== undefined)
195
+ .map(Number);
196
+ }
197
+ catch {
198
+ return [];
199
+ }
200
+ },
201
+ getProcessIdentity: (pid) => readProcIdentity(pid),
202
+ kill: (pid) => {
203
+ try {
204
+ process.kill(pid, 'SIGTERM');
205
+ }
206
+ catch {
207
+ // already gone
208
+ }
209
+ },
210
+ waitExit: async (pid, timeoutMs, signal) => {
211
+ const deadline = Date.now() + timeoutMs;
212
+ for (;;) {
213
+ if (signal?.aborted)
214
+ return false;
215
+ const identity = await readProcIdentity(pid);
216
+ if (!identity)
217
+ return true;
218
+ if (Date.now() >= deadline)
219
+ return false;
220
+ await new Promise((resolve) => setTimeout(resolve, 100));
221
+ }
222
+ },
223
+ bootId: readBootId,
224
+ env: process.env,
225
+ urlWaitMs: 15_000,
226
+ pollMs: 250,
227
+ killWaitMs: 5_000,
228
+ };
229
+ }
230
+ /**
231
+ * Default export factory consumed by `@bakers-build/addon-ports`'s
232
+ * `loadPortsTransport`. No `zone` — Quick has no fixed `<ticket>-<key>.
233
+ * <zone>` naming, unlike Named Cloudflare/frp.
234
+ */
235
+ export default async function createTransport(settings, context) {
236
+ validateSettings(settings);
237
+ const deps = createNodeDeps();
238
+ const home = context.home;
239
+ return {
240
+ id: 'cloudflare-quick',
241
+ identity: 'cloudflare-quick',
242
+ validateData,
243
+ reconcile: (records, { signal, checkpoint }) => reconcileQuick(records, deps, home, { signal, checkpoint }),
244
+ };
245
+ }
@@ -0,0 +1,127 @@
1
+ import type { TunnelDesiredRecord } from '@bakers-build/addon-ports/transport';
2
+ /** The nonsecret identity fields a live/recorded process is matched on. A
3
+ * bare PID is NEVER sufficient — PIDs get reused — so every comparison
4
+ * checks all of these together. */
5
+ export interface ManagedProcessIdentity {
6
+ readonly pid: number;
7
+ readonly exe: string;
8
+ readonly argv: readonly string[];
9
+ readonly uid: number;
10
+ readonly startTime: number;
11
+ /** Machine boot identity (boot time or boot id) — a PID+startTime pair
12
+ * from BEFORE a machine reboot must never be mistaken for a match after
13
+ * one, even if the raw numbers happen to coincide. */
14
+ readonly bootId: string;
15
+ }
16
+ export interface QuickOrigin {
17
+ readonly service: string;
18
+ readonly hostHeader?: string | undefined;
19
+ readonly insecureTls?: boolean | undefined;
20
+ }
21
+ /** The allowlisted provider state this transport persists in
22
+ * `TunnelDesiredRecord.data` — validated by `validateData` in `index.ts`. */
23
+ export interface QuickRouteData {
24
+ readonly pid: number;
25
+ readonly exe: string;
26
+ readonly argv: readonly string[];
27
+ readonly uid: number;
28
+ readonly startTime: number;
29
+ readonly bootId: string;
30
+ readonly routeDir: string;
31
+ readonly generation: number;
32
+ readonly origin: QuickOrigin;
33
+ }
34
+ export declare function originEquals(a: QuickOrigin, b: QuickOrigin): boolean;
35
+ export declare function identityEquals(a: ManagedProcessIdentity, b: ManagedProcessIdentity): boolean;
36
+ /** Deterministic, collision-proof per-route directory under `BB_HOME`:
37
+ * worktree+ticket+key hashed together, so two different routes never share
38
+ * a directory even when a key string repeats across worktrees/tickets. */
39
+ export declare function routeDirFor(home: string, worktree: string, ticket: string, key: string): string;
40
+ export declare function configPathFor(dir: string, generation: number): string;
41
+ export declare function logPathFor(dir: string, generation: number): string;
42
+ /** Pulls the generation number back out of a recorded `--config` path —
43
+ * used to rediscover an adopted process's log without trusting any other
44
+ * recorded state. */
45
+ export declare function generationFromConfigPath(configPath: string): number | null;
46
+ /** `--no-autoupdate`, explicit `--no-tls-verify=true|false` (never omitted
47
+ * when it matters — absence must never silently disable verification),
48
+ * and `--http-host-header` only when the rule carries one. `--logfile` is
49
+ * REQUIRED, not cosmetic: `waitForUrl` reads the allocated URL only from
50
+ * this exact generation's log file (BB-681 review C2 — cloudflared never
51
+ * wrote one here before, so every `up` timed out). Built as an ARGV ARRAY —
52
+ * never a shell string — so nothing in `origin.service`/`hostHeader` can be
53
+ * interpreted as a shell metacharacter. */
54
+ export declare function buildArgs(configPath: string, logPath: string, origin: QuickOrigin): string[];
55
+ /** Reads back the origin `buildArgs` encoded into a live process's argv —
56
+ * used to tell whether an adopted orphan (recognized only by its
57
+ * `--config <dir>/…` marker) is actually serving the DESIRED origin before
58
+ * trusting it (BB-681 review I2 remainder). `null` when the argv carries
59
+ * no `--url` at all (not one of ours, or a shape this parser predates). */
60
+ export declare function originFromArgv(argv: readonly string[]): QuickOrigin | null;
61
+ /** Marker byte-for-byte present in every managed quick process's argv (the
62
+ * `--config <routeDir>/gen-N/config.yaml` pair) — used both to recognize
63
+ * OUR OWN processes during recovery and to tell the generic legacy-reap
64
+ * scan (`addons/ports/src/deps-node.ts`) to leave them alone. */
65
+ export declare function isOwnedArgv(argv: readonly string[], dir: string): boolean;
66
+ export declare function parseLiveUrl(log: string): string | null;
67
+ /** Strips cloudflared tunnel-selection/config/credential overrides the
68
+ * PARENT environment might carry (`TUNNEL_*`, and cloudflared's own
69
+ * config-path override) from the CHILD's env — `HOME` itself is NEVER
70
+ * touched, so the child never redirects to a different (or the user's
71
+ * real) cloudflared config/credentials on disk. */
72
+ export declare function sanitizeEnv(parentEnv: Readonly<Record<string, string | undefined>>): Record<string, string>;
73
+ export declare class AmbiguousOwnershipError extends Error {
74
+ constructor(dir: string, count: number);
75
+ }
76
+ export interface QuickDeps {
77
+ /** Spawn the real child — an argv ARRAY, never a shell string. */
78
+ spawn(bin: string, args: readonly string[], opts: {
79
+ readonly cwd: string;
80
+ readonly env: Readonly<Record<string, string>>;
81
+ }): {
82
+ readonly pid: number;
83
+ };
84
+ /** Resolved path to the `cloudflared` binary, or `null` if missing. */
85
+ findBinary(): Promise<string | null>;
86
+ now(): number;
87
+ /** Resolves early if `signal` aborts mid-sleep — never leaves an abort
88
+ * waiting out the full duration. */
89
+ sleep(ms: number, signal?: AbortSignal): Promise<void>;
90
+ /** `null` when the file doesn't exist yet (not spawned, or log not yet
91
+ * flushed) — never an error for this case. */
92
+ readFile(path: string): Promise<string | null>;
93
+ writeFile(path: string, data: string): Promise<void>;
94
+ mkdir(path: string): Promise<void>;
95
+ /** Scans the WHOLE process table for anything whose argv carries this
96
+ * route's `--config` marker (see `isOwnedArgv`) — the ground truth used
97
+ * for crash recovery, independent of anything recorded in `data`. */
98
+ listOwnedProcesses(dir: string): Promise<readonly ManagedProcessIdentity[]>;
99
+ /** Generation numbers with a `gen-N` directory already on disk under
100
+ * `dir` — read ONLY to pick the next unused generation (BB-681 review
101
+ * N7); a route's directory outlives `down`, so `recorded` alone is not
102
+ * enough to avoid reusing a previous life's generation/log. */
103
+ listGenerations(dir: string): Promise<readonly number[]>;
104
+ /** Direct re-check of a single remembered pid — `null` if gone or if the
105
+ * pid now belongs to an unrelated process (PID reuse is the caller's
106
+ * concern via `identityEquals`, not this method's). */
107
+ getProcessIdentity(pid: number): Promise<ManagedProcessIdentity | null>;
108
+ kill(pid: number): void;
109
+ /** Resolves once the pid is confirmed gone, or `signal` aborts/deadline
110
+ * elapses — termination is only reported back once ACTUALLY confirmed,
111
+ * never merely "signal sent". */
112
+ waitExit(pid: number, timeoutMs: number, signal?: AbortSignal): Promise<boolean>;
113
+ bootId(): Promise<string>;
114
+ readonly env: Readonly<Record<string, string | undefined>>;
115
+ readonly urlWaitMs: number;
116
+ readonly pollMs: number;
117
+ readonly killWaitMs: number;
118
+ }
119
+ /** `PortsTransport.reconcile` for Quick: no DNS/ingress phase — each
120
+ * record's remote state IS its own managed process. */
121
+ export declare function reconcileQuick(records: readonly TunnelDesiredRecord[], deps: QuickDeps, home: string, context: {
122
+ readonly signal?: AbortSignal | undefined;
123
+ checkpoint(records: readonly TunnelDesiredRecord[]): Promise<void>;
124
+ }): Promise<{
125
+ readonly records: readonly TunnelDesiredRecord[];
126
+ readonly ok: boolean;
127
+ }>;
@@ -0,0 +1,474 @@
1
+ // BB-681 Task 4 — Quick Tunnel as a `PortsTransport`: one managed
2
+ // `cloudflared tunnel --url …` process PER ROUTE, no zone, no fixed name.
3
+ // Unlike Named Cloudflare/frp (a fixed `<ticket>-<key>.<zone>` hostname),
4
+ // Quick allocates its own ephemeral `*.trycloudflare.com` URL per process —
5
+ // recreating a route MAY hand back a different URL, which is expected, not
6
+ // a bug (see README).
7
+ //
8
+ // Every real I/O seam (spawn, process inspection, fs, kill, clock, sleep) is
9
+ // injected via `QuickDeps`, exactly as `cloudflare.ts`'s `fetch` is injected
10
+ // — this file is pure logic, unit-tested without touching `/proc` or
11
+ // spawning anything real.
12
+ import { createHash } from 'node:crypto';
13
+ import { join } from 'node:path';
14
+ import { upsertRecord } from '@bakers-build/addon-ports/transport';
15
+ export function originEquals(a, b) {
16
+ return (a.service === b.service &&
17
+ (a.hostHeader ?? undefined) === (b.hostHeader ?? undefined) &&
18
+ Boolean(a.insecureTls) === Boolean(b.insecureTls));
19
+ }
20
+ export function identityEquals(a, b) {
21
+ return (a.pid === b.pid &&
22
+ a.exe === b.exe &&
23
+ a.uid === b.uid &&
24
+ a.startTime === b.startTime &&
25
+ a.bootId === b.bootId &&
26
+ a.argv.length === b.argv.length &&
27
+ a.argv.every((v, i) => v === b.argv[i]));
28
+ }
29
+ /** Deterministic, collision-proof per-route directory under `BB_HOME`:
30
+ * worktree+ticket+key hashed together, so two different routes never share
31
+ * a directory even when a key string repeats across worktrees/tickets. */
32
+ export function routeDirFor(home, worktree, ticket, key) {
33
+ const hash = createHash('sha256')
34
+ .update(`${worktree}\0${ticket}\0${key}`)
35
+ .digest('hex')
36
+ .slice(0, 20);
37
+ return join(home, 'ports', 'quick', hash);
38
+ }
39
+ export function configPathFor(dir, generation) {
40
+ return join(dir, `gen-${generation}`, 'config.yaml');
41
+ }
42
+ export function logPathFor(dir, generation) {
43
+ return join(dir, `gen-${generation}`, 'cloudflared.log');
44
+ }
45
+ /** Pulls the generation number back out of a recorded `--config` path —
46
+ * used to rediscover an adopted process's log without trusting any other
47
+ * recorded state. */
48
+ export function generationFromConfigPath(configPath) {
49
+ const match = /gen-(\d+)[/\\]config\.yaml$/.exec(configPath);
50
+ if (!match)
51
+ return null;
52
+ const n = Number(match[1]);
53
+ return Number.isInteger(n) ? n : null;
54
+ }
55
+ /** `--no-autoupdate`, explicit `--no-tls-verify=true|false` (never omitted
56
+ * when it matters — absence must never silently disable verification),
57
+ * and `--http-host-header` only when the rule carries one. `--logfile` is
58
+ * REQUIRED, not cosmetic: `waitForUrl` reads the allocated URL only from
59
+ * this exact generation's log file (BB-681 review C2 — cloudflared never
60
+ * wrote one here before, so every `up` timed out). Built as an ARGV ARRAY —
61
+ * never a shell string — so nothing in `origin.service`/`hostHeader` can be
62
+ * interpreted as a shell metacharacter. */
63
+ export function buildArgs(configPath, logPath, origin) {
64
+ return [
65
+ '--config',
66
+ configPath,
67
+ '--no-autoupdate',
68
+ '--logfile',
69
+ logPath,
70
+ 'tunnel',
71
+ '--url',
72
+ origin.service,
73
+ `--no-tls-verify=${origin.insecureTls ? 'true' : 'false'}`,
74
+ ...(origin.hostHeader ? ['--http-host-header', origin.hostHeader] : []),
75
+ ];
76
+ }
77
+ /** Reads back the origin `buildArgs` encoded into a live process's argv —
78
+ * used to tell whether an adopted orphan (recognized only by its
79
+ * `--config <dir>/…` marker) is actually serving the DESIRED origin before
80
+ * trusting it (BB-681 review I2 remainder). `null` when the argv carries
81
+ * no `--url` at all (not one of ours, or a shape this parser predates). */
82
+ export function originFromArgv(argv) {
83
+ const urlIdx = argv.indexOf('--url');
84
+ if (urlIdx === -1 || urlIdx + 1 >= argv.length)
85
+ return null;
86
+ const service = argv[urlIdx + 1];
87
+ const insecureTls = argv.includes('--no-tls-verify=true');
88
+ const headerIdx = argv.indexOf('--http-host-header');
89
+ const hostHeader = headerIdx !== -1 && headerIdx + 1 < argv.length
90
+ ? argv[headerIdx + 1]
91
+ : undefined;
92
+ return { service, hostHeader, insecureTls };
93
+ }
94
+ /** Marker byte-for-byte present in every managed quick process's argv (the
95
+ * `--config <routeDir>/gen-N/config.yaml` pair) — used both to recognize
96
+ * OUR OWN processes during recovery and to tell the generic legacy-reap
97
+ * scan (`addons/ports/src/deps-node.ts`) to leave them alone. */
98
+ export function isOwnedArgv(argv, dir) {
99
+ const idx = argv.indexOf('--config');
100
+ if (idx === -1 || idx + 1 >= argv.length)
101
+ return false;
102
+ const configPath = argv[idx + 1];
103
+ return typeof configPath === 'string' && configPath.startsWith(`${dir}/`);
104
+ }
105
+ /** Only a validated `https://<label>.trycloudflare.com` origin is ever
106
+ * trusted — log-spoofing resistance: any other URL-shaped text in the log
107
+ * (an attacker-controlled upstream response line, a stray info message) is
108
+ * rejected, never returned as the live URL. `api.trycloudflare.com` is
109
+ * cloudflared's OWN allocation-API host, which appears in the log's error
110
+ * lines (not an allocated tunnel) — explicitly excluded, never matched as a
111
+ * live route (BB-681 review I4). */
112
+ const TRYCLOUDFLARE_RE = /\bhttps:\/\/(?!api\.)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.trycloudflare\.com\b/i;
113
+ export function parseLiveUrl(log) {
114
+ const match = TRYCLOUDFLARE_RE.exec(log);
115
+ return match ? match[0] : null;
116
+ }
117
+ /** Strips cloudflared tunnel-selection/config/credential overrides the
118
+ * PARENT environment might carry (`TUNNEL_*`, and cloudflared's own
119
+ * config-path override) from the CHILD's env — `HOME` itself is NEVER
120
+ * touched, so the child never redirects to a different (or the user's
121
+ * real) cloudflared config/credentials on disk. */
122
+ export function sanitizeEnv(parentEnv) {
123
+ const out = {};
124
+ for (const [key, value] of Object.entries(parentEnv)) {
125
+ if (value === undefined)
126
+ continue;
127
+ if (/^TUNNEL_/i.test(key))
128
+ continue;
129
+ if (key === 'TUNNEL_ORIGIN_CERT' || key === 'CF_API_TOKEN')
130
+ continue;
131
+ out[key] = value;
132
+ }
133
+ return out;
134
+ }
135
+ export class AmbiguousOwnershipError extends Error {
136
+ constructor(dir, count) {
137
+ super(`ambiguous ownership for quick tunnel route '${dir}': ${count} candidate owned processes found — refusing to guess which is live (never killing or spawning alongside an ambiguous match)`);
138
+ this.name = 'AmbiguousOwnershipError';
139
+ }
140
+ }
141
+ function dataOf(record) {
142
+ return record.data;
143
+ }
144
+ /** Returns `true` only once the process is CONFIRMED gone (already gone, or
145
+ * killed and `waitExit` confirmed it) — `false` when a kill was sent but
146
+ * exit never confirmed within the deadline. BB-681 review I2 (remainder):
147
+ * callers must not spawn a fresh generation while an old one might still be
148
+ * alive, racing to route the same key. */
149
+ async function terminateOwned(deps, identity, signal) {
150
+ // Revalidate immediately before signalling — never trust a remembered pid
151
+ // alone; a PID-reuse scenario (same number, different identity) must read
152
+ // as "already gone", never as permission to kill whatever runs there now.
153
+ const fresh = await deps.getProcessIdentity(identity.pid);
154
+ if (!fresh || !identityEquals(fresh, identity))
155
+ return true;
156
+ deps.kill(identity.pid);
157
+ return deps.waitExit(identity.pid, deps.killWaitMs, signal);
158
+ }
159
+ /** Finds this route's single owned process, if any — the authoritative
160
+ * ground truth crash recovery relies on (never just a recorded pid). */
161
+ async function findOwned(deps, dir) {
162
+ const owned = await deps.listOwnedProcesses(dir);
163
+ if (owned.length > 1)
164
+ throw new AmbiguousOwnershipError(dir, owned.length);
165
+ return owned[0] ?? null;
166
+ }
167
+ async function waitForUrl(deps, logPath, signal) {
168
+ const deadline = deps.now() + deps.urlWaitMs;
169
+ for (;;) {
170
+ if (signal?.aborted)
171
+ return null;
172
+ const log = await deps.readFile(logPath);
173
+ if (log) {
174
+ const url = parseLiveUrl(log);
175
+ if (url)
176
+ return url;
177
+ }
178
+ if (deps.now() >= deadline)
179
+ return null;
180
+ await deps.sleep(deps.pollMs, signal);
181
+ }
182
+ }
183
+ async function spawnGeneration(deps, bin, dir, generation, origin) {
184
+ const genDir = join(dir, `gen-${generation}`);
185
+ await deps.mkdir(genDir);
186
+ const configPath = configPathFor(dir, generation);
187
+ // An empty `{}` config isolates the child from any user-level cloudflared
188
+ // config on disk — the only thing `--config` is here for.
189
+ await deps.writeFile(configPath, '{}\n');
190
+ const args = buildArgs(configPath, logPathFor(dir, generation), origin);
191
+ const env = sanitizeEnv(deps.env);
192
+ const child = deps.spawn(bin, args, { cwd: genDir, env });
193
+ const identity = await deps.getProcessIdentity(child.pid);
194
+ const bootId = await deps.bootId();
195
+ if (!identity) {
196
+ // The process already exited before we could even inspect it.
197
+ return {
198
+ pid: child.pid,
199
+ exe: bin,
200
+ argv: args,
201
+ uid: -1,
202
+ startTime: -1,
203
+ bootId,
204
+ routeDir: dir,
205
+ generation,
206
+ origin,
207
+ };
208
+ }
209
+ return { ...identity, routeDir: dir, generation, origin };
210
+ }
211
+ async function reconcilePresent(record, deps, home, context) {
212
+ const dir = routeDirFor(home, record.worktree, record.ticket, record.key);
213
+ const origin = {
214
+ service: record.service,
215
+ hostHeader: record.hostHeader,
216
+ insecureTls: record.insecureTls,
217
+ };
218
+ const recorded = dataOf(record);
219
+ // Direct re-check of the REMEMBERED pid, never a bare PID match: a UID
220
+ // mismatch or PID reuse (same number, different exe/startTime/bootId)
221
+ // means the recorded pid is NOT ours any more — treated as "our process
222
+ // is gone", never as permission to touch whatever now runs there.
223
+ let liveRecorded = null;
224
+ if (recorded) {
225
+ const fresh = await deps.getProcessIdentity(recorded.pid);
226
+ if (fresh && identityEquals(fresh, recorded)) {
227
+ liveRecorded = fresh;
228
+ if (originEquals(recorded.origin, origin)) {
229
+ // Fast path: a live, matching, owned process — reuse it, never
230
+ // spawn a duplicate alongside it.
231
+ return { ...recorded, pid: fresh.pid };
232
+ }
233
+ }
234
+ }
235
+ if (liveRecorded) {
236
+ // Confirmed ours, but the origin changed — terminate before the new
237
+ // generation starts, so two processes never route the same key. An
238
+ // UNCONFIRMED termination must stop here, not fall through to spawn
239
+ // alongside a possibly still-live old process (BB-681 review I2).
240
+ const confirmed = await terminateOwned(deps, liveRecorded, context.signal);
241
+ if (!confirmed) {
242
+ return { error: 'termination of the previous process not yet confirmed' };
243
+ }
244
+ }
245
+ else {
246
+ // Either no recorded data at all (first-ever reconcile for this route,
247
+ // OR a crash before the very first checkpoint), or recorded data existed
248
+ // but its pid is gone/reused. BB-681 review I2: scan for an orphan
249
+ // bearing this route's marker in BOTH cases, not only the no-recorded
250
+ // one — a crash after checkpointing generation N+1 but before THAT
251
+ // process's own next reconcile leaves it running, unmatched by
252
+ // `recorded` (whose pid may since have been reused), and the old code
253
+ // spawned a second, duplicate-routing generation N+2 alongside it. An
254
+ // AMBIGUOUS match (more than one candidate) is an error state, never
255
+ // permission to kill or spawn alongside it — `findOwned` throws for
256
+ // that, and the caller surfaces it as a failed record.
257
+ const owned = await findOwned(deps, dir);
258
+ if (owned) {
259
+ const generation = generationFromConfigPath(owned.argv[owned.argv.indexOf('--config') + 1] ?? '');
260
+ // BB-681 review I2 (remainder): an orphan is recognized only by its
261
+ // `--config <dir>/…` marker, not by the ORIGIN it was actually
262
+ // launched with — adopting it unconditionally with the DESIRED origin
263
+ // would silently keep routing a stale service/host-header/TLS setting.
264
+ // Read the origin back out of its OWN argv and compare.
265
+ const ownedOrigin = originFromArgv(owned.argv);
266
+ const originMatches = ownedOrigin !== null && originEquals(ownedOrigin, origin);
267
+ if (originMatches) {
268
+ return {
269
+ ...owned,
270
+ routeDir: dir,
271
+ generation: generation ?? 1,
272
+ origin,
273
+ };
274
+ }
275
+ // A mismatched orphan must be terminated — CONFIRMED — before a fresh
276
+ // generation spawns, so two processes never route the same key.
277
+ const confirmed = await terminateOwned(deps, owned, context.signal);
278
+ if (!confirmed) {
279
+ return {
280
+ error: 'termination of the previous process not yet confirmed',
281
+ };
282
+ }
283
+ }
284
+ }
285
+ const bin = await deps.findBinary();
286
+ if (!bin)
287
+ return { error: 'cloudflared binary not found' };
288
+ // BB-681 review N7: `recorded` is undefined both on a route's first-ever
289
+ // spawn AND after `down`/a timeout clears it — but the route DIRECTORY
290
+ // (hashed from worktree+ticket+key) is never deleted, so generation 1's
291
+ // log from a PREVIOUS life can still sit on disk. Starting from the
292
+ // highest generation actually present there (not just `recorded`) always
293
+ // lands on a brand-new, never-before-written directory, so `waitForUrl`
294
+ // can never read a dead generation's stale URL.
295
+ const existing = await deps.listGenerations(dir);
296
+ const highestOnDisk = existing.length > 0 ? Math.max(...existing) : 0;
297
+ const generation = Math.max(recorded?.generation ?? 0, highestOnDisk) + 1;
298
+ const spawned = await spawnGeneration(deps, bin, dir, generation, origin);
299
+ return spawned;
300
+ }
301
+ function recordWithData(record, data, patch) {
302
+ return {
303
+ ...record,
304
+ data: data,
305
+ ...patch,
306
+ };
307
+ }
308
+ async function reconcileOnePresent(record, deps, home, context, commit) {
309
+ let outcome;
310
+ try {
311
+ outcome = await reconcilePresent(record, deps, home, context);
312
+ }
313
+ catch (err) {
314
+ const message = err instanceof Error ? err.message : String(err);
315
+ const next = { ...record, applied: 'error', error: message };
316
+ return { record: next, ok: false };
317
+ }
318
+ if ('error' in outcome) {
319
+ const next = { ...record, applied: 'error', error: outcome.error };
320
+ return { record: next, ok: false };
321
+ }
322
+ const data = outcome;
323
+ // Checkpoint ownership BEFORE waiting for the URL — a crash between spawn
324
+ // and this point must never lose track of a process we just started.
325
+ const pending = recordWithData(record, data, {
326
+ applied: 'pending',
327
+ error: undefined,
328
+ });
329
+ try {
330
+ await commit(pending);
331
+ }
332
+ catch (err) {
333
+ // Checkpoint failure: the process (if freshly spawned) keeps running
334
+ // unkilled — the next reconcile's routeDir scan will find and adopt it.
335
+ // Report this attempt as failed without touching the process.
336
+ const message = err instanceof Error ? err.message : String(err);
337
+ return {
338
+ record: { ...record, applied: 'error', error: message },
339
+ ok: false,
340
+ };
341
+ }
342
+ // Only trust the PREVIOUSLY recorded URL when this is the exact same
343
+ // generation/pid as before (the fast-path reuse case) — a fresh spawn
344
+ // (even one whose desired origin happens to equal itself, which is
345
+ // tautological) always gets its URL re-read from ITS OWN generation's log.
346
+ const recordedBefore = dataOf(record);
347
+ const sameGeneration = recordedBefore !== undefined &&
348
+ recordedBefore.pid === data.pid &&
349
+ recordedBefore.generation === data.generation &&
350
+ recordedBefore.routeDir === data.routeDir;
351
+ const url = sameGeneration ? record.url : undefined;
352
+ const resolvedUrl = url ??
353
+ (await waitForUrl(deps, logPathFor(data.routeDir, data.generation), context.signal));
354
+ if (context.signal?.aborted && !resolvedUrl) {
355
+ // Aborted mid-wait: stop cleanly, don't terminate the legitimately
356
+ // started process, just report "not yet live" for this pass.
357
+ return { record: pending, ok: false };
358
+ }
359
+ if (!resolvedUrl) {
360
+ // Bounded timeout: terminate ONLY the process just established by THIS
361
+ // spawn — never an unrelated process.
362
+ const fresh = await deps.getProcessIdentity(data.pid);
363
+ if (fresh && identityEquals(fresh, data)) {
364
+ deps.kill(data.pid);
365
+ await deps.waitExit(data.pid, deps.killWaitMs, context.signal);
366
+ }
367
+ return {
368
+ record: {
369
+ ...record,
370
+ data: undefined,
371
+ applied: 'error',
372
+ error: 'timed out waiting for the Quick Tunnel URL',
373
+ },
374
+ ok: false,
375
+ };
376
+ }
377
+ return {
378
+ record: recordWithData(record, data, {
379
+ applied: 'applied',
380
+ error: undefined,
381
+ url: resolvedUrl,
382
+ }),
383
+ ok: true,
384
+ };
385
+ }
386
+ async function reconcileOneAbsent(record, deps, home, context) {
387
+ const dir = routeDirFor(home, record.worktree, record.ticket, record.key);
388
+ const recorded = dataOf(record);
389
+ let target = null;
390
+ if (recorded) {
391
+ // Direct re-check of the remembered pid — a UID mismatch/PID reuse
392
+ // reads as "already gone", never as permission to kill the impostor.
393
+ const fresh = await deps.getProcessIdentity(recorded.pid);
394
+ if (fresh && identityEquals(fresh, recorded))
395
+ target = fresh;
396
+ }
397
+ if (!target) {
398
+ // BB-681 review R4-M1: a crash between spawning a generation and
399
+ // checkpointing it left `recorded` stale (gone/reused pid) OR entirely
400
+ // absent — either way the route directory can still hold a live, owned
401
+ // orphan. Scanning only the no-`recorded` case let that orphan outlive
402
+ // a tombstone drop, keeping its tunnel publicly reachable with nothing
403
+ // left tracking it. Scan whenever the direct re-check didn't confirm a
404
+ // live owned process, not only when nothing was ever recorded.
405
+ try {
406
+ target = await findOwned(deps, dir);
407
+ }
408
+ catch (err) {
409
+ const message = err instanceof Error ? err.message : String(err);
410
+ return {
411
+ record: { ...record, applied: 'error', error: message },
412
+ ok: false,
413
+ };
414
+ }
415
+ }
416
+ if (target) {
417
+ await terminateOwned(deps, target, context.signal);
418
+ const stillThere = await deps.getProcessIdentity(target.pid);
419
+ if (stillThere && identityEquals(stillThere, target)) {
420
+ // Termination not yet confirmed — keep the tombstone, don't drop it.
421
+ return {
422
+ record: {
423
+ ...record,
424
+ applied: 'error',
425
+ error: 'termination not yet confirmed',
426
+ },
427
+ ok: false,
428
+ };
429
+ }
430
+ }
431
+ // Confirmed gone (or never existed) — safe to drop the tombstone entirely.
432
+ return {
433
+ record: {
434
+ ...record,
435
+ desired: 'absent',
436
+ applied: 'applied',
437
+ data: undefined,
438
+ },
439
+ ok: true,
440
+ };
441
+ }
442
+ /** `PortsTransport.reconcile` for Quick: no DNS/ingress phase — each
443
+ * record's remote state IS its own managed process. */
444
+ export async function reconcileQuick(records, deps, home, context) {
445
+ let next = records;
446
+ let ok = true;
447
+ const commit = async (updated) => {
448
+ next = upsertRecord(next, updated);
449
+ await context.checkpoint(next);
450
+ };
451
+ for (const record of records) {
452
+ if (record.desired === 'present') {
453
+ const result = await reconcileOnePresent(record, deps, home, context, commit);
454
+ next = upsertRecord(next, result.record);
455
+ if (!result.ok)
456
+ ok = false;
457
+ }
458
+ }
459
+ const absentIds = [];
460
+ for (const record of next) {
461
+ if (record.desired !== 'absent')
462
+ continue;
463
+ const result = await reconcileOneAbsent(record, deps, home, context);
464
+ if (result.ok) {
465
+ absentIds.push(`${record.worktree}:${record.key}`);
466
+ }
467
+ else {
468
+ next = upsertRecord(next, result.record);
469
+ ok = false;
470
+ }
471
+ }
472
+ next = next.filter((r) => !(r.desired === 'absent' && absentIds.includes(`${r.worktree}:${r.key}`)));
473
+ return { records: next, ok };
474
+ }
package/package.json CHANGED
@@ -1,6 +1,25 @@
1
1
  {
2
2
  "name": "@bakers-build/addon-ports-cloudflare-quick",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-next.38",
4
+ "description": "bakers.build ports transport: one cloudflared Quick Tunnel process per route, with no fixed name/URL/uptime guarantee. Implements @bakers-build/addon-ports's PortsTransport contract.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "exports": {
8
+ ".": "./dist/src/index.js"
9
+ },
10
+ "files": [
11
+ "dist/src"
12
+ ],
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://git.key-tec.de/bakers-build/bakers-build.git",
19
+ "directory": "bakers-cli/addons/ports-cloudflare-quick"
20
+ },
21
+ "peerDependencies": {
22
+ "@bakers-build/addon-ports": "^0.1.0-next.38",
23
+ "@bakers-build/core": "^0.1.0-next.38"
24
+ }
25
+ }