@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 +21 -0
- package/README.md +4 -2
- package/dist/src/index.d.ts +22 -0
- package/dist/src/index.js +245 -0
- package/dist/src/quick.d.ts +127 -0
- package/dist/src/quick.js +474 -0
- package/package.json +23 -4
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
|
-
#
|
|
1
|
+
# @bakers-build/addon-ports-cloudflare-quick
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|