@rubytech/create-realagent 1.0.616 → 1.0.618
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/admin/PLUGIN.md +1 -0
- package/payload/platform/plugins/admin/hooks/webfetch-preflight.mjs +363 -0
- package/payload/platform/plugins/admin/skills/stream-log-review/SKILL.md +4 -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 +158 -99
- package/payload/platform/plugins/cloudflare/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts +103 -70
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts.map +1 -1
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.js +300 -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 +18 -14
- package/payload/platform/plugins/docs/references/cloudflare.md +32 -49
- package/payload/platform/plugins/docs/references/plugins-guide.md +2 -0
- package/payload/platform/scripts/seed-neo4j.sh +12 -0
- package/payload/platform/templates/agents/admin/IDENTITY.md +2 -0
- package/payload/platform/templates/specialists/agents/personal-assistant.md +6 -6
- package/payload/server/public/assets/{admin-Df1liz4Y.js → admin-D7LRdkYB.js} +30 -30
- package/payload/server/public/index.html +1 -1
- package/payload/server/server.js +88 -23
- 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 = (() => {
|
|
@@ -173,30 +158,29 @@ export function logRefuse(detail) {
|
|
|
173
158
|
return null;
|
|
174
159
|
}
|
|
175
160
|
})();
|
|
161
|
+
const f = detail.fields ?? {};
|
|
176
162
|
const fields = {
|
|
177
163
|
reason: detail.reason,
|
|
178
164
|
brand: brand?.productName ?? "unknown",
|
|
179
|
-
|
|
180
|
-
boundAccountId:
|
|
181
|
-
certAccountId:
|
|
182
|
-
requestedDomain:
|
|
183
|
-
requestedHostname:
|
|
184
|
-
actualFqdn:
|
|
185
|
-
tunnelId:
|
|
165
|
+
accountZones: f.accountZones ?? null,
|
|
166
|
+
boundAccountId: f.boundAccountId ?? null,
|
|
167
|
+
certAccountId: f.certAccountId ?? null,
|
|
168
|
+
requestedDomain: f.requestedDomain ?? null,
|
|
169
|
+
requestedHostname: f.requestedHostname ?? null,
|
|
170
|
+
actualFqdn: f.actualFqdn ?? null,
|
|
171
|
+
tunnelId: f.tunnelId ?? null,
|
|
186
172
|
};
|
|
187
173
|
console.error(`[cloudflare:refuse] ${JSON.stringify(fields)}`);
|
|
188
174
|
}
|
|
189
175
|
/**
|
|
190
|
-
*
|
|
191
|
-
* tunnel-login under the account that owns the
|
|
192
|
-
* No alternative auth path exists.
|
|
176
|
+
* Single recovery instruction. Every refusal instructs the same path:
|
|
177
|
+
* tunnel-login under the Cloudflare account that owns the target zone.
|
|
193
178
|
*/
|
|
194
179
|
export function recoveryMessage() {
|
|
195
|
-
const brand = loadBrand();
|
|
196
180
|
return (`Recovery: run tunnel-login while signed into the Cloudflare account that owns ` +
|
|
197
|
-
`the
|
|
198
|
-
`
|
|
199
|
-
`
|
|
181
|
+
`the target zone. If you recently rotated the cert under a different account, ` +
|
|
182
|
+
`pass force=true to clear the existing cert.pem and account binding before ` +
|
|
183
|
+
`re-authenticating.`);
|
|
200
184
|
}
|
|
201
185
|
// ---------------------------------------------------------------------------
|
|
202
186
|
// Reset auth — unlinks cert.pem (both paths) and the account binding
|
|
@@ -468,17 +452,6 @@ export async function listZones() {
|
|
|
468
452
|
}
|
|
469
453
|
return zones;
|
|
470
454
|
}
|
|
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
455
|
export async function getZoneId(domain) {
|
|
483
456
|
const { client } = getClient();
|
|
484
457
|
const zones = await client.zones.list({ name: domain });
|
|
@@ -902,47 +875,39 @@ export function writeLocalConfig(tunnelId, credentialsPath, hostnames, port) {
|
|
|
902
875
|
return configPath;
|
|
903
876
|
}
|
|
904
877
|
// ---------------------------------------------------------------------------
|
|
905
|
-
// CLI-based DNS routing — guarded by
|
|
878
|
+
// CLI-based DNS routing — guarded by live account-zone check + post-flight
|
|
906
879
|
//
|
|
907
880
|
// `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:
|
|
881
|
+
// account. If the hostname's registrable parent zone is not on that account,
|
|
882
|
+
// cloudflared silently writes a CNAME under a different zone (the original
|
|
883
|
+
// joelsmalley.xyz wrong-routing bug). Defence in depth:
|
|
913
884
|
//
|
|
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.
|
|
885
|
+
// (1) Live account-zone check: list the bound account's zones, refuse if
|
|
886
|
+
// hostname's registrable parent is not active on that account.
|
|
887
|
+
// (2) Post-flight FQDN assertion: the FQDN cloudflared wrote must equal
|
|
888
|
+
// the requested hostname. Mismatch → reverse-and-refuse.
|
|
922
889
|
//
|
|
923
|
-
// `getClient()`
|
|
924
|
-
// (cert+binding+accountId match) so account-drift refusals fire before any
|
|
925
|
-
// CLI process spawns.
|
|
890
|
+
// `getClient()` runs first to enforce auth pre-conditions.
|
|
926
891
|
// ---------------------------------------------------------------------------
|
|
927
892
|
export async function routeDnsCli(tunnelId, hostname) {
|
|
928
893
|
const bin = findBinary();
|
|
929
894
|
if (!bin)
|
|
930
895
|
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
896
|
const { client, accountId: boundAccountId } = getClient();
|
|
934
|
-
// (1)
|
|
935
|
-
const
|
|
936
|
-
const scope =
|
|
897
|
+
// (1) Live account-zone check.
|
|
898
|
+
const accountZones = await listZones();
|
|
899
|
+
const scope = matchAccountZone(hostname, accountZones);
|
|
937
900
|
if (!scope.ok) {
|
|
938
901
|
const detail = {
|
|
939
902
|
reason: "scope-mismatch",
|
|
940
|
-
message: `Cannot route ${hostname} —
|
|
941
|
-
`
|
|
942
|
-
|
|
903
|
+
message: `Cannot route ${hostname} — no active zone on the bound Cloudflare account ` +
|
|
904
|
+
`owns this hostname's registrable parent. Active zones on this account: ` +
|
|
905
|
+
`${scope.accountZones.join(", ") || "none"}. Add the parent zone to the account ` +
|
|
906
|
+
`(cf-add-zone or via the Cloudflare dashboard), or pick a hostname under one ` +
|
|
907
|
+
`of the existing zones.`,
|
|
943
908
|
fields: {
|
|
944
909
|
requestedHostname: hostname,
|
|
945
|
-
|
|
910
|
+
accountZones: scope.accountZones,
|
|
946
911
|
},
|
|
947
912
|
};
|
|
948
913
|
logRefuse(detail);
|
|
@@ -963,19 +928,10 @@ export async function routeDnsCli(tunnelId, hostname) {
|
|
|
963
928
|
}
|
|
964
929
|
throw new Error(`cloudflared tunnel route dns failed: ${msg}`);
|
|
965
930
|
}
|
|
966
|
-
// (2) Post-flight FQDN assertion.
|
|
967
|
-
// actually created from its INF line:
|
|
968
|
-
// "INF Added CNAME <fqdn> which will route to this tunnel ..."
|
|
931
|
+
// (2) Post-flight FQDN assertion.
|
|
969
932
|
const fqdnMatch = output.match(/Added CNAME\s+(\S+?)\s+which will route/);
|
|
970
933
|
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
934
|
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
935
|
return {
|
|
980
936
|
created: true,
|
|
981
937
|
output: output.trim(),
|
|
@@ -985,45 +941,34 @@ export async function routeDnsCli(tunnelId, hostname) {
|
|
|
985
941
|
}
|
|
986
942
|
const actualFqdn = fqdnMatch[1];
|
|
987
943
|
if (actualFqdn !== hostname) {
|
|
988
|
-
//
|
|
989
|
-
//
|
|
990
|
-
//
|
|
991
|
-
// in-scope records as "cleanup"), then refuse.
|
|
944
|
+
// cloudflared wrote a CNAME under a different FQDN than requested —
|
|
945
|
+
// somehow the live-zone check passed but the routing landed elsewhere.
|
|
946
|
+
// Log evidence, attempt to delete the wrong CNAME, refuse.
|
|
992
947
|
console.error(`[cloudflare:post-flight-mismatch] ${JSON.stringify({
|
|
993
948
|
requestedHostname: hostname,
|
|
994
949
|
actualFqdn,
|
|
995
950
|
tunnelId,
|
|
996
951
|
boundAccountId,
|
|
997
952
|
})}`);
|
|
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
|
|
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 });
|
|
1020
965
|
}
|
|
1021
|
-
|
|
1022
|
-
catch (cleanupErr) {
|
|
1023
|
-
cleanupResult = "failed";
|
|
1024
|
-
console.error(`[cloudflare:post-flight-cleanup] error: ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`);
|
|
966
|
+
cleanupResult = "ok";
|
|
1025
967
|
}
|
|
1026
968
|
}
|
|
969
|
+
catch (cleanupErr) {
|
|
970
|
+
console.error(`[cloudflare:post-flight-cleanup] error: ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`);
|
|
971
|
+
}
|
|
1027
972
|
console.error(`[cloudflare:post-flight-cleanup] ${JSON.stringify({
|
|
1028
973
|
requestedHostname: hostname,
|
|
1029
974
|
actualFqdn,
|
|
@@ -1032,24 +977,18 @@ export async function routeDnsCli(tunnelId, hostname) {
|
|
|
1032
977
|
const detail = {
|
|
1033
978
|
reason: "post-flight-fqdn-mismatch",
|
|
1034
979
|
message: `cloudflared wrote the CNAME under ${actualFqdn} instead of the requested ${hostname}. ` +
|
|
1035
|
-
`
|
|
1036
|
-
`passing manifest scope. ` +
|
|
1037
|
-
recoveryMessage(),
|
|
980
|
+
`Cleanup ${cleanupResult === "ok" ? "succeeded" : "failed"}. Re-run cf-rebuild to reconcile.`,
|
|
1038
981
|
fields: {
|
|
1039
982
|
requestedHostname: hostname,
|
|
1040
983
|
actualFqdn,
|
|
1041
984
|
tunnelId,
|
|
1042
985
|
boundAccountId,
|
|
1043
|
-
manifestZones: brand.cloudflare.zones,
|
|
1044
986
|
},
|
|
1045
987
|
};
|
|
1046
988
|
logRefuse(detail);
|
|
1047
989
|
throw new CloudflareRefusalError(detail);
|
|
1048
990
|
}
|
|
1049
991
|
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
992
|
return { created: true, output: output.trim(), fqdn: actualFqdn, zone: scope.matchedZone };
|
|
1054
993
|
}
|
|
1055
994
|
// ---------------------------------------------------------------------------
|
|
@@ -1376,237 +1315,109 @@ export function stopTunnel() {
|
|
|
1376
1315
|
writeState({ ...state, pid: null, startedAt: null });
|
|
1377
1316
|
}
|
|
1378
1317
|
/**
|
|
1379
|
-
*
|
|
1380
|
-
*
|
|
1381
|
-
*
|
|
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.
|
|
1382
1326
|
*/
|
|
1383
|
-
export function
|
|
1327
|
+
export async function cfVerifyCore() {
|
|
1384
1328
|
const brand = loadBrand();
|
|
1329
|
+
console.error(`[cloudflare:verify-start] ${JSON.stringify({ brand: brand.productName })}`);
|
|
1330
|
+
const device = readDeviceSnapshot();
|
|
1385
1331
|
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) {
|
|
1332
|
+
let account = null;
|
|
1333
|
+
if (ro.client && ro.certAccountId) {
|
|
1440
1334
|
try {
|
|
1441
|
-
const [
|
|
1442
|
-
|
|
1443
|
-
|
|
1335
|
+
const [zones, tunnels] = await Promise.all([
|
|
1336
|
+
listZonesViaClient(ro.client),
|
|
1337
|
+
listTunnelsViaClient(ro.client, ro.certAccountId),
|
|
1444
1338
|
]);
|
|
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)
|
|
1339
|
+
const cnames = [];
|
|
1340
|
+
for (const z of zones) {
|
|
1341
|
+
if (z.status !== "active")
|
|
1504
1342
|
continue;
|
|
1505
1343
|
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
|
-
});
|
|
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 });
|
|
1519
1347
|
}
|
|
1520
1348
|
}
|
|
1521
1349
|
catch (err) {
|
|
1522
|
-
console.error(`[cloudflare:verify] failed to list CNAMEs under ${
|
|
1350
|
+
console.error(`[cloudflare:verify] failed to list CNAMEs under ${z.name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1523
1351
|
}
|
|
1524
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
|
+
};
|
|
1525
1359
|
}
|
|
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" });
|
|
1360
|
+
catch (err) {
|
|
1361
|
+
console.error(`[cloudflare:verify] account read failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1577
1362
|
}
|
|
1578
1363
|
}
|
|
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
|
-
};
|
|
1364
|
+
const pollution = computePollution(account, device);
|
|
1594
1365
|
console.error(`[cloudflare:verify-complete] ${JSON.stringify({
|
|
1595
1366
|
brand: brand.productName,
|
|
1596
|
-
|
|
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,
|
|
1597
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()];
|
|
1598
1380
|
return {
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
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,
|
|
1606
1393
|
};
|
|
1607
1394
|
}
|
|
1608
|
-
|
|
1609
|
-
|
|
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
|
+
}
|
|
1610
1421
|
async function listZonesViaClient(client) {
|
|
1611
1422
|
const zones = [];
|
|
1612
1423
|
for await (const zone of client.zones.list()) {
|
|
@@ -1644,251 +1455,211 @@ async function listCnamesUnderZone(client, zoneId) {
|
|
|
1644
1455
|
}
|
|
1645
1456
|
export async function cfRebuildCore(opts = {}) {
|
|
1646
1457
|
const dryRun = opts.dryRun ?? false;
|
|
1647
|
-
const ctx = opts.ctx ?? liveScopeContext();
|
|
1648
1458
|
const brand = loadBrand();
|
|
1649
|
-
console.error(`[cloudflare:rebuild-start] ${JSON.stringify({
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
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) {
|
|
1662
1484
|
return {
|
|
1663
1485
|
brand: brand.productName,
|
|
1664
|
-
declaredZones: ctx.declaredZones,
|
|
1665
1486
|
dryRun,
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
op: "skip-needs-operator",
|
|
1669
|
-
artefact: { type: "cert.pem", id: certPath(), tag: "missing" },
|
|
1670
|
-
planned: dryRun,
|
|
1671
|
-
resultDetail: msg,
|
|
1672
|
-
},
|
|
1673
|
-
],
|
|
1487
|
+
preserve: { zones: [], tunnelIds: [], cnames: null },
|
|
1488
|
+
actions: [],
|
|
1674
1489
|
halted: true,
|
|
1675
|
-
haltReason:
|
|
1490
|
+
haltReason: "Could not read account state (cf-verify returned no account snapshot).",
|
|
1676
1491
|
};
|
|
1677
1492
|
}
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
},
|
|
1692
|
-
planned: dryRun,
|
|
1693
|
-
resultDetail: msg,
|
|
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.",
|
|
1694
1506
|
});
|
|
1695
|
-
console.error(`[cloudflare:rebuild-discard] ${JSON.stringify({ type: "cert.pem", reason: "wrong-account", planned: true })}`);
|
|
1696
1507
|
return {
|
|
1697
1508
|
brand: brand.productName,
|
|
1698
|
-
declaredZones: ctx.declaredZones,
|
|
1699
1509
|
dryRun,
|
|
1700
|
-
|
|
1510
|
+
preserve: { zones: [], tunnelIds: [], cnames: null },
|
|
1511
|
+
actions: [],
|
|
1701
1512
|
halted: true,
|
|
1702
|
-
haltReason:
|
|
1513
|
+
haltReason: "no-intent",
|
|
1703
1514
|
};
|
|
1704
1515
|
}
|
|
1705
|
-
//
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
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
|
+
};
|
|
1717
1561
|
if (!dryRun) {
|
|
1718
1562
|
try {
|
|
1719
|
-
|
|
1563
|
+
await client.zeroTrust.tunnels.cloudflared.delete(t.id, { account_id: accountId });
|
|
1720
1564
|
}
|
|
1721
1565
|
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
|
-
};
|
|
1566
|
+
action.result = "failed";
|
|
1567
|
+
action.detail = err instanceof Error ? err.message : String(err);
|
|
1739
1568
|
}
|
|
1740
1569
|
}
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
artefact: {
|
|
1744
|
-
type: "binding",
|
|
1745
|
-
id: bindingFile(),
|
|
1746
|
-
tag: "missing",
|
|
1747
|
-
},
|
|
1748
|
-
planned: dryRun,
|
|
1749
|
-
result: "ok",
|
|
1750
|
-
});
|
|
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);
|
|
1751
1572
|
}
|
|
1752
|
-
//
|
|
1753
|
-
|
|
1754
|
-
//
|
|
1755
|
-
//
|
|
1756
|
-
//
|
|
1757
|
-
//
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
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)
|
|
1765
1590
|
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;
|
|
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 });
|
|
1822
1608
|
}
|
|
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:
|
|
1609
|
+
catch (err) {
|
|
1830
1610
|
action.result = "failed";
|
|
1831
|
-
action.
|
|
1611
|
+
action.detail = err instanceof Error ? err.message : String(err);
|
|
1612
|
+
}
|
|
1832
1613
|
}
|
|
1833
1614
|
}
|
|
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
|
-
}
|
|
1615
|
+
console.error(`[cloudflare:rebuild-discard] ${JSON.stringify({ type: "cname", id: c.recordId, name: c.name, zone: c.zone, planned: dryRun, result: action.result })}`);
|
|
1841
1616
|
actions.push(action);
|
|
1842
1617
|
}
|
|
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")
|
|
1618
|
+
// (c) Delete zones not in preserve.
|
|
1619
|
+
for (const z of verify.account.zones) {
|
|
1620
|
+
if (preserveZones.includes(z.name.toLowerCase()))
|
|
1851
1621
|
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
|
-
});
|
|
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
|
+
}
|
|
1872
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);
|
|
1873
1642
|
}
|
|
1874
|
-
// Step 6: rerun verify (unless dry-run) to capture final state.
|
|
1875
1643
|
let finalVerify;
|
|
1876
1644
|
if (!dryRun) {
|
|
1877
|
-
finalVerify = await cfVerifyCore(
|
|
1645
|
+
finalVerify = await cfVerifyCore();
|
|
1878
1646
|
}
|
|
1879
1647
|
console.error(`[cloudflare:rebuild-complete] ${JSON.stringify({
|
|
1880
1648
|
brand: brand.productName,
|
|
1881
1649
|
dryRun,
|
|
1882
1650
|
actionCount: actions.length,
|
|
1883
|
-
finalCounts: finalVerify?.counts ?? null,
|
|
1884
1651
|
})}`);
|
|
1885
1652
|
return {
|
|
1886
1653
|
brand: brand.productName,
|
|
1887
|
-
declaredZones: ctx.declaredZones,
|
|
1888
1654
|
dryRun,
|
|
1655
|
+
preserve: {
|
|
1656
|
+
zones: preserveZones,
|
|
1657
|
+
tunnelIds: preserveTunnelIds,
|
|
1658
|
+
cnames: preserveCnames,
|
|
1659
|
+
},
|
|
1889
1660
|
actions,
|
|
1890
|
-
halted: false,
|
|
1891
1661
|
finalVerify,
|
|
1662
|
+
halted: false,
|
|
1892
1663
|
};
|
|
1893
1664
|
}
|
|
1894
1665
|
//# sourceMappingURL=cloudflared.js.map
|