@rubytech/create-realagent 1.0.615 → 1.0.616
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/package.json +1 -1
- package/payload/platform/config/brand.json +4 -0
- package/payload/platform/lib/mcp-stderr-tee/dist/index.d.ts +23 -13
- package/payload/platform/lib/mcp-stderr-tee/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/mcp-stderr-tee/dist/index.js +86 -89
- package/payload/platform/lib/mcp-stderr-tee/dist/index.js.map +1 -1
- package/payload/platform/lib/mcp-stderr-tee/src/index.ts +86 -101
- package/payload/platform/plugins/admin/mcp/dist/index.js +33 -2
- package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/admin/skills/stream-log-review/SKILL.md +22 -8
- package/payload/platform/plugins/cloudflare/PLUGIN.md +5 -4
- package/payload/platform/plugins/cloudflare/mcp/__tests__/auth-binding.test.ts +196 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/brand-load.test.ts +81 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/manifest-scope.test.ts +65 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/verify-scenario-0.test.ts +70 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/verify-scenario-B.test.ts +124 -0
- package/payload/platform/plugins/cloudflare/mcp/dist/index.js +221 -200
- package/payload/platform/plugins/cloudflare/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts +174 -39
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts.map +1 -1
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.js +891 -194
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.js.map +1 -1
- package/payload/platform/plugins/cloudflare/mcp/package.json +5 -2
- package/payload/platform/plugins/cloudflare/mcp/vitest.config.ts +10 -0
- package/payload/platform/plugins/cloudflare/references/setup-guide.md +31 -32
- package/payload/platform/plugins/cloudflare/skills/setup-tunnel/SKILL.md +25 -3
- package/payload/platform/plugins/docs/PLUGIN.md +2 -0
- package/payload/platform/plugins/docs/references/cloudflare.md +68 -0
- package/payload/platform/plugins/docs/references/plugins-guide.md +8 -6
- package/payload/platform/scripts/logs-read.sh +114 -54
- package/payload/platform/templates/specialists/agents/personal-assistant.md +12 -8
- package/payload/server/server.js +387 -71
|
@@ -17,12 +17,35 @@ export function loadBrand() {
|
|
|
17
17
|
throw new Error(`brand.json not found at ${brandPath} — PLATFORM_ROOT may be incorrect`);
|
|
18
18
|
}
|
|
19
19
|
const parsed = JSON.parse(readFileSync(brandPath, "utf-8"));
|
|
20
|
+
// configDir/productName fall back to Maxy values for forward compat with
|
|
21
|
+
// older manifests; cloudflare.zones does NOT — its absence is an
|
|
22
|
+
// authoritative-scope problem the bundler should have caught.
|
|
23
|
+
if (!parsed.cloudflare ||
|
|
24
|
+
!Array.isArray(parsed.cloudflare.zones) ||
|
|
25
|
+
parsed.cloudflare.zones.length === 0) {
|
|
26
|
+
throw new Error(`brand.json at ${brandPath} is missing a non-empty cloudflare.zones array. ` +
|
|
27
|
+
`The Cloudflare plugin refuses to start without an explicit zone declaration. ` +
|
|
28
|
+
`Add cloudflare.zones to brands/<name>/brand.json and republish the brand package.`);
|
|
29
|
+
}
|
|
30
|
+
for (const zone of parsed.cloudflare.zones) {
|
|
31
|
+
if (typeof zone !== "string" || zone.length === 0) {
|
|
32
|
+
throw new Error(`brand.json at ${brandPath} has invalid cloudflare.zones entry: ${JSON.stringify(zone)}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
20
35
|
cachedBrand = {
|
|
21
36
|
configDir: parsed.configDir ?? ".maxy",
|
|
22
37
|
productName: parsed.productName ?? "Maxy",
|
|
38
|
+
cloudflare: { zones: parsed.cloudflare.zones.slice() },
|
|
23
39
|
};
|
|
24
40
|
return cachedBrand;
|
|
25
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Test-only: reset the cached brand so a subsequent loadBrand() re-reads
|
|
44
|
+
* the manifest. Production code never calls this.
|
|
45
|
+
*/
|
|
46
|
+
export function _resetBrandCache() {
|
|
47
|
+
cachedBrand = null;
|
|
48
|
+
}
|
|
26
49
|
// ---------------------------------------------------------------------------
|
|
27
50
|
// Binary detection (unchanged — cloudflared binary still needed for daemon)
|
|
28
51
|
// ---------------------------------------------------------------------------
|
|
@@ -73,69 +96,119 @@ export function version() {
|
|
|
73
96
|
return null;
|
|
74
97
|
}
|
|
75
98
|
}
|
|
76
|
-
|
|
77
|
-
// API token storage
|
|
78
|
-
//
|
|
79
|
-
// Stored under ~/{configDir}/cloudflare/ so each brand has its own token.
|
|
80
|
-
// Functions (not consts) because loadBrand() requires PLATFORM_ROOT at runtime.
|
|
81
|
-
// ---------------------------------------------------------------------------
|
|
82
|
-
function tokenDir() {
|
|
99
|
+
function bindingDir() {
|
|
83
100
|
return join(homedir(), loadBrand().configDir, "cloudflare");
|
|
84
101
|
}
|
|
85
|
-
function
|
|
86
|
-
return join(
|
|
102
|
+
function bindingFile() {
|
|
103
|
+
return join(bindingDir(), "account-binding.json");
|
|
87
104
|
}
|
|
88
|
-
export function
|
|
105
|
+
export function readAccountBinding() {
|
|
89
106
|
try {
|
|
90
|
-
const path =
|
|
107
|
+
const path = bindingFile();
|
|
91
108
|
if (!existsSync(path))
|
|
92
109
|
return null;
|
|
93
|
-
|
|
110
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
111
|
+
if (typeof parsed?.accountId !== "string" || typeof parsed?.boundAt !== "string") {
|
|
112
|
+
console.error(`[cloudflare:binding] malformed account-binding.json at ${path} — treating as absent`);
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
return { accountId: parsed.accountId, boundAt: parsed.boundAt };
|
|
94
116
|
}
|
|
95
|
-
catch {
|
|
117
|
+
catch (err) {
|
|
118
|
+
console.error(`[cloudflare:binding] failed to read ${bindingFile()}: ${err}`);
|
|
96
119
|
return null;
|
|
97
120
|
}
|
|
98
121
|
}
|
|
99
|
-
|
|
100
|
-
|
|
122
|
+
/**
|
|
123
|
+
* @internal — production code MUST go through `materializeBinding` so the
|
|
124
|
+
* source-of-truth lifecycle (fresh-login vs migration) is preserved and
|
|
125
|
+
* logged. This function is exported only so unit tests can pre-seed
|
|
126
|
+
* bindings without re-implementing the file format. A new tool handler
|
|
127
|
+
* that calls `writeAccountBinding` directly is a code-review block —
|
|
128
|
+
* silent overwrites mask account drift, defeating the four-step guard.
|
|
129
|
+
*/
|
|
130
|
+
export function writeAccountBinding(accountId) {
|
|
131
|
+
const dir = bindingDir();
|
|
101
132
|
mkdirSync(dir, { recursive: true });
|
|
102
|
-
const
|
|
103
|
-
writeFileSync(
|
|
104
|
-
|
|
105
|
-
export function hasToken() {
|
|
106
|
-
return readToken() !== null;
|
|
133
|
+
const binding = { accountId, boundAt: new Date().toISOString() };
|
|
134
|
+
writeFileSync(bindingFile(), JSON.stringify(binding, null, 2), { mode: 0o600 });
|
|
135
|
+
return binding;
|
|
107
136
|
}
|
|
108
137
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
138
|
+
* Reset the binding by unlinking the file. Only force-reset paths
|
|
139
|
+
* (tunnel-login force=true, cf-rebuild after wrong-account detection)
|
|
140
|
+
* call this. Tools must never overwrite the binding on a write path —
|
|
141
|
+
* an overwrite would mask account drift, defeating the guard.
|
|
112
142
|
*/
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
143
|
+
function resetAccountBinding() {
|
|
144
|
+
const path = bindingFile();
|
|
145
|
+
try {
|
|
146
|
+
unlinkSync(path);
|
|
147
|
+
console.error(`[cloudflare:binding] reset ${path}`);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
const code = err.code;
|
|
152
|
+
if (code === "ENOENT")
|
|
153
|
+
return false;
|
|
154
|
+
console.error(`[cloudflare:binding] failed to reset ${path}: ${err}`);
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
export function matchManifestZone(hostname, declaredZones) {
|
|
159
|
+
const lc = hostname.toLowerCase();
|
|
160
|
+
const candidates = declaredZones.filter((z) => lc === z.toLowerCase() || lc.endsWith("." + z.toLowerCase()));
|
|
161
|
+
if (candidates.length === 0) {
|
|
162
|
+
return { ok: false, matchedZone: null, declaredZones: declaredZones.slice() };
|
|
163
|
+
}
|
|
164
|
+
candidates.sort((a, b) => b.length - a.length);
|
|
165
|
+
return { ok: true, matchedZone: candidates[0], declaredZones: declaredZones.slice() };
|
|
166
|
+
}
|
|
167
|
+
export function logRefuse(detail) {
|
|
168
|
+
const brand = (() => {
|
|
169
|
+
try {
|
|
170
|
+
return loadBrand();
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
})();
|
|
176
|
+
const fields = {
|
|
177
|
+
reason: detail.reason,
|
|
178
|
+
brand: brand?.productName ?? "unknown",
|
|
179
|
+
manifestZones: detail.fields.manifestZones ?? brand?.cloudflare.zones ?? null,
|
|
180
|
+
boundAccountId: detail.fields.boundAccountId ?? null,
|
|
181
|
+
certAccountId: detail.fields.certAccountId ?? null,
|
|
182
|
+
requestedDomain: detail.fields.requestedDomain ?? null,
|
|
183
|
+
requestedHostname: detail.fields.requestedHostname ?? null,
|
|
184
|
+
actualFqdn: detail.fields.actualFqdn ?? null,
|
|
185
|
+
tunnelId: detail.fields.tunnelId ?? null,
|
|
186
|
+
};
|
|
187
|
+
console.error(`[cloudflare:refuse] ${JSON.stringify(fields)}`);
|
|
120
188
|
}
|
|
121
189
|
/**
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
* when the stored token has insufficient permissions.
|
|
126
|
-
*
|
|
127
|
-
* Dual-path cert deletion: cloudflared writes cert.pem to ~/.cloudflared/
|
|
128
|
-
* regardless of --origincert, so a complete reset must unlink BOTH the
|
|
129
|
-
* brand-specific path AND the legacy path. Without the legacy unlink,
|
|
130
|
-
* findCert() would re-import the stale cert on the next read.
|
|
190
|
+
* Emit a single recovery instruction. Every refusal instructs the same path:
|
|
191
|
+
* tunnel-login under the account that owns the brand's declared zones.
|
|
192
|
+
* No alternative auth path exists.
|
|
131
193
|
*/
|
|
194
|
+
export function recoveryMessage() {
|
|
195
|
+
const brand = loadBrand();
|
|
196
|
+
return (`Recovery: run tunnel-login while signed into the Cloudflare account that owns ` +
|
|
197
|
+
`the declared zones for this brand: ${brand.cloudflare.zones.join(", ")}. ` +
|
|
198
|
+
`If you recently rotated the cert under a different account, pass force=true ` +
|
|
199
|
+
`to clear the existing cert.pem and account binding before re-authenticating.`);
|
|
200
|
+
}
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// Reset auth — unlinks cert.pem (both paths) and the account binding
|
|
203
|
+
//
|
|
204
|
+
// Kill order matters: terminate any in-flight `cloudflared tunnel login`
|
|
205
|
+
// process BEFORE deleting cert state, otherwise its browser handshake can
|
|
206
|
+
// complete between our unlink and return, writing a fresh cert.pem to the
|
|
207
|
+
// legacy path that findCert() would silently re-import on the next read.
|
|
208
|
+
// Same pattern Task 531 closed; preserved here.
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
132
210
|
export function resetAuth() {
|
|
133
|
-
const result = {
|
|
134
|
-
// Kill any running login process BEFORE clearing cert/token state. Otherwise
|
|
135
|
-
// an in-flight `cloudflared tunnel login` could complete its browser handshake
|
|
136
|
-
// between our unlinks and return from resetAuth, writing a fresh cert.pem to
|
|
137
|
-
// the legacy path immediately after we just deleted it — and findCert would
|
|
138
|
-
// re-import it on the next read, defeating the reset entirely.
|
|
211
|
+
const result = { deletedCert: false, deletedBinding: false };
|
|
139
212
|
const loginState = readLoginState();
|
|
140
213
|
if (loginState?.pid && isProcessAlive(loginState.pid)) {
|
|
141
214
|
try {
|
|
@@ -147,21 +220,6 @@ export function resetAuth() {
|
|
|
147
220
|
}
|
|
148
221
|
}
|
|
149
222
|
clearLoginState();
|
|
150
|
-
const tf = tokenFile();
|
|
151
|
-
try {
|
|
152
|
-
unlinkSync(tf);
|
|
153
|
-
result.deletedToken = true;
|
|
154
|
-
console.error(`[tunnel-login] deleted: ${tf}`);
|
|
155
|
-
}
|
|
156
|
-
catch (err) {
|
|
157
|
-
const code = err.code;
|
|
158
|
-
if (code === "ENOENT") {
|
|
159
|
-
// File already gone — no-op
|
|
160
|
-
}
|
|
161
|
-
else {
|
|
162
|
-
console.error(`[tunnel-login] failed to delete token file ${tf}: ${err}`);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
223
|
const cp = certPath();
|
|
166
224
|
try {
|
|
167
225
|
unlinkSync(cp);
|
|
@@ -191,6 +249,7 @@ export function resetAuth() {
|
|
|
191
249
|
console.error(`[tunnel-login] failed to delete legacy cert.pem ${legacyCp}: ${err}`);
|
|
192
250
|
}
|
|
193
251
|
}
|
|
252
|
+
result.deletedBinding = resetAccountBinding();
|
|
194
253
|
return result;
|
|
195
254
|
}
|
|
196
255
|
// ---------------------------------------------------------------------------
|
|
@@ -280,80 +339,117 @@ export function parseCertPem() {
|
|
|
280
339
|
}
|
|
281
340
|
// ---------------------------------------------------------------------------
|
|
282
341
|
// SDK client
|
|
342
|
+
//
|
|
343
|
+
// Two entry points: getClient() for mutating operations enforces the auth
|
|
344
|
+
// pre-conditions (cert present, binding present, accountId-from-cert
|
|
345
|
+
// matches binding) — uncircumventable for any code path that needs an SDK
|
|
346
|
+
// instance. getReadOnlyClient() for cf-verify allows operating on an
|
|
347
|
+
// unbound device so the audit can report MISSING artefacts without
|
|
348
|
+
// throwing. Both rely on cert.pem as the single account identity source.
|
|
283
349
|
// ---------------------------------------------------------------------------
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
350
|
+
/** Wraps a structured RefusalDetail so call sites can branch on `reason`. */
|
|
351
|
+
export class CloudflareRefusalError extends Error {
|
|
352
|
+
refusal;
|
|
353
|
+
constructor(detail) {
|
|
354
|
+
super(detail.message);
|
|
355
|
+
this.name = "CloudflareRefusalError";
|
|
356
|
+
this.refusal = detail;
|
|
288
357
|
}
|
|
289
|
-
return {
|
|
290
|
-
client: new Cloudflare({ apiToken: token.apiToken }),
|
|
291
|
-
accountId: token.accountId,
|
|
292
|
-
};
|
|
293
358
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
accountId: account.id ?? null,
|
|
314
|
-
accountName: account.name ?? null,
|
|
315
|
-
};
|
|
316
|
-
}
|
|
317
|
-
// Token is valid but no zones and no accounts discoverable
|
|
318
|
-
return {
|
|
319
|
-
valid: true,
|
|
320
|
-
accountId: null,
|
|
321
|
-
accountName: null,
|
|
322
|
-
error: "Token is valid but could not discover account ID — no zones or accounts found.",
|
|
359
|
+
/**
|
|
360
|
+
* Build an SDK client for mutating operations. Throws CloudflareRefusalError
|
|
361
|
+
* with a structured `refusal` field if any auth pre-condition fails:
|
|
362
|
+
* - cert.pem absent or unparseable → unbound-device
|
|
363
|
+
* - account-binding.json absent → unbound-device
|
|
364
|
+
* - cert.pem accountId !== binding.accountId → account-drift
|
|
365
|
+
*
|
|
366
|
+
* The refusal is logged with [cloudflare:refuse] before the throw, so the
|
|
367
|
+
* single greppable signal exists regardless of how the caller handles the
|
|
368
|
+
* exception.
|
|
369
|
+
*/
|
|
370
|
+
export function getClient() {
|
|
371
|
+
const creds = parseCertPem();
|
|
372
|
+
if (!creds) {
|
|
373
|
+
const detail = {
|
|
374
|
+
reason: "unbound-device",
|
|
375
|
+
message: `No cert.pem found — this device has not completed Cloudflare authentication. ` +
|
|
376
|
+
recoveryMessage(),
|
|
377
|
+
fields: { boundAccountId: readAccountBinding()?.accountId ?? null },
|
|
323
378
|
};
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
379
|
+
logRefuse(detail);
|
|
380
|
+
throw new CloudflareRefusalError(detail);
|
|
381
|
+
}
|
|
382
|
+
const binding = readAccountBinding();
|
|
383
|
+
if (!binding) {
|
|
384
|
+
const detail = {
|
|
385
|
+
reason: "unbound-device",
|
|
386
|
+
message: `No account binding recorded — tunnel-login must complete a fresh authentication ` +
|
|
387
|
+
`to bind this device to a Cloudflare account before any other tool can run. ` +
|
|
388
|
+
recoveryMessage(),
|
|
389
|
+
fields: { certAccountId: creds.accountId },
|
|
390
|
+
};
|
|
391
|
+
logRefuse(detail);
|
|
392
|
+
throw new CloudflareRefusalError(detail);
|
|
393
|
+
}
|
|
394
|
+
if (creds.accountId !== binding.accountId) {
|
|
395
|
+
const detail = {
|
|
396
|
+
reason: "account-drift",
|
|
397
|
+
message: `cert.pem is bound to account ${creds.accountId}, but this device's recorded ` +
|
|
398
|
+
`binding is account ${binding.accountId}. The cert was rotated under a different ` +
|
|
399
|
+
`Cloudflare account since the binding was established. ` +
|
|
400
|
+
recoveryMessage(),
|
|
401
|
+
fields: {
|
|
402
|
+
boundAccountId: binding.accountId,
|
|
403
|
+
certAccountId: creds.accountId,
|
|
404
|
+
},
|
|
340
405
|
};
|
|
406
|
+
logRefuse(detail);
|
|
407
|
+
throw new CloudflareRefusalError(detail);
|
|
341
408
|
}
|
|
409
|
+
return {
|
|
410
|
+
client: new Cloudflare({ apiToken: creds.apiToken }),
|
|
411
|
+
accountId: binding.accountId,
|
|
412
|
+
};
|
|
342
413
|
}
|
|
343
|
-
export
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
414
|
+
export function getReadOnlyClient() {
|
|
415
|
+
const creds = parseCertPem();
|
|
416
|
+
const binding = readAccountBinding();
|
|
417
|
+
const certAccountId = creds?.accountId ?? null;
|
|
418
|
+
const boundAccountId = binding?.accountId ?? null;
|
|
419
|
+
const client = creds ? new Cloudflare({ apiToken: creds.apiToken }) : null;
|
|
420
|
+
const bindingMatches = !!(creds && binding && creds.accountId === binding.accountId);
|
|
421
|
+
return { client, certAccountId, boundAccountId, bindingMatches };
|
|
422
|
+
}
|
|
423
|
+
export function validateAuth() {
|
|
424
|
+
const creds = parseCertPem();
|
|
425
|
+
const binding = readAccountBinding();
|
|
426
|
+
return {
|
|
427
|
+
hasCert: !!creds,
|
|
428
|
+
hasBinding: !!binding,
|
|
429
|
+
bound: !!(creds && binding && creds.accountId === binding.accountId),
|
|
430
|
+
certAccountId: creds?.accountId ?? null,
|
|
431
|
+
boundAccountId: binding?.accountId ?? null,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Establish the device-local account binding from cert.pem. Used by:
|
|
436
|
+
* (1) tunnel-login fresh-success path — first time creds are derived
|
|
437
|
+
* (2) migration path — when cert.pem exists from a prior install but no
|
|
438
|
+
* binding has yet been written (silent materialization, since the
|
|
439
|
+
* cert was already trust-established by the operator's prior login)
|
|
440
|
+
*
|
|
441
|
+
* Caller is responsible for declared-zone visibility validation BEFORE
|
|
442
|
+
* calling this — bindings should never be written to an account that
|
|
443
|
+
* doesn't own the brand's declared zones (Task 201 write-after-confirm).
|
|
444
|
+
*/
|
|
445
|
+
export function materializeBinding(source) {
|
|
446
|
+
const creds = parseCertPem();
|
|
447
|
+
if (!creds) {
|
|
448
|
+
throw new Error("Cannot materialize binding — cert.pem absent or unparseable");
|
|
449
|
+
}
|
|
450
|
+
const binding = writeAccountBinding(creds.accountId);
|
|
451
|
+
console.error(`[cloudflare:binding-materialized] source=${source} accountId=${creds.accountId} boundAt=${binding.boundAt}`);
|
|
452
|
+
return binding;
|
|
357
453
|
}
|
|
358
454
|
export async function listZones() {
|
|
359
455
|
const { client } = getClient();
|
|
@@ -372,16 +468,16 @@ export async function listZones() {
|
|
|
372
468
|
}
|
|
373
469
|
return zones;
|
|
374
470
|
}
|
|
375
|
-
export function
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
471
|
+
export function checkDeclaredZonesOnAccount(declaredZones, accountZones) {
|
|
472
|
+
return declaredZones.map((zone) => {
|
|
473
|
+
const lc = zone.toLowerCase();
|
|
474
|
+
const match = accountZones.find((z) => z.name.toLowerCase() === lc);
|
|
475
|
+
return {
|
|
476
|
+
zone,
|
|
477
|
+
presentOnAccount: !!match,
|
|
478
|
+
activeOnAccount: match?.status === "active",
|
|
479
|
+
};
|
|
480
|
+
});
|
|
385
481
|
}
|
|
386
482
|
export async function getZoneId(domain) {
|
|
387
483
|
const { client } = getClient();
|
|
@@ -396,9 +492,6 @@ export async function getZoneId(domain) {
|
|
|
396
492
|
}
|
|
397
493
|
export async function createZone(domain) {
|
|
398
494
|
const { client, accountId } = getClient();
|
|
399
|
-
if (!accountId) {
|
|
400
|
-
throw new Error("No account ID available. Re-run cf-set-token — the token must be re-validated to discover the account ID.");
|
|
401
|
-
}
|
|
402
495
|
// Idempotent — check if zone already exists on this account
|
|
403
496
|
const existing = await client.zones.list({ name: domain });
|
|
404
497
|
const match = existing.result?.[0];
|
|
@@ -710,11 +803,11 @@ export async function createDnsRecord(zoneId, subdomain, tunnelId) {
|
|
|
710
803
|
return { created: true, existing: false, updated: false };
|
|
711
804
|
}
|
|
712
805
|
// ---------------------------------------------------------------------------
|
|
713
|
-
// CLI-based tunnel operations
|
|
806
|
+
// CLI-based tunnel operations
|
|
714
807
|
//
|
|
715
|
-
//
|
|
716
|
-
//
|
|
717
|
-
// Argo Tunnel permissions
|
|
808
|
+
// `cloudflared` CLI uses cert.pem directly for tunnel CRUD and DNS management.
|
|
809
|
+
// We use it (rather than the SDK) for tunnel-create / route-dns because the
|
|
810
|
+
// cert-bound credential carries the Argo Tunnel permissions needed in one step.
|
|
718
811
|
// ---------------------------------------------------------------------------
|
|
719
812
|
export function createTunnelCli(name) {
|
|
720
813
|
const bin = findBinary();
|
|
@@ -809,68 +902,155 @@ export function writeLocalConfig(tunnelId, credentialsPath, hostnames, port) {
|
|
|
809
902
|
return configPath;
|
|
810
903
|
}
|
|
811
904
|
// ---------------------------------------------------------------------------
|
|
812
|
-
// CLI-based DNS routing
|
|
905
|
+
// CLI-based DNS routing — guarded by manifest scope + post-flight FQDN check
|
|
906
|
+
//
|
|
907
|
+
// `cloudflared tunnel route dns` resolves the target zone from cert.pem's
|
|
908
|
+
// account. If the hostname's registrable parent zone is not on that
|
|
909
|
+
// account, cloudflared silently writes a CNAME under a different zone on
|
|
910
|
+
// the bound account (e.g. admin.example.com.othersite.example when the
|
|
911
|
+
// account holds othersite.example but not example.com). Two layers stop
|
|
912
|
+
// this:
|
|
813
913
|
//
|
|
814
|
-
//
|
|
815
|
-
//
|
|
816
|
-
//
|
|
817
|
-
//
|
|
914
|
+
// (1) Manifest-scope pre-flight: refuse before invoking the CLI when the
|
|
915
|
+
// hostname's registrable parent is not in `brand.cloudflare.zones`.
|
|
916
|
+
// The brand declared the zones; nothing else is routable.
|
|
917
|
+
// (2) Post-flight FQDN assertion: parse the actual FQDN from
|
|
918
|
+
// `cloudflared`'s INF line and refuse with a reverse-and-cleanup if
|
|
919
|
+
// it doesn't exactly equal the requested hostname — defence-in-depth
|
|
920
|
+
// against cloudflared writing under a sibling zone the manifest also
|
|
921
|
+
// declared but doesn't actually own on this account.
|
|
818
922
|
//
|
|
819
|
-
//
|
|
820
|
-
//
|
|
821
|
-
//
|
|
822
|
-
// choke point that intercepts that failure class before the CLI call.
|
|
923
|
+
// `getClient()` is invoked first to enforce auth pre-conditions
|
|
924
|
+
// (cert+binding+accountId match) so account-drift refusals fire before any
|
|
925
|
+
// CLI process spawns.
|
|
823
926
|
// ---------------------------------------------------------------------------
|
|
824
927
|
export async function routeDnsCli(tunnelId, hostname) {
|
|
825
928
|
const bin = findBinary();
|
|
826
929
|
if (!bin)
|
|
827
930
|
throw new Error("cloudflared is not installed");
|
|
931
|
+
// Auth pre-condition: cert + binding + accountId match. Throws
|
|
932
|
+
// CloudflareRefusalError on drift before any CLI call.
|
|
933
|
+
const { client, accountId: boundAccountId } = getClient();
|
|
934
|
+
// (1) Manifest-scope pre-flight against brand.cloudflare.zones.
|
|
935
|
+
const brand = loadBrand();
|
|
936
|
+
const scope = matchManifestZone(hostname, brand.cloudflare.zones);
|
|
937
|
+
if (!scope.ok) {
|
|
938
|
+
const detail = {
|
|
939
|
+
reason: "scope-mismatch",
|
|
940
|
+
message: `Cannot route ${hostname} — its registrable parent is not in this brand's declared ` +
|
|
941
|
+
`Cloudflare zones (${brand.cloudflare.zones.join(", ")}). ` +
|
|
942
|
+
`Hostnames outside the declared scope are refused by design.`,
|
|
943
|
+
fields: {
|
|
944
|
+
requestedHostname: hostname,
|
|
945
|
+
manifestZones: brand.cloudflare.zones,
|
|
946
|
+
},
|
|
947
|
+
};
|
|
948
|
+
logRefuse(detail);
|
|
949
|
+
throw new CloudflareRefusalError(detail);
|
|
950
|
+
}
|
|
828
951
|
const cert = findCert();
|
|
829
952
|
if (!cert)
|
|
830
|
-
throw new Error("No cert.pem found —
|
|
831
|
-
|
|
832
|
-
const match = verifyZoneOnAccount(hostname, zones);
|
|
833
|
-
console.error(`[tunnel-route-dns] zone-check hostname=${hostname} matchedZone=${match.zone ?? "none"} availableZones=${JSON.stringify(match.availableZones)}`);
|
|
834
|
-
if (!match.zone) {
|
|
835
|
-
const availableList = match.availableZones.length > 0
|
|
836
|
-
? match.availableZones.join(", ")
|
|
837
|
-
: "none";
|
|
838
|
-
throw new Error(`Cannot route ${hostname} to a tunnel — the zone that owns this hostname is not on the Cloudflare account bound to this device. ` +
|
|
839
|
-
`Best guess at missing zone: "${match.missingParent}" (derived from the last two labels of ${hostname}; for multi-label TLDs such as .co.uk the real zone may be longer). ` +
|
|
840
|
-
`Available zones on this account: ${availableList}. ` +
|
|
841
|
-
`Recovery: run tunnel-login while signed into the Cloudflare account that owns the zone for ${hostname}. ` +
|
|
842
|
-
`This rebinds cert.pem to the correct account. Do not use cf-set-token with a token from a different account — zone routing uses cert.pem, not the API token.`);
|
|
843
|
-
}
|
|
953
|
+
throw new Error("No cert.pem found — getClient() should have refused first");
|
|
954
|
+
let output;
|
|
844
955
|
try {
|
|
845
|
-
|
|
846
|
-
// Parse the CNAME name cloudflared actually created from its INF line:
|
|
847
|
-
// "INF Added CNAME <fqdn> which will route to this tunnel ..."
|
|
848
|
-
// Log a diagnostic when the parse fails so a silent format drift cannot
|
|
849
|
-
// mask a wrong-zone routing (which would otherwise defeat the warning
|
|
850
|
-
// branch in tunnel-create's result line). Falling back to the input
|
|
851
|
-
// hostname is safe because the pre-flight above guarantees zone match.
|
|
852
|
-
const fqdnMatch = output.match(/Added CNAME\s+(\S+?)\s+which will route/);
|
|
853
|
-
let fqdn;
|
|
854
|
-
if (fqdnMatch) {
|
|
855
|
-
fqdn = fqdnMatch[1];
|
|
856
|
-
}
|
|
857
|
-
else {
|
|
858
|
-
console.error(`[tunnel-route-dns] WARNING: could not parse CNAME FQDN from cloudflared output — format may have changed. Using input hostname. Output: ${output.trim()}`);
|
|
859
|
-
fqdn = hostname;
|
|
860
|
-
}
|
|
861
|
-
console.error(`[tunnel-route-dns] CLI: routed ${hostname} → tunnel ${tunnelId} (overwrite) fqdn=${fqdn} zone=${match.zone}`);
|
|
862
|
-
return { created: true, output: output.trim(), fqdn, zone: match.zone };
|
|
956
|
+
output = execFileSync(bin, ["tunnel", "--origincert", cert, "route", "dns", "--overwrite-dns", tunnelId, hostname], { encoding: "utf-8", timeout: 30000 });
|
|
863
957
|
}
|
|
864
958
|
catch (err) {
|
|
865
959
|
const msg = err instanceof Error ? err.stderr ?? err.message : String(err);
|
|
866
|
-
// With --overwrite-dns this path should not trigger for CNAME conflicts.
|
|
867
|
-
// If it fires, log the full error so the cause is diagnosable.
|
|
868
960
|
if (typeof msg === "string" && msg.includes("already exists")) {
|
|
869
961
|
console.error(`[tunnel-route-dns] WARNING: ${hostname} "already exists" despite --overwrite-dns: ${msg}`);
|
|
870
|
-
return { created: false, output: msg, fqdn: hostname, zone:
|
|
962
|
+
return { created: false, output: msg, fqdn: hostname, zone: scope.matchedZone };
|
|
871
963
|
}
|
|
872
964
|
throw new Error(`cloudflared tunnel route dns failed: ${msg}`);
|
|
873
965
|
}
|
|
966
|
+
// (2) Post-flight FQDN assertion. Parse the CNAME name cloudflared
|
|
967
|
+
// actually created from its INF line:
|
|
968
|
+
// "INF Added CNAME <fqdn> which will route to this tunnel ..."
|
|
969
|
+
const fqdnMatch = output.match(/Added CNAME\s+(\S+?)\s+which will route/);
|
|
970
|
+
if (!fqdnMatch) {
|
|
971
|
+
// Format drift — log loudly. We treat the absence of the parse as an
|
|
972
|
+
// observability gap rather than silent success because we can no
|
|
973
|
+
// longer assert wrong-zone safety.
|
|
974
|
+
console.error(`[tunnel-route-dns] WARNING: could not parse CNAME FQDN from cloudflared output — format may have changed. Output: ${output.trim()}`);
|
|
975
|
+
// Surfacing as refusal would block correct flows when cloudflared
|
|
976
|
+
// changes its log line; surface as unverified-success with the
|
|
977
|
+
// requested hostname instead. Operators following [cloudflare:*]
|
|
978
|
+
// grep cadence will see the WARNING line.
|
|
979
|
+
return {
|
|
980
|
+
created: true,
|
|
981
|
+
output: output.trim(),
|
|
982
|
+
fqdn: hostname,
|
|
983
|
+
zone: scope.matchedZone,
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
const actualFqdn = fqdnMatch[1];
|
|
987
|
+
if (actualFqdn !== hostname) {
|
|
988
|
+
// Defence-in-depth: cloudflared wrote the CNAME under a different
|
|
989
|
+
// FQDN than requested. Log raw evidence first, attempt cleanup ONLY
|
|
990
|
+
// if the wrong zone is NOT in our declared scope (we never delete
|
|
991
|
+
// in-scope records as "cleanup"), then refuse.
|
|
992
|
+
console.error(`[cloudflare:post-flight-mismatch] ${JSON.stringify({
|
|
993
|
+
requestedHostname: hostname,
|
|
994
|
+
actualFqdn,
|
|
995
|
+
tunnelId,
|
|
996
|
+
boundAccountId,
|
|
997
|
+
})}`);
|
|
998
|
+
const actualScope = matchManifestZone(actualFqdn, brand.cloudflare.zones);
|
|
999
|
+
let cleanupResult = "skipped-in-scope";
|
|
1000
|
+
if (!actualScope.ok) {
|
|
1001
|
+
try {
|
|
1002
|
+
// Find zone for actualFqdn on the bound account; if found, delete
|
|
1003
|
+
// the CNAME we just inadvertently created.
|
|
1004
|
+
const allZones = await listZones();
|
|
1005
|
+
const owningZone = allZones.find((z) => actualFqdn === z.name || actualFqdn.endsWith("." + z.name));
|
|
1006
|
+
if (owningZone) {
|
|
1007
|
+
const records = await client.dns.records.list({
|
|
1008
|
+
zone_id: owningZone.id,
|
|
1009
|
+
name: { exact: actualFqdn },
|
|
1010
|
+
type: "CNAME",
|
|
1011
|
+
});
|
|
1012
|
+
for (const r of records.result ?? []) {
|
|
1013
|
+
if (r.id)
|
|
1014
|
+
await client.dns.records.delete(r.id, { zone_id: owningZone.id });
|
|
1015
|
+
}
|
|
1016
|
+
cleanupResult = "ok";
|
|
1017
|
+
}
|
|
1018
|
+
else {
|
|
1019
|
+
cleanupResult = "failed"; // nothing to delete — surface as informational
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
catch (cleanupErr) {
|
|
1023
|
+
cleanupResult = "failed";
|
|
1024
|
+
console.error(`[cloudflare:post-flight-cleanup] error: ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
console.error(`[cloudflare:post-flight-cleanup] ${JSON.stringify({
|
|
1028
|
+
requestedHostname: hostname,
|
|
1029
|
+
actualFqdn,
|
|
1030
|
+
result: cleanupResult,
|
|
1031
|
+
})}`);
|
|
1032
|
+
const detail = {
|
|
1033
|
+
reason: "post-flight-fqdn-mismatch",
|
|
1034
|
+
message: `cloudflared wrote the CNAME under ${actualFqdn} instead of the requested ${hostname}. ` +
|
|
1035
|
+
`This indicates cert.pem's account does not own the zone for ${hostname} despite ` +
|
|
1036
|
+
`passing manifest scope. ` +
|
|
1037
|
+
recoveryMessage(),
|
|
1038
|
+
fields: {
|
|
1039
|
+
requestedHostname: hostname,
|
|
1040
|
+
actualFqdn,
|
|
1041
|
+
tunnelId,
|
|
1042
|
+
boundAccountId,
|
|
1043
|
+
manifestZones: brand.cloudflare.zones,
|
|
1044
|
+
},
|
|
1045
|
+
};
|
|
1046
|
+
logRefuse(detail);
|
|
1047
|
+
throw new CloudflareRefusalError(detail);
|
|
1048
|
+
}
|
|
1049
|
+
console.error(`[tunnel-route-dns] CLI: routed ${hostname} → tunnel ${tunnelId} (overwrite) fqdn=${actualFqdn} zone=${scope.matchedZone}`);
|
|
1050
|
+
if (process.env.CLOUDFLARE_DEBUG === "1") {
|
|
1051
|
+
console.error(`[cloudflare:guard-pass] ${JSON.stringify({ op: "routeDnsCli", requestedHostname: hostname, zone: scope.matchedZone })}`);
|
|
1052
|
+
}
|
|
1053
|
+
return { created: true, output: output.trim(), fqdn: actualFqdn, zone: scope.matchedZone };
|
|
874
1054
|
}
|
|
875
1055
|
// ---------------------------------------------------------------------------
|
|
876
1056
|
// tunnel login — spawn `cloudflared tunnel login` and capture the auth URL
|
|
@@ -1103,8 +1283,8 @@ export function getPersistedHostnames() {
|
|
|
1103
1283
|
}
|
|
1104
1284
|
return [];
|
|
1105
1285
|
}
|
|
1106
|
-
export
|
|
1107
|
-
const auth =
|
|
1286
|
+
export function getStatus(domain) {
|
|
1287
|
+
const auth = validateAuth();
|
|
1108
1288
|
const state = readState();
|
|
1109
1289
|
const running = state !== null && isProcessAlive(state.pid);
|
|
1110
1290
|
const effectiveDomain = domain ?? state?.domain ?? null;
|
|
@@ -1112,10 +1292,11 @@ export async function getStatus(domain) {
|
|
|
1112
1292
|
return {
|
|
1113
1293
|
installed: isInstalled(),
|
|
1114
1294
|
version: version(),
|
|
1115
|
-
hasCert: hasCert
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1295
|
+
hasCert: auth.hasCert,
|
|
1296
|
+
hasBinding: auth.hasBinding,
|
|
1297
|
+
bound: auth.bound,
|
|
1298
|
+
certAccountId: auth.certAccountId,
|
|
1299
|
+
boundAccountId: auth.boundAccountId,
|
|
1119
1300
|
running,
|
|
1120
1301
|
pid: running ? state.pid : null,
|
|
1121
1302
|
tunnelId: state?.tunnelId ?? null,
|
|
@@ -1194,4 +1375,520 @@ export function stopTunnel() {
|
|
|
1194
1375
|
// Preserve tunnel identity, clear process lifecycle
|
|
1195
1376
|
writeState({ ...state, pid: null, startedAt: null });
|
|
1196
1377
|
}
|
|
1378
|
+
/**
|
|
1379
|
+
* Resolve the live ScopeContext from the brand manifest + binding +
|
|
1380
|
+
* read-only SDK client. Used by tools; tests bypass with their own
|
|
1381
|
+
* ScopeContext.
|
|
1382
|
+
*/
|
|
1383
|
+
export function liveScopeContext() {
|
|
1384
|
+
const brand = loadBrand();
|
|
1385
|
+
const ro = getReadOnlyClient();
|
|
1386
|
+
return {
|
|
1387
|
+
declaredZones: brand.cloudflare.zones,
|
|
1388
|
+
binding: readAccountBinding(),
|
|
1389
|
+
client: ro.client,
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
/**
|
|
1393
|
+
* cf-verify implementation. Pure-ish: only reads (account state via SDK,
|
|
1394
|
+
* device state via fs). Never mutates. Always returns a structured report
|
|
1395
|
+
* even when cert/binding/account state is absent — fresh-install devices
|
|
1396
|
+
* get a report with everything tagged MISSING.
|
|
1397
|
+
*/
|
|
1398
|
+
export async function cfVerifyCore(ctx = liveScopeContext()) {
|
|
1399
|
+
const brand = loadBrand();
|
|
1400
|
+
const artefacts = [];
|
|
1401
|
+
console.error(`[cloudflare:verify-start] ${JSON.stringify({
|
|
1402
|
+
brand: brand.productName,
|
|
1403
|
+
declaredZones: ctx.declaredZones,
|
|
1404
|
+
bindingPresent: !!ctx.binding,
|
|
1405
|
+
})}`);
|
|
1406
|
+
// --- Local artefact: cert.pem ---
|
|
1407
|
+
const certCreds = parseCertPem();
|
|
1408
|
+
if (!certCreds) {
|
|
1409
|
+
artefacts.push({ type: "cert.pem", id: certPath(), tag: "missing" });
|
|
1410
|
+
}
|
|
1411
|
+
else if (ctx.binding && certCreds.accountId !== ctx.binding.accountId) {
|
|
1412
|
+
artefacts.push({
|
|
1413
|
+
type: "cert.pem",
|
|
1414
|
+
id: certPath(),
|
|
1415
|
+
tag: "out-of-scope",
|
|
1416
|
+
reason: `cert account ${certCreds.accountId} != binding account ${ctx.binding.accountId}`,
|
|
1417
|
+
detail: { certAccountId: certCreds.accountId, boundAccountId: ctx.binding.accountId },
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1420
|
+
else {
|
|
1421
|
+
artefacts.push({ type: "cert.pem", id: certPath(), tag: "in-scope" });
|
|
1422
|
+
}
|
|
1423
|
+
// --- Local artefact: binding ---
|
|
1424
|
+
if (!ctx.binding) {
|
|
1425
|
+
artefacts.push({ type: "binding", id: bindingFile(), tag: "missing" });
|
|
1426
|
+
}
|
|
1427
|
+
else {
|
|
1428
|
+
artefacts.push({
|
|
1429
|
+
type: "binding",
|
|
1430
|
+
id: bindingFile(),
|
|
1431
|
+
tag: "in-scope",
|
|
1432
|
+
detail: { accountId: ctx.binding.accountId, boundAt: ctx.binding.boundAt },
|
|
1433
|
+
});
|
|
1434
|
+
}
|
|
1435
|
+
// --- Account artefacts (only readable when bound and cert valid) ---
|
|
1436
|
+
// Determine if we can call the SDK at all. cfVerify must never fail on
|
|
1437
|
+
// unbound devices — it just reports everything as missing.
|
|
1438
|
+
let accountSummary = null;
|
|
1439
|
+
if (ctx.client && certCreds) {
|
|
1440
|
+
try {
|
|
1441
|
+
const [tunnels, zones] = await Promise.all([
|
|
1442
|
+
listTunnelsViaClient(ctx.client, certCreds.accountId),
|
|
1443
|
+
listZonesViaClient(ctx.client),
|
|
1444
|
+
]);
|
|
1445
|
+
accountSummary = { tunnels, zones };
|
|
1446
|
+
}
|
|
1447
|
+
catch (err) {
|
|
1448
|
+
console.error(`[cloudflare:verify] account read failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
// --- Declared zones: present + active on account? ---
|
|
1452
|
+
if (accountSummary) {
|
|
1453
|
+
const visibility = checkDeclaredZonesOnAccount(ctx.declaredZones, accountSummary.zones);
|
|
1454
|
+
for (const v of visibility) {
|
|
1455
|
+
if (!v.presentOnAccount) {
|
|
1456
|
+
artefacts.push({
|
|
1457
|
+
type: "declared-zone",
|
|
1458
|
+
id: v.zone,
|
|
1459
|
+
tag: "missing",
|
|
1460
|
+
reason: "declared in brand.json but absent from bound account",
|
|
1461
|
+
});
|
|
1462
|
+
}
|
|
1463
|
+
else if (!v.activeOnAccount) {
|
|
1464
|
+
artefacts.push({
|
|
1465
|
+
type: "declared-zone",
|
|
1466
|
+
id: v.zone,
|
|
1467
|
+
tag: "out-of-scope",
|
|
1468
|
+
reason: "present on bound account but not active (likely awaiting nameservers)",
|
|
1469
|
+
});
|
|
1470
|
+
}
|
|
1471
|
+
else {
|
|
1472
|
+
artefacts.push({ type: "declared-zone", id: v.zone, tag: "in-scope" });
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
// Zones on the account NOT in the declared scope — informational tag,
|
|
1476
|
+
// out-of-scope, no action required by rebuild (we never delete zones).
|
|
1477
|
+
for (const z of accountSummary.zones) {
|
|
1478
|
+
if (!ctx.declaredZones.some((d) => d.toLowerCase() === z.name.toLowerCase())) {
|
|
1479
|
+
artefacts.push({
|
|
1480
|
+
type: "declared-zone",
|
|
1481
|
+
id: z.name,
|
|
1482
|
+
tag: "out-of-scope",
|
|
1483
|
+
reason: "zone on bound account but not in brand.cloudflare.zones",
|
|
1484
|
+
});
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
// --- Tunnels on account ---
|
|
1488
|
+
const persistedTunnelId = readState()?.tunnelId ?? null;
|
|
1489
|
+
for (const t of accountSummary.tunnels) {
|
|
1490
|
+
const isOurs = t.id === persistedTunnelId;
|
|
1491
|
+
artefacts.push({
|
|
1492
|
+
type: "tunnel",
|
|
1493
|
+
id: `${t.name} (${t.id})`,
|
|
1494
|
+
tag: isOurs ? "in-scope" : "out-of-scope",
|
|
1495
|
+
reason: isOurs ? undefined : "tunnel on account but not in this brand's persisted state",
|
|
1496
|
+
detail: { tunnelId: t.id, tunnelName: t.name },
|
|
1497
|
+
});
|
|
1498
|
+
}
|
|
1499
|
+
// --- DNS CNAMEs under declared zones ---
|
|
1500
|
+
if (ctx.client) {
|
|
1501
|
+
for (const declared of ctx.declaredZones) {
|
|
1502
|
+
const zone = accountSummary.zones.find((z) => z.name.toLowerCase() === declared.toLowerCase() && z.status === "active");
|
|
1503
|
+
if (!zone)
|
|
1504
|
+
continue;
|
|
1505
|
+
try {
|
|
1506
|
+
const records = await listCnamesUnderZone(ctx.client, zone.id);
|
|
1507
|
+
for (const rec of records) {
|
|
1508
|
+
const ourTunnelTarget = persistedTunnelId
|
|
1509
|
+
? `${persistedTunnelId}.cfargotunnel.com`
|
|
1510
|
+
: null;
|
|
1511
|
+
const isOurs = ourTunnelTarget !== null && rec.content === ourTunnelTarget;
|
|
1512
|
+
artefacts.push({
|
|
1513
|
+
type: "dns-cname",
|
|
1514
|
+
id: rec.name,
|
|
1515
|
+
tag: isOurs ? "in-scope" : "out-of-scope",
|
|
1516
|
+
reason: isOurs ? undefined : `CNAME → ${rec.content} (not this brand's tunnel)`,
|
|
1517
|
+
detail: { zoneId: zone.id, recordId: rec.id, content: rec.content },
|
|
1518
|
+
});
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
catch (err) {
|
|
1522
|
+
console.error(`[cloudflare:verify] failed to list CNAMEs under ${declared}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
else {
|
|
1528
|
+
// Without an SDK client we cannot enumerate account state — surface
|
|
1529
|
+
// each declared zone as MISSING.
|
|
1530
|
+
for (const zone of ctx.declaredZones) {
|
|
1531
|
+
artefacts.push({
|
|
1532
|
+
type: "declared-zone",
|
|
1533
|
+
id: zone,
|
|
1534
|
+
tag: "missing",
|
|
1535
|
+
reason: "cannot read bound account (no cert or no client)",
|
|
1536
|
+
});
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
// --- Local artefacts: tunnel.state, config.yml, alias-domains.json ---
|
|
1540
|
+
const persisted = readState();
|
|
1541
|
+
if (!persisted) {
|
|
1542
|
+
artefacts.push({ type: "tunnel.state", id: statePath(), tag: "missing" });
|
|
1543
|
+
}
|
|
1544
|
+
else {
|
|
1545
|
+
// tunnel.state is in-scope when its tunnelId is present on the bound
|
|
1546
|
+
// account AND its hostnames are all in declared scope.
|
|
1547
|
+
const allHostnames = getPersistedHostnames();
|
|
1548
|
+
const allInScope = allHostnames.every((h) => matchManifestZone(h, ctx.declaredZones).ok);
|
|
1549
|
+
const tunnelOnAccount = accountSummary?.tunnels.some((t) => t.id === persisted.tunnelId) ?? null;
|
|
1550
|
+
if (allInScope && tunnelOnAccount !== false) {
|
|
1551
|
+
artefacts.push({ type: "tunnel.state", id: statePath(), tag: "in-scope" });
|
|
1552
|
+
}
|
|
1553
|
+
else {
|
|
1554
|
+
const reasons = [];
|
|
1555
|
+
if (!allInScope)
|
|
1556
|
+
reasons.push("contains hostnames outside declared scope");
|
|
1557
|
+
if (tunnelOnAccount === false)
|
|
1558
|
+
reasons.push("references tunnel absent from bound account");
|
|
1559
|
+
artefacts.push({
|
|
1560
|
+
type: "tunnel.state",
|
|
1561
|
+
id: statePath(),
|
|
1562
|
+
tag: "out-of-scope",
|
|
1563
|
+
reason: reasons.join("; "),
|
|
1564
|
+
detail: { tunnelId: persisted.tunnelId, hostnames: JSON.stringify(allHostnames) },
|
|
1565
|
+
});
|
|
1566
|
+
}
|
|
1567
|
+
// config.yml mirrors tunnel.state in scope assessment.
|
|
1568
|
+
if (existsSync(persisted.configPath)) {
|
|
1569
|
+
artefacts.push({
|
|
1570
|
+
type: "config.yml",
|
|
1571
|
+
id: persisted.configPath,
|
|
1572
|
+
tag: allInScope ? "in-scope" : "out-of-scope",
|
|
1573
|
+
});
|
|
1574
|
+
}
|
|
1575
|
+
else {
|
|
1576
|
+
artefacts.push({ type: "config.yml", id: persisted.configPath, tag: "missing" });
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
const aliases = loadAliasDomains();
|
|
1580
|
+
for (const alias of aliases) {
|
|
1581
|
+
const inScope = matchManifestZone(alias, ctx.declaredZones).ok;
|
|
1582
|
+
artefacts.push({
|
|
1583
|
+
type: "alias-domain",
|
|
1584
|
+
id: alias,
|
|
1585
|
+
tag: inScope ? "in-scope" : "out-of-scope",
|
|
1586
|
+
reason: inScope ? undefined : "alias hostname outside brand.cloudflare.zones",
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
const counts = {
|
|
1590
|
+
inScope: artefacts.filter((a) => a.tag === "in-scope").length,
|
|
1591
|
+
outOfScope: artefacts.filter((a) => a.tag === "out-of-scope").length,
|
|
1592
|
+
missing: artefacts.filter((a) => a.tag === "missing").length,
|
|
1593
|
+
};
|
|
1594
|
+
console.error(`[cloudflare:verify-complete] ${JSON.stringify({
|
|
1595
|
+
brand: brand.productName,
|
|
1596
|
+
...counts,
|
|
1597
|
+
})}`);
|
|
1598
|
+
return {
|
|
1599
|
+
brand: brand.productName,
|
|
1600
|
+
declaredZones: ctx.declaredZones,
|
|
1601
|
+
bindingPresent: !!ctx.binding,
|
|
1602
|
+
bindingMatchesCert: !!(certCreds && ctx.binding && certCreds.accountId === ctx.binding.accountId),
|
|
1603
|
+
certPresent: !!certCreds,
|
|
1604
|
+
artefacts,
|
|
1605
|
+
counts,
|
|
1606
|
+
};
|
|
1607
|
+
}
|
|
1608
|
+
// Internal helpers — wrap SDK calls so the verify/rebuild cores don't
|
|
1609
|
+
// need to know about the SDK shape directly. Keeps mocks shallow.
|
|
1610
|
+
async function listZonesViaClient(client) {
|
|
1611
|
+
const zones = [];
|
|
1612
|
+
for await (const zone of client.zones.list()) {
|
|
1613
|
+
zones.push({
|
|
1614
|
+
id: zone.id,
|
|
1615
|
+
name: zone.name,
|
|
1616
|
+
status: zone.status ?? "unknown",
|
|
1617
|
+
nameservers: zone.name_servers ?? [],
|
|
1618
|
+
account: { id: zone.account.id ?? "", name: zone.account.name ?? "" },
|
|
1619
|
+
});
|
|
1620
|
+
}
|
|
1621
|
+
return zones;
|
|
1622
|
+
}
|
|
1623
|
+
async function listTunnelsViaClient(client, accountId) {
|
|
1624
|
+
const summaries = [];
|
|
1625
|
+
const result = await client.zeroTrust.tunnels.cloudflared.list({
|
|
1626
|
+
account_id: accountId,
|
|
1627
|
+
is_deleted: false,
|
|
1628
|
+
});
|
|
1629
|
+
for (const t of result.result ?? []) {
|
|
1630
|
+
if (!t.id || !t.name)
|
|
1631
|
+
continue;
|
|
1632
|
+
summaries.push({ id: t.id, name: t.name, createdAt: t.created_at ?? null });
|
|
1633
|
+
}
|
|
1634
|
+
return summaries;
|
|
1635
|
+
}
|
|
1636
|
+
async function listCnamesUnderZone(client, zoneId) {
|
|
1637
|
+
const out = [];
|
|
1638
|
+
for await (const rec of client.dns.records.list({ zone_id: zoneId, type: "CNAME" })) {
|
|
1639
|
+
if (!rec.id || !rec.name)
|
|
1640
|
+
continue;
|
|
1641
|
+
out.push({ id: rec.id, name: rec.name, content: rec.content ?? "" });
|
|
1642
|
+
}
|
|
1643
|
+
return out;
|
|
1644
|
+
}
|
|
1645
|
+
export async function cfRebuildCore(opts = {}) {
|
|
1646
|
+
const dryRun = opts.dryRun ?? false;
|
|
1647
|
+
const ctx = opts.ctx ?? liveScopeContext();
|
|
1648
|
+
const brand = loadBrand();
|
|
1649
|
+
console.error(`[cloudflare:rebuild-start] ${JSON.stringify({
|
|
1650
|
+
brand: brand.productName,
|
|
1651
|
+
declaredZones: ctx.declaredZones,
|
|
1652
|
+
dryRun,
|
|
1653
|
+
})}`);
|
|
1654
|
+
// Step 1: cert.pem account integrity. cf-rebuild refuses to delete a
|
|
1655
|
+
// wrong-account cert.pem mid-flow — that would create a dead authoring
|
|
1656
|
+
// window with no clear next step. Instead surface the discard intent
|
|
1657
|
+
// and halt with an actionable instruction.
|
|
1658
|
+
const certCreds = parseCertPem();
|
|
1659
|
+
const actions = [];
|
|
1660
|
+
if (!certCreds) {
|
|
1661
|
+
const msg = `cf-rebuild requires cert.pem before it can reconstruct state. ` + recoveryMessage();
|
|
1662
|
+
return {
|
|
1663
|
+
brand: brand.productName,
|
|
1664
|
+
declaredZones: ctx.declaredZones,
|
|
1665
|
+
dryRun,
|
|
1666
|
+
actions: [
|
|
1667
|
+
{
|
|
1668
|
+
op: "skip-needs-operator",
|
|
1669
|
+
artefact: { type: "cert.pem", id: certPath(), tag: "missing" },
|
|
1670
|
+
planned: dryRun,
|
|
1671
|
+
resultDetail: msg,
|
|
1672
|
+
},
|
|
1673
|
+
],
|
|
1674
|
+
halted: true,
|
|
1675
|
+
haltReason: msg,
|
|
1676
|
+
};
|
|
1677
|
+
}
|
|
1678
|
+
if (ctx.binding && certCreds.accountId !== ctx.binding.accountId) {
|
|
1679
|
+
const msg = `cert.pem is bound to account ${certCreds.accountId} but the recorded binding is ` +
|
|
1680
|
+
`account ${ctx.binding.accountId}. cf-rebuild will not silently delete cert.pem — ` +
|
|
1681
|
+
`re-run tunnel-login force=true to clear the wrong cert and binding, then re-run cf-rebuild. ` +
|
|
1682
|
+
recoveryMessage();
|
|
1683
|
+
actions.push({
|
|
1684
|
+
op: "skip-needs-operator",
|
|
1685
|
+
artefact: {
|
|
1686
|
+
type: "cert.pem",
|
|
1687
|
+
id: certPath(),
|
|
1688
|
+
tag: "out-of-scope",
|
|
1689
|
+
reason: "wrong-account",
|
|
1690
|
+
detail: { certAccountId: certCreds.accountId, boundAccountId: ctx.binding.accountId },
|
|
1691
|
+
},
|
|
1692
|
+
planned: dryRun,
|
|
1693
|
+
resultDetail: msg,
|
|
1694
|
+
});
|
|
1695
|
+
console.error(`[cloudflare:rebuild-discard] ${JSON.stringify({ type: "cert.pem", reason: "wrong-account", planned: true })}`);
|
|
1696
|
+
return {
|
|
1697
|
+
brand: brand.productName,
|
|
1698
|
+
declaredZones: ctx.declaredZones,
|
|
1699
|
+
dryRun,
|
|
1700
|
+
actions,
|
|
1701
|
+
halted: true,
|
|
1702
|
+
haltReason: msg,
|
|
1703
|
+
};
|
|
1704
|
+
}
|
|
1705
|
+
// Step 2: ensure binding is materialized. The fresh-install case passes
|
|
1706
|
+
// through here — binding is established from the cert. cf-rebuild does
|
|
1707
|
+
// NOT do declared-zone-visibility validation here because the operator
|
|
1708
|
+
// may be in the middle of adding zones; visibility shows in the final
|
|
1709
|
+
// verify report instead.
|
|
1710
|
+
//
|
|
1711
|
+
// materializeBinding can throw if cert.pem becomes unreadable between
|
|
1712
|
+
// the parseCertPem above and this call (e.g. a concurrent
|
|
1713
|
+
// tunnel-login force=true). Wrap in try/catch so the throw becomes a
|
|
1714
|
+
// structured halted result, not a raw JS Error escaping past the MCP
|
|
1715
|
+
// boundary.
|
|
1716
|
+
if (!ctx.binding) {
|
|
1717
|
+
if (!dryRun) {
|
|
1718
|
+
try {
|
|
1719
|
+
materializeBinding("migration");
|
|
1720
|
+
}
|
|
1721
|
+
catch (err) {
|
|
1722
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1723
|
+
const halt = `cf-rebuild could not establish account binding: ${msg}. ${recoveryMessage()}`;
|
|
1724
|
+
actions.push({
|
|
1725
|
+
op: "skip-needs-operator",
|
|
1726
|
+
artefact: { type: "binding", id: bindingFile(), tag: "missing" },
|
|
1727
|
+
planned: dryRun,
|
|
1728
|
+
result: "failed",
|
|
1729
|
+
resultDetail: halt,
|
|
1730
|
+
});
|
|
1731
|
+
return {
|
|
1732
|
+
brand: brand.productName,
|
|
1733
|
+
declaredZones: ctx.declaredZones,
|
|
1734
|
+
dryRun,
|
|
1735
|
+
actions,
|
|
1736
|
+
halted: true,
|
|
1737
|
+
haltReason: halt,
|
|
1738
|
+
};
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
actions.push({
|
|
1742
|
+
op: "recreate",
|
|
1743
|
+
artefact: {
|
|
1744
|
+
type: "binding",
|
|
1745
|
+
id: bindingFile(),
|
|
1746
|
+
tag: "missing",
|
|
1747
|
+
},
|
|
1748
|
+
planned: dryRun,
|
|
1749
|
+
result: "ok",
|
|
1750
|
+
});
|
|
1751
|
+
}
|
|
1752
|
+
// Step 3: gather verify report so we have a plan.
|
|
1753
|
+
const verify = await cfVerifyCore(ctx);
|
|
1754
|
+
// Step 4: discard out-of-scope artefacts. Order matters: DNS records
|
|
1755
|
+
// first (so the tunnel can be deleted afterwards without orphaning
|
|
1756
|
+
// routes), then alias-domain entries, then tunnel.state, then config.yml.
|
|
1757
|
+
// We never delete cert.pem, never delete declared zones (out-of-scope
|
|
1758
|
+
// declared-zone entries are operator-action items, not rebuild work),
|
|
1759
|
+
// never delete tunnels owned by the bound account that don't match our
|
|
1760
|
+
// persisted state (those belong to other devices on the same account
|
|
1761
|
+
// — Task 525's multi-device contention pattern).
|
|
1762
|
+
const liveClient = ctx.client; // may be null in tests
|
|
1763
|
+
for (const a of verify.artefacts) {
|
|
1764
|
+
if (a.tag !== "out-of-scope")
|
|
1765
|
+
continue;
|
|
1766
|
+
const action = { op: "discard", artefact: a, planned: dryRun };
|
|
1767
|
+
try {
|
|
1768
|
+
switch (a.type) {
|
|
1769
|
+
case "dns-cname": {
|
|
1770
|
+
if (!dryRun && liveClient) {
|
|
1771
|
+
const zoneId = String(a.detail?.zoneId ?? "");
|
|
1772
|
+
const recordId = String(a.detail?.recordId ?? "");
|
|
1773
|
+
if (zoneId && recordId) {
|
|
1774
|
+
await liveClient.dns.records.delete(recordId, { zone_id: zoneId });
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
action.result = "ok";
|
|
1778
|
+
break;
|
|
1779
|
+
}
|
|
1780
|
+
case "alias-domain": {
|
|
1781
|
+
if (!dryRun) {
|
|
1782
|
+
const aliases = loadAliasDomains();
|
|
1783
|
+
aliases.delete(a.id);
|
|
1784
|
+
const path = aliasDomainPath();
|
|
1785
|
+
mkdirSync(join(homedir(), loadBrand().configDir), { recursive: true });
|
|
1786
|
+
writeFileSync(path, JSON.stringify([...aliases], null, 2), "utf-8");
|
|
1787
|
+
}
|
|
1788
|
+
action.result = "ok";
|
|
1789
|
+
break;
|
|
1790
|
+
}
|
|
1791
|
+
case "tunnel.state":
|
|
1792
|
+
case "config.yml": {
|
|
1793
|
+
if (!dryRun) {
|
|
1794
|
+
try {
|
|
1795
|
+
unlinkSync(a.id);
|
|
1796
|
+
}
|
|
1797
|
+
catch (err) {
|
|
1798
|
+
const code = err.code;
|
|
1799
|
+
if (code !== "ENOENT")
|
|
1800
|
+
throw err;
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
action.result = "ok";
|
|
1804
|
+
break;
|
|
1805
|
+
}
|
|
1806
|
+
case "tunnel": {
|
|
1807
|
+
// Out-of-scope tunnels are siblings on the same account — leave
|
|
1808
|
+
// them alone. Other devices may rely on them. Surface as
|
|
1809
|
+
// discard intent but skip without mutating.
|
|
1810
|
+
action.op = "skip-needs-operator";
|
|
1811
|
+
action.result = "ok";
|
|
1812
|
+
action.resultDetail = "tunnel on bound account but not this brand's — left untouched";
|
|
1813
|
+
break;
|
|
1814
|
+
}
|
|
1815
|
+
case "declared-zone": {
|
|
1816
|
+
// Out-of-scope declared-zones are zones on the bound account that
|
|
1817
|
+
// aren't in the manifest. Informational; never deleted.
|
|
1818
|
+
action.op = "skip-needs-operator";
|
|
1819
|
+
action.result = "ok";
|
|
1820
|
+
action.resultDetail = "zone on bound account outside brand scope — informational";
|
|
1821
|
+
break;
|
|
1822
|
+
}
|
|
1823
|
+
case "cert.pem":
|
|
1824
|
+
case "binding":
|
|
1825
|
+
// Handled above (refuse-to-delete cert; binding mutated by force-reset path only)
|
|
1826
|
+
action.op = "skip-needs-operator";
|
|
1827
|
+
action.result = "ok";
|
|
1828
|
+
break;
|
|
1829
|
+
default:
|
|
1830
|
+
action.result = "failed";
|
|
1831
|
+
action.resultDetail = `unknown artefact type ${a.type}`;
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
catch (err) {
|
|
1835
|
+
action.result = "failed";
|
|
1836
|
+
action.resultDetail = err instanceof Error ? err.message : String(err);
|
|
1837
|
+
}
|
|
1838
|
+
if (action.op === "discard") {
|
|
1839
|
+
console.error(`[cloudflare:rebuild-discard] ${JSON.stringify({ type: a.type, id: a.id, planned: dryRun, result: action.result ?? null })}`);
|
|
1840
|
+
}
|
|
1841
|
+
actions.push(action);
|
|
1842
|
+
}
|
|
1843
|
+
// Step 5: create missing in-scope artefacts. We cannot create CF-side
|
|
1844
|
+
// missing zones (operator must add zones at cloudflare.com) — surface as
|
|
1845
|
+
// skip-needs-operator. We can re-create local state if the tunnel
|
|
1846
|
+
// exists on the account but local state is missing; that's a Task 521
|
|
1847
|
+
// scenario (re-attaching a tunnel) and is out of scope here. v1 of
|
|
1848
|
+
// cf-rebuild surfaces missing local artefacts as informational.
|
|
1849
|
+
for (const a of verify.artefacts) {
|
|
1850
|
+
if (a.tag !== "missing")
|
|
1851
|
+
continue;
|
|
1852
|
+
if (a.type === "declared-zone") {
|
|
1853
|
+
actions.push({
|
|
1854
|
+
op: "skip-needs-operator",
|
|
1855
|
+
artefact: a,
|
|
1856
|
+
planned: dryRun,
|
|
1857
|
+
result: "ok",
|
|
1858
|
+
resultDetail: `declared zone "${a.id}" must be added to the bound Cloudflare account before tunnel-create can route to it`,
|
|
1859
|
+
});
|
|
1860
|
+
}
|
|
1861
|
+
else if (a.type === "binding" || a.type === "cert.pem") {
|
|
1862
|
+
// Already handled above
|
|
1863
|
+
}
|
|
1864
|
+
else {
|
|
1865
|
+
actions.push({
|
|
1866
|
+
op: "skip-needs-operator",
|
|
1867
|
+
artefact: a,
|
|
1868
|
+
planned: dryRun,
|
|
1869
|
+
result: "ok",
|
|
1870
|
+
resultDetail: `missing local artefact — run cloudflare-setup or tunnel-create to create`,
|
|
1871
|
+
});
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
// Step 6: rerun verify (unless dry-run) to capture final state.
|
|
1875
|
+
let finalVerify;
|
|
1876
|
+
if (!dryRun) {
|
|
1877
|
+
finalVerify = await cfVerifyCore(ctx);
|
|
1878
|
+
}
|
|
1879
|
+
console.error(`[cloudflare:rebuild-complete] ${JSON.stringify({
|
|
1880
|
+
brand: brand.productName,
|
|
1881
|
+
dryRun,
|
|
1882
|
+
actionCount: actions.length,
|
|
1883
|
+
finalCounts: finalVerify?.counts ?? null,
|
|
1884
|
+
})}`);
|
|
1885
|
+
return {
|
|
1886
|
+
brand: brand.productName,
|
|
1887
|
+
declaredZones: ctx.declaredZones,
|
|
1888
|
+
dryRun,
|
|
1889
|
+
actions,
|
|
1890
|
+
halted: false,
|
|
1891
|
+
finalVerify,
|
|
1892
|
+
};
|
|
1893
|
+
}
|
|
1197
1894
|
//# sourceMappingURL=cloudflared.js.map
|