a2a-exposed 0.8.26 → 0.8.28
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/lib/config.mjs +8 -0
- package/lib/deploy.mjs +77 -10
- package/lib/feedback.mjs +2 -1
- package/lib/layers.mjs +205 -0
- package/lib/link-finish.mjs +178 -0
- package/lib/local-link.mjs +101 -0
- package/lib/main.mjs +34 -10
- package/lib/script.mjs +56 -0
- package/lib/status.mjs +281 -62
- package/lib/tunnel.mjs +2 -2
- package/lib/workersdev.mjs +12 -5
- package/package.json +1 -1
- package/worker/src/index.ts +1 -1
- package/worker/src/version.ts +1 -1
- package/worker/src/wake.ts +7 -3
- package/worker/test/pairing.test.ts +37 -0
package/lib/config.mjs
CHANGED
|
@@ -54,6 +54,14 @@ export function get(key, dflt = "") {
|
|
|
54
54
|
return f !== undefined && f !== "" ? f : dflt;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** Resolve a control credential with the origin from the same source.
|
|
58
|
+
* A partial environment override must never borrow saved credentials.
|
|
59
|
+
*/
|
|
60
|
+
export function controlSession(env = process.env, saved = fileConfig()) {
|
|
61
|
+
const source = env.CONTROL_URL || env.CONTROL_TOKEN ? env : saved;
|
|
62
|
+
return { origin: source.CONTROL_URL || "", token: source.CONTROL_TOKEN || "" };
|
|
63
|
+
}
|
|
64
|
+
|
|
57
65
|
function ensureDir() {
|
|
58
66
|
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
59
67
|
}
|
package/lib/deploy.mjs
CHANGED
|
@@ -124,6 +124,21 @@ function run(cmd, args, { cwd, env, capture = false, captureStderr = false, allo
|
|
|
124
124
|
return r;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/** Keep dependency installer output in a private local log. */
|
|
128
|
+
export function installDependencies(dir, { configDir = C.CONFIG_DIR, env = process.env } = {}) {
|
|
129
|
+
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
130
|
+
const log = path.join(configDir, "npm-install.log");
|
|
131
|
+
const fd = fs.openSync(log, "w", 0o600);
|
|
132
|
+
fs.fchmodSync(fd, 0o600);
|
|
133
|
+
let result;
|
|
134
|
+
try {
|
|
135
|
+
result = spawnSync("npm", ["install", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir, env, stdio: ["ignore", fd, fd] });
|
|
136
|
+
} finally {
|
|
137
|
+
fs.closeSync(fd);
|
|
138
|
+
}
|
|
139
|
+
if (result.error || result.status !== 0) die(`npm install failed; inspect the private log: ${log}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
127
142
|
export function cfBin(dir) {
|
|
128
143
|
const local = path.join(dir, "node_modules", ".bin", "cf");
|
|
129
144
|
return fs.existsSync(local) ? local : "cf";
|
|
@@ -439,15 +454,46 @@ async function deployAndLearn(dir, secrets, o) {
|
|
|
439
454
|
}
|
|
440
455
|
|
|
441
456
|
/**
|
|
442
|
-
* whoami before npm install. A previous install has node_modules/.bin/cf.
|
|
457
|
+
* whoami and accounts list run before npm install. A previous install has node_modules/.bin/cf.
|
|
443
458
|
* A fresh init does not: `npx cf auth login` does not put `cf` on PATH, so this check uses `npx --yes cf`.
|
|
459
|
+
* cwd stays outside the Worker project. Inside it, cf loads cloudflare.config.ts, and that import fails
|
|
460
|
+
* with "Cannot find package 'cf'" until npm install. These commands do not need the project.
|
|
444
461
|
*/
|
|
462
|
+
function cfAccountCwd() {
|
|
463
|
+
return os.tmpdir();
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function firstStreamLine(text) {
|
|
467
|
+
return String(text || "").split(/\r?\n/).map((s) => s.trim()).find(Boolean) || "";
|
|
468
|
+
}
|
|
469
|
+
|
|
445
470
|
function cfSpawn(dir, args, { allowFail = false } = {}) {
|
|
446
471
|
const local = path.join(dir, "node_modules", ".bin", "cf");
|
|
447
472
|
const full = cfArgs(args);
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
473
|
+
const cwd = cfAccountCwd();
|
|
474
|
+
const r = fs.existsSync(local)
|
|
475
|
+
? run(local, full, { cwd, capture: true, captureStderr: true, allowFail: true })
|
|
476
|
+
: run("npx", ["--yes", "cf", ...full], { cwd, capture: true, captureStderr: true, allowFail: true });
|
|
477
|
+
if (r.status !== 0 && !allowFail) die(cfCommandFailure(args, r));
|
|
478
|
+
return r;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function cfCommandFailure(args, r) {
|
|
482
|
+
const line = firstStreamLine(r && (r.stderr || r.stdout));
|
|
483
|
+
const code = r && r.status != null ? r.status : "unknown";
|
|
484
|
+
const shown = args.slice(0, 2).join(" ");
|
|
485
|
+
const prof = cfProfile();
|
|
486
|
+
const profileArg = prof ? ` --profile ${prof}` : "";
|
|
487
|
+
return `cf ${shown} failed (exit ${code})${line ? `: ${line}` : ""}\nCheck: npx --yes cf ${shown}${profileArg} (run it outside the project directory)`;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function accountsListFailure(r) {
|
|
491
|
+
const line = firstStreamLine(r && r.stderr) || firstStreamLine(r && r.stdout);
|
|
492
|
+
const code = r && r.status != null ? r.status : "unknown";
|
|
493
|
+
const prof = cfProfile();
|
|
494
|
+
const forProfile = prof ? ` for profile "${prof}"` : "";
|
|
495
|
+
const check = `npx --yes cf accounts list${prof ? ` --profile ${prof}` : ""}`;
|
|
496
|
+
return `could not list Cloudflare accounts${forProfile}${line ? `: ${line}` : ""}\ncf accounts list failed (exit ${code}).\nCheck: ${check} (run it outside the project directory)\nOr pass --account-id <id>.`;
|
|
451
497
|
}
|
|
452
498
|
|
|
453
499
|
function cfLoginJson(dir) {
|
|
@@ -460,13 +506,18 @@ function normalizeAccounts(raw) {
|
|
|
460
506
|
return list.filter((a) => a && typeof a.id === "string" && a.id).map((a) => ({ id: a.id, name: typeof a.name === "string" && a.name ? a.name : a.id }));
|
|
461
507
|
}
|
|
462
508
|
|
|
463
|
-
/** `cf accounts list` (GET /accounts). whoami intersects memberships and can return [] for a logged-in profile.
|
|
464
|
-
|
|
509
|
+
/** `cf accounts list` (GET /accounts). whoami intersects memberships and can return [] for a logged-in profile.
|
|
510
|
+
* A non-zero exit is a failed cf call. It is not an empty account list. When an account id is already
|
|
511
|
+
* set, a failed list is not fatal: init keeps that id. */
|
|
512
|
+
function accountsFromList(dir, { required = true } = {}) {
|
|
465
513
|
const all = [];
|
|
466
514
|
const seen = new Set();
|
|
467
515
|
for (let page = 1; page <= 20; page++) {
|
|
468
516
|
const r = cfSpawn(dir, ["accounts", "list", "--page", String(page), "--per-page", "50"], { allowFail: true });
|
|
469
|
-
if (!r || r.status !== 0)
|
|
517
|
+
if (!r || r.status !== 0) {
|
|
518
|
+
if (!required) return page === 1 ? [] : all;
|
|
519
|
+
die(accountsListFailure(r));
|
|
520
|
+
}
|
|
470
521
|
let data;
|
|
471
522
|
try { data = JSON.parse(r.stdout); } catch { return page === 1 ? [] : all; }
|
|
472
523
|
if (data && typeof data === "object" && !Array.isArray(data) && Array.isArray(data.result)) data = data.result;
|
|
@@ -489,7 +540,7 @@ function requireCfLogin(dir) {
|
|
|
489
540
|
? `cf profile "${prof}" is not logged in: run \`npx cf auth create ${prof} --no-browser\`, open the printed URL, enter the code, then re-run init`
|
|
490
541
|
: "not logged in to Cloudflare: run `npx cf auth login --no-browser`, open the printed URL, enter the code, then re-run init") + `\n${LOGIN_403_HELP}`);
|
|
491
542
|
let accts = normalizeAccounts(who.accounts);
|
|
492
|
-
if (!accts.length && prof) accts = accountsFromList(dir);
|
|
543
|
+
if (!accts.length && prof) accts = accountsFromList(dir, { required: !C.get("CLOUDFLARE_ACCOUNT_ID") });
|
|
493
544
|
if (!C.get("CLOUDFLARE_ACCOUNT_ID")) {
|
|
494
545
|
if (!accts.length && process.env.CLOUDFLARE_API_TOKEN)
|
|
495
546
|
die("the API token can't list accounts: add the permission User -> Memberships -> Read (or pass --account-id <id> / export CLOUDFLARE_ACCOUNT_ID; the id is in the dashboard URL)");
|
|
@@ -525,7 +576,7 @@ export async function init(o) {
|
|
|
525
576
|
step(`worker project -> ${dir}`);
|
|
526
577
|
syncTemplate(dir);
|
|
527
578
|
requireCfLogin(dir);
|
|
528
|
-
if (!o["skip-install"])
|
|
579
|
+
if (!o["skip-install"]) installDependencies(dir);
|
|
529
580
|
const env = cfEnv();
|
|
530
581
|
|
|
531
582
|
const dbName = C.get("A2A_D1_NAME") || C.get("A2A_WORKER_NAME");
|
|
@@ -552,6 +603,7 @@ export async function init(o) {
|
|
|
552
603
|
const base = deployedBase();
|
|
553
604
|
if (base) await verifyDeployedCard(base);
|
|
554
605
|
console.log(base);
|
|
606
|
+
await reportApprovalReadiness(base, ownerToken);
|
|
555
607
|
reportSwitch(switched);
|
|
556
608
|
proxyNote(secrets);
|
|
557
609
|
if (rotated) console.error(`owner token replaced. Update A2A_OWNER_TOKEN wherever this A2A endpoint is called, then run \`${CLI} peers sync\` so uploaded peers stay readable.`);
|
|
@@ -559,6 +611,21 @@ export async function init(o) {
|
|
|
559
611
|
printStar();
|
|
560
612
|
}
|
|
561
613
|
|
|
614
|
+
/** Read the deployed inbox, using its saved owner token rather than an environment override. */
|
|
615
|
+
async function reportApprovalReadiness(base, ownerToken) {
|
|
616
|
+
try {
|
|
617
|
+
if (!base) throw new Error();
|
|
618
|
+
const { status, data } = await httpJson(`${base}/owner/pairing`, { headers: { authorization: `Bearer ${ownerToken}` }, timeout: 3000 });
|
|
619
|
+
if (status !== 200 || typeof data?.passwordSet !== "boolean" || !["human", "agent", "off"].includes(data.mode)) throw new Error();
|
|
620
|
+
if (data.mode === "off" || (Array.isArray(data.approvalMethods) && !data.approvalMethods.includes("password"))) return;
|
|
621
|
+
console.error(data.passwordSet ? "Approval password: set."
|
|
622
|
+
: `Approval password: not set. To approve pairing on /device with a password, run: ${CLI} pair set-password --web`);
|
|
623
|
+
if (!data.passwordSet) console.error("Send the one-time setup link privately to your human. They set the password themselves; never open the page or ask for the password.");
|
|
624
|
+
} catch {
|
|
625
|
+
console.error(`Approval password: could not check. Run: ${CLI} pair list --json`);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
562
629
|
/** Proxy / expose mode reminder after a deploy (the upstream credentials are secrets: only their names are shown). */
|
|
563
630
|
function proxyNote(secrets = {}) {
|
|
564
631
|
const up = C.get("A2A_UPSTREAM_URL");
|
|
@@ -649,7 +716,7 @@ export async function deploy(o) {
|
|
|
649
716
|
guardTunnelUrl();
|
|
650
717
|
step(`worker project -> ${dir}${cfProfile() ? ` (cf profile ${cfProfile()})` : ""}`);
|
|
651
718
|
syncTemplate(dir);
|
|
652
|
-
if (!o["skip-install"])
|
|
719
|
+
if (!o["skip-install"]) installDependencies(dir);
|
|
653
720
|
const env = cfEnv();
|
|
654
721
|
applyMigrations(dir, env);
|
|
655
722
|
const secrets = { ...deploySecretsFromEnv(), ...upstreamSecrets, ...await eventSecretFromStdin(o) };
|
package/lib/feedback.mjs
CHANGED
|
@@ -32,7 +32,7 @@ const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
|
32
32
|
/** The repository that will receive the issue. The prefilled form is ISSUE_REPO. A hosted post uses the name that URL returns. */
|
|
33
33
|
export function publicIssueWarning(repo = ISSUE_REPO) {
|
|
34
34
|
const name = typeof repo === "string" && REPO_RE.test(repo) && repo.length <= 100 && !repo.includes("..") ? repo : ISSUE_REPO;
|
|
35
|
-
return `
|
|
35
|
+
return `Posting this report creates a public GitHub issue on ${name}. Do not include confidential information: secrets, tokens, owner or pairing tokens, private URLs or hostnames, message contents, or personal data.`;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
export const PUBLIC_ISSUE_WARNING = publicIssueWarning(ISSUE_REPO);
|
|
@@ -316,6 +316,7 @@ export async function runFeedback(input) {
|
|
|
316
316
|
}),
|
|
317
317
|
});
|
|
318
318
|
if (!endpoint) return fallback();
|
|
319
|
+
await input.beforePost?.();
|
|
319
320
|
let response;
|
|
320
321
|
try {
|
|
321
322
|
response = await call(endpoint, {
|
package/lib/layers.mjs
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// Eight read-only checks for `status`. Pure: the caller supplies DNS and HTTP results.
|
|
2
|
+
// Each layer is PASS, FAIL, or SKIP. A failure names the missing or mismatched object and a fix.
|
|
3
|
+
// Nothing here prints a token, a client secret, or a webhook key.
|
|
4
|
+
import { CLI, isPrivateHost } from "./a2a.mjs";
|
|
5
|
+
import { isIP } from "node:net";
|
|
6
|
+
|
|
7
|
+
export const LAYER_NAMES = [
|
|
8
|
+
"Worker DNS",
|
|
9
|
+
"Worker",
|
|
10
|
+
"Owner API",
|
|
11
|
+
"Wake DNS",
|
|
12
|
+
"Access",
|
|
13
|
+
"Connector",
|
|
14
|
+
"Local webhook",
|
|
15
|
+
"Pairing",
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const TLS_RE = /CERT_HAS_EXPIRED|UNABLE_TO_VERIFY_LEAF_SIGNATURE|ERR_TLS_CERT_ALTNAME_INVALID|DEPTH_ZERO_SELF_SIGNED_CERT|UNABLE_TO_GET_ISSUER_CERT|CERT_UNTRUSTED/;
|
|
19
|
+
|
|
20
|
+
/** Shown when public DNS has the Worker hostname and this machine's resolver does not. */
|
|
21
|
+
export function publicDnsMismatch(host) {
|
|
22
|
+
return `public DNS resolves ${host}; this machine's resolver does not (local cache or resolver). The Worker is reachable through Cloudflare.`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Shown when public DNS has no A record for the Worker hostname. */
|
|
26
|
+
export function noPublicDns(host) {
|
|
27
|
+
return `no public DNS record for ${host} yet: check the Worker custom domain in Cloudflare (Workers → Settings → Domains)`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const MACOS_RESOLVER_FIX = "sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder";
|
|
31
|
+
|
|
32
|
+
/** An address from DNS-over-HTTPS that is safe to connect to (not loopback, LAN, or link-local). */
|
|
33
|
+
export function usablePin(ip) {
|
|
34
|
+
return typeof ip === "string" && isIP(ip) !== 0 && !isPrivateHost(ip);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function layer(name, result, detail, fix = "") {
|
|
38
|
+
return { name, result, detail, fix: result === "FAIL" ? fix : "" };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One status row: `PASS|FAIL|SKIP detail` and, on failure, `Fix: …`. */
|
|
42
|
+
export function layerValue(l) {
|
|
43
|
+
if (!l || l.result !== "FAIL" || !l.fix) return `${l.result} ${l.detail}`;
|
|
44
|
+
const gap = /[.]$/.test(l.detail) ? " " : ". ";
|
|
45
|
+
return `${l.result} ${l.detail}${gap}Fix: ${l.fix}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function workerDnsLayer(f) {
|
|
49
|
+
if (!f.host) return layer("Worker DNS", "SKIP", "no base URL saved");
|
|
50
|
+
const d = f.workerDns || { kind: "skip" };
|
|
51
|
+
if (d.kind === "ip") return layer("Worker DNS", "PASS", `${f.host} is an address (no DNS name to resolve)`);
|
|
52
|
+
if (d.kind === "system") return layer("Worker DNS", "PASS", `system resolver resolved ${f.host}${d.ip ? ` to ${d.ip}` : ""}`);
|
|
53
|
+
if (d.kind === "public-only") {
|
|
54
|
+
const mac = f.platform === "darwin" ? ` ${MACOS_RESOLVER_FIX}` : "";
|
|
55
|
+
const fix = f.platform === "darwin" ? MACOS_RESOLVER_FIX : "flush this machine's DNS cache";
|
|
56
|
+
return layer("Worker DNS", "FAIL", `${publicDnsMismatch(f.host)}${mac}`, fix);
|
|
57
|
+
}
|
|
58
|
+
if (d.kind === "no-record") return layer("Worker DNS", "FAIL", noPublicDns(f.host));
|
|
59
|
+
if (d.kind === "fail") return layer("Worker DNS", "FAIL", `DNS lookup failed (${d.error || "unknown"}). Likely mismatched: resolver for ${f.host}`, "flush this machine's DNS cache, or check the hostname");
|
|
60
|
+
return layer("Worker DNS", "SKIP", "not checked");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function workerLayer(f) {
|
|
64
|
+
if (!f.baseUrl) return layer("Worker", "SKIP", "not checked (no base URL)");
|
|
65
|
+
const https = /^https:/i.test(f.baseUrl);
|
|
66
|
+
const card = f.card || { ok: false, error: "not checked" };
|
|
67
|
+
if (https && card.error && TLS_RE.test(card.error))
|
|
68
|
+
return layer("Worker", "FAIL", `Likely mismatched: TLS certificate for ${f.host}. ${card.error}`, `check the certificate for ${f.host} in Cloudflare (SSL/TLS)`);
|
|
69
|
+
if (!card.ok)
|
|
70
|
+
return layer("Worker", "FAIL", `Likely missing: deployed Worker. Agent card: ${card.error || "unreachable"}`, `${CLI} deploy`);
|
|
71
|
+
if (card.urlProblem)
|
|
72
|
+
return layer("Worker", "FAIL", `Likely mismatched: agent card URL. ${card.urlProblem}`, `${CLI} deploy`);
|
|
73
|
+
const tls = https ? "TLS ok" : "TLS skipped (not https)";
|
|
74
|
+
const named = card.name ? ` ("${card.name}")` : "";
|
|
75
|
+
const cardBit = `${tls}; agent card HTTP ${card.httpStatus || 200}${named}`;
|
|
76
|
+
const h = f.health;
|
|
77
|
+
if (!h || h.ok !== true) {
|
|
78
|
+
const problem = !h || h.httpStatus == null
|
|
79
|
+
? `/health unreachable${h && h.error ? ` (${h.error})` : ""}`
|
|
80
|
+
: h.httpStatus < 200 || h.httpStatus >= 300
|
|
81
|
+
? `/health HTTP ${h.httpStatus}`
|
|
82
|
+
: `/health HTTP ${h.httpStatus} did not report ok`;
|
|
83
|
+
return layer("Worker", "FAIL", `Likely missing: Worker /health. ${cardBit}; ${problem}`, `${CLI} deploy`);
|
|
84
|
+
}
|
|
85
|
+
return layer("Worker", "PASS", `${cardBit}; /health HTTP ${h.httpStatus || 200}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function ownerLayer(f) {
|
|
89
|
+
if (!f.deployed || !f.baseUrl) return layer("Owner API", "SKIP", "not checked (nothing deployed)");
|
|
90
|
+
if (!f.hasOwnerToken) return layer("Owner API", "FAIL", "Likely missing: owner token", `${CLI} init`);
|
|
91
|
+
if (f.ownerApi && f.ownerApi.ok) return layer("Owner API", "PASS", "owner token accepted");
|
|
92
|
+
const err = (f.ownerApi && f.ownerApi.error) || "not checked";
|
|
93
|
+
if (err === "not checked") return layer("Owner API", "SKIP", "not checked");
|
|
94
|
+
if (/HTTP 401/.test(err)) return layer("Owner API", "FAIL", "Likely mismatched: owner token", `${CLI} init --rotate-owner-token`);
|
|
95
|
+
return layer("Owner API", "FAIL", `Likely missing: deployed Worker. ${err}`, `${CLI} deploy`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function wakeDnsLayer(f) {
|
|
99
|
+
if (!f.wakeHost) return layer("Wake DNS", "SKIP", "no wake hostname");
|
|
100
|
+
const d = f.wakeDns || { kind: "skip" };
|
|
101
|
+
if (d.kind === "pass") return layer("Wake DNS", "PASS", `public DNS resolves ${f.wakeHost}${d.ip ? ` to ${d.ip}` : ""}`);
|
|
102
|
+
if (d.kind === "no-record") return layer("Wake DNS", "FAIL", `no public DNS record for ${f.wakeHost} yet. Likely missing: tunnel DNS record for ${f.wakeHost}`, `${CLI} tunnel status`);
|
|
103
|
+
if (d.kind === "fail") return layer("Wake DNS", "FAIL", `wake DNS check failed (${d.error || "unknown"}). Likely missing: tunnel DNS record for ${f.wakeHost}`, `${CLI} tunnel status`);
|
|
104
|
+
return layer("Wake DNS", "SKIP", "not checked");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function accessProtected(bare) {
|
|
108
|
+
if (!bare || bare.error || bare.status == null) return false;
|
|
109
|
+
if (bare.status === 401 || bare.status === 403) return true;
|
|
110
|
+
return bare.status >= 300 && bare.status < 400 && /cloudflareaccess\.com/i.test(bare.location || "");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function accessLayer(f) {
|
|
114
|
+
if (!f.wakeHost) return layer("Access", "SKIP", "no wake hostname");
|
|
115
|
+
const dns = f.wakeDns || {};
|
|
116
|
+
if (dns.kind === "no-record" || dns.kind === "fail") return layer("Access", "SKIP", "not checked (wake DNS failed)");
|
|
117
|
+
const bare = f.bare;
|
|
118
|
+
if (!bare) return layer("Access", "SKIP", "not checked");
|
|
119
|
+
if (bare.error) return layer("Access", "FAIL", `wake endpoint not reachable (${bare.error}). Likely missing: DNS record or Access application for ${f.wakeHost}`, `${CLI} tunnel status`);
|
|
120
|
+
if (!accessProtected(bare))
|
|
121
|
+
return layer("Access", "FAIL", `bare request was not blocked (HTTP ${bare.status}). Likely missing: Access application for ${f.wakeHost}`, `${CLI} tunnel status`);
|
|
122
|
+
if (f.serviceToken !== "configured")
|
|
123
|
+
return layer("Access", "FAIL", `protected (HTTP ${bare.status}); Access service token not configured. Likely missing: Access service token`, `${CLI} tunnel create`);
|
|
124
|
+
return layer("Access", "PASS", `protected (HTTP ${bare.status}); Access service token configured`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function connectorLayer(f) {
|
|
128
|
+
const t = f.tunnel;
|
|
129
|
+
if (!t) return layer("Connector", "SKIP", "no tunnel configured");
|
|
130
|
+
const file = t.tokenFile || "tunnel-token";
|
|
131
|
+
if (!t.complete) return layer("Connector", "FAIL", "Likely mismatched: unfinished tunnel", `${CLI} tunnel rm, then ${CLI} tunnel create`);
|
|
132
|
+
if (!t.tokenFileOk) return layer("Connector", "FAIL", `Likely missing: connector token file (${file})`, `${CLI} tunnel create`);
|
|
133
|
+
if (t.grace) return layer("Connector", "SKIP", `waiting for the connector to connect. Start \`cloudflared tunnel run --token-file ${file}\``);
|
|
134
|
+
if (t.down1033 || t.connections === 0)
|
|
135
|
+
return layer("Connector", "FAIL", `Likely missing: cloudflared connector${t.down1033 ? " (Cloudflare error 1033)" : ""}`, `cloudflared tunnel run --token-file ${file}`);
|
|
136
|
+
if (typeof t.connections === "number" && t.connections > 0) return layer("Connector", "PASS", `connected (${t.connections})`);
|
|
137
|
+
return layer("Connector", "SKIP", "connector state unknown");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function webhookLayer(f) {
|
|
141
|
+
if (!f.wakeHost) return layer("Local webhook", "SKIP", "no wake hostname");
|
|
142
|
+
const dns = f.wakeDns || {};
|
|
143
|
+
if (dns.kind === "no-record" || dns.kind === "fail") return layer("Local webhook", "SKIP", "not checked (wake DNS failed)");
|
|
144
|
+
if (f.tunnel && f.tunnel.grace) return layer("Local webhook", "SKIP", "not checked (connector still connecting)");
|
|
145
|
+
if (!f.localServiceToken) {
|
|
146
|
+
const where = f.serviceToken === "configured" ? "the Worker has an Access service token; this machine does not" : "no Access service token in the local config";
|
|
147
|
+
return layer("Local webhook", "SKIP", `not probed (${where})`);
|
|
148
|
+
}
|
|
149
|
+
const p = f.authed;
|
|
150
|
+
if (!p) return layer("Local webhook", "SKIP", "not checked");
|
|
151
|
+
if (p.cf === "1033") return layer("Local webhook", "SKIP", "not reached (Cloudflare error 1033)");
|
|
152
|
+
const origin = f.tunnel && f.tunnel.origin ? ` at ${f.tunnel.origin}` : "";
|
|
153
|
+
if (p.error) return layer("Local webhook", "FAIL", `wake endpoint not reachable (${p.error}). Likely missing: local webhook${origin}`, "start the agent's webhook");
|
|
154
|
+
const refusal = serviceTokenRefusal(p);
|
|
155
|
+
if (refusal) return layer("Local webhook", "FAIL", `Likely mismatched: Access service token or Access policy. The service-token request was refused (${refusal})`, `${CLI} tunnel status`);
|
|
156
|
+
if (p.status === 502 || p.status === 504 || p.status === 530)
|
|
157
|
+
return layer("Local webhook", "FAIL", `wake endpoint HTTP ${p.status}. Likely missing: local webhook${origin}`, "start the agent's webhook");
|
|
158
|
+
return layer("Local webhook", "PASS", `wake endpoint HTTP ${p.status}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Login host, Access path, Access error page, or an Access challenge. "Forbidden" alone is not a marker. */
|
|
162
|
+
const ACCESS_MARK = /cloudflareaccess\.com|\/cdn-cgi\/access\/|cloudflare[- ]access/i;
|
|
163
|
+
|
|
164
|
+
/** True when this response shows Cloudflare Access, from its headers or its body. */
|
|
165
|
+
function accessMarked(p) {
|
|
166
|
+
const headers = Array.isArray(p.accessHeaders) ? p.accessHeaders : [];
|
|
167
|
+
if (headers.some((n) => String(n).toLowerCase().startsWith("cf-access-"))) return true;
|
|
168
|
+
return ACCESS_MARK.test(String(p.location || ""))
|
|
169
|
+
|| ACCESS_MARK.test(String(p.wwwAuthenticate || ""))
|
|
170
|
+
|| ACCESS_MARK.test(String(p.body || ""));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Access rejected the service token: a login redirect, or a 401/403 that carries Access markers.
|
|
174
|
+
* A 401/403 without those markers is not an Access refusal. The location URL is not repeated. */
|
|
175
|
+
function serviceTokenRefusal(p) {
|
|
176
|
+
if (!p || p.error || p.status == null) return "";
|
|
177
|
+
const loc = String(p.location || "");
|
|
178
|
+
const login = /cloudflareaccess\.com|\/cdn-cgi\/access\//i.test(loc);
|
|
179
|
+
if (p.status >= 300 && p.status < 400 && login) return `HTTP ${p.status} redirect to the Cloudflare Access login`;
|
|
180
|
+
if ((p.status === 401 || p.status === 403) && accessMarked(p)) return `HTTP ${p.status} from Cloudflare Access`;
|
|
181
|
+
return "";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function pairingLayer(f) {
|
|
185
|
+
if (!f.deployed || !f.baseUrl) return layer("Pairing", "SKIP", "not checked (nothing deployed)");
|
|
186
|
+
const p = f.pairing;
|
|
187
|
+
if (!p) return layer("Pairing", "SKIP", "not checked (owner API did not report pairing)");
|
|
188
|
+
if (p.mode === "off") return layer("Pairing", "PASS", "off (token issue only)");
|
|
189
|
+
if (p.mode === "agent") return layer("Pairing", "PASS", "agent approval");
|
|
190
|
+
if (p.mode === "human" && p.passwordSet) return layer("Pairing", "PASS", "approval password set");
|
|
191
|
+
if (p.mode === "human" && p.oidcIssuer) return layer("Pairing", "PASS", `OpenID Connect (${p.oidcIssuer})`);
|
|
192
|
+
if (p.mode === "human") return layer("Pairing", "FAIL", "Likely missing: approval password", `${CLI} pair set-password --web`);
|
|
193
|
+
return layer("Pairing", "SKIP", "not checked");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The eight layers, in order. `f` is the snapshot gathered by `status` (no secrets). */
|
|
197
|
+
export function buildLayers(f) {
|
|
198
|
+
const layers = [
|
|
199
|
+
workerDnsLayer(f), workerLayer(f), ownerLayer(f), wakeDnsLayer(f),
|
|
200
|
+
accessLayer(f), connectorLayer(f), webhookLayer(f), pairingLayer(f),
|
|
201
|
+
];
|
|
202
|
+
if (layers.length !== LAYER_NAMES.length || layers.some((l, i) => l.name !== LAYER_NAMES[i] || !["PASS", "FAIL", "SKIP"].includes(l.result)))
|
|
203
|
+
throw new Error("status layers drifted");
|
|
204
|
+
return layers;
|
|
205
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { CliError } from "./a2a.mjs";
|
|
5
|
+
import { controlOrigin } from "./account.mjs";
|
|
6
|
+
import * as C from "./config.mjs";
|
|
7
|
+
import { readSigningSecret, validateSecretSource, writeTokenFile, startConnector, connectorCommand } from "./local-link.mjs";
|
|
8
|
+
|
|
9
|
+
const sleepDefault = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
|
+
const ERRORS = {
|
|
11
|
+
unauthorized: "Run a2a-exposed login, then run finish again.",
|
|
12
|
+
invalid_request: "Check the agent name, hostname and link options before running the command again.",
|
|
13
|
+
link_exists: "The link request was refused; review the approval page before changing or removing an existing link.",
|
|
14
|
+
apex_hostname: "The domain itself cannot be a secure link. Choose a hostname below it, such as wake.example.com, then run create again.",
|
|
15
|
+
outside_zone: "The hostname is not on a domain in your Cloudflare account. Choose a hostname on your domain, then run create again.",
|
|
16
|
+
existing_dns: "The hostname already has a DNS record. Choose another hostname, then run create again.",
|
|
17
|
+
existing_tunnel: "The hostname is already routed through another tunnel. Choose another hostname, then run create again.",
|
|
18
|
+
provision_failed: "Check the approval page for the failing provisioning phase and cleanup state before running the command again.",
|
|
19
|
+
preset: "Select a valid wake preset with --preset <id>, then run finish again.",
|
|
20
|
+
signing_secret: "Check the local signing-secret source and receiver configuration before running the command again.",
|
|
21
|
+
unreadable: "Reconnect Cloudflare on the website, then run finish again.",
|
|
22
|
+
unavailable: "The service is temporarily unavailable; retry later with fresh approval.",
|
|
23
|
+
};
|
|
24
|
+
const APPROVAL_ERRORS = new Set(["access_denied", "expired_token", "link_not_ready", "invalid_grant", "already_delivered", "connector_not_connected", "not_found", "cloudflare_unavailable"]);
|
|
25
|
+
|
|
26
|
+
// Only these public validation templates may cross the server-text boundary.
|
|
27
|
+
// Never echo arbitrary bodies, fetch errors, child output, or credential-bearing fields.
|
|
28
|
+
const VALIDATION_MESSAGES = new Set([
|
|
29
|
+
"Choose a wake preset.", "Choose a wake preset and try again.",
|
|
30
|
+
"Use a hostname on the selected domain.",
|
|
31
|
+
"Use a full hostname such as wake.example.com.",
|
|
32
|
+
"Remove the scheme (https://), port and path. Use a full hostname such as wake.example.com.",
|
|
33
|
+
"Enter the local origin URL, such as http://127.0.0.1:8644.",
|
|
34
|
+
"Use an http or https origin with no path or credentials.",
|
|
35
|
+
"Use a path beginning with /, with no query or fragment.",
|
|
36
|
+
]);
|
|
37
|
+
function validationDetail(body, hostname) {
|
|
38
|
+
if (typeof body?.message !== "string") return "";
|
|
39
|
+
if (VALIDATION_MESSAGES.has(body.message)) return body.message.replace(/ and try again\.$/, ".");
|
|
40
|
+
const apex = `Use a hostname under ${hostname}, such as wake.${hostname}. The domain itself is not a secure link.`;
|
|
41
|
+
return hostname && body.message === apex ? apex : "";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function failure(status, body, { command, hostname }) {
|
|
45
|
+
const code = Object.hasOwn(ERRORS, body?.error) || APPROVAL_ERRORS.has(body?.error) ? body.error : `HTTP ${status}`;
|
|
46
|
+
const detail = ["invalid_request", "apex_hostname"].includes(code) ? validationDetail(body, hostname) : "";
|
|
47
|
+
const message = detail || (Object.hasOwn(ERRORS, code) ? ERRORS[code].replace(/run finish/g, `run ${command}`) : "check the link on the website and rerun the command for fresh approval");
|
|
48
|
+
return new CliError(`link ${command} failed${hostname ? ` for ${hostname}` : ""} (${code}); ${message}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function savedLinkSession(origin, session = C.controlSession()) {
|
|
52
|
+
if (controlOrigin(session.origin) !== origin || !session.token)
|
|
53
|
+
throw new CliError("sign in to this service first: run login --control-url <service-origin>, then rerun link setup; environment sessions require both CONTROL_URL and CONTROL_TOKEN");
|
|
54
|
+
return session.token;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const CREATE_FIELDS = ["hostname", "origin", "path", "preset"];
|
|
58
|
+
|
|
59
|
+
function createPlan(o) {
|
|
60
|
+
for (const key of CREATE_FIELDS) {
|
|
61
|
+
if (typeof o[key] !== "string" || !o[key]) throw new CliError(`hosted link create requires --${key}`);
|
|
62
|
+
}
|
|
63
|
+
const hostname = o.hostname.trim().toLowerCase();
|
|
64
|
+
if (!/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(hostname))
|
|
65
|
+
throw new CliError("--hostname must be a DNS hostname on your Cloudflare domain");
|
|
66
|
+
let origin;
|
|
67
|
+
try { origin = new URL(o.origin); } catch { throw new CliError("--origin must be an http or https origin"); }
|
|
68
|
+
if (!/^https?:$/.test(origin.protocol) || origin.username || origin.password || origin.pathname !== "/" || origin.search || origin.hash)
|
|
69
|
+
throw new CliError("--origin must be an http or https origin with no path or credentials");
|
|
70
|
+
if (!o.path.startsWith("/") || o.path.startsWith("//") || /[\s?#\\\x00-\x1f\x7f]/.test(o.path) || o.path.length > 1000)
|
|
71
|
+
throw new CliError("--path must begin with / and have no query, fragment or whitespace");
|
|
72
|
+
if (!/^[a-z][a-z0-9-]{0,63}$/.test(o.preset)) throw new CliError("--preset must be a wake preset id");
|
|
73
|
+
return { hostname, origin: origin.origin, path: o.path, preset: o.preset };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function createLink(o, deps) {
|
|
77
|
+
const create = createPlan(o);
|
|
78
|
+
// Catch a wrong local route before asking the owner to create any resources.
|
|
79
|
+
validateSecretSource(o);
|
|
80
|
+
if (o["secret-from"] === "hermes" && (create.preset !== "hermes" || !/^\/webhooks\/[A-Za-z0-9_-]+$/.test(create.path) || (o.route && create.path !== `/webhooks/${o.route}`)))
|
|
81
|
+
throw new CliError("Hermes route does not match the create path and preset");
|
|
82
|
+
return setupLink(o, deps, create);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function finishLink(o, deps) {
|
|
86
|
+
if (CREATE_FIELDS.filter((key) => key !== "preset").some((key) => o[key] !== undefined)) throw new CliError("creation options require hosted link create; finish only reuses an existing link");
|
|
87
|
+
return setupLink(o, deps);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function setupLink(o, {
|
|
91
|
+
fetchImpl = fetch, sleep = sleepDefault, now = Date.now,
|
|
92
|
+
write = (line) => console.error(line), saveToken = writeTokenFile,
|
|
93
|
+
readSecret = readSigningSecret, runConnector = startConnector,
|
|
94
|
+
sessionToken = savedLinkSession,
|
|
95
|
+
} = {}, create) {
|
|
96
|
+
validateSecretSource(o);
|
|
97
|
+
const context = { command: create ? "create" : "finish", hostname: create?.hostname };
|
|
98
|
+
const agent = o.agent;
|
|
99
|
+
if (typeof agent !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,62}$/.test(agent)) throw new CliError("--agent must be an agent name (letters, digits, underscores or hyphens)");
|
|
100
|
+
if (o.preset !== undefined && (typeof o.preset !== "string" || !/^[a-z][a-z0-9-]{0,63}$/.test(o.preset))) throw new CliError("--preset must be a wake preset id");
|
|
101
|
+
const origin = controlOrigin(o["control-url"]);
|
|
102
|
+
if (!origin) throw new CliError("--control-url must be an https origin with no path");
|
|
103
|
+
const session = sessionToken(origin);
|
|
104
|
+
const request = async (endpoint, body, grant, timeout = 15000) => {
|
|
105
|
+
let response;
|
|
106
|
+
try {
|
|
107
|
+
response = await fetchImpl(`${origin}/api/link-finish/${endpoint}`, {
|
|
108
|
+
method: body === undefined ? "GET" : "POST", redirect: "error",
|
|
109
|
+
headers: { accept: "application/json", ...(body === undefined ? {} : { "content-type": "application/json" }), ...(endpoint === "requests" ? { authorization: `Bearer ${session}` } : grant ? { authorization: `Grant ${grant}` } : {}) },
|
|
110
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(timeout),
|
|
111
|
+
});
|
|
112
|
+
const text = await response.text();
|
|
113
|
+
if (text.length > 128 * 1024) throw new Error();
|
|
114
|
+
const parsed = JSON.parse(text);
|
|
115
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error();
|
|
116
|
+
return { response, body: parsed };
|
|
117
|
+
} catch { throw new CliError(`link ${context.command} request failed; check the connection and run ${context.command} again for fresh approval`); }
|
|
118
|
+
};
|
|
119
|
+
const verifier = crypto.randomBytes(32).toString("base64url");
|
|
120
|
+
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
|
|
121
|
+
const started = await request("requests", { agent, challenge, ...(create ? { create } : {}) });
|
|
122
|
+
if (started.response.status !== 201) throw failure(started.response.status, started.body, context);
|
|
123
|
+
const setup = started.body;
|
|
124
|
+
let approval;
|
|
125
|
+
try { approval = new URL(setup.approve_url); } catch { throw new CliError("invalid approval response"); }
|
|
126
|
+
if (approval.origin !== origin || approval.username || approval.password || typeof setup.request_id !== "string" || !setup.request_id || typeof setup.user_code !== "string" || !/^[A-Za-z0-9-]{4,32}$/.test(setup.user_code))
|
|
127
|
+
throw new CliError("invalid approval response");
|
|
128
|
+
write(`${setup.approve_url}\n${setup.user_code}`);
|
|
129
|
+
const deadline = now() + Math.min(600, Math.max(1, Number(setup.expires_in) || 600)) * 1000;
|
|
130
|
+
let interval = Math.min(60, Math.max(5, Number(setup.interval) || 5)), authorized;
|
|
131
|
+
for (let poll = 0; poll < 120 && now() < deadline; poll++) {
|
|
132
|
+
await sleep(Math.min(interval * 1000, deadline - now()));
|
|
133
|
+
if (now() >= deadline) break;
|
|
134
|
+
const token = await request("token", { request_id: setup.request_id, verifier });
|
|
135
|
+
if (token.response.status === 428 && token.body.error === "authorization_pending") continue;
|
|
136
|
+
if (token.response.status === 429 && token.body.error === "slow_down") { interval += 5; continue; }
|
|
137
|
+
if (!token.response.ok) throw failure(token.response.status, token.body, context);
|
|
138
|
+
authorized = token.body; break;
|
|
139
|
+
}
|
|
140
|
+
if (!authorized) throw failure(410, { error: "expired_token" }, context);
|
|
141
|
+
const { grant, link } = authorized;
|
|
142
|
+
if (typeof grant !== "string" || !/^[\x21-\x7e]+$/.test(grant) || grant.length > 4096 || link?.agent !== agent || typeof link.path !== "string" || !link.path.startsWith("/") || typeof link.preset !== "string" || typeof link.hostname !== "string" || typeof link.origin !== "string")
|
|
143
|
+
throw new CliError("invalid approved link response");
|
|
144
|
+
if (create && CREATE_FIELDS.some((key) => link[key] !== create[key]))
|
|
145
|
+
throw new CliError("approved link differs from the requested creation plan; no connector or signing secret was transferred");
|
|
146
|
+
const preset = o.preset ?? link.preset;
|
|
147
|
+
if (!/^[a-z][a-z0-9-]{0,63}$/.test(preset)) throw new CliError("approved link has no valid preset; rerun finish with --preset <id> before requesting fresh approval");
|
|
148
|
+
// Resolve and validate the source before spending the one-time connector delivery.
|
|
149
|
+
const secret = readSecret(o, { ...link, preset });
|
|
150
|
+
const connector = await request("connector", {}, grant);
|
|
151
|
+
if (!connector.response.ok) throw failure(connector.response.status, connector.body, context);
|
|
152
|
+
if (typeof connector.body.connector_token !== "string" || !connector.body.connector_token.trim() || /\s/.test(connector.body.connector_token)) throw new CliError("invalid connector response; run finish again for fresh approval");
|
|
153
|
+
const tokenFile = path.resolve(o["token-file"] || path.join(os.homedir(), ".a2a-exposed", "links", `${agent}.token`));
|
|
154
|
+
saveToken(tokenFile, connector.body.connector_token);
|
|
155
|
+
if (o["no-run"]) {
|
|
156
|
+
write(connectorCommand(tokenFile));
|
|
157
|
+
return { saved: false, connectorStarted: false };
|
|
158
|
+
}
|
|
159
|
+
await runConnector(tokenFile);
|
|
160
|
+
let connected = false;
|
|
161
|
+
const connectDeadline = now() + 60000;
|
|
162
|
+
for (let poll = 0; poll < 20 && now() < connectDeadline; poll++) {
|
|
163
|
+
const status = await request("status", undefined, grant, Math.min(15000, connectDeadline - now()));
|
|
164
|
+
if (now() >= connectDeadline) break;
|
|
165
|
+
if (!status.response.ok) throw failure(status.response.status, status.body, context);
|
|
166
|
+
if (status.body.connector === "connected" && Number.isInteger(status.body.connections) && status.body.connections > 0) { connected = true; break; }
|
|
167
|
+
await sleep(Math.min(3000, Math.max(0, connectDeadline - now())));
|
|
168
|
+
}
|
|
169
|
+
if (!connected) throw new CliError("connector did not report connected within 60 seconds; check cloudflared and run finish again for fresh approval");
|
|
170
|
+
const tested = await request("wake-test", { signing_secret: secret, preset }, grant);
|
|
171
|
+
if (!tested.response.ok) throw failure(tested.response.status, tested.body, context);
|
|
172
|
+
if (tested.body.result !== "ok" || tested.body.saved !== true) {
|
|
173
|
+
const result = (["dns-missing", "access-401", "access-403", "timeout"].includes(tested.body.result) || /^origin-\d{3}$/.test(tested.body.result)) ? tested.body.result : "unknown";
|
|
174
|
+
throw new CliError(`wake test did not save settings (${result}); fix the receiver and run finish again for fresh approval`);
|
|
175
|
+
}
|
|
176
|
+
write("Connector connected. Wake test passed and settings saved.");
|
|
177
|
+
return { saved: true, connectorStarted: true };
|
|
178
|
+
}
|