@rubytech/create-realagent 1.0.618 → 1.0.620
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.map +1 -1
- package/payload/platform/lib/mcp-stderr-tee/dist/index.js +11 -5
- package/payload/platform/lib/mcp-stderr-tee/dist/index.js.map +1 -1
- package/payload/platform/lib/mcp-stderr-tee/src/index.ts +10 -5
- package/payload/platform/plugins/admin/mcp/dist/lib/review-tools.d.ts.map +1 -1
- package/payload/platform/plugins/admin/mcp/dist/lib/review-tools.js +55 -6
- package/payload/platform/plugins/admin/mcp/dist/lib/review-tools.js.map +1 -1
- package/payload/platform/plugins/admin/skills/onboarding/SKILL.md +11 -7
- package/payload/platform/plugins/cloudflare/PLUGIN.md +10 -13
- package/payload/platform/plugins/cloudflare/mcp/dist/index.js +94 -1030
- package/payload/platform/plugins/cloudflare/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts +62 -258
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts.map +1 -1
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.js +297 -882
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.js.map +1 -1
- package/payload/platform/plugins/cloudflare/mcp/package.json +3 -7
- package/payload/platform/plugins/cloudflare/references/setup-guide.md +51 -77
- package/payload/platform/plugins/cloudflare/skills/setup-tunnel/SKILL.md +23 -81
- package/payload/platform/plugins/docs/PLUGIN.md +1 -1
- package/payload/platform/plugins/docs/references/cloudflare.md +21 -30
- package/payload/platform/templates/specialists/agents/personal-assistant.md +9 -9
- package/payload/server/server.js +161 -11
- package/payload/platform/plugins/cloudflare/mcp/__tests__/auth-binding.test.ts +0 -195
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { execFileSync, spawn } from "node:child_process";
|
|
2
2
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, openSync, unlinkSync, copyFileSync, } from "node:fs";
|
|
3
|
-
import { randomBytes } from "node:crypto";
|
|
4
3
|
import { join } from "node:path";
|
|
5
4
|
import { homedir } from "node:os";
|
|
6
|
-
import
|
|
5
|
+
import { Resolver } from "node:dns/promises";
|
|
7
6
|
let cachedBrand = null;
|
|
8
7
|
export function loadBrand() {
|
|
9
8
|
if (cachedBrand)
|
|
@@ -31,7 +30,7 @@ export function _resetBrandCache() {
|
|
|
31
30
|
cachedBrand = null;
|
|
32
31
|
}
|
|
33
32
|
// ---------------------------------------------------------------------------
|
|
34
|
-
// Binary detection
|
|
33
|
+
// Binary detection
|
|
35
34
|
// ---------------------------------------------------------------------------
|
|
36
35
|
let cachedBinaryPath = null;
|
|
37
36
|
const SEARCH_PATHS = [
|
|
@@ -42,7 +41,6 @@ const SEARCH_PATHS = [
|
|
|
42
41
|
export function findBinary() {
|
|
43
42
|
if (cachedBinaryPath)
|
|
44
43
|
return cachedBinaryPath;
|
|
45
|
-
// Try `which` first
|
|
46
44
|
try {
|
|
47
45
|
cachedBinaryPath = execFileSync("which", ["cloudflared"], {
|
|
48
46
|
encoding: "utf-8",
|
|
@@ -106,10 +104,8 @@ export function readAccountBinding() {
|
|
|
106
104
|
/**
|
|
107
105
|
* @internal — production code MUST go through `materializeBinding` so the
|
|
108
106
|
* source-of-truth lifecycle (fresh-login vs migration) is preserved and
|
|
109
|
-
* logged.
|
|
110
|
-
*
|
|
111
|
-
* that calls `writeAccountBinding` directly is a code-review block —
|
|
112
|
-
* silent overwrites mask account drift, defeating the four-step guard.
|
|
107
|
+
* logged. Exported only so unit tests can pre-seed bindings without
|
|
108
|
+
* re-implementing the file format.
|
|
113
109
|
*/
|
|
114
110
|
export function writeAccountBinding(accountId) {
|
|
115
111
|
const dir = bindingDir();
|
|
@@ -118,12 +114,6 @@ export function writeAccountBinding(accountId) {
|
|
|
118
114
|
writeFileSync(bindingFile(), JSON.stringify(binding, null, 2), { mode: 0o600 });
|
|
119
115
|
return binding;
|
|
120
116
|
}
|
|
121
|
-
/**
|
|
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.
|
|
126
|
-
*/
|
|
127
117
|
function resetAccountBinding() {
|
|
128
118
|
const path = bindingFile();
|
|
129
119
|
try {
|
|
@@ -139,16 +129,6 @@ function resetAccountBinding() {
|
|
|
139
129
|
return false;
|
|
140
130
|
}
|
|
141
131
|
}
|
|
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
132
|
export function logRefuse(detail) {
|
|
153
133
|
const brand = (() => {
|
|
154
134
|
try {
|
|
@@ -162,7 +142,6 @@ export function logRefuse(detail) {
|
|
|
162
142
|
const fields = {
|
|
163
143
|
reason: detail.reason,
|
|
164
144
|
brand: brand?.productName ?? "unknown",
|
|
165
|
-
accountZones: f.accountZones ?? null,
|
|
166
145
|
boundAccountId: f.boundAccountId ?? null,
|
|
167
146
|
certAccountId: f.certAccountId ?? null,
|
|
168
147
|
requestedDomain: f.requestedDomain ?? null,
|
|
@@ -173,14 +152,21 @@ export function logRefuse(detail) {
|
|
|
173
152
|
console.error(`[cloudflare:refuse] ${JSON.stringify(fields)}`);
|
|
174
153
|
}
|
|
175
154
|
/**
|
|
176
|
-
*
|
|
177
|
-
*
|
|
155
|
+
* The only recovery instruction — every refusal that involves the wrong
|
|
156
|
+
* Cloudflare account points here. Single source of truth so the four
|
|
157
|
+
* surfaces that quote it (tunnel-status, tunnel-add-hostname, personal
|
|
158
|
+
* assistant template, setup-tunnel skill) cannot drift.
|
|
178
159
|
*/
|
|
160
|
+
export const DASHBOARD_SWITCH_ACCOUNTS_INSTRUCTION = "Open Cloudflare in your browser. Look at the name in the top-left — " +
|
|
161
|
+
"that is the Cloudflare account this laptop will talk to. If it is not " +
|
|
162
|
+
"the account that owns the domain you want to use, switch accounts using " +
|
|
163
|
+
"the top-left dropdown. Once the correct account is showing, tell me and " +
|
|
164
|
+
"I will start the sign-in again.";
|
|
179
165
|
export function recoveryMessage() {
|
|
180
|
-
return (
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
166
|
+
return ("Recovery: confirm you are signed into the correct Cloudflare account " +
|
|
167
|
+
"in your browser, then ask me to run tunnel-login. If you recently switched " +
|
|
168
|
+
"accounts, ask me to run tunnel-login with force=true so the current sign-in " +
|
|
169
|
+
"is cleared before the new one starts.");
|
|
184
170
|
}
|
|
185
171
|
// ---------------------------------------------------------------------------
|
|
186
172
|
// Reset auth — unlinks cert.pem (both paths) and the account binding
|
|
@@ -261,10 +247,6 @@ function legacyCertPath() {
|
|
|
261
247
|
* file would survive `tunnel-login force=true` (which only deletes brand
|
|
262
248
|
* paths) and silently re-import on the next read — defeating the recovery
|
|
263
249
|
* path operators rely on after switching Cloudflare accounts.
|
|
264
|
-
*
|
|
265
|
-
* Failure to unlink the legacy file after a successful copy is non-fatal:
|
|
266
|
-
* the read still succeeds against the brand path, and the warning surfaces
|
|
267
|
-
* the residual stale file.
|
|
268
250
|
*/
|
|
269
251
|
function findCert() {
|
|
270
252
|
const brandPath = certPath();
|
|
@@ -290,9 +272,10 @@ export function hasCert() {
|
|
|
290
272
|
return findCert() !== null;
|
|
291
273
|
}
|
|
292
274
|
/**
|
|
293
|
-
* Parse the ARGO TUNNEL TOKEN PEM block from cert.pem to extract
|
|
294
|
-
*
|
|
295
|
-
*
|
|
275
|
+
* Parse the ARGO TUNNEL TOKEN PEM block from cert.pem to extract the
|
|
276
|
+
* AccountTag. Only the account ID is returned — nothing else in cert.pem
|
|
277
|
+
* is used by this codebase. The cert itself stays on disk; `cloudflared`
|
|
278
|
+
* shell-outs read it via `--origincert` for tunnel CRUD and DNS routing.
|
|
296
279
|
*/
|
|
297
280
|
export function parseCertPem() {
|
|
298
281
|
try {
|
|
@@ -311,26 +294,15 @@ export function parseCertPem() {
|
|
|
311
294
|
.replace(/\s+/g, "");
|
|
312
295
|
const json = Buffer.from(b64, "base64").toString("utf-8");
|
|
313
296
|
const data = JSON.parse(json);
|
|
314
|
-
const apiToken = data.APIToken ?? data.apiToken;
|
|
315
297
|
const accountId = data.AccountTag ?? data.accountTag ?? data.AccountID ?? data.accountID ?? data.accountId;
|
|
316
|
-
if (!
|
|
298
|
+
if (!accountId)
|
|
317
299
|
return null;
|
|
318
|
-
return {
|
|
300
|
+
return { accountId };
|
|
319
301
|
}
|
|
320
302
|
catch {
|
|
321
303
|
return null;
|
|
322
304
|
}
|
|
323
305
|
}
|
|
324
|
-
// ---------------------------------------------------------------------------
|
|
325
|
-
// SDK client
|
|
326
|
-
//
|
|
327
|
-
// Two entry points: getClient() for mutating operations enforces the auth
|
|
328
|
-
// pre-conditions (cert present, binding present, accountId-from-cert
|
|
329
|
-
// matches binding) — uncircumventable for any code path that needs an SDK
|
|
330
|
-
// instance. getReadOnlyClient() for cf-verify allows operating on an
|
|
331
|
-
// unbound device so the audit can report MISSING artefacts without
|
|
332
|
-
// throwing. Both rely on cert.pem as the single account identity source.
|
|
333
|
-
// ---------------------------------------------------------------------------
|
|
334
306
|
/** Wraps a structured RefusalDetail so call sites can branch on `reason`. */
|
|
335
307
|
export class CloudflareRefusalError extends Error {
|
|
336
308
|
refusal;
|
|
@@ -340,70 +312,6 @@ export class CloudflareRefusalError extends Error {
|
|
|
340
312
|
this.refusal = detail;
|
|
341
313
|
}
|
|
342
314
|
}
|
|
343
|
-
/**
|
|
344
|
-
* Build an SDK client for mutating operations. Throws CloudflareRefusalError
|
|
345
|
-
* with a structured `refusal` field if any auth pre-condition fails:
|
|
346
|
-
* - cert.pem absent or unparseable → unbound-device
|
|
347
|
-
* - account-binding.json absent → unbound-device
|
|
348
|
-
* - cert.pem accountId !== binding.accountId → account-drift
|
|
349
|
-
*
|
|
350
|
-
* The refusal is logged with [cloudflare:refuse] before the throw, so the
|
|
351
|
-
* single greppable signal exists regardless of how the caller handles the
|
|
352
|
-
* exception.
|
|
353
|
-
*/
|
|
354
|
-
export function getClient() {
|
|
355
|
-
const creds = parseCertPem();
|
|
356
|
-
if (!creds) {
|
|
357
|
-
const detail = {
|
|
358
|
-
reason: "unbound-device",
|
|
359
|
-
message: `No cert.pem found — this device has not completed Cloudflare authentication. ` +
|
|
360
|
-
recoveryMessage(),
|
|
361
|
-
fields: { boundAccountId: readAccountBinding()?.accountId ?? null },
|
|
362
|
-
};
|
|
363
|
-
logRefuse(detail);
|
|
364
|
-
throw new CloudflareRefusalError(detail);
|
|
365
|
-
}
|
|
366
|
-
const binding = readAccountBinding();
|
|
367
|
-
if (!binding) {
|
|
368
|
-
const detail = {
|
|
369
|
-
reason: "unbound-device",
|
|
370
|
-
message: `No account binding recorded — tunnel-login must complete a fresh authentication ` +
|
|
371
|
-
`to bind this device to a Cloudflare account before any other tool can run. ` +
|
|
372
|
-
recoveryMessage(),
|
|
373
|
-
fields: { certAccountId: creds.accountId },
|
|
374
|
-
};
|
|
375
|
-
logRefuse(detail);
|
|
376
|
-
throw new CloudflareRefusalError(detail);
|
|
377
|
-
}
|
|
378
|
-
if (creds.accountId !== binding.accountId) {
|
|
379
|
-
const detail = {
|
|
380
|
-
reason: "account-drift",
|
|
381
|
-
message: `cert.pem is bound to account ${creds.accountId}, but this device's recorded ` +
|
|
382
|
-
`binding is account ${binding.accountId}. The cert was rotated under a different ` +
|
|
383
|
-
`Cloudflare account since the binding was established. ` +
|
|
384
|
-
recoveryMessage(),
|
|
385
|
-
fields: {
|
|
386
|
-
boundAccountId: binding.accountId,
|
|
387
|
-
certAccountId: creds.accountId,
|
|
388
|
-
},
|
|
389
|
-
};
|
|
390
|
-
logRefuse(detail);
|
|
391
|
-
throw new CloudflareRefusalError(detail);
|
|
392
|
-
}
|
|
393
|
-
return {
|
|
394
|
-
client: new Cloudflare({ apiToken: creds.apiToken }),
|
|
395
|
-
accountId: binding.accountId,
|
|
396
|
-
};
|
|
397
|
-
}
|
|
398
|
-
export function getReadOnlyClient() {
|
|
399
|
-
const creds = parseCertPem();
|
|
400
|
-
const binding = readAccountBinding();
|
|
401
|
-
const certAccountId = creds?.accountId ?? null;
|
|
402
|
-
const boundAccountId = binding?.accountId ?? null;
|
|
403
|
-
const client = creds ? new Cloudflare({ apiToken: creds.apiToken }) : null;
|
|
404
|
-
const bindingMatches = !!(creds && binding && creds.accountId === binding.accountId);
|
|
405
|
-
return { client, certAccountId, boundAccountId, bindingMatches };
|
|
406
|
-
}
|
|
407
315
|
export function validateAuth() {
|
|
408
316
|
const creds = parseCertPem();
|
|
409
317
|
const binding = readAccountBinding();
|
|
@@ -417,14 +325,10 @@ export function validateAuth() {
|
|
|
417
325
|
}
|
|
418
326
|
/**
|
|
419
327
|
* Establish the device-local account binding from cert.pem. Used by:
|
|
420
|
-
* (1) tunnel-login fresh-success path — first time creds are derived
|
|
328
|
+
* (1) tunnel-login fresh-success path — first time creds are derived.
|
|
421
329
|
* (2) migration path — when cert.pem exists from a prior install but no
|
|
422
330
|
* binding has yet been written (silent materialization, since the
|
|
423
|
-
* cert was already trust-established by the operator's prior login)
|
|
424
|
-
*
|
|
425
|
-
* Caller is responsible for declared-zone visibility validation BEFORE
|
|
426
|
-
* calling this — bindings should never be written to an account that
|
|
427
|
-
* doesn't own the brand's declared zones (Task 201 write-after-confirm).
|
|
331
|
+
* cert was already trust-established by the operator's prior login).
|
|
428
332
|
*/
|
|
429
333
|
export function materializeBinding(source) {
|
|
430
334
|
const creds = parseCertPem();
|
|
@@ -435,228 +339,6 @@ export function materializeBinding(source) {
|
|
|
435
339
|
console.error(`[cloudflare:binding-materialized] source=${source} accountId=${creds.accountId} boundAt=${binding.boundAt}`);
|
|
436
340
|
return binding;
|
|
437
341
|
}
|
|
438
|
-
export async function listZones() {
|
|
439
|
-
const { client } = getClient();
|
|
440
|
-
const zones = [];
|
|
441
|
-
for await (const zone of client.zones.list()) {
|
|
442
|
-
zones.push({
|
|
443
|
-
id: zone.id,
|
|
444
|
-
name: zone.name,
|
|
445
|
-
status: zone.status ?? "unknown",
|
|
446
|
-
nameservers: zone.name_servers ?? [],
|
|
447
|
-
account: {
|
|
448
|
-
id: zone.account.id ?? "",
|
|
449
|
-
name: zone.account.name ?? "",
|
|
450
|
-
},
|
|
451
|
-
});
|
|
452
|
-
}
|
|
453
|
-
return zones;
|
|
454
|
-
}
|
|
455
|
-
export async function getZoneId(domain) {
|
|
456
|
-
const { client } = getClient();
|
|
457
|
-
const zones = await client.zones.list({ name: domain });
|
|
458
|
-
const zone = zones.result?.[0];
|
|
459
|
-
if (!zone) {
|
|
460
|
-
const all = await listZones();
|
|
461
|
-
const available = all.map((z) => z.name).join(", ");
|
|
462
|
-
throw new Error(`Zone "${domain}" not found on this account. Available zones: ${available || "none"}`);
|
|
463
|
-
}
|
|
464
|
-
return zone.id;
|
|
465
|
-
}
|
|
466
|
-
export async function createZone(domain) {
|
|
467
|
-
const { client, accountId } = getClient();
|
|
468
|
-
// Idempotent — check if zone already exists on this account
|
|
469
|
-
const existing = await client.zones.list({ name: domain });
|
|
470
|
-
const match = existing.result?.[0];
|
|
471
|
-
if (match) {
|
|
472
|
-
return {
|
|
473
|
-
id: match.id,
|
|
474
|
-
name: match.name,
|
|
475
|
-
status: match.status ?? "unknown",
|
|
476
|
-
nameservers: match.name_servers ?? [],
|
|
477
|
-
existing: true,
|
|
478
|
-
};
|
|
479
|
-
}
|
|
480
|
-
const zone = await client.zones.create({
|
|
481
|
-
name: domain,
|
|
482
|
-
type: "full",
|
|
483
|
-
account: { id: accountId },
|
|
484
|
-
});
|
|
485
|
-
return {
|
|
486
|
-
id: zone.id,
|
|
487
|
-
name: zone.name,
|
|
488
|
-
status: zone.status ?? "pending",
|
|
489
|
-
nameservers: zone.name_servers ?? [],
|
|
490
|
-
existing: false,
|
|
491
|
-
};
|
|
492
|
-
}
|
|
493
|
-
export async function createTunnel(name) {
|
|
494
|
-
const { client, accountId } = getClient();
|
|
495
|
-
// Check for existing tunnel with this name
|
|
496
|
-
const existing = await client.zeroTrust.tunnels.cloudflared.list({
|
|
497
|
-
account_id: accountId,
|
|
498
|
-
name,
|
|
499
|
-
is_deleted: false,
|
|
500
|
-
});
|
|
501
|
-
const match = existing.result?.find((t) => t.name === name);
|
|
502
|
-
if (match?.id) {
|
|
503
|
-
return { tunnelId: match.id, tunnelName: match.name ?? name };
|
|
504
|
-
}
|
|
505
|
-
// Create new tunnel — config_src: "cloudflare" means remotely-managed
|
|
506
|
-
const tunnel = await client.zeroTrust.tunnels.cloudflared.create({
|
|
507
|
-
account_id: accountId,
|
|
508
|
-
name,
|
|
509
|
-
config_src: "cloudflare",
|
|
510
|
-
tunnel_secret: generateTunnelSecret(),
|
|
511
|
-
});
|
|
512
|
-
if (!tunnel.id) {
|
|
513
|
-
throw new Error("Tunnel created but no ID returned from Cloudflare API");
|
|
514
|
-
}
|
|
515
|
-
return { tunnelId: tunnel.id, tunnelName: tunnel.name ?? name };
|
|
516
|
-
}
|
|
517
|
-
function generateTunnelSecret() {
|
|
518
|
-
return randomBytes(32).toString("base64");
|
|
519
|
-
}
|
|
520
|
-
export async function listTunnelsOnAccount() {
|
|
521
|
-
const { client, accountId } = getClient();
|
|
522
|
-
const summaries = [];
|
|
523
|
-
const result = await client.zeroTrust.tunnels.cloudflared.list({
|
|
524
|
-
account_id: accountId,
|
|
525
|
-
is_deleted: false,
|
|
526
|
-
});
|
|
527
|
-
for (const t of result.result ?? []) {
|
|
528
|
-
if (!t.id || !t.name)
|
|
529
|
-
continue;
|
|
530
|
-
summaries.push({ id: t.id, name: t.name, createdAt: t.created_at ?? null });
|
|
531
|
-
}
|
|
532
|
-
return summaries;
|
|
533
|
-
}
|
|
534
|
-
/**
|
|
535
|
-
* Find a zone whose existing CNAME records already point to the given tunnel.
|
|
536
|
-
* Used when the user selects an existing tunnel — we reuse the zone context
|
|
537
|
-
* rather than asking them to pick a zone again.
|
|
538
|
-
*
|
|
539
|
-
* Returns null when no zone routes to this tunnel. This is a legitimate
|
|
540
|
-
* outcome (e.g. tunnel created but never routed) — caller must handle it
|
|
541
|
-
* explicitly and fall back to zone selection, never substitute a default.
|
|
542
|
-
*/
|
|
543
|
-
export async function findZoneHostingTunnel(tunnelId) {
|
|
544
|
-
const { client } = getClient();
|
|
545
|
-
const expectedTarget = `${tunnelId}.cfargotunnel.com`;
|
|
546
|
-
const zones = await listZones();
|
|
547
|
-
for (const zone of zones) {
|
|
548
|
-
if (zone.status !== "active")
|
|
549
|
-
continue;
|
|
550
|
-
for await (const record of client.dns.records.list({
|
|
551
|
-
zone_id: zone.id,
|
|
552
|
-
type: "CNAME",
|
|
553
|
-
})) {
|
|
554
|
-
const content = record.content ?? "";
|
|
555
|
-
if (content === expectedTarget)
|
|
556
|
-
return zone.name;
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
return null;
|
|
560
|
-
}
|
|
561
|
-
export async function configureTunnel(tunnelId, hostnames, port) {
|
|
562
|
-
const { client, accountId } = getClient();
|
|
563
|
-
const ingress = [
|
|
564
|
-
...hostnames.map((h) => ({ hostname: h, service: `http://localhost:${port}` })),
|
|
565
|
-
{ hostname: "", service: "http_status:404" },
|
|
566
|
-
];
|
|
567
|
-
await client.zeroTrust.tunnels.cloudflared.configurations.update(tunnelId, {
|
|
568
|
-
account_id: accountId,
|
|
569
|
-
config: { ingress },
|
|
570
|
-
});
|
|
571
|
-
}
|
|
572
|
-
export async function getTunnelIngress(tunnelId) {
|
|
573
|
-
const { client, accountId } = getClient();
|
|
574
|
-
const config = await client.zeroTrust.tunnels.cloudflared.configurations.get(tunnelId, { account_id: accountId });
|
|
575
|
-
const ingress = config?.config?.ingress;
|
|
576
|
-
if (!Array.isArray(ingress))
|
|
577
|
-
return [];
|
|
578
|
-
return ingress.map((r) => ({
|
|
579
|
-
hostname: r.hostname ?? "",
|
|
580
|
-
service: r.service ?? "",
|
|
581
|
-
}));
|
|
582
|
-
}
|
|
583
|
-
export async function addHostnameToIngress(tunnelId, hostname, port) {
|
|
584
|
-
const effectivePort = port ?? parseInt(process.env.PLATFORM_PORT ?? "19200", 10);
|
|
585
|
-
const rules = await getTunnelIngress(tunnelId);
|
|
586
|
-
// Idempotent — skip if hostname already has an ingress rule
|
|
587
|
-
if (rules.some((r) => r.hostname === hostname)) {
|
|
588
|
-
return { added: false, alreadyPresent: true };
|
|
589
|
-
}
|
|
590
|
-
// Separate catch-all (hostname empty or missing) from named rules
|
|
591
|
-
const namedRules = rules
|
|
592
|
-
.filter((r) => r.hostname)
|
|
593
|
-
.map((r) => ({ hostname: r.hostname, service: r.service }));
|
|
594
|
-
// Rebuild: existing named rules + new rule + catch-all
|
|
595
|
-
const { client, accountId } = getClient();
|
|
596
|
-
await client.zeroTrust.tunnels.cloudflared.configurations.update(tunnelId, {
|
|
597
|
-
account_id: accountId,
|
|
598
|
-
config: {
|
|
599
|
-
ingress: [
|
|
600
|
-
...namedRules,
|
|
601
|
-
{ hostname, service: `http://localhost:${effectivePort}` },
|
|
602
|
-
{ hostname: "", service: "http_status:404" },
|
|
603
|
-
],
|
|
604
|
-
},
|
|
605
|
-
});
|
|
606
|
-
return { added: true, alreadyPresent: false };
|
|
607
|
-
}
|
|
608
|
-
// ---------------------------------------------------------------------------
|
|
609
|
-
// Ingress port verification — read-modify-write (preserves all hostnames)
|
|
610
|
-
//
|
|
611
|
-
// Reads current ingress rules, checks if any named rule's service port
|
|
612
|
-
// differs from expectedPort, and rewrites all rules with the correct port.
|
|
613
|
-
// Preserves all hostnames including aliases — unlike configureTunnel()
|
|
614
|
-
// which destructively sets only admin/public.
|
|
615
|
-
// ---------------------------------------------------------------------------
|
|
616
|
-
function extractPort(serviceUrl) {
|
|
617
|
-
try {
|
|
618
|
-
const url = new URL(serviceUrl);
|
|
619
|
-
return url.port ? parseInt(url.port, 10) : null;
|
|
620
|
-
}
|
|
621
|
-
catch {
|
|
622
|
-
return null;
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
export async function fixIngressPort(tunnelId, expectedPort) {
|
|
626
|
-
const rules = await getTunnelIngress(tunnelId);
|
|
627
|
-
const namedRules = rules.filter((r) => r.hostname);
|
|
628
|
-
if (namedRules.length === 0) {
|
|
629
|
-
return { corrected: false };
|
|
630
|
-
}
|
|
631
|
-
// Check if any named rule has a mismatched port
|
|
632
|
-
let mismatchPort = null;
|
|
633
|
-
for (const rule of namedRules) {
|
|
634
|
-
const port = extractPort(rule.service);
|
|
635
|
-
if (port !== null && port !== expectedPort) {
|
|
636
|
-
mismatchPort = port;
|
|
637
|
-
break;
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
if (mismatchPort === null) {
|
|
641
|
-
return { corrected: false };
|
|
642
|
-
}
|
|
643
|
-
// Rewrite all named rules with the correct port, preserving hostnames
|
|
644
|
-
const correctedRules = namedRules.map((r) => ({
|
|
645
|
-
hostname: r.hostname,
|
|
646
|
-
service: `http://localhost:${expectedPort}`,
|
|
647
|
-
}));
|
|
648
|
-
const { client, accountId } = getClient();
|
|
649
|
-
await client.zeroTrust.tunnels.cloudflared.configurations.update(tunnelId, {
|
|
650
|
-
account_id: accountId,
|
|
651
|
-
config: {
|
|
652
|
-
ingress: [
|
|
653
|
-
...correctedRules,
|
|
654
|
-
{ hostname: "", service: "http_status:404" },
|
|
655
|
-
],
|
|
656
|
-
},
|
|
657
|
-
});
|
|
658
|
-
return { corrected: true, fromPort: mismatchPort };
|
|
659
|
-
}
|
|
660
342
|
// ---------------------------------------------------------------------------
|
|
661
343
|
// Alias domain persistence — ~/{configDir}/alias-domains.json
|
|
662
344
|
// ---------------------------------------------------------------------------
|
|
@@ -689,98 +371,13 @@ export function saveAliasDomain(hostname) {
|
|
|
689
371
|
mkdirSync(dir, { recursive: true });
|
|
690
372
|
writeFileSync(aliasDomainPath(), JSON.stringify([...existing], null, 2), "utf-8");
|
|
691
373
|
}
|
|
692
|
-
export async function getConnectorToken(tunnelId) {
|
|
693
|
-
const { client, accountId } = getClient();
|
|
694
|
-
const response = await client.zeroTrust.tunnels.cloudflared.token.get(tunnelId, { account_id: accountId });
|
|
695
|
-
// Response is the token string directly
|
|
696
|
-
const token = typeof response === "string" ? response : String(response);
|
|
697
|
-
if (!token) {
|
|
698
|
-
throw new Error("Received empty connector token from Cloudflare API");
|
|
699
|
-
}
|
|
700
|
-
return token;
|
|
701
|
-
}
|
|
702
|
-
/**
|
|
703
|
-
* Check whether a hostname's CNAME points to a different tunnel.
|
|
704
|
-
* Returns collision=true if the CNAME target is *.cfargotunnel.com but
|
|
705
|
-
* for a different tunnel ID. Returns collision=false if:
|
|
706
|
-
* - No CNAME exists (NXDOMAIN/ENODATA)
|
|
707
|
-
* - CNAME points to the same tunnel (idempotent)
|
|
708
|
-
* - DNS lookup fails (can't prove collision — proceed with caution)
|
|
709
|
-
*/
|
|
710
|
-
export async function checkDnsCollision(hostname, tunnelId) {
|
|
711
|
-
const dns = await import("node:dns/promises");
|
|
712
|
-
const resolver = new dns.Resolver();
|
|
713
|
-
resolver.setServers(["8.8.8.8", "1.1.1.1"]);
|
|
714
|
-
try {
|
|
715
|
-
const cnames = await resolver.resolveCname(hostname);
|
|
716
|
-
if (cnames.length === 0) {
|
|
717
|
-
return { hostname, collision: false, existingTunnelId: null, error: null };
|
|
718
|
-
}
|
|
719
|
-
const cname = cnames[0];
|
|
720
|
-
const cfTunnelMatch = cname.match(/^([0-9a-f-]+)\.cfargotunnel\.com$/i);
|
|
721
|
-
if (!cfTunnelMatch) {
|
|
722
|
-
// CNAME exists but doesn't point to a Cloudflare tunnel — not our concern
|
|
723
|
-
return { hostname, collision: false, existingTunnelId: null, error: null };
|
|
724
|
-
}
|
|
725
|
-
const existingTunnelId = cfTunnelMatch[1];
|
|
726
|
-
if (existingTunnelId === tunnelId) {
|
|
727
|
-
// Same tunnel — idempotent, no collision
|
|
728
|
-
return { hostname, collision: false, existingTunnelId, error: null };
|
|
729
|
-
}
|
|
730
|
-
// Different tunnel — collision
|
|
731
|
-
console.error(`[tunnel-create] collision hostname=${hostname} existingTunnel=${existingTunnelId} requestedTunnel=${tunnelId}`);
|
|
732
|
-
return { hostname, collision: true, existingTunnelId, error: null };
|
|
733
|
-
}
|
|
734
|
-
catch (err) {
|
|
735
|
-
const code = err.code;
|
|
736
|
-
if (code === "ENODATA" || code === "ENOTFOUND") {
|
|
737
|
-
// No CNAME record exists — safe to create
|
|
738
|
-
return { hostname, collision: false, existingTunnelId: null, error: null };
|
|
739
|
-
}
|
|
740
|
-
// DNS lookup failed for other reasons — can't prove collision, proceed
|
|
741
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
742
|
-
console.error(`[tunnel-create] DNS collision check failed for ${hostname}: ${msg} — proceeding (cannot confirm collision)`);
|
|
743
|
-
return { hostname, collision: false, existingTunnelId: null, error: msg };
|
|
744
|
-
}
|
|
745
|
-
}
|
|
746
|
-
// ---------------------------------------------------------------------------
|
|
747
|
-
// DNS record management via SDK
|
|
748
|
-
// ---------------------------------------------------------------------------
|
|
749
|
-
export async function createDnsRecord(zoneId, subdomain, tunnelId) {
|
|
750
|
-
const { client } = getClient();
|
|
751
|
-
const cnameTarget = `${tunnelId}.cfargotunnel.com`;
|
|
752
|
-
// Check if record already exists
|
|
753
|
-
const existing = await client.dns.records.list({
|
|
754
|
-
zone_id: zoneId,
|
|
755
|
-
name: { exact: subdomain },
|
|
756
|
-
type: "CNAME",
|
|
757
|
-
});
|
|
758
|
-
if (existing.result?.length && existing.result.length > 0) {
|
|
759
|
-
const record = existing.result[0];
|
|
760
|
-
if (record.content === cnameTarget) {
|
|
761
|
-
return { created: false, existing: true, updated: false };
|
|
762
|
-
}
|
|
763
|
-
// CNAME points to a different tunnel — refuse to overwrite (collision guard)
|
|
764
|
-
const existingTarget = record.content ?? "(unknown)";
|
|
765
|
-
throw new Error(`DNS collision: ${subdomain} already has a CNAME pointing to ${existingTarget}, which belongs to a different tunnel. ` +
|
|
766
|
-
`Refusing to overwrite. To reclaim this hostname, delete the existing CNAME record first.`);
|
|
767
|
-
}
|
|
768
|
-
await client.dns.records.create({
|
|
769
|
-
zone_id: zoneId,
|
|
770
|
-
name: subdomain,
|
|
771
|
-
type: "CNAME",
|
|
772
|
-
content: cnameTarget,
|
|
773
|
-
proxied: true,
|
|
774
|
-
ttl: 1, // 1 = automatic
|
|
775
|
-
});
|
|
776
|
-
return { created: true, existing: false, updated: false };
|
|
777
|
-
}
|
|
778
374
|
// ---------------------------------------------------------------------------
|
|
779
375
|
// CLI-based tunnel operations
|
|
780
376
|
//
|
|
781
|
-
// `cloudflared` CLI uses cert.pem directly for tunnel CRUD and DNS
|
|
782
|
-
//
|
|
783
|
-
//
|
|
377
|
+
// `cloudflared` CLI uses cert.pem directly for tunnel CRUD and DNS routing.
|
|
378
|
+
// Shelling out to the CLI is architecturally indistinguishable from the
|
|
379
|
+
// operator clicking the same action in the dashboard — both use the
|
|
380
|
+
// operator's OAuth-bound cert, nothing more.
|
|
784
381
|
// ---------------------------------------------------------------------------
|
|
785
382
|
export function createTunnelCli(name) {
|
|
786
383
|
const bin = findBinary();
|
|
@@ -796,7 +393,6 @@ export function createTunnelCli(name) {
|
|
|
796
393
|
const existing = tunnels.find((t) => t.name === name && !t.deleted_at);
|
|
797
394
|
if (existing) {
|
|
798
395
|
console.error(`[tunnel-create] CLI: tunnel "${name}" already exists (${existing.id})`);
|
|
799
|
-
// Credentials file may exist at either location
|
|
800
396
|
const brandCredPath = join(homedir(), loadBrand().configDir, "cloudflared", `${existing.id}.json`);
|
|
801
397
|
const defaultCredPath = join(homedir(), ".cloudflared", `${existing.id}.json`);
|
|
802
398
|
if (!existsSync(brandCredPath) && existsSync(defaultCredPath)) {
|
|
@@ -875,39 +471,119 @@ export function writeLocalConfig(tunnelId, credentialsPath, hostnames, port) {
|
|
|
875
471
|
return configPath;
|
|
876
472
|
}
|
|
877
473
|
// ---------------------------------------------------------------------------
|
|
878
|
-
//
|
|
474
|
+
// Parse configured hostnames from config.yml
|
|
879
475
|
//
|
|
880
|
-
// `
|
|
881
|
-
//
|
|
882
|
-
//
|
|
883
|
-
//
|
|
476
|
+
// `tunnel-status`'s end-to-end probe reads the hostnames from config.yml
|
|
477
|
+
// rather than from tunnel.state — config.yml is the authoritative source
|
|
478
|
+
// for what `cloudflared` is actually ingressing. State-file hostnames may
|
|
479
|
+
// drift from on-disk config; the running tunnel only honours config.yml.
|
|
480
|
+
// ---------------------------------------------------------------------------
|
|
481
|
+
export function parseConfiguredHostnames(configYmlPath) {
|
|
482
|
+
try {
|
|
483
|
+
if (!existsSync(configYmlPath))
|
|
484
|
+
return [];
|
|
485
|
+
const yaml = readFileSync(configYmlPath, "utf-8");
|
|
486
|
+
const hostnames = [];
|
|
487
|
+
for (const line of yaml.split("\n")) {
|
|
488
|
+
const m = line.match(/^\s*-\s*hostname:\s*(\S+)\s*$/);
|
|
489
|
+
if (m)
|
|
490
|
+
hostnames.push(m[1]);
|
|
491
|
+
}
|
|
492
|
+
return hostnames;
|
|
493
|
+
}
|
|
494
|
+
catch (err) {
|
|
495
|
+
console.error(`[tunnel-status] failed to parse ${configYmlPath}: ${err}`);
|
|
496
|
+
return [];
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
// ---------------------------------------------------------------------------
|
|
500
|
+
// Nameserver pre-flight — zone-parent routability without any API
|
|
501
|
+
//
|
|
502
|
+
// Before shelling `cloudflared tunnel route dns`, confirm the hostname's
|
|
503
|
+
// registrable parent has its NS records pointing at Cloudflare. When they
|
|
504
|
+
// don't, `cloudflared` silently falls through to whichever zone the cert's
|
|
505
|
+
// account does own and creates a malformed CNAME (the historical
|
|
506
|
+
// `admin.maxy.bot.joelsmalley.xyz` failure mode). Refusing pre-flight is
|
|
507
|
+
// the only way to prevent that drift from inside this codebase.
|
|
508
|
+
//
|
|
509
|
+
// Heuristic for registrable parent: take the last two labels. This fails
|
|
510
|
+
// for multi-label public suffixes (e.g. `.co.uk`, `.com.au`) — not worth
|
|
511
|
+
// correctness here because Cloudflare's own sign-up flow rejects those
|
|
512
|
+
// at zone-add time, so the NS lookup will also fail for them, giving the
|
|
513
|
+
// same "not on Cloudflare yet" message.
|
|
514
|
+
// ---------------------------------------------------------------------------
|
|
515
|
+
function registrableParent(hostname) {
|
|
516
|
+
const labels = hostname.toLowerCase().split(".");
|
|
517
|
+
if (labels.length <= 2)
|
|
518
|
+
return hostname.toLowerCase();
|
|
519
|
+
return labels.slice(-2).join(".");
|
|
520
|
+
}
|
|
521
|
+
export async function checkZoneParentOnCloudflare(hostname) {
|
|
522
|
+
const zoneParent = registrableParent(hostname);
|
|
523
|
+
const resolver = new Resolver();
|
|
524
|
+
resolver.setServers(["1.1.1.1", "8.8.8.8"]);
|
|
525
|
+
try {
|
|
526
|
+
const ns = await resolver.resolveNs(zoneParent);
|
|
527
|
+
const onCloudflare = ns.some((n) => /\.ns\.cloudflare\.com\.?$/i.test(n));
|
|
528
|
+
return { zoneParent, nameservers: ns, onCloudflare };
|
|
529
|
+
}
|
|
530
|
+
catch (err) {
|
|
531
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
532
|
+
return { zoneParent, nameservers: [], onCloudflare: false, error: msg };
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
// ---------------------------------------------------------------------------
|
|
536
|
+
// CLI-based DNS routing — shells `cloudflared tunnel route dns` with defence
|
|
537
|
+
// in depth against the known malformed-CNAME fall-through.
|
|
884
538
|
//
|
|
885
|
-
// (1)
|
|
886
|
-
//
|
|
887
|
-
//
|
|
888
|
-
//
|
|
539
|
+
// (1) Pre-flight: zone-parent NS records must point to Cloudflare.
|
|
540
|
+
// Refusal reason `hostname-zone-not-routable` tells the operator
|
|
541
|
+
// to add the domain in the dashboard before coming back.
|
|
542
|
+
// (2) Post-flight: the FQDN `cloudflared` wrote must equal the requested
|
|
543
|
+
// hostname. A mismatch means `cloudflared` routed to a zone owned by
|
|
544
|
+
// the currently-bound account instead of the intended zone — usually
|
|
545
|
+
// because the device signed into the wrong Cloudflare account.
|
|
546
|
+
// Refusal reason `post-flight-fqdn-mismatch` tells the operator to
|
|
547
|
+
// check the dashboard account name and re-login if wrong.
|
|
889
548
|
//
|
|
890
|
-
//
|
|
549
|
+
// Authentication pre-condition is just "cert.pem + binding + they agree" —
|
|
550
|
+
// enforced by validateAuth().bound. No API client is constructed anywhere.
|
|
891
551
|
// ---------------------------------------------------------------------------
|
|
892
552
|
export async function routeDnsCli(tunnelId, hostname) {
|
|
893
553
|
const bin = findBinary();
|
|
894
554
|
if (!bin)
|
|
895
555
|
throw new Error("cloudflared is not installed");
|
|
896
|
-
const
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
556
|
+
const auth = validateAuth();
|
|
557
|
+
if (!auth.bound) {
|
|
558
|
+
const reason = auth.hasCert && auth.hasBinding
|
|
559
|
+
? "account-drift"
|
|
560
|
+
: "unbound-device";
|
|
561
|
+
const detail = {
|
|
562
|
+
reason,
|
|
563
|
+
message: `This laptop is not signed into Cloudflare yet${auth.hasCert ? " (sign-in has drifted since it was set up)" : ""}. ` +
|
|
564
|
+
`${DASHBOARD_SWITCH_ACCOUNTS_INSTRUCTION}`,
|
|
565
|
+
fields: {
|
|
566
|
+
boundAccountId: auth.boundAccountId,
|
|
567
|
+
certAccountId: auth.certAccountId,
|
|
568
|
+
requestedHostname: hostname,
|
|
569
|
+
},
|
|
570
|
+
};
|
|
571
|
+
logRefuse(detail);
|
|
572
|
+
throw new CloudflareRefusalError(detail);
|
|
573
|
+
}
|
|
574
|
+
// (1) Pre-flight zone-parent routability.
|
|
575
|
+
console.error(`[cloudflare:tunnel-add-hostname:preflight] hostname=${hostname}`);
|
|
576
|
+
const preflight = await checkZoneParentOnCloudflare(hostname);
|
|
577
|
+
if (!preflight.onCloudflare) {
|
|
901
578
|
const detail = {
|
|
902
|
-
reason: "
|
|
903
|
-
message: `
|
|
904
|
-
`
|
|
905
|
-
|
|
906
|
-
`
|
|
907
|
-
`of the existing zones.`,
|
|
579
|
+
reason: "hostname-zone-not-routable",
|
|
580
|
+
message: `The domain ${preflight.zoneParent} is not on Cloudflare yet — this laptop cannot ` +
|
|
581
|
+
`reach it. Open Cloudflare in your browser and add ${preflight.zoneParent} under the ` +
|
|
582
|
+
`Cloudflare account you want to use (Websites → Add a site). When the domain shows as ` +
|
|
583
|
+
`Active, tell me and I will add the address again.`,
|
|
908
584
|
fields: {
|
|
909
585
|
requestedHostname: hostname,
|
|
910
|
-
|
|
586
|
+
requestedDomain: preflight.zoneParent,
|
|
911
587
|
},
|
|
912
588
|
};
|
|
913
589
|
logRefuse(detail);
|
|
@@ -915,16 +591,21 @@ export async function routeDnsCli(tunnelId, hostname) {
|
|
|
915
591
|
}
|
|
916
592
|
const cert = findCert();
|
|
917
593
|
if (!cert)
|
|
918
|
-
throw new Error("No cert.pem found —
|
|
594
|
+
throw new Error("No cert.pem found — validateAuth() should have refused first");
|
|
919
595
|
let output;
|
|
920
596
|
try {
|
|
921
597
|
output = execFileSync(bin, ["tunnel", "--origincert", cert, "route", "dns", "--overwrite-dns", tunnelId, hostname], { encoding: "utf-8", timeout: 30000 });
|
|
922
598
|
}
|
|
923
599
|
catch (err) {
|
|
924
|
-
const
|
|
925
|
-
|
|
600
|
+
const errObj = err;
|
|
601
|
+
const rawStderr = errObj.stderr;
|
|
602
|
+
const stderrMsg = typeof rawStderr === "string"
|
|
603
|
+
? rawStderr
|
|
604
|
+
: Buffer.isBuffer(rawStderr) ? rawStderr.toString("utf-8") : "";
|
|
605
|
+
const msg = stderrMsg || errObj.message || String(err);
|
|
606
|
+
if (msg.includes("already exists")) {
|
|
926
607
|
console.error(`[tunnel-route-dns] WARNING: ${hostname} "already exists" despite --overwrite-dns: ${msg}`);
|
|
927
|
-
return { created: false, output: msg, fqdn: hostname
|
|
608
|
+
return { created: false, output: msg, fqdn: hostname };
|
|
928
609
|
}
|
|
929
610
|
throw new Error(`cloudflared tunnel route dns failed: ${msg}`);
|
|
930
611
|
}
|
|
@@ -932,74 +613,44 @@ export async function routeDnsCli(tunnelId, hostname) {
|
|
|
932
613
|
const fqdnMatch = output.match(/Added CNAME\s+(\S+?)\s+which will route/);
|
|
933
614
|
if (!fqdnMatch) {
|
|
934
615
|
console.error(`[tunnel-route-dns] WARNING: could not parse CNAME FQDN from cloudflared output — format may have changed. Output: ${output.trim()}`);
|
|
935
|
-
return {
|
|
936
|
-
created: true,
|
|
937
|
-
output: output.trim(),
|
|
938
|
-
fqdn: hostname,
|
|
939
|
-
zone: scope.matchedZone,
|
|
940
|
-
};
|
|
616
|
+
return { created: true, output: output.trim(), fqdn: hostname };
|
|
941
617
|
}
|
|
942
618
|
const actualFqdn = fqdnMatch[1];
|
|
943
619
|
if (actualFqdn !== hostname) {
|
|
944
|
-
// cloudflared wrote
|
|
945
|
-
//
|
|
946
|
-
//
|
|
620
|
+
// cloudflared wrote the address under a different domain than requested.
|
|
621
|
+
// That means the laptop is signed into a Cloudflare account that does
|
|
622
|
+
// not own the target domain, and `cloudflared` fell through to whichever
|
|
623
|
+
// domain the signed-in account does own. The agent cannot clean this up
|
|
624
|
+
// (no API access by design) — the operator cleans it up in the dashboard.
|
|
947
625
|
console.error(`[cloudflare:post-flight-mismatch] ${JSON.stringify({
|
|
948
626
|
requestedHostname: hostname,
|
|
949
627
|
actualFqdn,
|
|
950
628
|
tunnelId,
|
|
951
|
-
boundAccountId,
|
|
952
|
-
})}`);
|
|
953
|
-
let cleanupResult = "failed";
|
|
954
|
-
try {
|
|
955
|
-
const owningZone = accountZones.find((z) => actualFqdn === z.name || actualFqdn.endsWith("." + z.name));
|
|
956
|
-
if (owningZone) {
|
|
957
|
-
const records = await client.dns.records.list({
|
|
958
|
-
zone_id: owningZone.id,
|
|
959
|
-
name: { exact: actualFqdn },
|
|
960
|
-
type: "CNAME",
|
|
961
|
-
});
|
|
962
|
-
for (const r of records.result ?? []) {
|
|
963
|
-
if (r.id)
|
|
964
|
-
await client.dns.records.delete(r.id, { zone_id: owningZone.id });
|
|
965
|
-
}
|
|
966
|
-
cleanupResult = "ok";
|
|
967
|
-
}
|
|
968
|
-
}
|
|
969
|
-
catch (cleanupErr) {
|
|
970
|
-
console.error(`[cloudflare:post-flight-cleanup] error: ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`);
|
|
971
|
-
}
|
|
972
|
-
console.error(`[cloudflare:post-flight-cleanup] ${JSON.stringify({
|
|
973
|
-
requestedHostname: hostname,
|
|
974
|
-
actualFqdn,
|
|
975
|
-
result: cleanupResult,
|
|
629
|
+
boundAccountId: auth.boundAccountId,
|
|
976
630
|
})}`);
|
|
977
631
|
const detail = {
|
|
978
632
|
reason: "post-flight-fqdn-mismatch",
|
|
979
|
-
message: `
|
|
980
|
-
`
|
|
633
|
+
message: `Cloudflare created the address under ${actualFqdn} instead of the requested ${hostname}. ` +
|
|
634
|
+
`This laptop is signed into a Cloudflare account that does not own ${registrableParent(hostname)}. ` +
|
|
635
|
+
`Two things to do: (a) open Cloudflare in your browser, sign into the account that does own ` +
|
|
636
|
+
`${registrableParent(hostname)}, and tell me so I can re-login; (b) in the same browser, delete the ` +
|
|
637
|
+
`stray record at ${actualFqdn} from the other account so it does not confuse anything.`,
|
|
981
638
|
fields: {
|
|
982
639
|
requestedHostname: hostname,
|
|
983
640
|
actualFqdn,
|
|
984
641
|
tunnelId,
|
|
985
|
-
boundAccountId,
|
|
642
|
+
boundAccountId: auth.boundAccountId,
|
|
986
643
|
},
|
|
987
644
|
};
|
|
988
645
|
logRefuse(detail);
|
|
989
646
|
throw new CloudflareRefusalError(detail);
|
|
990
647
|
}
|
|
991
|
-
console.error(`[tunnel-route-dns] CLI: routed ${hostname} → tunnel ${tunnelId} (overwrite) fqdn=${actualFqdn}
|
|
992
|
-
return { created: true, output: output.trim(), fqdn: actualFqdn
|
|
648
|
+
console.error(`[tunnel-route-dns] CLI: routed ${hostname} → tunnel ${tunnelId} (overwrite) fqdn=${actualFqdn}`);
|
|
649
|
+
return { created: true, output: output.trim(), fqdn: actualFqdn };
|
|
993
650
|
}
|
|
994
651
|
// ---------------------------------------------------------------------------
|
|
995
652
|
// tunnel login — spawn `cloudflared tunnel login` and capture the auth URL
|
|
996
|
-
//
|
|
997
|
-
// Uses a log file fd (not pipes) for child stdio — eliminates SIGPIPE.
|
|
998
|
-
// Polls the log file for the auth URL instead of streaming from pipes.
|
|
999
|
-
// Tracks the login PID in a state file so subsequent calls detect an
|
|
1000
|
-
// existing process instead of spawning duplicates.
|
|
1001
653
|
// ---------------------------------------------------------------------------
|
|
1002
|
-
// Login state under ~/{configDir}/cloudflared/ — brand-isolated like tunnel state.
|
|
1003
654
|
function loginStatePath() {
|
|
1004
655
|
return join(homedir(), loadBrand().configDir, "cloudflared/login.state");
|
|
1005
656
|
}
|
|
@@ -1049,20 +700,15 @@ export function tunnelLogin() {
|
|
|
1049
700
|
const bin = findBinary();
|
|
1050
701
|
if (!bin)
|
|
1051
702
|
throw new Error("cloudflared is not installed");
|
|
1052
|
-
// Check for an existing login process
|
|
1053
703
|
const existing = getActiveLogin();
|
|
1054
704
|
if (existing) {
|
|
1055
705
|
console.error(`[tunnel-login] login process already running (PID ${existing.pid}), returning existing auth URL`);
|
|
1056
706
|
return Promise.resolve({ authUrl: existing.authUrl, alreadyRunning: true });
|
|
1057
707
|
}
|
|
1058
|
-
// Log to ~/{configDir}/logs/ — matches startTunnel() pattern
|
|
1059
708
|
const logDir = join(homedir(), loadBrand().configDir, "logs");
|
|
1060
709
|
mkdirSync(logDir, { recursive: true });
|
|
1061
710
|
const logPath = join(logDir, "cloudflared-login.log");
|
|
1062
711
|
const logFd = openSync(logPath, "a");
|
|
1063
|
-
// Direct cert.pem output to the brand-specific cloudflared directory.
|
|
1064
|
-
// Without --origincert, cloudflared writes to ~/.cloudflared/cert.pem —
|
|
1065
|
-
// but hasCert()/parseCertPem() now look at ~/{configDir}/cloudflared/.
|
|
1066
712
|
const certDir = join(homedir(), loadBrand().configDir, "cloudflared");
|
|
1067
713
|
mkdirSync(certDir, { recursive: true });
|
|
1068
714
|
const originCert = join(certDir, "cert.pem");
|
|
@@ -1076,7 +722,6 @@ export function tunnelLogin() {
|
|
|
1076
722
|
throw new Error("Failed to spawn cloudflared tunnel login — no PID returned");
|
|
1077
723
|
}
|
|
1078
724
|
console.error(`[tunnel-login] spawned PID ${child.pid}`);
|
|
1079
|
-
// Poll the log file for the auth URL
|
|
1080
725
|
const urlPattern = /https:\/\/dash\.cloudflare\.com[^\s]*/;
|
|
1081
726
|
return new Promise((resolve, reject) => {
|
|
1082
727
|
let elapsed = 0;
|
|
@@ -1084,7 +729,6 @@ export function tunnelLogin() {
|
|
|
1084
729
|
const timeout = 15000;
|
|
1085
730
|
const poller = setInterval(() => {
|
|
1086
731
|
elapsed += interval;
|
|
1087
|
-
// Check if process died before producing a URL
|
|
1088
732
|
if (!isProcessAlive(child.pid)) {
|
|
1089
733
|
clearInterval(poller);
|
|
1090
734
|
console.error(`[tunnel-login] PID ${child.pid} exited before producing auth URL`);
|
|
@@ -1098,7 +742,6 @@ export function tunnelLogin() {
|
|
|
1098
742
|
}
|
|
1099
743
|
return;
|
|
1100
744
|
}
|
|
1101
|
-
// Poll log file for the URL
|
|
1102
745
|
try {
|
|
1103
746
|
const content = readFileSync(logPath, "utf-8");
|
|
1104
747
|
const match = content.match(urlPattern);
|
|
@@ -1140,11 +783,7 @@ export function tunnelLogin() {
|
|
|
1140
783
|
}
|
|
1141
784
|
// ---------------------------------------------------------------------------
|
|
1142
785
|
// Process management — detached, survives MCP server exit
|
|
1143
|
-
//
|
|
1144
|
-
// Tunnel state stored at ~/{configDir}/cloudflared/tunnel.state so each
|
|
1145
|
-
// brand manages its own tunnel independently on multi-brand devices.
|
|
1146
786
|
// ---------------------------------------------------------------------------
|
|
1147
|
-
// Function (not const) because loadBrand() requires PLATFORM_ROOT at runtime.
|
|
1148
787
|
function statePath() {
|
|
1149
788
|
return join(homedir(), loadBrand().configDir, "cloudflared/tunnel.state");
|
|
1150
789
|
}
|
|
@@ -1184,10 +823,6 @@ export function getPersistedDomain() {
|
|
|
1184
823
|
export function getPersistedState() {
|
|
1185
824
|
return readState();
|
|
1186
825
|
}
|
|
1187
|
-
/**
|
|
1188
|
-
* Save tunnel identity to state file (pid/startedAt null until tunnel-enable).
|
|
1189
|
-
* Called by tunnel-create so tunnel-enable can find configPath and credentialsPath.
|
|
1190
|
-
*/
|
|
1191
826
|
export function saveTunnelIdentity(params) {
|
|
1192
827
|
writeState({
|
|
1193
828
|
tunnelId: params.tunnelId,
|
|
@@ -1202,32 +837,162 @@ export function saveTunnelIdentity(params) {
|
|
|
1202
837
|
});
|
|
1203
838
|
}
|
|
1204
839
|
/**
|
|
1205
|
-
* Return the actual hostnames from persisted state
|
|
1206
|
-
*
|
|
840
|
+
* Return the actual hostnames from persisted state (or derive from domain
|
|
841
|
+
* for pre-521 state files).
|
|
1207
842
|
*/
|
|
1208
843
|
export function getPersistedHostnames() {
|
|
1209
844
|
const state = readState();
|
|
1210
845
|
if (!state)
|
|
1211
846
|
return [];
|
|
1212
|
-
// New state files have explicit hostname fields
|
|
1213
847
|
if (state.adminHostname) {
|
|
1214
848
|
return state.publicHostname
|
|
1215
849
|
? [state.adminHostname, state.publicHostname]
|
|
1216
850
|
: [state.adminHostname];
|
|
1217
851
|
}
|
|
1218
|
-
// Backward compat: pre-521 state files without hostname fields
|
|
1219
852
|
if (state.domain) {
|
|
1220
853
|
console.error(`[tunnel] backward-compat: deriving hostnames from domain=${state.domain} (no adminHostname in state)`);
|
|
1221
854
|
return [`admin.${state.domain}`, `public.${state.domain}`];
|
|
1222
855
|
}
|
|
1223
856
|
return [];
|
|
1224
857
|
}
|
|
1225
|
-
export function
|
|
858
|
+
export async function probeHostname(hostname, expectedTunnelId) {
|
|
859
|
+
const resolver = new Resolver();
|
|
860
|
+
resolver.setServers(["1.1.1.1", "8.8.8.8"]);
|
|
861
|
+
let cname = null;
|
|
862
|
+
try {
|
|
863
|
+
const cnames = await resolver.resolveCname(hostname);
|
|
864
|
+
cname = cnames[0] ?? null;
|
|
865
|
+
}
|
|
866
|
+
catch (err) {
|
|
867
|
+
const code = err.code;
|
|
868
|
+
if (code === "ENODATA") {
|
|
869
|
+
// Proxied records can flatten — try A records as a secondary signal
|
|
870
|
+
// that the name exists, but without a CNAME we cannot confirm this
|
|
871
|
+
// tunnel. Treat as cname-points-elsewhere (name exists but does not
|
|
872
|
+
// point here) only when an A record is present; otherwise dns-missing.
|
|
873
|
+
try {
|
|
874
|
+
await resolver.resolve4(hostname);
|
|
875
|
+
cname = null; // flattened — still need edge probe to confirm
|
|
876
|
+
}
|
|
877
|
+
catch {
|
|
878
|
+
return { hostname, resolves: false, cname: null, httpStatus: null, failureMode: "dns-missing" };
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
else if (code === "ENOTFOUND" || code === "ESERVFAIL") {
|
|
882
|
+
return { hostname, resolves: false, cname: null, httpStatus: null, failureMode: "dns-missing" };
|
|
883
|
+
}
|
|
884
|
+
else {
|
|
885
|
+
console.error(`[cloudflare:tunnel-status:probe] unexpected DNS error for ${hostname}: ${err}`);
|
|
886
|
+
return { hostname, resolves: false, cname: null, httpStatus: null, failureMode: "dns-missing" };
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
if (expectedTunnelId && cname) {
|
|
890
|
+
const cnameLc = cname.toLowerCase().replace(/\.$/, "");
|
|
891
|
+
const expected = `${expectedTunnelId}.cfargotunnel.com`.toLowerCase();
|
|
892
|
+
if (cnameLc !== expected) {
|
|
893
|
+
return { hostname, resolves: true, cname, httpStatus: null, failureMode: "cname-points-elsewhere" };
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
// HTTPS probe — any response proves edge routing is wired up.
|
|
897
|
+
let httpStatus = null;
|
|
898
|
+
try {
|
|
899
|
+
const controller = new AbortController();
|
|
900
|
+
const to = setTimeout(() => controller.abort(), 10000);
|
|
901
|
+
try {
|
|
902
|
+
const res = await fetch(`https://${hostname}/`, {
|
|
903
|
+
method: "GET",
|
|
904
|
+
redirect: "manual",
|
|
905
|
+
signal: controller.signal,
|
|
906
|
+
});
|
|
907
|
+
httpStatus = res.status;
|
|
908
|
+
}
|
|
909
|
+
finally {
|
|
910
|
+
clearTimeout(to);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
catch (err) {
|
|
914
|
+
console.error(`[cloudflare:tunnel-status:probe] HTTPS probe failed for ${hostname}: ${err instanceof Error ? err.message : String(err)}`);
|
|
915
|
+
return { hostname, resolves: true, cname, httpStatus: null, failureMode: "edge-unreachable" };
|
|
916
|
+
}
|
|
917
|
+
// Cloudflare edge error page status codes that indicate the tunnel is
|
|
918
|
+
// either not connected (530) or the hostname is not served by any tunnel
|
|
919
|
+
// (1033/1016 surface as 530 or 502 at the HTTP layer; Cloudflare sometimes
|
|
920
|
+
// uses 502/503 for unreachable origins). Any 5xx from the edge with
|
|
921
|
+
// missing CNAME match means the tunnel-edge wiring is wrong.
|
|
922
|
+
if (httpStatus >= 500 && httpStatus < 600) {
|
|
923
|
+
return { hostname, resolves: true, cname, httpStatus, failureMode: "tunnel-not-matched" };
|
|
924
|
+
}
|
|
925
|
+
return { hostname, resolves: true, cname, httpStatus, failureMode: "ok" };
|
|
926
|
+
}
|
|
927
|
+
export async function getStatus(domain) {
|
|
1226
928
|
const auth = validateAuth();
|
|
1227
929
|
const state = readState();
|
|
1228
930
|
const running = state !== null && isProcessAlive(state.pid);
|
|
1229
931
|
const effectiveDomain = domain ?? state?.domain ?? null;
|
|
1230
|
-
const
|
|
932
|
+
const persistedHostnames = getPersistedHostnames();
|
|
933
|
+
const configuredHostnames = state?.configPath
|
|
934
|
+
? parseConfiguredHostnames(state.configPath)
|
|
935
|
+
: [];
|
|
936
|
+
// Probe the authoritative set (config.yml). If config.yml is absent, fall
|
|
937
|
+
// back to persisted hostnames so partial-setup states still surface.
|
|
938
|
+
const hostnamesToProbe = configuredHostnames.length > 0
|
|
939
|
+
? configuredHostnames
|
|
940
|
+
: persistedHostnames;
|
|
941
|
+
let probes = [];
|
|
942
|
+
let boundAccountOwnsHostnames = false;
|
|
943
|
+
let healthy = false;
|
|
944
|
+
let unhealthyReason = null;
|
|
945
|
+
if (!running || !state?.tunnelId || hostnamesToProbe.length === 0) {
|
|
946
|
+
unhealthyReason = !running
|
|
947
|
+
? "not-running"
|
|
948
|
+
: "no-tunnel-configured";
|
|
949
|
+
}
|
|
950
|
+
else {
|
|
951
|
+
probes = await Promise.all(hostnamesToProbe.map((h) => probeHostname(h, state.tunnelId)));
|
|
952
|
+
// A probe reaching the edge with a non-5xx response proves the signed-in
|
|
953
|
+
// account owns the domain: the edge only routes to this tunnel's origin
|
|
954
|
+
// when the domain's DNS on this account points at this tunnel. The CNAME
|
|
955
|
+
// check inside probeHostname already short-circuits the "points to a
|
|
956
|
+
// different tunnel" case to `cname-points-elsewhere` — so here we trust
|
|
957
|
+
// `ok`. We do not gate on `p.cname !== null` because Cloudflare can flatten
|
|
958
|
+
// CNAMEs (zone-apex records, some proxy configurations) — in that case
|
|
959
|
+
// cname is null but the HTTPS response is still authoritative.
|
|
960
|
+
boundAccountOwnsHostnames = probes.some((p) => p.failureMode === "ok");
|
|
961
|
+
healthy = probes.every((p) => p.failureMode === "ok");
|
|
962
|
+
if (!healthy) {
|
|
963
|
+
// Distinguishing "wrong account" from "right account, tunnel broken":
|
|
964
|
+
// a probe that resolved DNS at all is evidence the domain lives on
|
|
965
|
+
// some Cloudflare account (the NS is Cloudflare's). A probe that
|
|
966
|
+
// resolved AND doesn't point elsewhere is evidence this laptop's
|
|
967
|
+
// account owns it — any other failure mode (edge-unreachable,
|
|
968
|
+
// tunnel-not-matched, or a healthy flattened probe) indicates the
|
|
969
|
+
// DNS points at *something* on the signed-in account, just not
|
|
970
|
+
// working. Only when every probe is `dns-missing` or
|
|
971
|
+
// `cname-points-elsewhere` do we conclude the bound account does
|
|
972
|
+
// not own the hostnames.
|
|
973
|
+
const anyCnamePointsHere = probes.some((p) => p.resolves &&
|
|
974
|
+
p.failureMode !== "cname-points-elsewhere" &&
|
|
975
|
+
p.failureMode !== "dns-missing");
|
|
976
|
+
unhealthyReason = anyCnamePointsHere
|
|
977
|
+
? "hostname-probes-failed"
|
|
978
|
+
: "bound-account-does-not-own-hostname";
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
if (unhealthyReason === "bound-account-does-not-own-hostname") {
|
|
982
|
+
// Surface the structured refusal reason so the review-detector rule
|
|
983
|
+
// fires on the next admin turn — same channel the agent-facing text
|
|
984
|
+
// uses, but keyed on the enum.
|
|
985
|
+
logRefuse({
|
|
986
|
+
reason: "bound-account-does-not-own-hostname",
|
|
987
|
+
message: "tunnel-status probe: no configured hostname's DNS lands on this tunnel. " +
|
|
988
|
+
`${DASHBOARD_SWITCH_ACCOUNTS_INSTRUCTION}`,
|
|
989
|
+
fields: {
|
|
990
|
+
boundAccountId: auth.boundAccountId,
|
|
991
|
+
certAccountId: auth.certAccountId,
|
|
992
|
+
tunnelId: state?.tunnelId ?? undefined,
|
|
993
|
+
},
|
|
994
|
+
});
|
|
995
|
+
}
|
|
1231
996
|
return {
|
|
1232
997
|
installed: isInstalled(),
|
|
1233
998
|
version: version(),
|
|
@@ -1240,11 +1005,15 @@ export function getStatus(domain) {
|
|
|
1240
1005
|
pid: running ? state.pid : null,
|
|
1241
1006
|
tunnelId: state?.tunnelId ?? null,
|
|
1242
1007
|
domain: effectiveDomain,
|
|
1243
|
-
|
|
1008
|
+
configuredHostnames,
|
|
1009
|
+
persistedHostnames,
|
|
1244
1010
|
adminHostname: state?.adminHostname ?? (effectiveDomain ? `admin.${effectiveDomain}` : null),
|
|
1245
1011
|
publicHostname: state?.publicHostname ?? (effectiveDomain ? `public.${effectiveDomain}` : null),
|
|
1246
1012
|
upSince: running ? state.startedAt : null,
|
|
1247
|
-
|
|
1013
|
+
probes,
|
|
1014
|
+
boundAccountOwnsHostnames,
|
|
1015
|
+
healthy,
|
|
1016
|
+
unhealthyReason,
|
|
1248
1017
|
};
|
|
1249
1018
|
}
|
|
1250
1019
|
export function startTunnel(params) {
|
|
@@ -1254,17 +1023,14 @@ export function startTunnel(params) {
|
|
|
1254
1023
|
const cert = findCert();
|
|
1255
1024
|
if (!cert)
|
|
1256
1025
|
throw new Error("No cert.pem found — run tunnel-login first");
|
|
1257
|
-
// Check if already running via state file
|
|
1258
1026
|
const state = readState();
|
|
1259
1027
|
if (state && isProcessAlive(state.pid)) {
|
|
1260
1028
|
throw new Error(`Tunnel is already running (PID ${state.pid})`);
|
|
1261
1029
|
}
|
|
1262
|
-
// Log to ~/{configDir}/logs/ so crashes are diagnosable
|
|
1263
1030
|
const logDir = join(homedir(), loadBrand().configDir, "logs");
|
|
1264
1031
|
mkdirSync(logDir, { recursive: true });
|
|
1265
1032
|
const logPath = join(logDir, "cloudflared.log");
|
|
1266
1033
|
const logFd = openSync(logPath, "a");
|
|
1267
|
-
// Single deterministic command: config.yml + cert.pem
|
|
1268
1034
|
const child = spawn(bin, ["--origincert", cert, "--config", params.configPath, "tunnel", "run"], {
|
|
1269
1035
|
stdio: ["ignore", logFd, logFd],
|
|
1270
1036
|
detached: true,
|
|
@@ -1273,7 +1039,6 @@ export function startTunnel(params) {
|
|
|
1273
1039
|
if (!child.pid)
|
|
1274
1040
|
throw new Error("Failed to spawn tunnel process");
|
|
1275
1041
|
console.error(`[tunnel-enable] started PID ${child.pid} with config ${params.configPath}`);
|
|
1276
|
-
// Preserve hostname fields from existing state when starting the tunnel
|
|
1277
1042
|
const existingState = readState();
|
|
1278
1043
|
writeState({
|
|
1279
1044
|
pid: child.pid,
|
|
@@ -1299,7 +1064,6 @@ export function stopTunnel() {
|
|
|
1299
1064
|
catch {
|
|
1300
1065
|
// process may have exited between check and kill
|
|
1301
1066
|
}
|
|
1302
|
-
// Force kill after 5 seconds
|
|
1303
1067
|
setTimeout(() => {
|
|
1304
1068
|
if (isProcessAlive(pid)) {
|
|
1305
1069
|
try {
|
|
@@ -1311,355 +1075,6 @@ export function stopTunnel() {
|
|
|
1311
1075
|
}
|
|
1312
1076
|
}, 5000);
|
|
1313
1077
|
}
|
|
1314
|
-
// Preserve tunnel identity, clear process lifecycle
|
|
1315
1078
|
writeState({ ...state, pid: null, startedAt: null });
|
|
1316
1079
|
}
|
|
1317
|
-
/**
|
|
1318
|
-
* cf-verify: read everything, tag nothing as good or bad. The bound
|
|
1319
|
-
* Cloudflare account is the universe; the agent decides what is pollution
|
|
1320
|
-
* from the operator's stated intent in the current conversation.
|
|
1321
|
-
*
|
|
1322
|
-
* The `pollution` field returned here is the default no-intent view —
|
|
1323
|
-
* account artefacts the device's persisted `tunnel.state` + `alias-domains.json`
|
|
1324
|
-
* don't reference. The orchestrator recomputes against explicit intent
|
|
1325
|
-
* when the operator picks a zone.
|
|
1326
|
-
*/
|
|
1327
|
-
export async function cfVerifyCore() {
|
|
1328
|
-
const brand = loadBrand();
|
|
1329
|
-
console.error(`[cloudflare:verify-start] ${JSON.stringify({ brand: brand.productName })}`);
|
|
1330
|
-
const device = readDeviceSnapshot();
|
|
1331
|
-
const ro = getReadOnlyClient();
|
|
1332
|
-
let account = null;
|
|
1333
|
-
if (ro.client && ro.certAccountId) {
|
|
1334
|
-
try {
|
|
1335
|
-
const [zones, tunnels] = await Promise.all([
|
|
1336
|
-
listZonesViaClient(ro.client),
|
|
1337
|
-
listTunnelsViaClient(ro.client, ro.certAccountId),
|
|
1338
|
-
]);
|
|
1339
|
-
const cnames = [];
|
|
1340
|
-
for (const z of zones) {
|
|
1341
|
-
if (z.status !== "active")
|
|
1342
|
-
continue;
|
|
1343
|
-
try {
|
|
1344
|
-
const recs = await listCnamesUnderZone(ro.client, z.id);
|
|
1345
|
-
for (const r of recs) {
|
|
1346
|
-
cnames.push({ zone: z.name, recordId: r.id, name: r.name, content: r.content });
|
|
1347
|
-
}
|
|
1348
|
-
}
|
|
1349
|
-
catch (err) {
|
|
1350
|
-
console.error(`[cloudflare:verify] failed to list CNAMEs under ${z.name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1351
|
-
}
|
|
1352
|
-
}
|
|
1353
|
-
account = {
|
|
1354
|
-
accountId: ro.certAccountId,
|
|
1355
|
-
zones: zones.map((z) => ({ id: z.id, name: z.name, status: z.status })),
|
|
1356
|
-
tunnels: tunnels.map((t) => ({ id: t.id, name: t.name })),
|
|
1357
|
-
cnames,
|
|
1358
|
-
};
|
|
1359
|
-
}
|
|
1360
|
-
catch (err) {
|
|
1361
|
-
console.error(`[cloudflare:verify] account read failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1362
|
-
}
|
|
1363
|
-
}
|
|
1364
|
-
const pollution = computePollution(account, device);
|
|
1365
|
-
console.error(`[cloudflare:verify-complete] ${JSON.stringify({
|
|
1366
|
-
brand: brand.productName,
|
|
1367
|
-
accountZones: account?.zones.length ?? 0,
|
|
1368
|
-
accountTunnels: account?.tunnels.length ?? 0,
|
|
1369
|
-
accountCnames: account?.cnames.length ?? 0,
|
|
1370
|
-
pollutionTunnels: pollution.tunnels.length,
|
|
1371
|
-
pollutionCnames: pollution.cnames.length,
|
|
1372
|
-
pollutionZones: pollution.zones.length,
|
|
1373
|
-
})}`);
|
|
1374
|
-
return { brand: brand.productName, device, account, pollution };
|
|
1375
|
-
}
|
|
1376
|
-
function readDeviceSnapshot() {
|
|
1377
|
-
const auth = validateAuth();
|
|
1378
|
-
const state = readState();
|
|
1379
|
-
const aliases = [...loadAliasDomains()];
|
|
1380
|
-
return {
|
|
1381
|
-
certPath: certPath(),
|
|
1382
|
-
certPresent: auth.hasCert,
|
|
1383
|
-
bindingPath: bindingFile(),
|
|
1384
|
-
bindingPresent: auth.hasBinding,
|
|
1385
|
-
bindingMatchesCert: auth.bound,
|
|
1386
|
-
certAccountId: auth.certAccountId,
|
|
1387
|
-
boundAccountId: auth.boundAccountId,
|
|
1388
|
-
tunnelStatePath: statePath(),
|
|
1389
|
-
tunnelState: state,
|
|
1390
|
-
configYmlPath: state?.configPath ?? null,
|
|
1391
|
-
configYmlPresent: !!(state?.configPath && existsSync(state.configPath)),
|
|
1392
|
-
aliasDomains: aliases,
|
|
1393
|
-
};
|
|
1394
|
-
}
|
|
1395
|
-
function computePollution(account, device) {
|
|
1396
|
-
if (!account)
|
|
1397
|
-
return { tunnels: [], cnames: [], zones: [] };
|
|
1398
|
-
const intendedTunnelId = device.tunnelState?.tunnelId ?? null;
|
|
1399
|
-
const intendedHostnames = new Set();
|
|
1400
|
-
if (device.tunnelState?.adminHostname)
|
|
1401
|
-
intendedHostnames.add(device.tunnelState.adminHostname.toLowerCase());
|
|
1402
|
-
if (device.tunnelState?.publicHostname)
|
|
1403
|
-
intendedHostnames.add(device.tunnelState.publicHostname.toLowerCase());
|
|
1404
|
-
for (const a of device.aliasDomains)
|
|
1405
|
-
intendedHostnames.add(a.toLowerCase());
|
|
1406
|
-
// Zones the intended hostnames live under — those are "in use".
|
|
1407
|
-
const inUseZones = new Set();
|
|
1408
|
-
for (const h of intendedHostnames) {
|
|
1409
|
-
for (const z of account.zones) {
|
|
1410
|
-
if (h === z.name.toLowerCase() || h.endsWith("." + z.name.toLowerCase())) {
|
|
1411
|
-
inUseZones.add(z.name.toLowerCase());
|
|
1412
|
-
break;
|
|
1413
|
-
}
|
|
1414
|
-
}
|
|
1415
|
-
}
|
|
1416
|
-
const pollutionTunnels = account.tunnels.filter((t) => t.id !== intendedTunnelId);
|
|
1417
|
-
const pollutionCnames = account.cnames.filter((c) => !intendedHostnames.has(c.name.toLowerCase()));
|
|
1418
|
-
const pollutionZones = account.zones.filter((z) => !inUseZones.has(z.name.toLowerCase()));
|
|
1419
|
-
return { tunnels: pollutionTunnels, cnames: pollutionCnames, zones: pollutionZones };
|
|
1420
|
-
}
|
|
1421
|
-
async function listZonesViaClient(client) {
|
|
1422
|
-
const zones = [];
|
|
1423
|
-
for await (const zone of client.zones.list()) {
|
|
1424
|
-
zones.push({
|
|
1425
|
-
id: zone.id,
|
|
1426
|
-
name: zone.name,
|
|
1427
|
-
status: zone.status ?? "unknown",
|
|
1428
|
-
nameservers: zone.name_servers ?? [],
|
|
1429
|
-
account: { id: zone.account.id ?? "", name: zone.account.name ?? "" },
|
|
1430
|
-
});
|
|
1431
|
-
}
|
|
1432
|
-
return zones;
|
|
1433
|
-
}
|
|
1434
|
-
async function listTunnelsViaClient(client, accountId) {
|
|
1435
|
-
const summaries = [];
|
|
1436
|
-
const result = await client.zeroTrust.tunnels.cloudflared.list({
|
|
1437
|
-
account_id: accountId,
|
|
1438
|
-
is_deleted: false,
|
|
1439
|
-
});
|
|
1440
|
-
for (const t of result.result ?? []) {
|
|
1441
|
-
if (!t.id || !t.name)
|
|
1442
|
-
continue;
|
|
1443
|
-
summaries.push({ id: t.id, name: t.name, createdAt: t.created_at ?? null });
|
|
1444
|
-
}
|
|
1445
|
-
return summaries;
|
|
1446
|
-
}
|
|
1447
|
-
async function listCnamesUnderZone(client, zoneId) {
|
|
1448
|
-
const out = [];
|
|
1449
|
-
for await (const rec of client.dns.records.list({ zone_id: zoneId, type: "CNAME" })) {
|
|
1450
|
-
if (!rec.id || !rec.name)
|
|
1451
|
-
continue;
|
|
1452
|
-
out.push({ id: rec.id, name: rec.name, content: rec.content ?? "" });
|
|
1453
|
-
}
|
|
1454
|
-
return out;
|
|
1455
|
-
}
|
|
1456
|
-
export async function cfRebuildCore(opts = {}) {
|
|
1457
|
-
const dryRun = opts.dryRun ?? false;
|
|
1458
|
-
const brand = loadBrand();
|
|
1459
|
-
console.error(`[cloudflare:rebuild-start] ${JSON.stringify({ brand: brand.productName, dryRun })}`);
|
|
1460
|
-
// Auth gate. We need a client to mutate the account.
|
|
1461
|
-
let client;
|
|
1462
|
-
let accountId;
|
|
1463
|
-
try {
|
|
1464
|
-
const c = getClient();
|
|
1465
|
-
client = c.client;
|
|
1466
|
-
accountId = c.accountId;
|
|
1467
|
-
}
|
|
1468
|
-
catch (err) {
|
|
1469
|
-
if (err instanceof CloudflareRefusalError) {
|
|
1470
|
-
return {
|
|
1471
|
-
brand: brand.productName,
|
|
1472
|
-
dryRun,
|
|
1473
|
-
preserve: { zones: [], tunnelIds: [], cnames: null },
|
|
1474
|
-
actions: [],
|
|
1475
|
-
halted: true,
|
|
1476
|
-
haltReason: err.refusal.message,
|
|
1477
|
-
};
|
|
1478
|
-
}
|
|
1479
|
-
throw err;
|
|
1480
|
-
}
|
|
1481
|
-
// Snapshot the account before mutating.
|
|
1482
|
-
const verify = await cfVerifyCore();
|
|
1483
|
-
if (!verify.account) {
|
|
1484
|
-
return {
|
|
1485
|
-
brand: brand.productName,
|
|
1486
|
-
dryRun,
|
|
1487
|
-
preserve: { zones: [], tunnelIds: [], cnames: null },
|
|
1488
|
-
actions: [],
|
|
1489
|
-
halted: true,
|
|
1490
|
-
haltReason: "Could not read account state (cf-verify returned no account snapshot).",
|
|
1491
|
-
};
|
|
1492
|
-
}
|
|
1493
|
-
// No-intent refusal. If the caller passed no preserve AND the device
|
|
1494
|
-
// has no persisted intent (no tunnel.state, no alias domains), refuse
|
|
1495
|
-
// rather than guess — an empty preserve set would nuke the account.
|
|
1496
|
-
// An explicit `{ preserve: { zones: [], tunnelIds: [] } }` is valid
|
|
1497
|
-
// stated intent ("nuke everything") and proceeds.
|
|
1498
|
-
const deviceHasIntent = Boolean(verify.device.tunnelState || verify.device.aliasDomains.length > 0);
|
|
1499
|
-
if (opts.preserve === undefined && !deviceHasIntent) {
|
|
1500
|
-
logRefuse({
|
|
1501
|
-
reason: "no-intent",
|
|
1502
|
-
message: "cf-rebuild refused: no `preserve` was supplied and this device has no persisted tunnel.state or alias-domains. " +
|
|
1503
|
-
"Stating explicit intent is required before destructive operations — " +
|
|
1504
|
-
"pass `preserve: { zones: [...], tunnelIds: [...] }` (an empty array is a valid 'nuke everything' intent), " +
|
|
1505
|
-
"or use `cloudflare-setup` which derives intent from the conversation.",
|
|
1506
|
-
});
|
|
1507
|
-
return {
|
|
1508
|
-
brand: brand.productName,
|
|
1509
|
-
dryRun,
|
|
1510
|
-
preserve: { zones: [], tunnelIds: [], cnames: null },
|
|
1511
|
-
actions: [],
|
|
1512
|
-
halted: true,
|
|
1513
|
-
haltReason: "no-intent",
|
|
1514
|
-
};
|
|
1515
|
-
}
|
|
1516
|
-
// Resolve the preserve set. Defaults from the device's intended state.
|
|
1517
|
-
const intendedTunnelId = verify.device.tunnelState?.tunnelId ?? null;
|
|
1518
|
-
const intendedHostnames = [];
|
|
1519
|
-
if (verify.device.tunnelState?.adminHostname)
|
|
1520
|
-
intendedHostnames.push(verify.device.tunnelState.adminHostname);
|
|
1521
|
-
if (verify.device.tunnelState?.publicHostname)
|
|
1522
|
-
intendedHostnames.push(verify.device.tunnelState.publicHostname);
|
|
1523
|
-
for (const a of verify.device.aliasDomains)
|
|
1524
|
-
intendedHostnames.push(a);
|
|
1525
|
-
const inferredZones = new Set();
|
|
1526
|
-
for (const h of intendedHostnames) {
|
|
1527
|
-
const lc = h.toLowerCase();
|
|
1528
|
-
for (const z of verify.account.zones) {
|
|
1529
|
-
if (lc === z.name.toLowerCase() || lc.endsWith("." + z.name.toLowerCase())) {
|
|
1530
|
-
inferredZones.add(z.name);
|
|
1531
|
-
break;
|
|
1532
|
-
}
|
|
1533
|
-
}
|
|
1534
|
-
}
|
|
1535
|
-
const preserveZones = (opts.preserve?.zones ?? [...inferredZones]).map((s) => s.toLowerCase());
|
|
1536
|
-
const preserveTunnelIds = opts.preserve?.tunnelIds ?? (intendedTunnelId ? [intendedTunnelId] : []);
|
|
1537
|
-
const preserveCnames = opts.preserve?.cnames
|
|
1538
|
-
? opts.preserve.cnames.map((c) => ({ zone: c.zone.toLowerCase(), name: c.name.toLowerCase() }))
|
|
1539
|
-
: null;
|
|
1540
|
-
// Identify tunnels that will be deleted — their `.cfargotunnel.com`
|
|
1541
|
-
// CNAMEs on preserved zones must also be scrubbed (else the zone
|
|
1542
|
-
// keeps a stale pointer to a dead tunnel).
|
|
1543
|
-
const deletedTunnelIds = new Set(verify.account.tunnels.filter((t) => !preserveTunnelIds.includes(t.id)).map((t) => t.id));
|
|
1544
|
-
const isStaleTunnelPointer = (content) => {
|
|
1545
|
-
const lc = content.toLowerCase().trim();
|
|
1546
|
-
const m = lc.match(/^([0-9a-f-]{36})\.cfargotunnel\.com\.?$/);
|
|
1547
|
-
return m ? deletedTunnelIds.has(m[1]) : false;
|
|
1548
|
-
};
|
|
1549
|
-
const actions = [];
|
|
1550
|
-
// (a) Delete tunnels not in preserve.
|
|
1551
|
-
for (const t of verify.account.tunnels) {
|
|
1552
|
-
if (preserveTunnelIds.includes(t.id))
|
|
1553
|
-
continue;
|
|
1554
|
-
const action = {
|
|
1555
|
-
op: "delete-tunnel",
|
|
1556
|
-
type: "tunnel",
|
|
1557
|
-
id: t.id,
|
|
1558
|
-
name: t.name,
|
|
1559
|
-
result: dryRun ? "planned" : "ok",
|
|
1560
|
-
};
|
|
1561
|
-
if (!dryRun) {
|
|
1562
|
-
try {
|
|
1563
|
-
await client.zeroTrust.tunnels.cloudflared.delete(t.id, { account_id: accountId });
|
|
1564
|
-
}
|
|
1565
|
-
catch (err) {
|
|
1566
|
-
action.result = "failed";
|
|
1567
|
-
action.detail = err instanceof Error ? err.message : String(err);
|
|
1568
|
-
}
|
|
1569
|
-
}
|
|
1570
|
-
console.error(`[cloudflare:rebuild-discard] ${JSON.stringify({ type: "tunnel", id: t.id, name: t.name, planned: dryRun, result: action.result })}`);
|
|
1571
|
-
actions.push(action);
|
|
1572
|
-
}
|
|
1573
|
-
// (b) Delete CNAMEs not in preserve.
|
|
1574
|
-
// If preserve.cnames is set, ONLY those exact records survive.
|
|
1575
|
-
// Otherwise: CNAMEs on preserved zones survive, EXCEPT those pointing
|
|
1576
|
-
// to a `<tunnelId>.cfargotunnel.com` target where the tunnel is being
|
|
1577
|
-
// deleted (stale tunnel pointers on otherwise-preserved zones would
|
|
1578
|
-
// resolve to dead DNS — always scrub).
|
|
1579
|
-
for (const c of verify.account.cnames) {
|
|
1580
|
-
const zoneLc = c.zone.toLowerCase();
|
|
1581
|
-
const nameLc = c.name.toLowerCase();
|
|
1582
|
-
let keep = false;
|
|
1583
|
-
if (preserveCnames) {
|
|
1584
|
-
keep = preserveCnames.some((p) => p.zone === zoneLc && p.name === nameLc);
|
|
1585
|
-
}
|
|
1586
|
-
else {
|
|
1587
|
-
keep = preserveZones.includes(zoneLc) && !isStaleTunnelPointer(c.content);
|
|
1588
|
-
}
|
|
1589
|
-
if (keep)
|
|
1590
|
-
continue;
|
|
1591
|
-
const action = {
|
|
1592
|
-
op: "delete-cname",
|
|
1593
|
-
type: "cname",
|
|
1594
|
-
id: c.recordId,
|
|
1595
|
-
name: c.name,
|
|
1596
|
-
result: dryRun ? "planned" : "ok",
|
|
1597
|
-
detail: `${c.name} → ${c.content} under zone ${c.zone}`,
|
|
1598
|
-
};
|
|
1599
|
-
if (!dryRun) {
|
|
1600
|
-
const zoneRec = verify.account.zones.find((z) => z.name.toLowerCase() === zoneLc);
|
|
1601
|
-
if (!zoneRec) {
|
|
1602
|
-
action.result = "failed";
|
|
1603
|
-
action.detail = `zone ${c.zone} not found in snapshot`;
|
|
1604
|
-
}
|
|
1605
|
-
else {
|
|
1606
|
-
try {
|
|
1607
|
-
await client.dns.records.delete(c.recordId, { zone_id: zoneRec.id });
|
|
1608
|
-
}
|
|
1609
|
-
catch (err) {
|
|
1610
|
-
action.result = "failed";
|
|
1611
|
-
action.detail = err instanceof Error ? err.message : String(err);
|
|
1612
|
-
}
|
|
1613
|
-
}
|
|
1614
|
-
}
|
|
1615
|
-
console.error(`[cloudflare:rebuild-discard] ${JSON.stringify({ type: "cname", id: c.recordId, name: c.name, zone: c.zone, planned: dryRun, result: action.result })}`);
|
|
1616
|
-
actions.push(action);
|
|
1617
|
-
}
|
|
1618
|
-
// (c) Delete zones not in preserve.
|
|
1619
|
-
for (const z of verify.account.zones) {
|
|
1620
|
-
if (preserveZones.includes(z.name.toLowerCase()))
|
|
1621
|
-
continue;
|
|
1622
|
-
const action = {
|
|
1623
|
-
op: "delete-zone",
|
|
1624
|
-
type: "zone",
|
|
1625
|
-
id: z.id,
|
|
1626
|
-
name: z.name,
|
|
1627
|
-
result: dryRun ? "planned" : "ok",
|
|
1628
|
-
};
|
|
1629
|
-
if (!dryRun) {
|
|
1630
|
-
try {
|
|
1631
|
-
await client.zones.delete({ zone_id: z.id });
|
|
1632
|
-
}
|
|
1633
|
-
catch (err) {
|
|
1634
|
-
// Zone deletion may fail (permissions, registrar lock). Surface but
|
|
1635
|
-
// do not halt — orphan zones are informational, not blocking.
|
|
1636
|
-
action.result = "failed";
|
|
1637
|
-
action.detail = err instanceof Error ? err.message : String(err);
|
|
1638
|
-
}
|
|
1639
|
-
}
|
|
1640
|
-
console.error(`[cloudflare:rebuild-discard] ${JSON.stringify({ type: "zone", id: z.id, name: z.name, planned: dryRun, result: action.result })}`);
|
|
1641
|
-
actions.push(action);
|
|
1642
|
-
}
|
|
1643
|
-
let finalVerify;
|
|
1644
|
-
if (!dryRun) {
|
|
1645
|
-
finalVerify = await cfVerifyCore();
|
|
1646
|
-
}
|
|
1647
|
-
console.error(`[cloudflare:rebuild-complete] ${JSON.stringify({
|
|
1648
|
-
brand: brand.productName,
|
|
1649
|
-
dryRun,
|
|
1650
|
-
actionCount: actions.length,
|
|
1651
|
-
})}`);
|
|
1652
|
-
return {
|
|
1653
|
-
brand: brand.productName,
|
|
1654
|
-
dryRun,
|
|
1655
|
-
preserve: {
|
|
1656
|
-
zones: preserveZones,
|
|
1657
|
-
tunnelIds: preserveTunnelIds,
|
|
1658
|
-
cnames: preserveCnames,
|
|
1659
|
-
},
|
|
1660
|
-
actions,
|
|
1661
|
-
finalVerify,
|
|
1662
|
-
halted: false,
|
|
1663
|
-
};
|
|
1664
|
-
}
|
|
1665
1080
|
//# sourceMappingURL=cloudflared.js.map
|