@rubytech/create-realagent 1.0.615 → 1.0.617
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/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/package-lock.json +1547 -1
- 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 +195 -0
- package/payload/platform/plugins/cloudflare/mcp/dist/index.js +160 -214
- package/payload/platform/plugins/cloudflare/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts +203 -42
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts.map +1 -1
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.js +623 -195
- 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 +26 -30
- package/payload/platform/plugins/cloudflare/skills/setup-tunnel/SKILL.md +28 -4
- package/payload/platform/plugins/docs/PLUGIN.md +2 -0
- package/payload/platform/plugins/docs/references/cloudflare.md +51 -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 -70
|
@@ -23,6 +23,13 @@ export function loadBrand() {
|
|
|
23
23
|
};
|
|
24
24
|
return cachedBrand;
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Test-only: reset the cached brand so a subsequent loadBrand() re-reads
|
|
28
|
+
* the manifest. Production code never calls this.
|
|
29
|
+
*/
|
|
30
|
+
export function _resetBrandCache() {
|
|
31
|
+
cachedBrand = null;
|
|
32
|
+
}
|
|
26
33
|
// ---------------------------------------------------------------------------
|
|
27
34
|
// Binary detection (unchanged — cloudflared binary still needed for daemon)
|
|
28
35
|
// ---------------------------------------------------------------------------
|
|
@@ -73,69 +80,118 @@ export function version() {
|
|
|
73
80
|
return null;
|
|
74
81
|
}
|
|
75
82
|
}
|
|
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() {
|
|
83
|
+
function bindingDir() {
|
|
83
84
|
return join(homedir(), loadBrand().configDir, "cloudflare");
|
|
84
85
|
}
|
|
85
|
-
function
|
|
86
|
-
return join(
|
|
86
|
+
function bindingFile() {
|
|
87
|
+
return join(bindingDir(), "account-binding.json");
|
|
87
88
|
}
|
|
88
|
-
export function
|
|
89
|
+
export function readAccountBinding() {
|
|
89
90
|
try {
|
|
90
|
-
const path =
|
|
91
|
+
const path = bindingFile();
|
|
91
92
|
if (!existsSync(path))
|
|
92
93
|
return null;
|
|
93
|
-
|
|
94
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
95
|
+
if (typeof parsed?.accountId !== "string" || typeof parsed?.boundAt !== "string") {
|
|
96
|
+
console.error(`[cloudflare:binding] malformed account-binding.json at ${path} — treating as absent`);
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
return { accountId: parsed.accountId, boundAt: parsed.boundAt };
|
|
94
100
|
}
|
|
95
|
-
catch {
|
|
101
|
+
catch (err) {
|
|
102
|
+
console.error(`[cloudflare:binding] failed to read ${bindingFile()}: ${err}`);
|
|
96
103
|
return null;
|
|
97
104
|
}
|
|
98
105
|
}
|
|
99
|
-
|
|
100
|
-
|
|
106
|
+
/**
|
|
107
|
+
* @internal — production code MUST go through `materializeBinding` so the
|
|
108
|
+
* source-of-truth lifecycle (fresh-login vs migration) is preserved and
|
|
109
|
+
* logged. This function is exported only so unit tests can pre-seed
|
|
110
|
+
* bindings without re-implementing the file format. A new tool handler
|
|
111
|
+
* that calls `writeAccountBinding` directly is a code-review block —
|
|
112
|
+
* silent overwrites mask account drift, defeating the four-step guard.
|
|
113
|
+
*/
|
|
114
|
+
export function writeAccountBinding(accountId) {
|
|
115
|
+
const dir = bindingDir();
|
|
101
116
|
mkdirSync(dir, { recursive: true });
|
|
102
|
-
const
|
|
103
|
-
writeFileSync(
|
|
104
|
-
|
|
105
|
-
export function hasToken() {
|
|
106
|
-
return readToken() !== null;
|
|
117
|
+
const binding = { accountId, boundAt: new Date().toISOString() };
|
|
118
|
+
writeFileSync(bindingFile(), JSON.stringify(binding, null, 2), { mode: 0o600 });
|
|
119
|
+
return binding;
|
|
107
120
|
}
|
|
108
121
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
122
|
+
* Reset the binding by unlinking the file. Only force-reset paths
|
|
123
|
+
* (tunnel-login force=true, cf-rebuild after wrong-account detection)
|
|
124
|
+
* call this. Tools must never overwrite the binding on a write path —
|
|
125
|
+
* an overwrite would mask account drift, defeating the guard.
|
|
112
126
|
*/
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
127
|
+
function resetAccountBinding() {
|
|
128
|
+
const path = bindingFile();
|
|
129
|
+
try {
|
|
130
|
+
unlinkSync(path);
|
|
131
|
+
console.error(`[cloudflare:binding] reset ${path}`);
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
const code = err.code;
|
|
136
|
+
if (code === "ENOENT")
|
|
137
|
+
return false;
|
|
138
|
+
console.error(`[cloudflare:binding] failed to reset ${path}: ${err}`);
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
export function matchAccountZone(hostname, accountZones) {
|
|
143
|
+
const active = accountZones.filter((z) => z.status === "active").map((z) => z.name);
|
|
144
|
+
const lc = hostname.toLowerCase();
|
|
145
|
+
const candidates = active.filter((z) => lc === z.toLowerCase() || lc.endsWith("." + z.toLowerCase()));
|
|
146
|
+
if (candidates.length === 0) {
|
|
147
|
+
return { ok: false, matchedZone: null, accountZones: active };
|
|
148
|
+
}
|
|
149
|
+
candidates.sort((a, b) => b.length - a.length);
|
|
150
|
+
return { ok: true, matchedZone: candidates[0], accountZones: active };
|
|
151
|
+
}
|
|
152
|
+
export function logRefuse(detail) {
|
|
153
|
+
const brand = (() => {
|
|
154
|
+
try {
|
|
155
|
+
return loadBrand();
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
})();
|
|
161
|
+
const fields = {
|
|
162
|
+
reason: detail.reason,
|
|
163
|
+
brand: brand?.productName ?? "unknown",
|
|
164
|
+
accountZones: detail.fields.accountZones ?? null,
|
|
165
|
+
boundAccountId: detail.fields.boundAccountId ?? null,
|
|
166
|
+
certAccountId: detail.fields.certAccountId ?? null,
|
|
167
|
+
requestedDomain: detail.fields.requestedDomain ?? null,
|
|
168
|
+
requestedHostname: detail.fields.requestedHostname ?? null,
|
|
169
|
+
actualFqdn: detail.fields.actualFqdn ?? null,
|
|
170
|
+
tunnelId: detail.fields.tunnelId ?? null,
|
|
171
|
+
};
|
|
172
|
+
console.error(`[cloudflare:refuse] ${JSON.stringify(fields)}`);
|
|
120
173
|
}
|
|
121
174
|
/**
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* gracefully. Used by tunnel-login --force to break out of the dead-end
|
|
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.
|
|
175
|
+
* Single recovery instruction. Every refusal instructs the same path:
|
|
176
|
+
* tunnel-login under the Cloudflare account that owns the target zone.
|
|
131
177
|
*/
|
|
178
|
+
export function recoveryMessage() {
|
|
179
|
+
return (`Recovery: run tunnel-login while signed into the Cloudflare account that owns ` +
|
|
180
|
+
`the target zone. If you recently rotated the cert under a different account, ` +
|
|
181
|
+
`pass force=true to clear the existing cert.pem and account binding before ` +
|
|
182
|
+
`re-authenticating.`);
|
|
183
|
+
}
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// Reset auth — unlinks cert.pem (both paths) and the account binding
|
|
186
|
+
//
|
|
187
|
+
// Kill order matters: terminate any in-flight `cloudflared tunnel login`
|
|
188
|
+
// process BEFORE deleting cert state, otherwise its browser handshake can
|
|
189
|
+
// complete between our unlink and return, writing a fresh cert.pem to the
|
|
190
|
+
// legacy path that findCert() would silently re-import on the next read.
|
|
191
|
+
// Same pattern Task 531 closed; preserved here.
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
132
193
|
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.
|
|
194
|
+
const result = { deletedCert: false, deletedBinding: false };
|
|
139
195
|
const loginState = readLoginState();
|
|
140
196
|
if (loginState?.pid && isProcessAlive(loginState.pid)) {
|
|
141
197
|
try {
|
|
@@ -147,21 +203,6 @@ export function resetAuth() {
|
|
|
147
203
|
}
|
|
148
204
|
}
|
|
149
205
|
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
206
|
const cp = certPath();
|
|
166
207
|
try {
|
|
167
208
|
unlinkSync(cp);
|
|
@@ -191,6 +232,7 @@ export function resetAuth() {
|
|
|
191
232
|
console.error(`[tunnel-login] failed to delete legacy cert.pem ${legacyCp}: ${err}`);
|
|
192
233
|
}
|
|
193
234
|
}
|
|
235
|
+
result.deletedBinding = resetAccountBinding();
|
|
194
236
|
return result;
|
|
195
237
|
}
|
|
196
238
|
// ---------------------------------------------------------------------------
|
|
@@ -280,80 +322,117 @@ export function parseCertPem() {
|
|
|
280
322
|
}
|
|
281
323
|
// ---------------------------------------------------------------------------
|
|
282
324
|
// SDK client
|
|
325
|
+
//
|
|
326
|
+
// Two entry points: getClient() for mutating operations enforces the auth
|
|
327
|
+
// pre-conditions (cert present, binding present, accountId-from-cert
|
|
328
|
+
// matches binding) — uncircumventable for any code path that needs an SDK
|
|
329
|
+
// instance. getReadOnlyClient() for cf-verify allows operating on an
|
|
330
|
+
// unbound device so the audit can report MISSING artefacts without
|
|
331
|
+
// throwing. Both rely on cert.pem as the single account identity source.
|
|
283
332
|
// ---------------------------------------------------------------------------
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
333
|
+
/** Wraps a structured RefusalDetail so call sites can branch on `reason`. */
|
|
334
|
+
export class CloudflareRefusalError extends Error {
|
|
335
|
+
refusal;
|
|
336
|
+
constructor(detail) {
|
|
337
|
+
super(detail.message);
|
|
338
|
+
this.name = "CloudflareRefusalError";
|
|
339
|
+
this.refusal = detail;
|
|
288
340
|
}
|
|
289
|
-
return {
|
|
290
|
-
client: new Cloudflare({ apiToken: token.apiToken }),
|
|
291
|
-
accountId: token.accountId,
|
|
292
|
-
};
|
|
293
341
|
}
|
|
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.",
|
|
342
|
+
/**
|
|
343
|
+
* Build an SDK client for mutating operations. Throws CloudflareRefusalError
|
|
344
|
+
* with a structured `refusal` field if any auth pre-condition fails:
|
|
345
|
+
* - cert.pem absent or unparseable → unbound-device
|
|
346
|
+
* - account-binding.json absent → unbound-device
|
|
347
|
+
* - cert.pem accountId !== binding.accountId → account-drift
|
|
348
|
+
*
|
|
349
|
+
* The refusal is logged with [cloudflare:refuse] before the throw, so the
|
|
350
|
+
* single greppable signal exists regardless of how the caller handles the
|
|
351
|
+
* exception.
|
|
352
|
+
*/
|
|
353
|
+
export function getClient() {
|
|
354
|
+
const creds = parseCertPem();
|
|
355
|
+
if (!creds) {
|
|
356
|
+
const detail = {
|
|
357
|
+
reason: "unbound-device",
|
|
358
|
+
message: `No cert.pem found — this device has not completed Cloudflare authentication. ` +
|
|
359
|
+
recoveryMessage(),
|
|
360
|
+
fields: { boundAccountId: readAccountBinding()?.accountId ?? null },
|
|
323
361
|
};
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
362
|
+
logRefuse(detail);
|
|
363
|
+
throw new CloudflareRefusalError(detail);
|
|
364
|
+
}
|
|
365
|
+
const binding = readAccountBinding();
|
|
366
|
+
if (!binding) {
|
|
367
|
+
const detail = {
|
|
368
|
+
reason: "unbound-device",
|
|
369
|
+
message: `No account binding recorded — tunnel-login must complete a fresh authentication ` +
|
|
370
|
+
`to bind this device to a Cloudflare account before any other tool can run. ` +
|
|
371
|
+
recoveryMessage(),
|
|
372
|
+
fields: { certAccountId: creds.accountId },
|
|
373
|
+
};
|
|
374
|
+
logRefuse(detail);
|
|
375
|
+
throw new CloudflareRefusalError(detail);
|
|
376
|
+
}
|
|
377
|
+
if (creds.accountId !== binding.accountId) {
|
|
378
|
+
const detail = {
|
|
379
|
+
reason: "account-drift",
|
|
380
|
+
message: `cert.pem is bound to account ${creds.accountId}, but this device's recorded ` +
|
|
381
|
+
`binding is account ${binding.accountId}. The cert was rotated under a different ` +
|
|
382
|
+
`Cloudflare account since the binding was established. ` +
|
|
383
|
+
recoveryMessage(),
|
|
384
|
+
fields: {
|
|
385
|
+
boundAccountId: binding.accountId,
|
|
386
|
+
certAccountId: creds.accountId,
|
|
387
|
+
},
|
|
340
388
|
};
|
|
389
|
+
logRefuse(detail);
|
|
390
|
+
throw new CloudflareRefusalError(detail);
|
|
341
391
|
}
|
|
392
|
+
return {
|
|
393
|
+
client: new Cloudflare({ apiToken: creds.apiToken }),
|
|
394
|
+
accountId: binding.accountId,
|
|
395
|
+
};
|
|
342
396
|
}
|
|
343
|
-
export
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
397
|
+
export function getReadOnlyClient() {
|
|
398
|
+
const creds = parseCertPem();
|
|
399
|
+
const binding = readAccountBinding();
|
|
400
|
+
const certAccountId = creds?.accountId ?? null;
|
|
401
|
+
const boundAccountId = binding?.accountId ?? null;
|
|
402
|
+
const client = creds ? new Cloudflare({ apiToken: creds.apiToken }) : null;
|
|
403
|
+
const bindingMatches = !!(creds && binding && creds.accountId === binding.accountId);
|
|
404
|
+
return { client, certAccountId, boundAccountId, bindingMatches };
|
|
405
|
+
}
|
|
406
|
+
export function validateAuth() {
|
|
407
|
+
const creds = parseCertPem();
|
|
408
|
+
const binding = readAccountBinding();
|
|
409
|
+
return {
|
|
410
|
+
hasCert: !!creds,
|
|
411
|
+
hasBinding: !!binding,
|
|
412
|
+
bound: !!(creds && binding && creds.accountId === binding.accountId),
|
|
413
|
+
certAccountId: creds?.accountId ?? null,
|
|
414
|
+
boundAccountId: binding?.accountId ?? null,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Establish the device-local account binding from cert.pem. Used by:
|
|
419
|
+
* (1) tunnel-login fresh-success path — first time creds are derived
|
|
420
|
+
* (2) migration path — when cert.pem exists from a prior install but no
|
|
421
|
+
* binding has yet been written (silent materialization, since the
|
|
422
|
+
* cert was already trust-established by the operator's prior login)
|
|
423
|
+
*
|
|
424
|
+
* Caller is responsible for declared-zone visibility validation BEFORE
|
|
425
|
+
* calling this — bindings should never be written to an account that
|
|
426
|
+
* doesn't own the brand's declared zones (Task 201 write-after-confirm).
|
|
427
|
+
*/
|
|
428
|
+
export function materializeBinding(source) {
|
|
429
|
+
const creds = parseCertPem();
|
|
430
|
+
if (!creds) {
|
|
431
|
+
throw new Error("Cannot materialize binding — cert.pem absent or unparseable");
|
|
432
|
+
}
|
|
433
|
+
const binding = writeAccountBinding(creds.accountId);
|
|
434
|
+
console.error(`[cloudflare:binding-materialized] source=${source} accountId=${creds.accountId} boundAt=${binding.boundAt}`);
|
|
435
|
+
return binding;
|
|
357
436
|
}
|
|
358
437
|
export async function listZones() {
|
|
359
438
|
const { client } = getClient();
|
|
@@ -372,17 +451,6 @@ export async function listZones() {
|
|
|
372
451
|
}
|
|
373
452
|
return zones;
|
|
374
453
|
}
|
|
375
|
-
export function verifyZoneOnAccount(hostname, zones) {
|
|
376
|
-
const activeZones = zones.filter((z) => z.status === "active").map((z) => z.name);
|
|
377
|
-
const candidates = activeZones.filter((z) => hostname === z || hostname.endsWith(`.${z}`));
|
|
378
|
-
if (candidates.length > 0) {
|
|
379
|
-
candidates.sort((a, b) => b.length - a.length);
|
|
380
|
-
return { zone: candidates[0], missingParent: null, availableZones: activeZones };
|
|
381
|
-
}
|
|
382
|
-
const parts = hostname.split(".");
|
|
383
|
-
const missingParent = parts.length >= 2 ? parts.slice(-2).join(".") : hostname;
|
|
384
|
-
return { zone: null, missingParent, availableZones: activeZones };
|
|
385
|
-
}
|
|
386
454
|
export async function getZoneId(domain) {
|
|
387
455
|
const { client } = getClient();
|
|
388
456
|
const zones = await client.zones.list({ name: domain });
|
|
@@ -396,9 +464,6 @@ export async function getZoneId(domain) {
|
|
|
396
464
|
}
|
|
397
465
|
export async function createZone(domain) {
|
|
398
466
|
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
467
|
// Idempotent — check if zone already exists on this account
|
|
403
468
|
const existing = await client.zones.list({ name: domain });
|
|
404
469
|
const match = existing.result?.[0];
|
|
@@ -710,11 +775,11 @@ export async function createDnsRecord(zoneId, subdomain, tunnelId) {
|
|
|
710
775
|
return { created: true, existing: false, updated: false };
|
|
711
776
|
}
|
|
712
777
|
// ---------------------------------------------------------------------------
|
|
713
|
-
// CLI-based tunnel operations
|
|
778
|
+
// CLI-based tunnel operations
|
|
714
779
|
//
|
|
715
|
-
//
|
|
716
|
-
//
|
|
717
|
-
// Argo Tunnel permissions
|
|
780
|
+
// `cloudflared` CLI uses cert.pem directly for tunnel CRUD and DNS management.
|
|
781
|
+
// We use it (rather than the SDK) for tunnel-create / route-dns because the
|
|
782
|
+
// cert-bound credential carries the Argo Tunnel permissions needed in one step.
|
|
718
783
|
// ---------------------------------------------------------------------------
|
|
719
784
|
export function createTunnelCli(name) {
|
|
720
785
|
const bin = findBinary();
|
|
@@ -809,68 +874,121 @@ export function writeLocalConfig(tunnelId, credentialsPath, hostnames, port) {
|
|
|
809
874
|
return configPath;
|
|
810
875
|
}
|
|
811
876
|
// ---------------------------------------------------------------------------
|
|
812
|
-
// CLI-based DNS routing
|
|
877
|
+
// CLI-based DNS routing — guarded by live account-zone check + post-flight
|
|
878
|
+
//
|
|
879
|
+
// `cloudflared tunnel route dns` resolves the target zone from cert.pem's
|
|
880
|
+
// account. If the hostname's registrable parent zone is not on that account,
|
|
881
|
+
// cloudflared silently writes a CNAME under a different zone (the original
|
|
882
|
+
// joelsmalley.xyz wrong-routing bug). Defence in depth:
|
|
813
883
|
//
|
|
814
|
-
//
|
|
815
|
-
//
|
|
816
|
-
//
|
|
817
|
-
//
|
|
884
|
+
// (1) Live account-zone check: list the bound account's zones, refuse if
|
|
885
|
+
// hostname's registrable parent is not active on that account.
|
|
886
|
+
// (2) Post-flight FQDN assertion: the FQDN cloudflared wrote must equal
|
|
887
|
+
// the requested hostname. Mismatch → reverse-and-refuse.
|
|
818
888
|
//
|
|
819
|
-
//
|
|
820
|
-
// from cert.pem's account and silently falls back to a sibling zone when
|
|
821
|
-
// the hostname's parent zone is not present. verifyZoneOnAccount() is the
|
|
822
|
-
// choke point that intercepts that failure class before the CLI call.
|
|
889
|
+
// `getClient()` runs first to enforce auth pre-conditions.
|
|
823
890
|
// ---------------------------------------------------------------------------
|
|
824
891
|
export async function routeDnsCli(tunnelId, hostname) {
|
|
825
892
|
const bin = findBinary();
|
|
826
893
|
if (!bin)
|
|
827
894
|
throw new Error("cloudflared is not installed");
|
|
895
|
+
const { client, accountId: boundAccountId } = getClient();
|
|
896
|
+
// (1) Live account-zone check.
|
|
897
|
+
const accountZones = await listZones();
|
|
898
|
+
const scope = matchAccountZone(hostname, accountZones);
|
|
899
|
+
if (!scope.ok) {
|
|
900
|
+
const detail = {
|
|
901
|
+
reason: "scope-mismatch",
|
|
902
|
+
message: `Cannot route ${hostname} — no active zone on the bound Cloudflare account ` +
|
|
903
|
+
`owns this hostname's registrable parent. Active zones on this account: ` +
|
|
904
|
+
`${scope.accountZones.join(", ") || "none"}. Add the parent zone to the account ` +
|
|
905
|
+
`(cf-add-zone or via the Cloudflare dashboard), or pick a hostname under one ` +
|
|
906
|
+
`of the existing zones.`,
|
|
907
|
+
fields: {
|
|
908
|
+
requestedHostname: hostname,
|
|
909
|
+
accountZones: scope.accountZones,
|
|
910
|
+
},
|
|
911
|
+
};
|
|
912
|
+
logRefuse(detail);
|
|
913
|
+
throw new CloudflareRefusalError(detail);
|
|
914
|
+
}
|
|
828
915
|
const cert = findCert();
|
|
829
916
|
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
|
-
}
|
|
917
|
+
throw new Error("No cert.pem found — getClient() should have refused first");
|
|
918
|
+
let output;
|
|
844
919
|
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 };
|
|
920
|
+
output = execFileSync(bin, ["tunnel", "--origincert", cert, "route", "dns", "--overwrite-dns", tunnelId, hostname], { encoding: "utf-8", timeout: 30000 });
|
|
863
921
|
}
|
|
864
922
|
catch (err) {
|
|
865
923
|
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
924
|
if (typeof msg === "string" && msg.includes("already exists")) {
|
|
869
925
|
console.error(`[tunnel-route-dns] WARNING: ${hostname} "already exists" despite --overwrite-dns: ${msg}`);
|
|
870
|
-
return { created: false, output: msg, fqdn: hostname, zone:
|
|
926
|
+
return { created: false, output: msg, fqdn: hostname, zone: scope.matchedZone };
|
|
871
927
|
}
|
|
872
928
|
throw new Error(`cloudflared tunnel route dns failed: ${msg}`);
|
|
873
929
|
}
|
|
930
|
+
// (2) Post-flight FQDN assertion.
|
|
931
|
+
const fqdnMatch = output.match(/Added CNAME\s+(\S+?)\s+which will route/);
|
|
932
|
+
if (!fqdnMatch) {
|
|
933
|
+
console.error(`[tunnel-route-dns] WARNING: could not parse CNAME FQDN from cloudflared output — format may have changed. Output: ${output.trim()}`);
|
|
934
|
+
return {
|
|
935
|
+
created: true,
|
|
936
|
+
output: output.trim(),
|
|
937
|
+
fqdn: hostname,
|
|
938
|
+
zone: scope.matchedZone,
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
const actualFqdn = fqdnMatch[1];
|
|
942
|
+
if (actualFqdn !== hostname) {
|
|
943
|
+
// cloudflared wrote a CNAME under a different FQDN than requested —
|
|
944
|
+
// somehow the live-zone check passed but the routing landed elsewhere.
|
|
945
|
+
// Log evidence, attempt to delete the wrong CNAME, refuse.
|
|
946
|
+
console.error(`[cloudflare:post-flight-mismatch] ${JSON.stringify({
|
|
947
|
+
requestedHostname: hostname,
|
|
948
|
+
actualFqdn,
|
|
949
|
+
tunnelId,
|
|
950
|
+
boundAccountId,
|
|
951
|
+
})}`);
|
|
952
|
+
let cleanupResult = "failed";
|
|
953
|
+
try {
|
|
954
|
+
const owningZone = accountZones.find((z) => actualFqdn === z.name || actualFqdn.endsWith("." + z.name));
|
|
955
|
+
if (owningZone) {
|
|
956
|
+
const records = await client.dns.records.list({
|
|
957
|
+
zone_id: owningZone.id,
|
|
958
|
+
name: { exact: actualFqdn },
|
|
959
|
+
type: "CNAME",
|
|
960
|
+
});
|
|
961
|
+
for (const r of records.result ?? []) {
|
|
962
|
+
if (r.id)
|
|
963
|
+
await client.dns.records.delete(r.id, { zone_id: owningZone.id });
|
|
964
|
+
}
|
|
965
|
+
cleanupResult = "ok";
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
catch (cleanupErr) {
|
|
969
|
+
console.error(`[cloudflare:post-flight-cleanup] error: ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`);
|
|
970
|
+
}
|
|
971
|
+
console.error(`[cloudflare:post-flight-cleanup] ${JSON.stringify({
|
|
972
|
+
requestedHostname: hostname,
|
|
973
|
+
actualFqdn,
|
|
974
|
+
result: cleanupResult,
|
|
975
|
+
})}`);
|
|
976
|
+
const detail = {
|
|
977
|
+
reason: "post-flight-fqdn-mismatch",
|
|
978
|
+
message: `cloudflared wrote the CNAME under ${actualFqdn} instead of the requested ${hostname}. ` +
|
|
979
|
+
`Cleanup ${cleanupResult === "ok" ? "succeeded" : "failed"}. Re-run cf-rebuild to reconcile.`,
|
|
980
|
+
fields: {
|
|
981
|
+
requestedHostname: hostname,
|
|
982
|
+
actualFqdn,
|
|
983
|
+
tunnelId,
|
|
984
|
+
boundAccountId,
|
|
985
|
+
},
|
|
986
|
+
};
|
|
987
|
+
logRefuse(detail);
|
|
988
|
+
throw new CloudflareRefusalError(detail);
|
|
989
|
+
}
|
|
990
|
+
console.error(`[tunnel-route-dns] CLI: routed ${hostname} → tunnel ${tunnelId} (overwrite) fqdn=${actualFqdn} zone=${scope.matchedZone}`);
|
|
991
|
+
return { created: true, output: output.trim(), fqdn: actualFqdn, zone: scope.matchedZone };
|
|
874
992
|
}
|
|
875
993
|
// ---------------------------------------------------------------------------
|
|
876
994
|
// tunnel login — spawn `cloudflared tunnel login` and capture the auth URL
|
|
@@ -1103,8 +1221,8 @@ export function getPersistedHostnames() {
|
|
|
1103
1221
|
}
|
|
1104
1222
|
return [];
|
|
1105
1223
|
}
|
|
1106
|
-
export
|
|
1107
|
-
const auth =
|
|
1224
|
+
export function getStatus(domain) {
|
|
1225
|
+
const auth = validateAuth();
|
|
1108
1226
|
const state = readState();
|
|
1109
1227
|
const running = state !== null && isProcessAlive(state.pid);
|
|
1110
1228
|
const effectiveDomain = domain ?? state?.domain ?? null;
|
|
@@ -1112,10 +1230,11 @@ export async function getStatus(domain) {
|
|
|
1112
1230
|
return {
|
|
1113
1231
|
installed: isInstalled(),
|
|
1114
1232
|
version: version(),
|
|
1115
|
-
hasCert: hasCert
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1233
|
+
hasCert: auth.hasCert,
|
|
1234
|
+
hasBinding: auth.hasBinding,
|
|
1235
|
+
bound: auth.bound,
|
|
1236
|
+
certAccountId: auth.certAccountId,
|
|
1237
|
+
boundAccountId: auth.boundAccountId,
|
|
1119
1238
|
running,
|
|
1120
1239
|
pid: running ? state.pid : null,
|
|
1121
1240
|
tunnelId: state?.tunnelId ?? null,
|
|
@@ -1194,4 +1313,313 @@ export function stopTunnel() {
|
|
|
1194
1313
|
// Preserve tunnel identity, clear process lifecycle
|
|
1195
1314
|
writeState({ ...state, pid: null, startedAt: null });
|
|
1196
1315
|
}
|
|
1316
|
+
/**
|
|
1317
|
+
* cf-verify: read everything, classify nothing as good or bad. The agent
|
|
1318
|
+
* decides what to keep based on what the user is establishing now.
|
|
1319
|
+
*
|
|
1320
|
+
* "Orphans" are simply account-side things the device's current
|
|
1321
|
+
* tunnel.state + alias-domains.json don't reference. They may be pollution
|
|
1322
|
+
* from a previous setup, or may belong to other devices on the same
|
|
1323
|
+
* account — the agent surfaces them and the user decides.
|
|
1324
|
+
*/
|
|
1325
|
+
export async function cfVerifyCore() {
|
|
1326
|
+
const brand = loadBrand();
|
|
1327
|
+
console.error(`[cloudflare:verify-start] ${JSON.stringify({ brand: brand.productName })}`);
|
|
1328
|
+
const device = readDeviceSnapshot();
|
|
1329
|
+
const ro = getReadOnlyClient();
|
|
1330
|
+
let account = null;
|
|
1331
|
+
if (ro.client && ro.certAccountId) {
|
|
1332
|
+
try {
|
|
1333
|
+
const [zones, tunnels] = await Promise.all([
|
|
1334
|
+
listZonesViaClient(ro.client),
|
|
1335
|
+
listTunnelsViaClient(ro.client, ro.certAccountId),
|
|
1336
|
+
]);
|
|
1337
|
+
const cnames = [];
|
|
1338
|
+
for (const z of zones) {
|
|
1339
|
+
if (z.status !== "active")
|
|
1340
|
+
continue;
|
|
1341
|
+
try {
|
|
1342
|
+
const recs = await listCnamesUnderZone(ro.client, z.id);
|
|
1343
|
+
for (const r of recs) {
|
|
1344
|
+
cnames.push({ zone: z.name, recordId: r.id, name: r.name, content: r.content });
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
catch (err) {
|
|
1348
|
+
console.error(`[cloudflare:verify] failed to list CNAMEs under ${z.name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
account = {
|
|
1352
|
+
accountId: ro.certAccountId,
|
|
1353
|
+
zones: zones.map((z) => ({ id: z.id, name: z.name, status: z.status })),
|
|
1354
|
+
tunnels: tunnels.map((t) => ({ id: t.id, name: t.name })),
|
|
1355
|
+
cnames,
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
catch (err) {
|
|
1359
|
+
console.error(`[cloudflare:verify] account read failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
const orphans = computeOrphans(account, device);
|
|
1363
|
+
console.error(`[cloudflare:verify-complete] ${JSON.stringify({
|
|
1364
|
+
brand: brand.productName,
|
|
1365
|
+
orphanTunnels: orphans.tunnels.length,
|
|
1366
|
+
orphanCnames: orphans.cnames.length,
|
|
1367
|
+
orphanZones: orphans.zones.length,
|
|
1368
|
+
})}`);
|
|
1369
|
+
return { brand: brand.productName, device, account, orphans };
|
|
1370
|
+
}
|
|
1371
|
+
function readDeviceSnapshot() {
|
|
1372
|
+
const auth = validateAuth();
|
|
1373
|
+
const state = readState();
|
|
1374
|
+
const aliases = [...loadAliasDomains()];
|
|
1375
|
+
return {
|
|
1376
|
+
certPath: certPath(),
|
|
1377
|
+
certPresent: auth.hasCert,
|
|
1378
|
+
bindingPath: bindingFile(),
|
|
1379
|
+
bindingPresent: auth.hasBinding,
|
|
1380
|
+
bindingMatchesCert: auth.bound,
|
|
1381
|
+
certAccountId: auth.certAccountId,
|
|
1382
|
+
boundAccountId: auth.boundAccountId,
|
|
1383
|
+
tunnelStatePath: statePath(),
|
|
1384
|
+
tunnelState: state,
|
|
1385
|
+
configYmlPath: state?.configPath ?? null,
|
|
1386
|
+
configYmlPresent: !!(state?.configPath && existsSync(state.configPath)),
|
|
1387
|
+
aliasDomains: aliases,
|
|
1388
|
+
};
|
|
1389
|
+
}
|
|
1390
|
+
function computeOrphans(account, device) {
|
|
1391
|
+
if (!account)
|
|
1392
|
+
return { tunnels: [], cnames: [], zones: [] };
|
|
1393
|
+
const intendedTunnelId = device.tunnelState?.tunnelId ?? null;
|
|
1394
|
+
const intendedHostnames = new Set();
|
|
1395
|
+
if (device.tunnelState?.adminHostname)
|
|
1396
|
+
intendedHostnames.add(device.tunnelState.adminHostname.toLowerCase());
|
|
1397
|
+
if (device.tunnelState?.publicHostname)
|
|
1398
|
+
intendedHostnames.add(device.tunnelState.publicHostname.toLowerCase());
|
|
1399
|
+
for (const a of device.aliasDomains)
|
|
1400
|
+
intendedHostnames.add(a.toLowerCase());
|
|
1401
|
+
// Zones the intended hostnames live under — those are "in use".
|
|
1402
|
+
const inUseZones = new Set();
|
|
1403
|
+
for (const h of intendedHostnames) {
|
|
1404
|
+
for (const z of account.zones) {
|
|
1405
|
+
if (h === z.name.toLowerCase() || h.endsWith("." + z.name.toLowerCase())) {
|
|
1406
|
+
inUseZones.add(z.name.toLowerCase());
|
|
1407
|
+
break;
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
const orphanTunnels = account.tunnels.filter((t) => t.id !== intendedTunnelId);
|
|
1412
|
+
const orphanCnames = account.cnames.filter((c) => !intendedHostnames.has(c.name.toLowerCase()));
|
|
1413
|
+
const orphanZones = account.zones.filter((z) => !inUseZones.has(z.name.toLowerCase()));
|
|
1414
|
+
return { tunnels: orphanTunnels, cnames: orphanCnames, zones: orphanZones };
|
|
1415
|
+
}
|
|
1416
|
+
async function listZonesViaClient(client) {
|
|
1417
|
+
const zones = [];
|
|
1418
|
+
for await (const zone of client.zones.list()) {
|
|
1419
|
+
zones.push({
|
|
1420
|
+
id: zone.id,
|
|
1421
|
+
name: zone.name,
|
|
1422
|
+
status: zone.status ?? "unknown",
|
|
1423
|
+
nameservers: zone.name_servers ?? [],
|
|
1424
|
+
account: { id: zone.account.id ?? "", name: zone.account.name ?? "" },
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
return zones;
|
|
1428
|
+
}
|
|
1429
|
+
async function listTunnelsViaClient(client, accountId) {
|
|
1430
|
+
const summaries = [];
|
|
1431
|
+
const result = await client.zeroTrust.tunnels.cloudflared.list({
|
|
1432
|
+
account_id: accountId,
|
|
1433
|
+
is_deleted: false,
|
|
1434
|
+
});
|
|
1435
|
+
for (const t of result.result ?? []) {
|
|
1436
|
+
if (!t.id || !t.name)
|
|
1437
|
+
continue;
|
|
1438
|
+
summaries.push({ id: t.id, name: t.name, createdAt: t.created_at ?? null });
|
|
1439
|
+
}
|
|
1440
|
+
return summaries;
|
|
1441
|
+
}
|
|
1442
|
+
async function listCnamesUnderZone(client, zoneId) {
|
|
1443
|
+
const out = [];
|
|
1444
|
+
for await (const rec of client.dns.records.list({ zone_id: zoneId, type: "CNAME" })) {
|
|
1445
|
+
if (!rec.id || !rec.name)
|
|
1446
|
+
continue;
|
|
1447
|
+
out.push({ id: rec.id, name: rec.name, content: rec.content ?? "" });
|
|
1448
|
+
}
|
|
1449
|
+
return out;
|
|
1450
|
+
}
|
|
1451
|
+
export async function cfRebuildCore(opts = {}) {
|
|
1452
|
+
const dryRun = opts.dryRun ?? false;
|
|
1453
|
+
const brand = loadBrand();
|
|
1454
|
+
console.error(`[cloudflare:rebuild-start] ${JSON.stringify({ brand: brand.productName, dryRun })}`);
|
|
1455
|
+
// Auth gate. We need a client to mutate the account.
|
|
1456
|
+
let client;
|
|
1457
|
+
let accountId;
|
|
1458
|
+
try {
|
|
1459
|
+
const c = getClient();
|
|
1460
|
+
client = c.client;
|
|
1461
|
+
accountId = c.accountId;
|
|
1462
|
+
}
|
|
1463
|
+
catch (err) {
|
|
1464
|
+
if (err instanceof CloudflareRefusalError) {
|
|
1465
|
+
return {
|
|
1466
|
+
brand: brand.productName,
|
|
1467
|
+
dryRun,
|
|
1468
|
+
preserve: { zones: [], tunnelIds: [], cnames: null },
|
|
1469
|
+
actions: [],
|
|
1470
|
+
halted: true,
|
|
1471
|
+
haltReason: err.refusal.message,
|
|
1472
|
+
};
|
|
1473
|
+
}
|
|
1474
|
+
throw err;
|
|
1475
|
+
}
|
|
1476
|
+
// Snapshot the account before mutating.
|
|
1477
|
+
const verify = await cfVerifyCore();
|
|
1478
|
+
if (!verify.account) {
|
|
1479
|
+
return {
|
|
1480
|
+
brand: brand.productName,
|
|
1481
|
+
dryRun,
|
|
1482
|
+
preserve: { zones: [], tunnelIds: [], cnames: null },
|
|
1483
|
+
actions: [],
|
|
1484
|
+
halted: true,
|
|
1485
|
+
haltReason: "Could not read account state (cf-verify returned no account snapshot).",
|
|
1486
|
+
};
|
|
1487
|
+
}
|
|
1488
|
+
// Resolve the preserve set. Defaults from the device's intended state.
|
|
1489
|
+
const intendedTunnelId = verify.device.tunnelState?.tunnelId ?? null;
|
|
1490
|
+
const intendedHostnames = [];
|
|
1491
|
+
if (verify.device.tunnelState?.adminHostname)
|
|
1492
|
+
intendedHostnames.push(verify.device.tunnelState.adminHostname);
|
|
1493
|
+
if (verify.device.tunnelState?.publicHostname)
|
|
1494
|
+
intendedHostnames.push(verify.device.tunnelState.publicHostname);
|
|
1495
|
+
for (const a of verify.device.aliasDomains)
|
|
1496
|
+
intendedHostnames.push(a);
|
|
1497
|
+
const inferredZones = new Set();
|
|
1498
|
+
for (const h of intendedHostnames) {
|
|
1499
|
+
const lc = h.toLowerCase();
|
|
1500
|
+
for (const z of verify.account.zones) {
|
|
1501
|
+
if (lc === z.name.toLowerCase() || lc.endsWith("." + z.name.toLowerCase())) {
|
|
1502
|
+
inferredZones.add(z.name);
|
|
1503
|
+
break;
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
const preserveZones = (opts.preserve?.zones ?? [...inferredZones]).map((s) => s.toLowerCase());
|
|
1508
|
+
const preserveTunnelIds = opts.preserve?.tunnelIds ?? (intendedTunnelId ? [intendedTunnelId] : []);
|
|
1509
|
+
const preserveCnames = opts.preserve?.cnames
|
|
1510
|
+
? opts.preserve.cnames.map((c) => ({ zone: c.zone.toLowerCase(), name: c.name.toLowerCase() }))
|
|
1511
|
+
: null;
|
|
1512
|
+
const actions = [];
|
|
1513
|
+
// (a) Delete tunnels not in preserve.
|
|
1514
|
+
for (const t of verify.account.tunnels) {
|
|
1515
|
+
if (preserveTunnelIds.includes(t.id))
|
|
1516
|
+
continue;
|
|
1517
|
+
const action = {
|
|
1518
|
+
op: "delete-tunnel",
|
|
1519
|
+
type: "tunnel",
|
|
1520
|
+
id: t.id,
|
|
1521
|
+
name: t.name,
|
|
1522
|
+
result: dryRun ? "planned" : "ok",
|
|
1523
|
+
};
|
|
1524
|
+
if (!dryRun) {
|
|
1525
|
+
try {
|
|
1526
|
+
await client.zeroTrust.tunnels.cloudflared.delete(t.id, { account_id: accountId });
|
|
1527
|
+
}
|
|
1528
|
+
catch (err) {
|
|
1529
|
+
action.result = "failed";
|
|
1530
|
+
action.detail = err instanceof Error ? err.message : String(err);
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
console.error(`[cloudflare:rebuild-discard] ${JSON.stringify({ type: "tunnel", id: t.id, name: t.name, planned: dryRun, result: action.result })}`);
|
|
1534
|
+
actions.push(action);
|
|
1535
|
+
}
|
|
1536
|
+
// (b) Delete CNAMEs not in preserve.
|
|
1537
|
+
// If preserve.cnames is set, ONLY those exact records survive.
|
|
1538
|
+
// Otherwise: any CNAME under a preserved zone is preserved.
|
|
1539
|
+
for (const c of verify.account.cnames) {
|
|
1540
|
+
const zoneLc = c.zone.toLowerCase();
|
|
1541
|
+
const nameLc = c.name.toLowerCase();
|
|
1542
|
+
let keep = false;
|
|
1543
|
+
if (preserveCnames) {
|
|
1544
|
+
keep = preserveCnames.some((p) => p.zone === zoneLc && p.name === nameLc);
|
|
1545
|
+
}
|
|
1546
|
+
else {
|
|
1547
|
+
keep = preserveZones.includes(zoneLc);
|
|
1548
|
+
}
|
|
1549
|
+
if (keep)
|
|
1550
|
+
continue;
|
|
1551
|
+
const action = {
|
|
1552
|
+
op: "delete-cname",
|
|
1553
|
+
type: "cname",
|
|
1554
|
+
id: c.recordId,
|
|
1555
|
+
name: c.name,
|
|
1556
|
+
result: dryRun ? "planned" : "ok",
|
|
1557
|
+
detail: `${c.name} → ${c.content} under zone ${c.zone}`,
|
|
1558
|
+
};
|
|
1559
|
+
if (!dryRun) {
|
|
1560
|
+
const zoneRec = verify.account.zones.find((z) => z.name.toLowerCase() === zoneLc);
|
|
1561
|
+
if (!zoneRec) {
|
|
1562
|
+
action.result = "failed";
|
|
1563
|
+
action.detail = `zone ${c.zone} not found in snapshot`;
|
|
1564
|
+
}
|
|
1565
|
+
else {
|
|
1566
|
+
try {
|
|
1567
|
+
await client.dns.records.delete(c.recordId, { zone_id: zoneRec.id });
|
|
1568
|
+
}
|
|
1569
|
+
catch (err) {
|
|
1570
|
+
action.result = "failed";
|
|
1571
|
+
action.detail = err instanceof Error ? err.message : String(err);
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
console.error(`[cloudflare:rebuild-discard] ${JSON.stringify({ type: "cname", id: c.recordId, name: c.name, zone: c.zone, planned: dryRun, result: action.result })}`);
|
|
1576
|
+
actions.push(action);
|
|
1577
|
+
}
|
|
1578
|
+
// (c) Delete zones not in preserve.
|
|
1579
|
+
for (const z of verify.account.zones) {
|
|
1580
|
+
if (preserveZones.includes(z.name.toLowerCase()))
|
|
1581
|
+
continue;
|
|
1582
|
+
const action = {
|
|
1583
|
+
op: "delete-zone",
|
|
1584
|
+
type: "zone",
|
|
1585
|
+
id: z.id,
|
|
1586
|
+
name: z.name,
|
|
1587
|
+
result: dryRun ? "planned" : "ok",
|
|
1588
|
+
};
|
|
1589
|
+
if (!dryRun) {
|
|
1590
|
+
try {
|
|
1591
|
+
await client.zones.delete({ zone_id: z.id });
|
|
1592
|
+
}
|
|
1593
|
+
catch (err) {
|
|
1594
|
+
// Zone deletion may fail (permissions, registrar lock). Surface but
|
|
1595
|
+
// do not halt — orphan zones are informational, not blocking.
|
|
1596
|
+
action.result = "failed";
|
|
1597
|
+
action.detail = err instanceof Error ? err.message : String(err);
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
console.error(`[cloudflare:rebuild-discard] ${JSON.stringify({ type: "zone", id: z.id, name: z.name, planned: dryRun, result: action.result })}`);
|
|
1601
|
+
actions.push(action);
|
|
1602
|
+
}
|
|
1603
|
+
let finalVerify;
|
|
1604
|
+
if (!dryRun) {
|
|
1605
|
+
finalVerify = await cfVerifyCore();
|
|
1606
|
+
}
|
|
1607
|
+
console.error(`[cloudflare:rebuild-complete] ${JSON.stringify({
|
|
1608
|
+
brand: brand.productName,
|
|
1609
|
+
dryRun,
|
|
1610
|
+
actionCount: actions.length,
|
|
1611
|
+
})}`);
|
|
1612
|
+
return {
|
|
1613
|
+
brand: brand.productName,
|
|
1614
|
+
dryRun,
|
|
1615
|
+
preserve: {
|
|
1616
|
+
zones: preserveZones,
|
|
1617
|
+
tunnelIds: preserveTunnelIds,
|
|
1618
|
+
cnames: preserveCnames,
|
|
1619
|
+
},
|
|
1620
|
+
actions,
|
|
1621
|
+
finalVerify,
|
|
1622
|
+
halted: false,
|
|
1623
|
+
};
|
|
1624
|
+
}
|
|
1197
1625
|
//# sourceMappingURL=cloudflared.js.map
|