@rubytech/create-realagent 1.0.616 → 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/config/brand.json +0 -4
- package/payload/platform/package-lock.json +1547 -1
- package/payload/platform/plugins/cloudflare/PLUGIN.md +2 -2
- package/payload/platform/plugins/cloudflare/mcp/__tests__/auth-binding.test.ts +0 -1
- package/payload/platform/plugins/cloudflare/mcp/dist/index.js +23 -98
- package/payload/platform/plugins/cloudflare/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts +94 -68
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts.map +1 -1
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.js +260 -529
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.js.map +1 -1
- package/payload/platform/plugins/cloudflare/references/setup-guide.md +10 -13
- package/payload/platform/plugins/cloudflare/skills/setup-tunnel/SKILL.md +16 -14
- package/payload/platform/plugins/docs/references/cloudflare.md +31 -48
- package/payload/platform/templates/specialists/agents/personal-assistant.md +1 -1
- package/payload/server/server.js +1 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/brand-load.test.ts +0 -81
- package/payload/platform/plugins/cloudflare/mcp/__tests__/manifest-scope.test.ts +0 -65
- package/payload/platform/plugins/cloudflare/mcp/__tests__/verify-scenario-0.test.ts +0 -70
- package/payload/platform/plugins/cloudflare/mcp/__tests__/verify-scenario-B.test.ts +0 -124
|
@@ -17,25 +17,9 @@ export function loadBrand() {
|
|
|
17
17
|
throw new Error(`brand.json not found at ${brandPath} — PLATFORM_ROOT may be incorrect`);
|
|
18
18
|
}
|
|
19
19
|
const parsed = JSON.parse(readFileSync(brandPath, "utf-8"));
|
|
20
|
-
// configDir/productName fall back to Maxy values for forward compat with
|
|
21
|
-
// older manifests; cloudflare.zones does NOT — its absence is an
|
|
22
|
-
// authoritative-scope problem the bundler should have caught.
|
|
23
|
-
if (!parsed.cloudflare ||
|
|
24
|
-
!Array.isArray(parsed.cloudflare.zones) ||
|
|
25
|
-
parsed.cloudflare.zones.length === 0) {
|
|
26
|
-
throw new Error(`brand.json at ${brandPath} is missing a non-empty cloudflare.zones array. ` +
|
|
27
|
-
`The Cloudflare plugin refuses to start without an explicit zone declaration. ` +
|
|
28
|
-
`Add cloudflare.zones to brands/<name>/brand.json and republish the brand package.`);
|
|
29
|
-
}
|
|
30
|
-
for (const zone of parsed.cloudflare.zones) {
|
|
31
|
-
if (typeof zone !== "string" || zone.length === 0) {
|
|
32
|
-
throw new Error(`brand.json at ${brandPath} has invalid cloudflare.zones entry: ${JSON.stringify(zone)}`);
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
20
|
cachedBrand = {
|
|
36
21
|
configDir: parsed.configDir ?? ".maxy",
|
|
37
22
|
productName: parsed.productName ?? "Maxy",
|
|
38
|
-
cloudflare: { zones: parsed.cloudflare.zones.slice() },
|
|
39
23
|
};
|
|
40
24
|
return cachedBrand;
|
|
41
25
|
}
|
|
@@ -155,14 +139,15 @@ function resetAccountBinding() {
|
|
|
155
139
|
return false;
|
|
156
140
|
}
|
|
157
141
|
}
|
|
158
|
-
export function
|
|
142
|
+
export function matchAccountZone(hostname, accountZones) {
|
|
143
|
+
const active = accountZones.filter((z) => z.status === "active").map((z) => z.name);
|
|
159
144
|
const lc = hostname.toLowerCase();
|
|
160
|
-
const candidates =
|
|
145
|
+
const candidates = active.filter((z) => lc === z.toLowerCase() || lc.endsWith("." + z.toLowerCase()));
|
|
161
146
|
if (candidates.length === 0) {
|
|
162
|
-
return { ok: false, matchedZone: null,
|
|
147
|
+
return { ok: false, matchedZone: null, accountZones: active };
|
|
163
148
|
}
|
|
164
149
|
candidates.sort((a, b) => b.length - a.length);
|
|
165
|
-
return { ok: true, matchedZone: candidates[0],
|
|
150
|
+
return { ok: true, matchedZone: candidates[0], accountZones: active };
|
|
166
151
|
}
|
|
167
152
|
export function logRefuse(detail) {
|
|
168
153
|
const brand = (() => {
|
|
@@ -176,7 +161,7 @@ export function logRefuse(detail) {
|
|
|
176
161
|
const fields = {
|
|
177
162
|
reason: detail.reason,
|
|
178
163
|
brand: brand?.productName ?? "unknown",
|
|
179
|
-
|
|
164
|
+
accountZones: detail.fields.accountZones ?? null,
|
|
180
165
|
boundAccountId: detail.fields.boundAccountId ?? null,
|
|
181
166
|
certAccountId: detail.fields.certAccountId ?? null,
|
|
182
167
|
requestedDomain: detail.fields.requestedDomain ?? null,
|
|
@@ -187,16 +172,14 @@ export function logRefuse(detail) {
|
|
|
187
172
|
console.error(`[cloudflare:refuse] ${JSON.stringify(fields)}`);
|
|
188
173
|
}
|
|
189
174
|
/**
|
|
190
|
-
*
|
|
191
|
-
* tunnel-login under the account that owns the
|
|
192
|
-
* No alternative auth path exists.
|
|
175
|
+
* Single recovery instruction. Every refusal instructs the same path:
|
|
176
|
+
* tunnel-login under the Cloudflare account that owns the target zone.
|
|
193
177
|
*/
|
|
194
178
|
export function recoveryMessage() {
|
|
195
|
-
const brand = loadBrand();
|
|
196
179
|
return (`Recovery: run tunnel-login while signed into the Cloudflare account that owns ` +
|
|
197
|
-
`the
|
|
198
|
-
`
|
|
199
|
-
`
|
|
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.`);
|
|
200
183
|
}
|
|
201
184
|
// ---------------------------------------------------------------------------
|
|
202
185
|
// Reset auth — unlinks cert.pem (both paths) and the account binding
|
|
@@ -468,17 +451,6 @@ export async function listZones() {
|
|
|
468
451
|
}
|
|
469
452
|
return zones;
|
|
470
453
|
}
|
|
471
|
-
export function checkDeclaredZonesOnAccount(declaredZones, accountZones) {
|
|
472
|
-
return declaredZones.map((zone) => {
|
|
473
|
-
const lc = zone.toLowerCase();
|
|
474
|
-
const match = accountZones.find((z) => z.name.toLowerCase() === lc);
|
|
475
|
-
return {
|
|
476
|
-
zone,
|
|
477
|
-
presentOnAccount: !!match,
|
|
478
|
-
activeOnAccount: match?.status === "active",
|
|
479
|
-
};
|
|
480
|
-
});
|
|
481
|
-
}
|
|
482
454
|
export async function getZoneId(domain) {
|
|
483
455
|
const { client } = getClient();
|
|
484
456
|
const zones = await client.zones.list({ name: domain });
|
|
@@ -902,47 +874,39 @@ export function writeLocalConfig(tunnelId, credentialsPath, hostnames, port) {
|
|
|
902
874
|
return configPath;
|
|
903
875
|
}
|
|
904
876
|
// ---------------------------------------------------------------------------
|
|
905
|
-
// CLI-based DNS routing — guarded by
|
|
877
|
+
// CLI-based DNS routing — guarded by live account-zone check + post-flight
|
|
906
878
|
//
|
|
907
879
|
// `cloudflared tunnel route dns` resolves the target zone from cert.pem's
|
|
908
|
-
// account. If the hostname's registrable parent zone is not on that
|
|
909
|
-
//
|
|
910
|
-
//
|
|
911
|
-
// account holds othersite.example but not example.com). Two layers stop
|
|
912
|
-
// this:
|
|
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:
|
|
913
883
|
//
|
|
914
|
-
// (1)
|
|
915
|
-
// hostname's registrable parent is not
|
|
916
|
-
//
|
|
917
|
-
//
|
|
918
|
-
// `cloudflared`'s INF line and refuse with a reverse-and-cleanup if
|
|
919
|
-
// it doesn't exactly equal the requested hostname — defence-in-depth
|
|
920
|
-
// against cloudflared writing under a sibling zone the manifest also
|
|
921
|
-
// declared but doesn't actually own on this account.
|
|
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.
|
|
922
888
|
//
|
|
923
|
-
// `getClient()`
|
|
924
|
-
// (cert+binding+accountId match) so account-drift refusals fire before any
|
|
925
|
-
// CLI process spawns.
|
|
889
|
+
// `getClient()` runs first to enforce auth pre-conditions.
|
|
926
890
|
// ---------------------------------------------------------------------------
|
|
927
891
|
export async function routeDnsCli(tunnelId, hostname) {
|
|
928
892
|
const bin = findBinary();
|
|
929
893
|
if (!bin)
|
|
930
894
|
throw new Error("cloudflared is not installed");
|
|
931
|
-
// Auth pre-condition: cert + binding + accountId match. Throws
|
|
932
|
-
// CloudflareRefusalError on drift before any CLI call.
|
|
933
895
|
const { client, accountId: boundAccountId } = getClient();
|
|
934
|
-
// (1)
|
|
935
|
-
const
|
|
936
|
-
const scope =
|
|
896
|
+
// (1) Live account-zone check.
|
|
897
|
+
const accountZones = await listZones();
|
|
898
|
+
const scope = matchAccountZone(hostname, accountZones);
|
|
937
899
|
if (!scope.ok) {
|
|
938
900
|
const detail = {
|
|
939
901
|
reason: "scope-mismatch",
|
|
940
|
-
message: `Cannot route ${hostname} —
|
|
941
|
-
`
|
|
942
|
-
|
|
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.`,
|
|
943
907
|
fields: {
|
|
944
908
|
requestedHostname: hostname,
|
|
945
|
-
|
|
909
|
+
accountZones: scope.accountZones,
|
|
946
910
|
},
|
|
947
911
|
};
|
|
948
912
|
logRefuse(detail);
|
|
@@ -963,19 +927,10 @@ export async function routeDnsCli(tunnelId, hostname) {
|
|
|
963
927
|
}
|
|
964
928
|
throw new Error(`cloudflared tunnel route dns failed: ${msg}`);
|
|
965
929
|
}
|
|
966
|
-
// (2) Post-flight FQDN assertion.
|
|
967
|
-
// actually created from its INF line:
|
|
968
|
-
// "INF Added CNAME <fqdn> which will route to this tunnel ..."
|
|
930
|
+
// (2) Post-flight FQDN assertion.
|
|
969
931
|
const fqdnMatch = output.match(/Added CNAME\s+(\S+?)\s+which will route/);
|
|
970
932
|
if (!fqdnMatch) {
|
|
971
|
-
// Format drift — log loudly. We treat the absence of the parse as an
|
|
972
|
-
// observability gap rather than silent success because we can no
|
|
973
|
-
// longer assert wrong-zone safety.
|
|
974
933
|
console.error(`[tunnel-route-dns] WARNING: could not parse CNAME FQDN from cloudflared output — format may have changed. Output: ${output.trim()}`);
|
|
975
|
-
// Surfacing as refusal would block correct flows when cloudflared
|
|
976
|
-
// changes its log line; surface as unverified-success with the
|
|
977
|
-
// requested hostname instead. Operators following [cloudflare:*]
|
|
978
|
-
// grep cadence will see the WARNING line.
|
|
979
934
|
return {
|
|
980
935
|
created: true,
|
|
981
936
|
output: output.trim(),
|
|
@@ -985,45 +940,34 @@ export async function routeDnsCli(tunnelId, hostname) {
|
|
|
985
940
|
}
|
|
986
941
|
const actualFqdn = fqdnMatch[1];
|
|
987
942
|
if (actualFqdn !== hostname) {
|
|
988
|
-
//
|
|
989
|
-
//
|
|
990
|
-
//
|
|
991
|
-
// in-scope records as "cleanup"), then refuse.
|
|
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.
|
|
992
946
|
console.error(`[cloudflare:post-flight-mismatch] ${JSON.stringify({
|
|
993
947
|
requestedHostname: hostname,
|
|
994
948
|
actualFqdn,
|
|
995
949
|
tunnelId,
|
|
996
950
|
boundAccountId,
|
|
997
951
|
})}`);
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
type: "CNAME",
|
|
1011
|
-
});
|
|
1012
|
-
for (const r of records.result ?? []) {
|
|
1013
|
-
if (r.id)
|
|
1014
|
-
await client.dns.records.delete(r.id, { zone_id: owningZone.id });
|
|
1015
|
-
}
|
|
1016
|
-
cleanupResult = "ok";
|
|
1017
|
-
}
|
|
1018
|
-
else {
|
|
1019
|
-
cleanupResult = "failed"; // nothing to delete — surface as informational
|
|
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 });
|
|
1020
964
|
}
|
|
1021
|
-
|
|
1022
|
-
catch (cleanupErr) {
|
|
1023
|
-
cleanupResult = "failed";
|
|
1024
|
-
console.error(`[cloudflare:post-flight-cleanup] error: ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`);
|
|
965
|
+
cleanupResult = "ok";
|
|
1025
966
|
}
|
|
1026
967
|
}
|
|
968
|
+
catch (cleanupErr) {
|
|
969
|
+
console.error(`[cloudflare:post-flight-cleanup] error: ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`);
|
|
970
|
+
}
|
|
1027
971
|
console.error(`[cloudflare:post-flight-cleanup] ${JSON.stringify({
|
|
1028
972
|
requestedHostname: hostname,
|
|
1029
973
|
actualFqdn,
|
|
@@ -1032,24 +976,18 @@ export async function routeDnsCli(tunnelId, hostname) {
|
|
|
1032
976
|
const detail = {
|
|
1033
977
|
reason: "post-flight-fqdn-mismatch",
|
|
1034
978
|
message: `cloudflared wrote the CNAME under ${actualFqdn} instead of the requested ${hostname}. ` +
|
|
1035
|
-
`
|
|
1036
|
-
`passing manifest scope. ` +
|
|
1037
|
-
recoveryMessage(),
|
|
979
|
+
`Cleanup ${cleanupResult === "ok" ? "succeeded" : "failed"}. Re-run cf-rebuild to reconcile.`,
|
|
1038
980
|
fields: {
|
|
1039
981
|
requestedHostname: hostname,
|
|
1040
982
|
actualFqdn,
|
|
1041
983
|
tunnelId,
|
|
1042
984
|
boundAccountId,
|
|
1043
|
-
manifestZones: brand.cloudflare.zones,
|
|
1044
985
|
},
|
|
1045
986
|
};
|
|
1046
987
|
logRefuse(detail);
|
|
1047
988
|
throw new CloudflareRefusalError(detail);
|
|
1048
989
|
}
|
|
1049
990
|
console.error(`[tunnel-route-dns] CLI: routed ${hostname} → tunnel ${tunnelId} (overwrite) fqdn=${actualFqdn} zone=${scope.matchedZone}`);
|
|
1050
|
-
if (process.env.CLOUDFLARE_DEBUG === "1") {
|
|
1051
|
-
console.error(`[cloudflare:guard-pass] ${JSON.stringify({ op: "routeDnsCli", requestedHostname: hostname, zone: scope.matchedZone })}`);
|
|
1052
|
-
}
|
|
1053
991
|
return { created: true, output: output.trim(), fqdn: actualFqdn, zone: scope.matchedZone };
|
|
1054
992
|
}
|
|
1055
993
|
// ---------------------------------------------------------------------------
|
|
@@ -1376,237 +1314,105 @@ export function stopTunnel() {
|
|
|
1376
1314
|
writeState({ ...state, pid: null, startedAt: null });
|
|
1377
1315
|
}
|
|
1378
1316
|
/**
|
|
1379
|
-
*
|
|
1380
|
-
*
|
|
1381
|
-
*
|
|
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.
|
|
1382
1324
|
*/
|
|
1383
|
-
export function
|
|
1325
|
+
export async function cfVerifyCore() {
|
|
1384
1326
|
const brand = loadBrand();
|
|
1327
|
+
console.error(`[cloudflare:verify-start] ${JSON.stringify({ brand: brand.productName })}`);
|
|
1328
|
+
const device = readDeviceSnapshot();
|
|
1385
1329
|
const ro = getReadOnlyClient();
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
binding: readAccountBinding(),
|
|
1389
|
-
client: ro.client,
|
|
1390
|
-
};
|
|
1391
|
-
}
|
|
1392
|
-
/**
|
|
1393
|
-
* cf-verify implementation. Pure-ish: only reads (account state via SDK,
|
|
1394
|
-
* device state via fs). Never mutates. Always returns a structured report
|
|
1395
|
-
* even when cert/binding/account state is absent — fresh-install devices
|
|
1396
|
-
* get a report with everything tagged MISSING.
|
|
1397
|
-
*/
|
|
1398
|
-
export async function cfVerifyCore(ctx = liveScopeContext()) {
|
|
1399
|
-
const brand = loadBrand();
|
|
1400
|
-
const artefacts = [];
|
|
1401
|
-
console.error(`[cloudflare:verify-start] ${JSON.stringify({
|
|
1402
|
-
brand: brand.productName,
|
|
1403
|
-
declaredZones: ctx.declaredZones,
|
|
1404
|
-
bindingPresent: !!ctx.binding,
|
|
1405
|
-
})}`);
|
|
1406
|
-
// --- Local artefact: cert.pem ---
|
|
1407
|
-
const certCreds = parseCertPem();
|
|
1408
|
-
if (!certCreds) {
|
|
1409
|
-
artefacts.push({ type: "cert.pem", id: certPath(), tag: "missing" });
|
|
1410
|
-
}
|
|
1411
|
-
else if (ctx.binding && certCreds.accountId !== ctx.binding.accountId) {
|
|
1412
|
-
artefacts.push({
|
|
1413
|
-
type: "cert.pem",
|
|
1414
|
-
id: certPath(),
|
|
1415
|
-
tag: "out-of-scope",
|
|
1416
|
-
reason: `cert account ${certCreds.accountId} != binding account ${ctx.binding.accountId}`,
|
|
1417
|
-
detail: { certAccountId: certCreds.accountId, boundAccountId: ctx.binding.accountId },
|
|
1418
|
-
});
|
|
1419
|
-
}
|
|
1420
|
-
else {
|
|
1421
|
-
artefacts.push({ type: "cert.pem", id: certPath(), tag: "in-scope" });
|
|
1422
|
-
}
|
|
1423
|
-
// --- Local artefact: binding ---
|
|
1424
|
-
if (!ctx.binding) {
|
|
1425
|
-
artefacts.push({ type: "binding", id: bindingFile(), tag: "missing" });
|
|
1426
|
-
}
|
|
1427
|
-
else {
|
|
1428
|
-
artefacts.push({
|
|
1429
|
-
type: "binding",
|
|
1430
|
-
id: bindingFile(),
|
|
1431
|
-
tag: "in-scope",
|
|
1432
|
-
detail: { accountId: ctx.binding.accountId, boundAt: ctx.binding.boundAt },
|
|
1433
|
-
});
|
|
1434
|
-
}
|
|
1435
|
-
// --- Account artefacts (only readable when bound and cert valid) ---
|
|
1436
|
-
// Determine if we can call the SDK at all. cfVerify must never fail on
|
|
1437
|
-
// unbound devices — it just reports everything as missing.
|
|
1438
|
-
let accountSummary = null;
|
|
1439
|
-
if (ctx.client && certCreds) {
|
|
1330
|
+
let account = null;
|
|
1331
|
+
if (ro.client && ro.certAccountId) {
|
|
1440
1332
|
try {
|
|
1441
|
-
const [
|
|
1442
|
-
|
|
1443
|
-
|
|
1333
|
+
const [zones, tunnels] = await Promise.all([
|
|
1334
|
+
listZonesViaClient(ro.client),
|
|
1335
|
+
listTunnelsViaClient(ro.client, ro.certAccountId),
|
|
1444
1336
|
]);
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
console.error(`[cloudflare:verify] account read failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1449
|
-
}
|
|
1450
|
-
}
|
|
1451
|
-
// --- Declared zones: present + active on account? ---
|
|
1452
|
-
if (accountSummary) {
|
|
1453
|
-
const visibility = checkDeclaredZonesOnAccount(ctx.declaredZones, accountSummary.zones);
|
|
1454
|
-
for (const v of visibility) {
|
|
1455
|
-
if (!v.presentOnAccount) {
|
|
1456
|
-
artefacts.push({
|
|
1457
|
-
type: "declared-zone",
|
|
1458
|
-
id: v.zone,
|
|
1459
|
-
tag: "missing",
|
|
1460
|
-
reason: "declared in brand.json but absent from bound account",
|
|
1461
|
-
});
|
|
1462
|
-
}
|
|
1463
|
-
else if (!v.activeOnAccount) {
|
|
1464
|
-
artefacts.push({
|
|
1465
|
-
type: "declared-zone",
|
|
1466
|
-
id: v.zone,
|
|
1467
|
-
tag: "out-of-scope",
|
|
1468
|
-
reason: "present on bound account but not active (likely awaiting nameservers)",
|
|
1469
|
-
});
|
|
1470
|
-
}
|
|
1471
|
-
else {
|
|
1472
|
-
artefacts.push({ type: "declared-zone", id: v.zone, tag: "in-scope" });
|
|
1473
|
-
}
|
|
1474
|
-
}
|
|
1475
|
-
// Zones on the account NOT in the declared scope — informational tag,
|
|
1476
|
-
// out-of-scope, no action required by rebuild (we never delete zones).
|
|
1477
|
-
for (const z of accountSummary.zones) {
|
|
1478
|
-
if (!ctx.declaredZones.some((d) => d.toLowerCase() === z.name.toLowerCase())) {
|
|
1479
|
-
artefacts.push({
|
|
1480
|
-
type: "declared-zone",
|
|
1481
|
-
id: z.name,
|
|
1482
|
-
tag: "out-of-scope",
|
|
1483
|
-
reason: "zone on bound account but not in brand.cloudflare.zones",
|
|
1484
|
-
});
|
|
1485
|
-
}
|
|
1486
|
-
}
|
|
1487
|
-
// --- Tunnels on account ---
|
|
1488
|
-
const persistedTunnelId = readState()?.tunnelId ?? null;
|
|
1489
|
-
for (const t of accountSummary.tunnels) {
|
|
1490
|
-
const isOurs = t.id === persistedTunnelId;
|
|
1491
|
-
artefacts.push({
|
|
1492
|
-
type: "tunnel",
|
|
1493
|
-
id: `${t.name} (${t.id})`,
|
|
1494
|
-
tag: isOurs ? "in-scope" : "out-of-scope",
|
|
1495
|
-
reason: isOurs ? undefined : "tunnel on account but not in this brand's persisted state",
|
|
1496
|
-
detail: { tunnelId: t.id, tunnelName: t.name },
|
|
1497
|
-
});
|
|
1498
|
-
}
|
|
1499
|
-
// --- DNS CNAMEs under declared zones ---
|
|
1500
|
-
if (ctx.client) {
|
|
1501
|
-
for (const declared of ctx.declaredZones) {
|
|
1502
|
-
const zone = accountSummary.zones.find((z) => z.name.toLowerCase() === declared.toLowerCase() && z.status === "active");
|
|
1503
|
-
if (!zone)
|
|
1337
|
+
const cnames = [];
|
|
1338
|
+
for (const z of zones) {
|
|
1339
|
+
if (z.status !== "active")
|
|
1504
1340
|
continue;
|
|
1505
1341
|
try {
|
|
1506
|
-
const
|
|
1507
|
-
for (const
|
|
1508
|
-
|
|
1509
|
-
? `${persistedTunnelId}.cfargotunnel.com`
|
|
1510
|
-
: null;
|
|
1511
|
-
const isOurs = ourTunnelTarget !== null && rec.content === ourTunnelTarget;
|
|
1512
|
-
artefacts.push({
|
|
1513
|
-
type: "dns-cname",
|
|
1514
|
-
id: rec.name,
|
|
1515
|
-
tag: isOurs ? "in-scope" : "out-of-scope",
|
|
1516
|
-
reason: isOurs ? undefined : `CNAME → ${rec.content} (not this brand's tunnel)`,
|
|
1517
|
-
detail: { zoneId: zone.id, recordId: rec.id, content: rec.content },
|
|
1518
|
-
});
|
|
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 });
|
|
1519
1345
|
}
|
|
1520
1346
|
}
|
|
1521
1347
|
catch (err) {
|
|
1522
|
-
console.error(`[cloudflare:verify] failed to list CNAMEs under ${
|
|
1348
|
+
console.error(`[cloudflare:verify] failed to list CNAMEs under ${z.name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1523
1349
|
}
|
|
1524
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
|
+
};
|
|
1525
1357
|
}
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
// Without an SDK client we cannot enumerate account state — surface
|
|
1529
|
-
// each declared zone as MISSING.
|
|
1530
|
-
for (const zone of ctx.declaredZones) {
|
|
1531
|
-
artefacts.push({
|
|
1532
|
-
type: "declared-zone",
|
|
1533
|
-
id: zone,
|
|
1534
|
-
tag: "missing",
|
|
1535
|
-
reason: "cannot read bound account (no cert or no client)",
|
|
1536
|
-
});
|
|
1537
|
-
}
|
|
1538
|
-
}
|
|
1539
|
-
// --- Local artefacts: tunnel.state, config.yml, alias-domains.json ---
|
|
1540
|
-
const persisted = readState();
|
|
1541
|
-
if (!persisted) {
|
|
1542
|
-
artefacts.push({ type: "tunnel.state", id: statePath(), tag: "missing" });
|
|
1543
|
-
}
|
|
1544
|
-
else {
|
|
1545
|
-
// tunnel.state is in-scope when its tunnelId is present on the bound
|
|
1546
|
-
// account AND its hostnames are all in declared scope.
|
|
1547
|
-
const allHostnames = getPersistedHostnames();
|
|
1548
|
-
const allInScope = allHostnames.every((h) => matchManifestZone(h, ctx.declaredZones).ok);
|
|
1549
|
-
const tunnelOnAccount = accountSummary?.tunnels.some((t) => t.id === persisted.tunnelId) ?? null;
|
|
1550
|
-
if (allInScope && tunnelOnAccount !== false) {
|
|
1551
|
-
artefacts.push({ type: "tunnel.state", id: statePath(), tag: "in-scope" });
|
|
1552
|
-
}
|
|
1553
|
-
else {
|
|
1554
|
-
const reasons = [];
|
|
1555
|
-
if (!allInScope)
|
|
1556
|
-
reasons.push("contains hostnames outside declared scope");
|
|
1557
|
-
if (tunnelOnAccount === false)
|
|
1558
|
-
reasons.push("references tunnel absent from bound account");
|
|
1559
|
-
artefacts.push({
|
|
1560
|
-
type: "tunnel.state",
|
|
1561
|
-
id: statePath(),
|
|
1562
|
-
tag: "out-of-scope",
|
|
1563
|
-
reason: reasons.join("; "),
|
|
1564
|
-
detail: { tunnelId: persisted.tunnelId, hostnames: JSON.stringify(allHostnames) },
|
|
1565
|
-
});
|
|
1566
|
-
}
|
|
1567
|
-
// config.yml mirrors tunnel.state in scope assessment.
|
|
1568
|
-
if (existsSync(persisted.configPath)) {
|
|
1569
|
-
artefacts.push({
|
|
1570
|
-
type: "config.yml",
|
|
1571
|
-
id: persisted.configPath,
|
|
1572
|
-
tag: allInScope ? "in-scope" : "out-of-scope",
|
|
1573
|
-
});
|
|
1574
|
-
}
|
|
1575
|
-
else {
|
|
1576
|
-
artefacts.push({ type: "config.yml", id: persisted.configPath, tag: "missing" });
|
|
1358
|
+
catch (err) {
|
|
1359
|
+
console.error(`[cloudflare:verify] account read failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1577
1360
|
}
|
|
1578
1361
|
}
|
|
1579
|
-
const
|
|
1580
|
-
for (const alias of aliases) {
|
|
1581
|
-
const inScope = matchManifestZone(alias, ctx.declaredZones).ok;
|
|
1582
|
-
artefacts.push({
|
|
1583
|
-
type: "alias-domain",
|
|
1584
|
-
id: alias,
|
|
1585
|
-
tag: inScope ? "in-scope" : "out-of-scope",
|
|
1586
|
-
reason: inScope ? undefined : "alias hostname outside brand.cloudflare.zones",
|
|
1587
|
-
});
|
|
1588
|
-
}
|
|
1589
|
-
const counts = {
|
|
1590
|
-
inScope: artefacts.filter((a) => a.tag === "in-scope").length,
|
|
1591
|
-
outOfScope: artefacts.filter((a) => a.tag === "out-of-scope").length,
|
|
1592
|
-
missing: artefacts.filter((a) => a.tag === "missing").length,
|
|
1593
|
-
};
|
|
1362
|
+
const orphans = computeOrphans(account, device);
|
|
1594
1363
|
console.error(`[cloudflare:verify-complete] ${JSON.stringify({
|
|
1595
1364
|
brand: brand.productName,
|
|
1596
|
-
|
|
1365
|
+
orphanTunnels: orphans.tunnels.length,
|
|
1366
|
+
orphanCnames: orphans.cnames.length,
|
|
1367
|
+
orphanZones: orphans.zones.length,
|
|
1597
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()];
|
|
1598
1375
|
return {
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
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,
|
|
1606
1388
|
};
|
|
1607
1389
|
}
|
|
1608
|
-
|
|
1609
|
-
|
|
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
|
+
}
|
|
1610
1416
|
async function listZonesViaClient(client) {
|
|
1611
1417
|
const zones = [];
|
|
1612
1418
|
for await (const zone of client.zones.list()) {
|
|
@@ -1644,251 +1450,176 @@ async function listCnamesUnderZone(client, zoneId) {
|
|
|
1644
1450
|
}
|
|
1645
1451
|
export async function cfRebuildCore(opts = {}) {
|
|
1646
1452
|
const dryRun = opts.dryRun ?? false;
|
|
1647
|
-
const ctx = opts.ctx ?? liveScopeContext();
|
|
1648
1453
|
const brand = loadBrand();
|
|
1649
|
-
console.error(`[cloudflare:rebuild-start] ${JSON.stringify({
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
// and halt with an actionable instruction.
|
|
1658
|
-
const certCreds = parseCertPem();
|
|
1659
|
-
const actions = [];
|
|
1660
|
-
if (!certCreds) {
|
|
1661
|
-
const msg = `cf-rebuild requires cert.pem before it can reconstruct state. ` + recoveryMessage();
|
|
1662
|
-
return {
|
|
1663
|
-
brand: brand.productName,
|
|
1664
|
-
declaredZones: ctx.declaredZones,
|
|
1665
|
-
dryRun,
|
|
1666
|
-
actions: [
|
|
1667
|
-
{
|
|
1668
|
-
op: "skip-needs-operator",
|
|
1669
|
-
artefact: { type: "cert.pem", id: certPath(), tag: "missing" },
|
|
1670
|
-
planned: dryRun,
|
|
1671
|
-
resultDetail: msg,
|
|
1672
|
-
},
|
|
1673
|
-
],
|
|
1674
|
-
halted: true,
|
|
1675
|
-
haltReason: msg,
|
|
1676
|
-
};
|
|
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;
|
|
1677
1462
|
}
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
});
|
|
1695
|
-
console.error(`[cloudflare:rebuild-discard] ${JSON.stringify({ type: "cert.pem", reason: "wrong-account", planned: true })}`);
|
|
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) {
|
|
1696
1479
|
return {
|
|
1697
1480
|
brand: brand.productName,
|
|
1698
|
-
declaredZones: ctx.declaredZones,
|
|
1699
1481
|
dryRun,
|
|
1700
|
-
|
|
1482
|
+
preserve: { zones: [], tunnelIds: [], cnames: null },
|
|
1483
|
+
actions: [],
|
|
1701
1484
|
halted: true,
|
|
1702
|
-
haltReason:
|
|
1485
|
+
haltReason: "Could not read account state (cf-verify returned no account snapshot).",
|
|
1703
1486
|
};
|
|
1704
1487
|
}
|
|
1705
|
-
//
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
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
|
+
};
|
|
1717
1524
|
if (!dryRun) {
|
|
1718
1525
|
try {
|
|
1719
|
-
|
|
1526
|
+
await client.zeroTrust.tunnels.cloudflared.delete(t.id, { account_id: accountId });
|
|
1720
1527
|
}
|
|
1721
1528
|
catch (err) {
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
actions.push({
|
|
1725
|
-
op: "skip-needs-operator",
|
|
1726
|
-
artefact: { type: "binding", id: bindingFile(), tag: "missing" },
|
|
1727
|
-
planned: dryRun,
|
|
1728
|
-
result: "failed",
|
|
1729
|
-
resultDetail: halt,
|
|
1730
|
-
});
|
|
1731
|
-
return {
|
|
1732
|
-
brand: brand.productName,
|
|
1733
|
-
declaredZones: ctx.declaredZones,
|
|
1734
|
-
dryRun,
|
|
1735
|
-
actions,
|
|
1736
|
-
halted: true,
|
|
1737
|
-
haltReason: halt,
|
|
1738
|
-
};
|
|
1529
|
+
action.result = "failed";
|
|
1530
|
+
action.detail = err instanceof Error ? err.message : String(err);
|
|
1739
1531
|
}
|
|
1740
1532
|
}
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
artefact: {
|
|
1744
|
-
type: "binding",
|
|
1745
|
-
id: bindingFile(),
|
|
1746
|
-
tag: "missing",
|
|
1747
|
-
},
|
|
1748
|
-
planned: dryRun,
|
|
1749
|
-
result: "ok",
|
|
1750
|
-
});
|
|
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);
|
|
1751
1535
|
}
|
|
1752
|
-
//
|
|
1753
|
-
|
|
1754
|
-
//
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
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)
|
|
1765
1550
|
continue;
|
|
1766
|
-
const action = {
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
aliases.delete(a.id);
|
|
1784
|
-
const path = aliasDomainPath();
|
|
1785
|
-
mkdirSync(join(homedir(), loadBrand().configDir), { recursive: true });
|
|
1786
|
-
writeFileSync(path, JSON.stringify([...aliases], null, 2), "utf-8");
|
|
1787
|
-
}
|
|
1788
|
-
action.result = "ok";
|
|
1789
|
-
break;
|
|
1790
|
-
}
|
|
1791
|
-
case "tunnel.state":
|
|
1792
|
-
case "config.yml": {
|
|
1793
|
-
if (!dryRun) {
|
|
1794
|
-
try {
|
|
1795
|
-
unlinkSync(a.id);
|
|
1796
|
-
}
|
|
1797
|
-
catch (err) {
|
|
1798
|
-
const code = err.code;
|
|
1799
|
-
if (code !== "ENOENT")
|
|
1800
|
-
throw err;
|
|
1801
|
-
}
|
|
1802
|
-
}
|
|
1803
|
-
action.result = "ok";
|
|
1804
|
-
break;
|
|
1805
|
-
}
|
|
1806
|
-
case "tunnel": {
|
|
1807
|
-
// Out-of-scope tunnels are siblings on the same account — leave
|
|
1808
|
-
// them alone. Other devices may rely on them. Surface as
|
|
1809
|
-
// discard intent but skip without mutating.
|
|
1810
|
-
action.op = "skip-needs-operator";
|
|
1811
|
-
action.result = "ok";
|
|
1812
|
-
action.resultDetail = "tunnel on bound account but not this brand's — left untouched";
|
|
1813
|
-
break;
|
|
1814
|
-
}
|
|
1815
|
-
case "declared-zone": {
|
|
1816
|
-
// Out-of-scope declared-zones are zones on the bound account that
|
|
1817
|
-
// aren't in the manifest. Informational; never deleted.
|
|
1818
|
-
action.op = "skip-needs-operator";
|
|
1819
|
-
action.result = "ok";
|
|
1820
|
-
action.resultDetail = "zone on bound account outside brand scope — informational";
|
|
1821
|
-
break;
|
|
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 });
|
|
1822
1568
|
}
|
|
1823
|
-
|
|
1824
|
-
case "binding":
|
|
1825
|
-
// Handled above (refuse-to-delete cert; binding mutated by force-reset path only)
|
|
1826
|
-
action.op = "skip-needs-operator";
|
|
1827
|
-
action.result = "ok";
|
|
1828
|
-
break;
|
|
1829
|
-
default:
|
|
1569
|
+
catch (err) {
|
|
1830
1570
|
action.result = "failed";
|
|
1831
|
-
action.
|
|
1571
|
+
action.detail = err instanceof Error ? err.message : String(err);
|
|
1572
|
+
}
|
|
1832
1573
|
}
|
|
1833
1574
|
}
|
|
1834
|
-
|
|
1835
|
-
action.result = "failed";
|
|
1836
|
-
action.resultDetail = err instanceof Error ? err.message : String(err);
|
|
1837
|
-
}
|
|
1838
|
-
if (action.op === "discard") {
|
|
1839
|
-
console.error(`[cloudflare:rebuild-discard] ${JSON.stringify({ type: a.type, id: a.id, planned: dryRun, result: action.result ?? null })}`);
|
|
1840
|
-
}
|
|
1575
|
+
console.error(`[cloudflare:rebuild-discard] ${JSON.stringify({ type: "cname", id: c.recordId, name: c.name, zone: c.zone, planned: dryRun, result: action.result })}`);
|
|
1841
1576
|
actions.push(action);
|
|
1842
1577
|
}
|
|
1843
|
-
//
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
// exists on the account but local state is missing; that's a Task 521
|
|
1847
|
-
// scenario (re-attaching a tunnel) and is out of scope here. v1 of
|
|
1848
|
-
// cf-rebuild surfaces missing local artefacts as informational.
|
|
1849
|
-
for (const a of verify.artefacts) {
|
|
1850
|
-
if (a.tag !== "missing")
|
|
1578
|
+
// (c) Delete zones not in preserve.
|
|
1579
|
+
for (const z of verify.account.zones) {
|
|
1580
|
+
if (preserveZones.includes(z.name.toLowerCase()))
|
|
1851
1581
|
continue;
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
result: "ok",
|
|
1870
|
-
resultDetail: `missing local artefact — run cloudflare-setup or tunnel-create to create`,
|
|
1871
|
-
});
|
|
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
|
+
}
|
|
1872
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);
|
|
1873
1602
|
}
|
|
1874
|
-
// Step 6: rerun verify (unless dry-run) to capture final state.
|
|
1875
1603
|
let finalVerify;
|
|
1876
1604
|
if (!dryRun) {
|
|
1877
|
-
finalVerify = await cfVerifyCore(
|
|
1605
|
+
finalVerify = await cfVerifyCore();
|
|
1878
1606
|
}
|
|
1879
1607
|
console.error(`[cloudflare:rebuild-complete] ${JSON.stringify({
|
|
1880
1608
|
brand: brand.productName,
|
|
1881
1609
|
dryRun,
|
|
1882
1610
|
actionCount: actions.length,
|
|
1883
|
-
finalCounts: finalVerify?.counts ?? null,
|
|
1884
1611
|
})}`);
|
|
1885
1612
|
return {
|
|
1886
1613
|
brand: brand.productName,
|
|
1887
|
-
declaredZones: ctx.declaredZones,
|
|
1888
1614
|
dryRun,
|
|
1615
|
+
preserve: {
|
|
1616
|
+
zones: preserveZones,
|
|
1617
|
+
tunnelIds: preserveTunnelIds,
|
|
1618
|
+
cnames: preserveCnames,
|
|
1619
|
+
},
|
|
1889
1620
|
actions,
|
|
1890
|
-
halted: false,
|
|
1891
1621
|
finalVerify,
|
|
1622
|
+
halted: false,
|
|
1892
1623
|
};
|
|
1893
1624
|
}
|
|
1894
1625
|
//# sourceMappingURL=cloudflared.js.map
|