@rubytech/create-realagent 1.0.619 → 1.0.620
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/platform/lib/mcp-stderr-tee/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/mcp-stderr-tee/dist/index.js +11 -5
- package/payload/platform/lib/mcp-stderr-tee/dist/index.js.map +1 -1
- package/payload/platform/lib/mcp-stderr-tee/src/index.ts +10 -5
- package/payload/platform/plugins/admin/skills/onboarding/SKILL.md +11 -7
- package/payload/platform/plugins/cloudflare/PLUGIN.md +10 -13
- package/payload/platform/plugins/cloudflare/mcp/dist/index.js +94 -1030
- package/payload/platform/plugins/cloudflare/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts +62 -258
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts.map +1 -1
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.js +297 -882
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.js.map +1 -1
- package/payload/platform/plugins/cloudflare/mcp/package.json +3 -7
- package/payload/platform/plugins/cloudflare/references/setup-guide.md +51 -77
- package/payload/platform/plugins/cloudflare/skills/setup-tunnel/SKILL.md +23 -81
- package/payload/platform/plugins/docs/PLUGIN.md +1 -1
- package/payload/platform/plugins/docs/references/cloudflare.md +21 -30
- package/payload/platform/templates/specialists/agents/personal-assistant.md +9 -9
- package/payload/server/server.js +44 -9
- package/payload/platform/plugins/cloudflare/mcp/__tests__/auth-binding.test.ts +0 -195
|
@@ -3,21 +3,21 @@ initStderrTee("cloudflare");
|
|
|
3
3
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
-
import {
|
|
7
|
-
import { hostname, homedir } from "node:os";
|
|
8
|
-
import { existsSync } from "node:fs";
|
|
6
|
+
import { Resolver } from "node:dns/promises";
|
|
9
7
|
import * as cloudflared from "./lib/cloudflared.js";
|
|
10
8
|
const server = new McpServer({
|
|
11
9
|
name: "cloudflare",
|
|
12
|
-
version: "0.
|
|
10
|
+
version: "0.3.0",
|
|
13
11
|
});
|
|
14
12
|
// ===================================================================
|
|
15
|
-
// Authentication — cert.pem only
|
|
13
|
+
// Authentication — OAuth cert.pem only
|
|
16
14
|
//
|
|
17
|
-
// The plugin recognises exactly one
|
|
18
|
-
// cert.pem was bound to via
|
|
19
|
-
//
|
|
20
|
-
//
|
|
15
|
+
// The plugin recognises exactly one identity: the Cloudflare account
|
|
16
|
+
// cert.pem was bound to via `cloudflared tunnel login`. No API token
|
|
17
|
+
// handling, no SDK, no read of account state by any code path. The
|
|
18
|
+
// operator's logged-in Cloudflare dashboard session is the source of
|
|
19
|
+
// truth for which domains exist and who owns them; the agent's role
|
|
20
|
+
// is to instruct the operator where to click.
|
|
21
21
|
// ===================================================================
|
|
22
22
|
server.tool("tunnel-login", "Authenticate with Cloudflare via `cloudflared tunnel login`. Three phases: (1) if cert.pem exists and matches the recorded account binding, reports status; (2) if cert.pem exists but no binding has been recorded, materializes the binding from the cert (migration path); (3) if no cert.pem, spawns login process and returns the auth URL. Detects existing login processes — safe to call repeatedly without spawning duplicates. Pass force=true to delete cert.pem (both brand-specific and legacy paths) and account-binding.json before re-authenticating — required when switching Cloudflare accounts.", {
|
|
23
23
|
force: z
|
|
@@ -26,26 +26,22 @@ server.tool("tunnel-login", "Authenticate with Cloudflare via `cloudflared tunne
|
|
|
26
26
|
.describe("Delete cert.pem (brand path + legacy ~/.cloudflared/cert.pem) and account-binding.json before re-authenticating. Required when switching Cloudflare accounts (e.g. after the prior account was scrubbed or the cert was rotated under a different account)."),
|
|
27
27
|
}, async ({ force }) => {
|
|
28
28
|
try {
|
|
29
|
-
// Force: delete cert.pem (both paths) and binding before restarting
|
|
30
29
|
if (force) {
|
|
31
30
|
console.error("[tunnel-login] force=true — clearing cert.pem and account binding before re-auth");
|
|
32
31
|
cloudflared.resetAuth();
|
|
33
32
|
}
|
|
34
33
|
const auth = cloudflared.validateAuth();
|
|
35
|
-
// Phase: Already bound — cert + binding present and accountIds agree
|
|
36
34
|
if (auth.bound) {
|
|
37
35
|
console.error(`[tunnel-login] short-circuit: cert and binding agree on account ${auth.boundAccountId}`);
|
|
38
36
|
return {
|
|
39
37
|
content: [
|
|
40
38
|
{
|
|
41
39
|
type: "text",
|
|
42
|
-
text: `Already
|
|
40
|
+
text: `Already signed into Cloudflare. If this is not the right account, ask me to run tunnel-login again with force=true — that will sign this laptop out so you can sign in with the correct account.`,
|
|
43
41
|
},
|
|
44
42
|
],
|
|
45
43
|
};
|
|
46
44
|
}
|
|
47
|
-
// Phase: cert.pem exists — derive credentials and either materialize
|
|
48
|
-
// or reconcile the binding.
|
|
49
45
|
if (auth.hasCert) {
|
|
50
46
|
const creds = cloudflared.parseCertPem();
|
|
51
47
|
if (!creds) {
|
|
@@ -53,53 +49,46 @@ server.tool("tunnel-login", "Authenticate with Cloudflare via `cloudflared tunne
|
|
|
53
49
|
content: [
|
|
54
50
|
{
|
|
55
51
|
type: "text",
|
|
56
|
-
text: `
|
|
52
|
+
text: `Sign-in file exists but its contents are unreadable. Ask me to run tunnel-login with force=true so the file is cleared and a fresh sign-in can start.`,
|
|
57
53
|
},
|
|
58
54
|
],
|
|
59
55
|
isError: true,
|
|
60
56
|
};
|
|
61
57
|
}
|
|
62
|
-
// Migration path: cert exists, no binding yet — silently materialize
|
|
63
|
-
// (the operator already trusted this cert by completing the prior
|
|
64
|
-
// OAuth flow; we don't re-confirm).
|
|
65
58
|
if (!auth.hasBinding) {
|
|
66
59
|
const binding = cloudflared.materializeBinding("migration");
|
|
67
60
|
return {
|
|
68
61
|
content: [
|
|
69
62
|
{
|
|
70
63
|
type: "text",
|
|
71
|
-
text: `
|
|
64
|
+
text: `Sign-in recorded from the existing Cloudflare session on this laptop.\n\n Bound at: ${binding.boundAt}\n\nReady to create a tunnel.`,
|
|
72
65
|
},
|
|
73
66
|
],
|
|
74
67
|
};
|
|
75
68
|
}
|
|
76
|
-
// Drift: cert says account X, binding says account Y — refuse with
|
|
77
|
-
// recovery instruction (force=true will clear both).
|
|
78
69
|
if (auth.certAccountId !== auth.boundAccountId) {
|
|
79
70
|
return {
|
|
80
71
|
content: [
|
|
81
72
|
{
|
|
82
73
|
type: "text",
|
|
83
|
-
text: `
|
|
74
|
+
text: `The Cloudflare sign-in on this laptop has changed since it was first set up. ${cloudflared.recoveryMessage()}`,
|
|
84
75
|
},
|
|
85
76
|
],
|
|
86
77
|
isError: true,
|
|
87
78
|
};
|
|
88
79
|
}
|
|
89
80
|
}
|
|
90
|
-
// Phase: Login process already running — wait for authorization
|
|
91
81
|
const activeLogin = cloudflared.getActiveLogin();
|
|
92
82
|
if (activeLogin) {
|
|
93
83
|
return {
|
|
94
84
|
content: [
|
|
95
85
|
{
|
|
96
86
|
type: "text",
|
|
97
|
-
text: `
|
|
87
|
+
text: `Sign-in in progress — a browser window is already open (started ${new Date(activeLogin.startedAt).toISOString()}).\n\nSign-in URL: ${activeLogin.authUrl}\n\nAsk the user to finish the sign-in in their browser and click Authorize. Call tunnel-login again once the user confirms — it will detect the completed sign-in automatically.`,
|
|
98
88
|
},
|
|
99
89
|
],
|
|
100
90
|
};
|
|
101
91
|
}
|
|
102
|
-
// Phase: No cert.pem, no active login — spawn cloudflared tunnel login
|
|
103
92
|
if (!cloudflared.isInstalled()) {
|
|
104
93
|
return {
|
|
105
94
|
content: [
|
|
@@ -113,13 +102,13 @@ server.tool("tunnel-login", "Authenticate with Cloudflare via `cloudflared tunne
|
|
|
113
102
|
}
|
|
114
103
|
const result = await cloudflared.tunnelLogin();
|
|
115
104
|
const alreadyNote = result.alreadyRunning
|
|
116
|
-
? " (reusing existing
|
|
105
|
+
? " (reusing existing sign-in window)"
|
|
117
106
|
: "";
|
|
118
107
|
return {
|
|
119
108
|
content: [
|
|
120
109
|
{
|
|
121
110
|
type: "text",
|
|
122
|
-
text: `Cloudflare
|
|
111
|
+
text: `Cloudflare sign-in started${alreadyNote}. Open this URL to authorize:\n\n${result.authUrl}\n\nAsk the user to open the URL, pick the Cloudflare account they want this laptop signed into, and click Authorize. Call tunnel-login again once they confirm — it will detect the completed sign-in automatically.`,
|
|
123
112
|
},
|
|
124
113
|
],
|
|
125
114
|
};
|
|
@@ -129,134 +118,46 @@ server.tool("tunnel-login", "Authenticate with Cloudflare via `cloudflared tunne
|
|
|
129
118
|
content: [
|
|
130
119
|
{
|
|
131
120
|
type: "text",
|
|
132
|
-
text: `
|
|
121
|
+
text: `Sign-in failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
133
122
|
},
|
|
134
123
|
],
|
|
135
124
|
isError: true,
|
|
136
125
|
};
|
|
137
126
|
}
|
|
138
127
|
});
|
|
139
|
-
server.tool("
|
|
140
|
-
try {
|
|
141
|
-
const zones = await cloudflared.listZones();
|
|
142
|
-
if (zones.length === 0) {
|
|
143
|
-
return {
|
|
144
|
-
content: [
|
|
145
|
-
{
|
|
146
|
-
type: "text",
|
|
147
|
-
text: "No zones found on this account. Add a domain to Cloudflare first.",
|
|
148
|
-
},
|
|
149
|
-
],
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
const lines = zones.map((z) => ` ${z.name} — ${z.status}${z.nameservers.length > 0 ? `\n Nameservers: ${z.nameservers.join(", ")}` : ""}`);
|
|
153
|
-
return {
|
|
154
|
-
content: [
|
|
155
|
-
{
|
|
156
|
-
type: "text",
|
|
157
|
-
text: `Zones on this account:\n\n${lines.join("\n\n")}`,
|
|
158
|
-
},
|
|
159
|
-
],
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
catch (err) {
|
|
163
|
-
return {
|
|
164
|
-
content: [
|
|
165
|
-
{
|
|
166
|
-
type: "text",
|
|
167
|
-
text: `Failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
168
|
-
},
|
|
169
|
-
],
|
|
170
|
-
isError: true,
|
|
171
|
-
};
|
|
172
|
-
}
|
|
173
|
-
});
|
|
174
|
-
server.tool("cf-add-zone", "Add a domain to the bound Cloudflare account via the official API. Idempotent: returns existing zone info if already on the account. Requires a successful tunnel-login (cert.pem + account-binding.json present and matching). The bound account must have permission to create zones; if not, the operator must add the zone via the Cloudflare dashboard.", {
|
|
175
|
-
domain: z
|
|
176
|
-
.string()
|
|
177
|
-
.describe("Registrable domain to add to the bound Cloudflare account (e.g. 'mybusiness.com')."),
|
|
178
|
-
}, async ({ domain }) => {
|
|
179
|
-
try {
|
|
180
|
-
const start = Date.now();
|
|
181
|
-
const result = await cloudflared.createZone(domain);
|
|
182
|
-
const elapsed = Date.now() - start;
|
|
183
|
-
const action = result.existing ? "already on this account" : "added";
|
|
184
|
-
const nsLines = result.nameservers.length > 0
|
|
185
|
-
? `\n Nameservers: ${result.nameservers.join(", ")}`
|
|
186
|
-
: "";
|
|
187
|
-
console.error(`[cf-add-zone] ${domain} ${action} (${result.status}) in ${elapsed}ms`);
|
|
188
|
-
return {
|
|
189
|
-
content: [
|
|
190
|
-
{
|
|
191
|
-
type: "text",
|
|
192
|
-
text: `Domain ${action}.\n\n Domain: ${result.name}\n Status: ${result.status}${nsLines}\n\n${result.status === "pending"
|
|
193
|
-
? "The domain is pending activation — update the nameservers at your registrar to the Cloudflare-assigned nameservers shown above. Once nameservers propagate (minutes to 24 hours), the status changes to Active."
|
|
194
|
-
: result.status === "active"
|
|
195
|
-
? "The domain is active on Cloudflare."
|
|
196
|
-
: `Current status: ${result.status}.`}`,
|
|
197
|
-
},
|
|
198
|
-
],
|
|
199
|
-
};
|
|
200
|
-
}
|
|
201
|
-
catch (err) {
|
|
202
|
-
// Refusal-shaped errors thrown by getClient() carry structured detail —
|
|
203
|
-
// surface them as agent-facing refusals with the same single recovery path.
|
|
204
|
-
if (err instanceof cloudflared.CloudflareRefusalError) {
|
|
205
|
-
return {
|
|
206
|
-
content: [{ type: "text", text: err.refusal.message }],
|
|
207
|
-
isError: true,
|
|
208
|
-
};
|
|
209
|
-
}
|
|
210
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
211
|
-
console.error(`[cf-add-zone] failed for ${domain}: ${msg}`);
|
|
212
|
-
return {
|
|
213
|
-
content: [
|
|
214
|
-
{
|
|
215
|
-
type: "text",
|
|
216
|
-
text: `Zone creation failed: ${msg}\n\n` +
|
|
217
|
-
`If this looks like a permissions error, the cert.pem account may not own ` +
|
|
218
|
-
`this brand's zones. ${cloudflared.recoveryMessage()}`,
|
|
219
|
-
},
|
|
220
|
-
],
|
|
221
|
-
isError: true,
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
});
|
|
225
|
-
// ===================================================================
|
|
226
|
-
// Cloudflare Tunnel tools
|
|
227
|
-
// ===================================================================
|
|
228
|
-
server.tool("tunnel-status", "Full Cloudflare Tunnel status: installed, version, token valid, running, domain, hostnames.", {
|
|
128
|
+
server.tool("tunnel-status", "End-to-end Cloudflare Tunnel status. Reads the tunnel process state, parses configured hostnames from config.yml, and probes each hostname (DNS + HTTPS via Cloudflare's edge) to confirm traffic actually reaches this laptop from the internet. `healthy: true` requires every configured hostname to probe `ok`. `boundAccountOwnsHostnames: false` means the laptop is running a tunnel that nothing on the internet can reach — almost always because it is signed into a Cloudflare account that does not own the domain.", {
|
|
229
129
|
domain: z
|
|
230
130
|
.string()
|
|
231
131
|
.optional()
|
|
232
|
-
.describe("The bare domain (e.g. 'maxy.bot'). If omitted,
|
|
132
|
+
.describe("The bare domain (e.g. 'maxy.bot'). If omitted, derived from persisted state."),
|
|
233
133
|
}, async ({ domain }) => {
|
|
234
134
|
try {
|
|
235
135
|
const status = await cloudflared.getStatus(domain);
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
136
|
+
let guidance = "";
|
|
137
|
+
if (status.unhealthyReason === "bound-account-does-not-own-hostname") {
|
|
138
|
+
guidance =
|
|
139
|
+
`\n\nThe tunnel is running on this laptop but nothing from the internet is reaching it. ` +
|
|
140
|
+
`The Cloudflare account this laptop is signed into does not own the domain you are trying to use. ` +
|
|
141
|
+
`${cloudflared.DASHBOARD_SWITCH_ACCOUNTS_INSTRUCTION}`;
|
|
142
|
+
}
|
|
143
|
+
else if (status.unhealthyReason === "hostname-probes-failed") {
|
|
144
|
+
const failures = status.probes
|
|
145
|
+
.filter((p) => p.failureMode !== "ok")
|
|
146
|
+
.map((p) => ` ${p.hostname}: ${p.failureMode}`)
|
|
147
|
+
.join("\n");
|
|
148
|
+
guidance = `\n\nOne or more addresses are not reachable through Cloudflare:\n${failures}\n\nIf the tunnel was just created, wait 1-5 minutes for DNS to propagate. Otherwise ask the user to open Cloudflare in their browser and check that the relevant records exist.`;
|
|
149
|
+
}
|
|
150
|
+
else if (status.unhealthyReason === "not-running") {
|
|
151
|
+
guidance = `\n\nThe tunnel process is not running. Ask the user if they want me to enable it.`;
|
|
152
|
+
}
|
|
153
|
+
else if (status.unhealthyReason === "no-tunnel-configured") {
|
|
154
|
+
guidance = `\n\nNo tunnel is configured on this laptop yet. Run tunnel-create to set one up.`;
|
|
254
155
|
}
|
|
255
156
|
return {
|
|
256
157
|
content: [
|
|
257
158
|
{
|
|
258
159
|
type: "text",
|
|
259
|
-
text: JSON.stringify(status, null, 2) +
|
|
160
|
+
text: JSON.stringify(status, null, 2) + guidance,
|
|
260
161
|
},
|
|
261
162
|
],
|
|
262
163
|
};
|
|
@@ -317,7 +218,7 @@ server.tool("tunnel-install", "Install the cloudflared binary if not already pre
|
|
|
317
218
|
};
|
|
318
219
|
}
|
|
319
220
|
});
|
|
320
|
-
server.tool("tunnel-create", "Create a Cloudflare Tunnel and route DNS for the specified subdomains. The admin subdomain is required (e.g. 'admin' → admin.{domain}, 'joel' → joel.{domain}). The public subdomain is optional — omit to skip the public endpoint.
|
|
221
|
+
server.tool("tunnel-create", "Create a Cloudflare Tunnel and route DNS for the specified subdomains. The admin subdomain is required (e.g. 'admin' → admin.{domain}, 'joel' → joel.{domain}). The public subdomain is optional — omit to skip the public endpoint. Shells out to `cloudflared` which uses the signed-in cert.pem; refuses when the configured hostname's zone parent is not on Cloudflare. Idempotent — reuses existing tunnel if name matches.", {
|
|
321
222
|
domain: z
|
|
322
223
|
.string()
|
|
323
224
|
.describe("The bare domain (e.g. 'maxy.bot')."),
|
|
@@ -335,7 +236,6 @@ server.tool("tunnel-create", "Create a Cloudflare Tunnel and route DNS for the s
|
|
|
335
236
|
.optional()
|
|
336
237
|
.describe("Human-readable tunnel name (default: derived from domain)"),
|
|
337
238
|
}, async ({ domain, adminSubdomain, publicSubdomain, tunnelName }) => {
|
|
338
|
-
// Validate: admin and public subdomains must differ
|
|
339
239
|
if (publicSubdomain && adminSubdomain === publicSubdomain) {
|
|
340
240
|
return {
|
|
341
241
|
content: [
|
|
@@ -348,35 +248,27 @@ server.tool("tunnel-create", "Create a Cloudflare Tunnel and route DNS for the s
|
|
|
348
248
|
};
|
|
349
249
|
}
|
|
350
250
|
try {
|
|
251
|
+
const auth = cloudflared.validateAuth();
|
|
252
|
+
if (!auth.bound) {
|
|
253
|
+
return {
|
|
254
|
+
content: [
|
|
255
|
+
{
|
|
256
|
+
type: "text",
|
|
257
|
+
text: `REFUSED: this laptop is not signed into Cloudflare yet. ${cloudflared.recoveryMessage()}`,
|
|
258
|
+
},
|
|
259
|
+
],
|
|
260
|
+
isError: true,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
351
263
|
const name = tunnelName ?? domain.replace(/\./g, "-");
|
|
352
264
|
const platformPort = parseInt(process.env.PLATFORM_PORT ?? "19200", 10);
|
|
353
|
-
// Build hostname list from subdomains
|
|
354
265
|
const adminHostname = `${adminSubdomain}.${domain}`;
|
|
355
266
|
const publicHostname = publicSubdomain ? `${publicSubdomain}.${domain}` : null;
|
|
356
267
|
const hostnames = publicHostname ? [adminHostname, publicHostname] : [adminHostname];
|
|
357
268
|
console.error(`[tunnel-create] hostnames=${JSON.stringify(hostnames)} domain=${domain}`);
|
|
358
|
-
// Live account-zone check happens inside routeDnsCli (Step 4 below).
|
|
359
|
-
// getClient() inside routeDnsCli enforces auth pre-conditions
|
|
360
|
-
// (cert + binding + accountId match).
|
|
361
269
|
// Step 1: Create tunnel via CLI (idempotent — reuses if name exists)
|
|
362
270
|
const tunnel = cloudflared.createTunnelCli(name);
|
|
363
|
-
// Step 2:
|
|
364
|
-
for (const h of hostnames) {
|
|
365
|
-
const check = await cloudflared.checkDnsCollision(h, tunnel.tunnelId);
|
|
366
|
-
if (check.collision) {
|
|
367
|
-
return {
|
|
368
|
-
content: [
|
|
369
|
-
{
|
|
370
|
-
type: "text",
|
|
371
|
-
text: `DNS collision: ${h} already points to a different tunnel (${check.existingTunnelId}). ` +
|
|
372
|
-
`This hostname belongs to another device. Choose a different subdomain or remove the existing DNS record first.`,
|
|
373
|
-
},
|
|
374
|
-
],
|
|
375
|
-
isError: true,
|
|
376
|
-
};
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
// Step 3: Write local config.yml with ingress rules
|
|
271
|
+
// Step 2: Write local config.yml with ingress rules
|
|
380
272
|
const configPath = cloudflared.writeLocalConfig(tunnel.tunnelId, tunnel.credentialsPath, hostnames, platformPort);
|
|
381
273
|
// Persist tunnel identity with actual hostnames
|
|
382
274
|
cloudflared.saveTunnelIdentity({
|
|
@@ -388,21 +280,19 @@ server.tool("tunnel-create", "Create a Cloudflare Tunnel and route DNS for the s
|
|
|
388
280
|
adminHostname,
|
|
389
281
|
publicHostname,
|
|
390
282
|
});
|
|
391
|
-
// Step
|
|
283
|
+
// Step 3: Route DNS via CLI for each hostname (pre-flight + post-flight in routeDnsCli)
|
|
392
284
|
const dnsResults = [];
|
|
393
285
|
for (const h of hostnames) {
|
|
394
286
|
const route = await cloudflared.routeDnsCli(tunnel.tunnelId, h);
|
|
395
287
|
dnsResults.push({
|
|
396
288
|
hostname: h,
|
|
397
289
|
fqdn: route.fqdn,
|
|
398
|
-
zone: route.zone,
|
|
399
290
|
note: route.created ? "created" : "already existed",
|
|
400
291
|
});
|
|
401
292
|
}
|
|
402
|
-
// Step
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
resolver.setServers(["8.8.8.8", "1.1.1.1"]);
|
|
293
|
+
// Step 4: Verify hostnames resolve via public DNS
|
|
294
|
+
const resolver = new Resolver();
|
|
295
|
+
resolver.setServers(["1.1.1.1", "8.8.8.8"]);
|
|
406
296
|
const dnsWarnings = [];
|
|
407
297
|
for (const h of hostnames) {
|
|
408
298
|
try {
|
|
@@ -414,16 +304,16 @@ server.tool("tunnel-create", "Create a Cloudflare Tunnel and route DNS for the s
|
|
|
414
304
|
}
|
|
415
305
|
let dnsNote = "";
|
|
416
306
|
if (dnsWarnings.length > 0) {
|
|
417
|
-
dnsNote = `\n\nDNS WARNING: ${dnsWarnings.join(", ")} did not resolve via
|
|
307
|
+
dnsNote = `\n\nDNS WARNING: ${dnsWarnings.join(", ")} did not resolve via public DNS yet. DNS propagation typically takes 1-5 minutes. Re-run tunnel-status in a few minutes to confirm.`;
|
|
418
308
|
}
|
|
419
309
|
const dnsLines = dnsResults
|
|
420
|
-
.map((r) => `
|
|
310
|
+
.map((r) => ` ${r.hostname} → this tunnel (${r.note})`)
|
|
421
311
|
.join("\n");
|
|
422
312
|
return {
|
|
423
313
|
content: [
|
|
424
314
|
{
|
|
425
315
|
type: "text",
|
|
426
|
-
text: `Tunnel created.\n\n
|
|
316
|
+
text: `Tunnel created.\n\n Name: ${tunnel.tunnelName}\n${dnsLines}\n Config: ${configPath}\n\nUse tunnel-enable to start the tunnel.${dnsNote}`,
|
|
427
317
|
},
|
|
428
318
|
],
|
|
429
319
|
};
|
|
@@ -451,7 +341,6 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
|
|
|
451
341
|
domain: z.string().optional().describe("The bare domain (e.g. 'maxy.bot'). If omitted, reads from persisted tunnel state."),
|
|
452
342
|
}, async ({ tunnelId: tunnelIdParam, domain: domainParam }) => {
|
|
453
343
|
try {
|
|
454
|
-
// Resolve tunnelId and domain from params or persisted state
|
|
455
344
|
const tunnelId = tunnelIdParam ?? cloudflared.getPersistedTunnelId();
|
|
456
345
|
const domain = domainParam ?? cloudflared.getPersistedDomain();
|
|
457
346
|
if (!tunnelId) {
|
|
@@ -459,7 +348,7 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
|
|
|
459
348
|
content: [
|
|
460
349
|
{
|
|
461
350
|
type: "text",
|
|
462
|
-
text: "No tunnel
|
|
351
|
+
text: "No tunnel found on this laptop. Run tunnel-create first.",
|
|
463
352
|
},
|
|
464
353
|
],
|
|
465
354
|
isError: true,
|
|
@@ -470,30 +359,28 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
|
|
|
470
359
|
content: [
|
|
471
360
|
{
|
|
472
361
|
type: "text",
|
|
473
|
-
text: "No domain
|
|
362
|
+
text: "No domain recorded for this tunnel. Run tunnel-create first to set one up with a domain.",
|
|
474
363
|
},
|
|
475
364
|
],
|
|
476
365
|
isError: true,
|
|
477
366
|
};
|
|
478
367
|
}
|
|
479
|
-
// Read actual hostnames from persisted state (backward compat: derives from domain)
|
|
480
368
|
const hostnames = cloudflared.getPersistedHostnames();
|
|
481
369
|
if (hostnames.length === 0) {
|
|
482
370
|
return {
|
|
483
371
|
content: [
|
|
484
372
|
{
|
|
485
373
|
type: "text",
|
|
486
|
-
text: "No
|
|
374
|
+
text: "No addresses recorded for this tunnel. Run tunnel-create first.",
|
|
487
375
|
},
|
|
488
376
|
],
|
|
489
377
|
isError: true,
|
|
490
378
|
};
|
|
491
379
|
}
|
|
492
380
|
console.error(`[tunnel-enable] checking hostnames=${JSON.stringify(hostnames)}`);
|
|
493
|
-
// GATE 1: Check if actual hostnames resolve
|
|
494
|
-
const
|
|
495
|
-
|
|
496
|
-
resolver.setServers(["8.8.8.8", "1.1.1.1"]);
|
|
381
|
+
// GATE 1: Check if actual hostnames resolve via public DNS
|
|
382
|
+
const resolver = new Resolver();
|
|
383
|
+
resolver.setServers(["1.1.1.1", "8.8.8.8"]);
|
|
497
384
|
const unresolvedSubs = [];
|
|
498
385
|
for (const h of hostnames) {
|
|
499
386
|
try {
|
|
@@ -508,7 +395,7 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
|
|
|
508
395
|
content: [
|
|
509
396
|
{
|
|
510
397
|
type: "text",
|
|
511
|
-
text: `REFUSED: ${unresolvedSubs.join(", ")} do not resolve
|
|
398
|
+
text: `REFUSED: ${unresolvedSubs.join(", ")} do not resolve via public DNS. The tunnel's URLs will not work.\n\nUsually this means the DNS records were never created or are still propagating. Run tunnel-create first. If that has already run, wait 1-5 minutes for propagation or ask the user to confirm in the Cloudflare dashboard that the addresses exist on the correct domain.\n\nDo not call tunnel-enable until the addresses resolve.`,
|
|
512
399
|
},
|
|
513
400
|
],
|
|
514
401
|
isError: true,
|
|
@@ -517,7 +404,7 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
|
|
|
517
404
|
const brand = cloudflared.loadBrand();
|
|
518
405
|
const platformPort = parseInt(process.env.PLATFORM_PORT ?? "19200", 10);
|
|
519
406
|
console.error(`[tunnel-enable] using PLATFORM_PORT=${platformPort}`);
|
|
520
|
-
// GATE 2: Remote auth must be configured
|
|
407
|
+
// GATE 2: Remote auth must be configured
|
|
521
408
|
try {
|
|
522
409
|
const res = await fetch(`http://127.0.0.1:${platformPort}/api/remote-auth/status`, {
|
|
523
410
|
signal: AbortSignal.timeout(5000),
|
|
@@ -546,23 +433,20 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
|
|
|
546
433
|
isError: true,
|
|
547
434
|
};
|
|
548
435
|
}
|
|
549
|
-
// GATE 3:
|
|
550
|
-
// We use validateAuth (non-throwing) so we can produce a uniform
|
|
551
|
-
// refusal message regardless of which condition failed.
|
|
436
|
+
// GATE 3: cert.pem + binding
|
|
552
437
|
const enableAuth = cloudflared.validateAuth();
|
|
553
438
|
if (!enableAuth.bound) {
|
|
554
|
-
const missing = !enableAuth.hasCert ? "cert.pem" : !enableAuth.hasBinding ? "account binding" : "matching cert/binding";
|
|
555
439
|
return {
|
|
556
440
|
content: [
|
|
557
441
|
{
|
|
558
442
|
type: "text",
|
|
559
|
-
text: `REFUSED: Cannot start tunnel —
|
|
443
|
+
text: `REFUSED: Cannot start tunnel — this laptop is not signed into Cloudflare or the sign-in has drifted. ${cloudflared.recoveryMessage()}`,
|
|
560
444
|
},
|
|
561
445
|
],
|
|
562
446
|
isError: true,
|
|
563
447
|
};
|
|
564
448
|
}
|
|
565
|
-
// GATE 4: config.yml must exist
|
|
449
|
+
// GATE 4: config.yml must exist
|
|
566
450
|
const tunnelState = cloudflared.getPersistedState();
|
|
567
451
|
const configPath = tunnelState?.configPath;
|
|
568
452
|
const credentialsPath = tunnelState?.credentialsPath;
|
|
@@ -578,7 +462,6 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
|
|
|
578
462
|
isError: true,
|
|
579
463
|
};
|
|
580
464
|
}
|
|
581
|
-
// Start the tunnel daemon — config.yml + cert.pem, deterministic
|
|
582
465
|
cloudflared.startTunnel({
|
|
583
466
|
tunnelId,
|
|
584
467
|
tunnelName,
|
|
@@ -586,7 +469,6 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
|
|
|
586
469
|
configPath,
|
|
587
470
|
credentialsPath: credentialsPath ?? "",
|
|
588
471
|
});
|
|
589
|
-
// Wait up to 5 seconds to verify the process didn't immediately crash
|
|
590
472
|
const logHint = `~/${brand.configDir}/logs/cloudflared.log`;
|
|
591
473
|
for (let i = 0; i < 10; i++) {
|
|
592
474
|
await new Promise((r) => setTimeout(r, 500));
|
|
@@ -604,9 +486,7 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
|
|
|
604
486
|
}
|
|
605
487
|
}
|
|
606
488
|
const status = await cloudflared.getStatus(domain);
|
|
607
|
-
|
|
608
|
-
// via Cloudflare's edge. If public URL exists, check that too.
|
|
609
|
-
const verifyUrl = `https://${hostnames[0]}`; // admin hostname is always first
|
|
489
|
+
const verifyUrl = `https://${hostnames[0]}`;
|
|
610
490
|
let verified = false;
|
|
611
491
|
console.error(`[tunnel-enable] verifying url=${verifyUrl}`);
|
|
612
492
|
for (let attempt = 0; attempt < 6; attempt++) {
|
|
@@ -632,20 +512,19 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
|
|
|
632
512
|
content: [
|
|
633
513
|
{
|
|
634
514
|
type: "text",
|
|
635
|
-
text: `Tunnel process is running (PID ${status.pid}) but ${verifyUrl} returned
|
|
515
|
+
text: `Tunnel process is running (PID ${status.pid}) but ${verifyUrl} returned 530 after 30s of retries. Cloudflare's edge cannot reach this tunnel.\n\nCommon causes:\n 1. Stale DNS records pointing to a previous tunnel — run tunnel-create again to fix\n 2. UDP buffer too small for QUIC — check ${logHint}\n 3. Firewall blocking outbound QUIC to Cloudflare's edge\n\nThe tunnel is NOT working. Do not report success.`,
|
|
636
516
|
},
|
|
637
517
|
],
|
|
638
518
|
isError: true,
|
|
639
519
|
};
|
|
640
520
|
}
|
|
641
|
-
// Build URL list for output
|
|
642
521
|
const urlLines = hostnames.map((h, i) => {
|
|
643
522
|
const label = i === 0 ? "Admin" : "Public";
|
|
644
523
|
return ` ${label}: https://${h}`;
|
|
645
524
|
}).join("\n");
|
|
646
525
|
const reachableNote = hostnames.length > 1
|
|
647
|
-
? "All URLs are reachable through Cloudflare
|
|
648
|
-
: "The admin URL is reachable through Cloudflare
|
|
526
|
+
? "All URLs are reachable through Cloudflare."
|
|
527
|
+
: "The admin URL is reachable through Cloudflare.";
|
|
649
528
|
return {
|
|
650
529
|
content: [
|
|
651
530
|
{
|
|
@@ -697,40 +576,25 @@ server.tool("tunnel-disable", "Stop the Cloudflare Tunnel process. Config is pre
|
|
|
697
576
|
};
|
|
698
577
|
}
|
|
699
578
|
});
|
|
700
|
-
server.tool("tunnel-add-hostname", "Add an
|
|
579
|
+
server.tool("tunnel-add-hostname", "Add an additional address to an existing Cloudflare Tunnel. Refuses pre-flight when the hostname's domain is not on Cloudflare, and refuses post-flight when `cloudflared` routes the address under a different domain (meaning the laptop is signed into the wrong Cloudflare account). Idempotent — safe to call again with the same hostname.", {
|
|
701
580
|
hostname: z
|
|
702
581
|
.string()
|
|
703
|
-
.describe("The
|
|
582
|
+
.describe("The address to add (e.g. 'maxy.chat'). The domain must be on the Cloudflare account this laptop is signed into."),
|
|
704
583
|
tunnelId: z
|
|
705
584
|
.string()
|
|
706
585
|
.describe("The tunnel UUID. Get this from tunnel-status."),
|
|
707
586
|
}, async ({ hostname, tunnelId }) => {
|
|
708
587
|
try {
|
|
709
|
-
|
|
710
|
-
// domain isn't a zone on the bound account.
|
|
711
|
-
const zoneId = await cloudflared.getZoneId(hostname);
|
|
712
|
-
// Step 2: Add hostname to tunnel ingress (read-then-append)
|
|
713
|
-
const ingress = await cloudflared.addHostnameToIngress(tunnelId, hostname);
|
|
714
|
-
// Step 3: Create CNAME via SDK. The cert-bound credential carries
|
|
715
|
-
// Zone:DNS:Edit for zones owned by the bound account. On any error,
|
|
716
|
-
// surface as failure — no fallback path remains.
|
|
717
|
-
const dns = await cloudflared.createDnsRecord(zoneId, hostname, tunnelId);
|
|
718
|
-
// Step 4: Persist alias domain for web server recognition
|
|
588
|
+
const route = await cloudflared.routeDnsCli(tunnelId, hostname);
|
|
719
589
|
cloudflared.saveAliasDomain(hostname);
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
: "added to tunnel ingress";
|
|
724
|
-
const dnsNote = dns.updated
|
|
725
|
-
? "updated — was pointing to wrong tunnel"
|
|
726
|
-
: dns.existing
|
|
727
|
-
? "already existed"
|
|
728
|
-
: "created";
|
|
590
|
+
const routeNote = route.created
|
|
591
|
+
? "created"
|
|
592
|
+
: "already existed";
|
|
729
593
|
return {
|
|
730
594
|
content: [
|
|
731
595
|
{
|
|
732
596
|
type: "text",
|
|
733
|
-
text: `
|
|
597
|
+
text: `Address configured.\n\n Address: ${hostname}\n Routing: ${routeNote}\n Web server: registered in alias-domains.json (hot-reloaded automatically)\n\nhttps://${hostname} will be active once DNS propagates (1-5 minutes). Run tunnel-status to confirm.`,
|
|
734
598
|
},
|
|
735
599
|
],
|
|
736
600
|
};
|
|
@@ -746,7 +610,7 @@ server.tool("tunnel-add-hostname", "Add an alias hostname to an existing Cloudfl
|
|
|
746
610
|
content: [
|
|
747
611
|
{
|
|
748
612
|
type: "text",
|
|
749
|
-
text: `Failed to add
|
|
613
|
+
text: `Failed to add address: ${err instanceof Error ? err.message : String(err)}`,
|
|
750
614
|
},
|
|
751
615
|
],
|
|
752
616
|
isError: true,
|
|
@@ -764,12 +628,11 @@ server.tool("dns-lookup", "Resolve DNS records for a hostname. Replaces dig/nslo
|
|
|
764
628
|
nameserver: z
|
|
765
629
|
.string()
|
|
766
630
|
.optional()
|
|
767
|
-
.describe("Custom nameserver IP (default:
|
|
631
|
+
.describe("Custom nameserver IP (default: Cloudflare 1.1.1.1 + Google 8.8.8.8)"),
|
|
768
632
|
}, async ({ hostname, type, nameserver }) => {
|
|
769
633
|
try {
|
|
770
|
-
const
|
|
771
|
-
|
|
772
|
-
resolver.setServers(nameserver ? [nameserver] : ["8.8.8.8", "1.1.1.1"]);
|
|
634
|
+
const resolver = new Resolver();
|
|
635
|
+
resolver.setServers(nameserver ? [nameserver] : ["1.1.1.1", "8.8.8.8"]);
|
|
773
636
|
const recordType = type ?? "A";
|
|
774
637
|
const results = {
|
|
775
638
|
hostname,
|
|
@@ -791,15 +654,13 @@ server.tool("dns-lookup", "Resolve DNS records for a hostname. Replaces dig/nslo
|
|
|
791
654
|
catch (cnameErr) {
|
|
792
655
|
const cnameCode = cnameErr.code;
|
|
793
656
|
if (cnameCode === "ENODATA") {
|
|
794
|
-
// Cloudflare-proxied CNAMEs are flattened to A records — resolveCname
|
|
795
|
-
// returns ENODATA even though the hostname resolves. Fall back to A.
|
|
796
657
|
try {
|
|
797
658
|
const aRecords = await resolver.resolve4(hostname);
|
|
798
|
-
results.note = "CNAME lookup returned ENODATA — record is likely
|
|
659
|
+
results.note = "CNAME lookup returned ENODATA — record is likely flattened to A";
|
|
799
660
|
results.addresses = aRecords;
|
|
800
661
|
}
|
|
801
662
|
catch {
|
|
802
|
-
throw cnameErr;
|
|
663
|
+
throw cnameErr;
|
|
803
664
|
}
|
|
804
665
|
}
|
|
805
666
|
else {
|
|
@@ -845,809 +706,12 @@ server.tool("dns-lookup", "Resolve DNS records for a hostname. Replaces dig/nslo
|
|
|
845
706
|
};
|
|
846
707
|
}
|
|
847
708
|
});
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
server.tool("cf-verify", "Read the bound Cloudflare account and the device's local state, return a JSON snapshot. Account section: zones, tunnels, CNAMEs (grouped by zone). Device section: cert.pem, account-binding.json, tunnel.state, config.yml, alias-domains.json. Orphans section: account artefacts not referenced by the device's current intended state (tunnels, CNAMEs, zones not used by any intended hostname). Non-mutating. Runs in any state including fresh-install-before-login.", {}, async () => {
|
|
852
|
-
try {
|
|
853
|
-
const report = await cloudflared.cfVerifyCore();
|
|
854
|
-
return {
|
|
855
|
-
content: [{ type: "text", text: JSON.stringify(report, null, 2) }],
|
|
856
|
-
};
|
|
857
|
-
}
|
|
858
|
-
catch (err) {
|
|
859
|
-
return {
|
|
860
|
-
content: [
|
|
861
|
-
{
|
|
862
|
-
type: "text",
|
|
863
|
-
text: `cf-verify failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
864
|
-
},
|
|
865
|
-
],
|
|
866
|
-
isError: true,
|
|
867
|
-
};
|
|
868
|
-
}
|
|
869
|
-
});
|
|
870
|
-
server.tool("cf-rebuild", "Nuclear cleanup of the bound Cloudflare account. Deletes every tunnel, CNAME, and (where the account permits) zone NOT in the `preserve` set. With no preserve args, infers what to keep from the device's `tunnel.state` and `alias-domains.json`. Pass `preserve.zones`, `preserve.tunnelIds`, or `preserve.cnames` to override. `dryRun=true` plans without mutating. The agent owns the bound account absolutely — anything not in preserve is treated as junk.", {
|
|
871
|
-
dryRun: z
|
|
872
|
-
.boolean()
|
|
873
|
-
.optional()
|
|
874
|
-
.describe("If true, report what cf-rebuild WOULD delete without performing any mutations."),
|
|
875
|
-
preserve: z
|
|
876
|
-
.object({
|
|
877
|
-
zones: z.array(z.string()).optional().describe("Zone names to keep (whole zones; all CNAMEs under them survive unless preserve.cnames overrides)."),
|
|
878
|
-
tunnelIds: z.array(z.string()).optional().describe("Tunnel UUIDs to keep."),
|
|
879
|
-
cnames: z.array(z.object({ zone: z.string(), name: z.string() })).optional().describe("Per-CNAME preserve list. When set, ONLY these CNAMEs survive within preserved zones."),
|
|
880
|
-
})
|
|
881
|
-
.optional()
|
|
882
|
-
.describe("Preservation set. Anything on the account not listed here is deleted. Defaults to whatever the device's tunnel.state and alias-domains.json reference."),
|
|
883
|
-
}, async ({ dryRun, preserve }) => {
|
|
884
|
-
try {
|
|
885
|
-
const report = await cloudflared.cfRebuildCore({ dryRun: dryRun ?? false, preserve });
|
|
886
|
-
return {
|
|
887
|
-
content: [{ type: "text", text: JSON.stringify(report, null, 2) }],
|
|
888
|
-
isError: report.halted,
|
|
889
|
-
};
|
|
890
|
-
}
|
|
891
|
-
catch (err) {
|
|
892
|
-
if (err instanceof cloudflared.CloudflareRefusalError) {
|
|
893
|
-
return {
|
|
894
|
-
content: [{ type: "text", text: err.refusal.message }],
|
|
895
|
-
isError: true,
|
|
896
|
-
};
|
|
897
|
-
}
|
|
898
|
-
return {
|
|
899
|
-
content: [
|
|
900
|
-
{
|
|
901
|
-
type: "text",
|
|
902
|
-
text: `cf-rebuild failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
903
|
-
},
|
|
904
|
-
],
|
|
905
|
-
isError: true,
|
|
906
|
-
};
|
|
907
|
-
}
|
|
908
|
-
});
|
|
909
|
-
const LABEL_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
910
|
-
function sanitizedDomain(domain) {
|
|
911
|
-
return domain.replace(/\./g, "-");
|
|
709
|
+
async function main() {
|
|
710
|
+
const transport = new StdioServerTransport();
|
|
711
|
+
await server.connect(transport);
|
|
912
712
|
}
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
function hasBrandCredsFor(tunnelId) {
|
|
917
|
-
const path = join(homedir(), cloudflared.loadBrand().configDir, "cloudflared", `${tunnelId}.json`);
|
|
918
|
-
return existsSync(path);
|
|
919
|
-
}
|
|
920
|
-
server.tool("cloudflare-setup", "Deterministic, UI-driven state machine for Cloudflare Tunnel setup. Call with no parameters to begin; the tool evaluates current state and returns a structured result describing what to render next. When a returned status begins with 'awaiting_', read `data.render` and call render-component with its contents verbatim — do not paraphrase, do not prompt the user for values. When the user submits a component (single-select, tunnel-route-picker), the agent's sole job is to relay the submission's fields (selectedTunnelId, selectedZoneName, adminLabel, publicLabel) back into this tool unchanged. The agent never synthesises subdomains or domain fragments from free text.", {
|
|
921
|
-
selectedTunnelId: z
|
|
922
|
-
.string()
|
|
923
|
-
.optional()
|
|
924
|
-
.describe("Tunnel ID from the single-select submission. The literal string '__new__' means the user chose to create a new tunnel. Pass only when the last component rendered was the tunnel selection list."),
|
|
925
|
-
selectedZoneName: z
|
|
926
|
-
.string()
|
|
927
|
-
.optional()
|
|
928
|
-
.describe("Zone name from the single-select submission. Pass only when the last component rendered was the zone selection list."),
|
|
929
|
-
adminLabel: z
|
|
930
|
-
.string()
|
|
931
|
-
.optional()
|
|
932
|
-
.describe("Admin DNS label from the tunnel-route-picker submission. DNS-label-safe (a-z, 0-9, hyphen, no leading/trailing hyphen). Pass only when the last component rendered was tunnel-route-picker."),
|
|
933
|
-
publicLabel: z
|
|
934
|
-
.string()
|
|
935
|
-
.optional()
|
|
936
|
-
.describe("Public DNS label from the tunnel-route-picker submission. Optional; omit to skip the public endpoint. DNS-label-safe. Pass only when the last component rendered was tunnel-route-picker."),
|
|
937
|
-
cleanupConfirmed: z
|
|
938
|
-
.boolean()
|
|
939
|
-
.optional()
|
|
940
|
-
.describe("Operator confirmation to delete pollution on the bound Cloudflare account (zones, tunnels, and CNAMEs not matching the chosen domain). Pass only when the last component rendered was the cleanup confirmation — comes from the component's submitMessage verbatim. Never synthesise from free text."),
|
|
941
|
-
}, async ({ selectedTunnelId, selectedZoneName, adminLabel, publicLabel, cleanupConfirmed }) => {
|
|
942
|
-
const log = (msg) => console.error(`[cloudflare-setup] ${msg}`);
|
|
943
|
-
try {
|
|
944
|
-
// ── Step 1: cloudflared binary ──────────────────────────────────
|
|
945
|
-
log("checking cloudflared installation");
|
|
946
|
-
if (!cloudflared.isInstalled()) {
|
|
947
|
-
log("cloudflared not installed — installing");
|
|
948
|
-
const { execFileSync } = await import("node:child_process");
|
|
949
|
-
const arch = process.arch === "arm64" ? "arm64" : "amd64";
|
|
950
|
-
const debUrl = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${arch}.deb`;
|
|
951
|
-
try {
|
|
952
|
-
execFileSync("curl", ["-fsSL", debUrl, "-o", "/tmp/cloudflared.deb"], { timeout: 60000 });
|
|
953
|
-
execFileSync("sudo", ["dpkg", "-i", "/tmp/cloudflared.deb"], { timeout: 30000 });
|
|
954
|
-
execFileSync("rm", ["-f", "/tmp/cloudflared.deb"]);
|
|
955
|
-
log("cloudflared installed successfully");
|
|
956
|
-
}
|
|
957
|
-
catch (err) {
|
|
958
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
959
|
-
log(`installation failed: ${msg}`);
|
|
960
|
-
return result({ status: "error", message: `Could not install cloudflared: ${msg}` });
|
|
961
|
-
}
|
|
962
|
-
}
|
|
963
|
-
// ── Step 2: Authentication ──────────────────────────────────────
|
|
964
|
-
log("checking authentication");
|
|
965
|
-
const auth = cloudflared.validateAuth();
|
|
966
|
-
if (auth.bound) {
|
|
967
|
-
log(`authenticated — cert and binding agree on account ${auth.boundAccountId}`);
|
|
968
|
-
}
|
|
969
|
-
else if (auth.hasCert) {
|
|
970
|
-
// cert.pem exists — materialize binding (migration path) or surface
|
|
971
|
-
// drift if the binding disagrees with the cert.
|
|
972
|
-
const creds = cloudflared.parseCertPem();
|
|
973
|
-
if (!creds) {
|
|
974
|
-
log("cert.pem exists but could not extract credentials");
|
|
975
|
-
return result({
|
|
976
|
-
status: "error",
|
|
977
|
-
message: "Found a Cloudflare certificate but could not extract credentials from it. Sign in again — I'll clear the old certificate and start fresh.",
|
|
978
|
-
});
|
|
979
|
-
}
|
|
980
|
-
if (!auth.hasBinding) {
|
|
981
|
-
const binding = cloudflared.materializeBinding("migration");
|
|
982
|
-
log(`materialized binding from existing cert — account ${binding.accountId}`);
|
|
983
|
-
}
|
|
984
|
-
else if (auth.certAccountId !== auth.boundAccountId) {
|
|
985
|
-
log(`account drift: cert=${auth.certAccountId} binding=${auth.boundAccountId}`);
|
|
986
|
-
return result({
|
|
987
|
-
status: "error",
|
|
988
|
-
message: `Your Cloudflare certificate is bound to a different account than this device's recorded binding. ${cloudflared.recoveryMessage()}`,
|
|
989
|
-
});
|
|
990
|
-
}
|
|
991
|
-
}
|
|
992
|
-
else {
|
|
993
|
-
// No cert, no token — need login
|
|
994
|
-
log("no authentication found — starting login");
|
|
995
|
-
// Get auth URL from existing login process or spawn a fresh one
|
|
996
|
-
let authUrl;
|
|
997
|
-
const activeLogin = cloudflared.getActiveLogin();
|
|
998
|
-
if (activeLogin) {
|
|
999
|
-
log(`login process already running (PID ${activeLogin.pid})`);
|
|
1000
|
-
authUrl = activeLogin.authUrl;
|
|
1001
|
-
}
|
|
1002
|
-
else {
|
|
1003
|
-
try {
|
|
1004
|
-
const loginResult = await cloudflared.tunnelLogin();
|
|
1005
|
-
log(`login spawned — auth URL: ${loginResult.authUrl}`);
|
|
1006
|
-
authUrl = loginResult.authUrl;
|
|
1007
|
-
}
|
|
1008
|
-
catch (err) {
|
|
1009
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1010
|
-
log(`login failed: ${msg}`);
|
|
1011
|
-
return result({
|
|
1012
|
-
status: "error",
|
|
1013
|
-
message: `Could not start Cloudflare sign-in: ${msg}`,
|
|
1014
|
-
});
|
|
1015
|
-
}
|
|
1016
|
-
}
|
|
1017
|
-
// Return immediately — the agent shows the URL, user authorizes,
|
|
1018
|
-
// then re-calls cloudflare-setup. hasCert() at line 1082 handles re-entry.
|
|
1019
|
-
log(`returning awaiting_auth — auth URL: ${authUrl}`);
|
|
1020
|
-
return result({
|
|
1021
|
-
status: "awaiting_auth",
|
|
1022
|
-
message: "Open the authorization URL in your browser and click Authorize. Once done, let me know and I'll continue the setup.",
|
|
1023
|
-
data: { authUrl },
|
|
1024
|
-
});
|
|
1025
|
-
}
|
|
1026
|
-
// ── Step 3: Discover state — tunnel, zone, domain ───────────────
|
|
1027
|
-
const existingState = cloudflared.getPersistedState();
|
|
1028
|
-
const platformPort = parseInt(process.env.PLATFORM_PORT ?? "19200", 10);
|
|
1029
|
-
// If state already has a tunnel identity with hostnames, labels are
|
|
1030
|
-
// reconstructed from the persisted hostnames on every re-entry where
|
|
1031
|
-
// the agent doesn't supply fresh labels. This is how the flow
|
|
1032
|
-
// survives the awaiting_password round-trip.
|
|
1033
|
-
const stateAdminLabel = existingState?.adminHostname && existingState.domain
|
|
1034
|
-
? existingState.adminHostname.replace(`.${existingState.domain}`, "")
|
|
1035
|
-
: null;
|
|
1036
|
-
const statePublicLabel = existingState?.publicHostname && existingState?.domain
|
|
1037
|
-
? existingState.publicHostname.replace(`.${existingState.domain}`, "")
|
|
1038
|
-
: null;
|
|
1039
|
-
let allTunnels = [];
|
|
1040
|
-
let allZones = [];
|
|
1041
|
-
try {
|
|
1042
|
-
[allTunnels, allZones] = await Promise.all([
|
|
1043
|
-
cloudflared.listTunnelsOnAccount(),
|
|
1044
|
-
cloudflared.listZones(),
|
|
1045
|
-
]);
|
|
1046
|
-
}
|
|
1047
|
-
catch (err) {
|
|
1048
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1049
|
-
log(`discovery failed: ${msg}`);
|
|
1050
|
-
return result({
|
|
1051
|
-
status: "error",
|
|
1052
|
-
message: `Could not read your Cloudflare account: ${msg}`,
|
|
1053
|
-
});
|
|
1054
|
-
}
|
|
1055
|
-
log(`entry tunnelsOnAccount=${allTunnels.length} zonesOnAccount=${allZones.length}`);
|
|
1056
|
-
const activeZones = allZones.filter((z) => z.status === "active");
|
|
1057
|
-
const pendingZones = allZones.filter((z) => z.status === "pending");
|
|
1058
|
-
// Resolve domain — precedence: persisted state > tunnel selection > zone selection > auto-only-active
|
|
1059
|
-
let domain = existingState?.domain ?? null;
|
|
1060
|
-
if (!domain && selectedTunnelId && selectedTunnelId !== "__new__") {
|
|
1061
|
-
const tunnel = allTunnels.find((t) => t.id === selectedTunnelId);
|
|
1062
|
-
if (!tunnel) {
|
|
1063
|
-
log(`selectedTunnelId ${selectedTunnelId} not found on account`);
|
|
1064
|
-
return result({
|
|
1065
|
-
status: "error",
|
|
1066
|
-
message: "That tunnel is no longer on your account. Let me check again.",
|
|
1067
|
-
});
|
|
1068
|
-
}
|
|
1069
|
-
const zoneName = await cloudflared.findZoneHostingTunnel(selectedTunnelId);
|
|
1070
|
-
if (!zoneName) {
|
|
1071
|
-
log(`selected tunnel ${selectedTunnelId} has no zone hosting it — falling back to zone selection`);
|
|
1072
|
-
// Fall through to zone-selection branch below
|
|
1073
|
-
}
|
|
1074
|
-
else {
|
|
1075
|
-
domain = zoneName;
|
|
1076
|
-
log(`branch=existing-tunnel tunnelId=${selectedTunnelId} domain=${domain}`);
|
|
1077
|
-
}
|
|
1078
|
-
}
|
|
1079
|
-
if (!domain && selectedZoneName) {
|
|
1080
|
-
const zone = activeZones.find((z) => z.name === selectedZoneName);
|
|
1081
|
-
if (!zone) {
|
|
1082
|
-
log(`selectedZoneName ${selectedZoneName} not active on account`);
|
|
1083
|
-
return result({
|
|
1084
|
-
status: "error",
|
|
1085
|
-
message: "That domain is no longer Active on your account. Let me check again.",
|
|
1086
|
-
});
|
|
1087
|
-
}
|
|
1088
|
-
domain = selectedZoneName;
|
|
1089
|
-
}
|
|
1090
|
-
// No prior state, no selection — prompt the user for a tunnel
|
|
1091
|
-
// (if account has any) or a zone (if account has >1 active zone).
|
|
1092
|
-
if (!domain) {
|
|
1093
|
-
if (allTunnels.length > 0 && !selectedTunnelId) {
|
|
1094
|
-
log(`awaiting_tunnel_selection tunnelsOnAccount=${allTunnels.length}`);
|
|
1095
|
-
const options = [
|
|
1096
|
-
{ value: "__new__", label: "Create a new tunnel (recommended)" },
|
|
1097
|
-
...allTunnels.map((t) => ({ value: t.id, label: t.name })),
|
|
1098
|
-
];
|
|
1099
|
-
return result({
|
|
1100
|
-
status: "awaiting_tunnel_selection",
|
|
1101
|
-
message: "Pick a tunnel for this device. For a fresh setup, choose Create a new tunnel.",
|
|
1102
|
-
data: {
|
|
1103
|
-
tunnels: allTunnels.map((t) => ({ id: t.id, name: t.name })),
|
|
1104
|
-
render: {
|
|
1105
|
-
name: "single-select",
|
|
1106
|
-
data: {
|
|
1107
|
-
title: "Tunnel",
|
|
1108
|
-
description: "Each device uses its own tunnel. Creating a new one is the normal choice.",
|
|
1109
|
-
options,
|
|
1110
|
-
submitLabel: "Use this tunnel",
|
|
1111
|
-
submitMessage: '{"selectedTunnelId":"{{value}}"}',
|
|
1112
|
-
},
|
|
1113
|
-
},
|
|
1114
|
-
},
|
|
1115
|
-
});
|
|
1116
|
-
}
|
|
1117
|
-
// From here: selectedTunnelId is "__new__" or undefined (no tunnels on account).
|
|
1118
|
-
if (activeZones.length === 0) {
|
|
1119
|
-
if (pendingZones.length > 0) {
|
|
1120
|
-
const z = pendingZones[0];
|
|
1121
|
-
log(`awaiting_nameservers zone=${z.name}`);
|
|
1122
|
-
return result({
|
|
1123
|
-
status: "awaiting_nameservers",
|
|
1124
|
-
message: `Your domain ${z.name} is on Cloudflare but not yet active. Update the nameservers at your registrar to:\n\n• ${z.nameservers[0]}\n• ${z.nameservers[1]}\n\nLet me know when you've updated them.`,
|
|
1125
|
-
data: { nameservers: z.nameservers },
|
|
1126
|
-
});
|
|
1127
|
-
}
|
|
1128
|
-
log(`branch=zone-add-fallback reason=noZones`);
|
|
1129
|
-
return result({
|
|
1130
|
-
status: "awaiting_zone_add_dashboard",
|
|
1131
|
-
message: "No domain on your Cloudflare account yet. Sign in at cloudflare.com, click Add a site, and add your domain. When it appears on the account, let me know and I'll continue.",
|
|
1132
|
-
data: { dashboardUrl: "https://dash.cloudflare.com" },
|
|
1133
|
-
});
|
|
1134
|
-
}
|
|
1135
|
-
if (activeZones.length > 1) {
|
|
1136
|
-
log(`awaiting_zone_selection activeZones=${activeZones.length}`);
|
|
1137
|
-
return result({
|
|
1138
|
-
status: "awaiting_zone_selection",
|
|
1139
|
-
message: "Multiple domains on your Cloudflare account. Pick the one to use for this device.",
|
|
1140
|
-
data: {
|
|
1141
|
-
zones: activeZones.map((z) => ({ name: z.name, status: z.status })),
|
|
1142
|
-
render: {
|
|
1143
|
-
name: "single-select",
|
|
1144
|
-
data: {
|
|
1145
|
-
title: "Domain",
|
|
1146
|
-
options: activeZones.map((z) => ({ value: z.name, label: z.name })),
|
|
1147
|
-
submitLabel: "Use this domain",
|
|
1148
|
-
submitMessage: '{"selectedZoneName":"{{value}}"}',
|
|
1149
|
-
},
|
|
1150
|
-
},
|
|
1151
|
-
},
|
|
1152
|
-
});
|
|
1153
|
-
}
|
|
1154
|
-
// Exactly one active zone — use it.
|
|
1155
|
-
domain = activeZones[0].name;
|
|
1156
|
-
log(`auto-selected only active zone: ${domain}`);
|
|
1157
|
-
}
|
|
1158
|
-
// Verify the resolved zone is active (guard against state with a since-pending zone)
|
|
1159
|
-
const zone = allZones.find((z) => z.name === domain);
|
|
1160
|
-
if (zone && zone.status === "pending") {
|
|
1161
|
-
log(`zone ${domain} is pending — returning awaiting_nameservers`);
|
|
1162
|
-
return result({
|
|
1163
|
-
status: "awaiting_nameservers",
|
|
1164
|
-
message: `Your domain ${domain} is not yet active on Cloudflare. Update the nameservers at your registrar to:\n\n• ${zone.nameservers[0]}\n• ${zone.nameservers[1]}\n\nLet me know when you've updated them.`,
|
|
1165
|
-
data: { nameservers: zone.nameservers },
|
|
1166
|
-
});
|
|
1167
|
-
}
|
|
1168
|
-
// ── Step 3b: Cleanup — bound account is the universe ────────────
|
|
1169
|
-
// The chosen zone + any currently-persisted tunnel are intent;
|
|
1170
|
-
// everything else on the account is pollution. Surface it, require
|
|
1171
|
-
// explicit operator confirmation, then delete before creating the
|
|
1172
|
-
// new tunnel (so the label-collision check runs against a clean
|
|
1173
|
-
// zone, not one littered with stale `<deadTunnel>.cfargotunnel.com`
|
|
1174
|
-
// pointers).
|
|
1175
|
-
const preservedTunnelIds = new Set();
|
|
1176
|
-
if (existingState?.tunnelId)
|
|
1177
|
-
preservedTunnelIds.add(existingState.tunnelId);
|
|
1178
|
-
if (selectedTunnelId && selectedTunnelId !== "__new__") {
|
|
1179
|
-
preservedTunnelIds.add(selectedTunnelId);
|
|
1180
|
-
}
|
|
1181
|
-
const domainLc = domain.toLowerCase();
|
|
1182
|
-
const pollutionZonesList = allZones.filter((z) => z.status === "active" && z.name.toLowerCase() !== domainLc);
|
|
1183
|
-
const pollutionTunnelsList = allTunnels.filter((t) => !preservedTunnelIds.has(t.id));
|
|
1184
|
-
// Only surface CNAMEs on the PRESERVED zone that point to deleted
|
|
1185
|
-
// tunnels — those need explicit disclosure because the zone itself
|
|
1186
|
-
// survives. CNAMEs under pollution zones are cascade-deleted when
|
|
1187
|
-
// the zone is deleted; listing them separately would duplicate
|
|
1188
|
-
// the same destruction in the confirm UI.
|
|
1189
|
-
let pollutionCnamesList = [];
|
|
1190
|
-
if (pollutionTunnelsList.length > 0) {
|
|
1191
|
-
try {
|
|
1192
|
-
const verify = await cloudflared.cfVerifyCore();
|
|
1193
|
-
if (verify.account) {
|
|
1194
|
-
const pollutionTunnelIdSet = new Set(pollutionTunnelsList.map((t) => t.id));
|
|
1195
|
-
pollutionCnamesList = verify.account.cnames
|
|
1196
|
-
.filter((c) => {
|
|
1197
|
-
if (c.zone.toLowerCase() !== domainLc)
|
|
1198
|
-
return false;
|
|
1199
|
-
const m = c.content.toLowerCase().trim().match(/^([0-9a-f-]{36})\.cfargotunnel\.com\.?$/);
|
|
1200
|
-
return Boolean(m && pollutionTunnelIdSet.has(m[1]));
|
|
1201
|
-
})
|
|
1202
|
-
.map((c) => ({ zone: c.zone, name: c.name, content: c.content }));
|
|
1203
|
-
}
|
|
1204
|
-
}
|
|
1205
|
-
catch (err) {
|
|
1206
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1207
|
-
log(`cfVerifyCore during cleanup check failed: ${msg}`);
|
|
1208
|
-
// Continue with zone/tunnel pollution only — conservative.
|
|
1209
|
-
}
|
|
1210
|
-
}
|
|
1211
|
-
const pollutionTotal = pollutionZonesList.length +
|
|
1212
|
-
pollutionTunnelsList.length +
|
|
1213
|
-
pollutionCnamesList.length;
|
|
1214
|
-
if (pollutionTotal > 0) {
|
|
1215
|
-
if (!cleanupConfirmed) {
|
|
1216
|
-
log(`awaiting_cleanup_confirmation pollutionZones=${pollutionZonesList.length} pollutionTunnels=${pollutionTunnelsList.length} pollutionCnames=${pollutionCnamesList.length}`);
|
|
1217
|
-
const items = [
|
|
1218
|
-
...pollutionZonesList.map((z) => ({ label: "Domain (zone)", value: z.name })),
|
|
1219
|
-
...pollutionTunnelsList.map((t) => ({ label: "Tunnel", value: t.name })),
|
|
1220
|
-
...pollutionCnamesList.map((c) => ({
|
|
1221
|
-
label: "DNS record",
|
|
1222
|
-
value: `${c.name} → ${c.content}`,
|
|
1223
|
-
})),
|
|
1224
|
-
];
|
|
1225
|
-
const confirmPayload = {
|
|
1226
|
-
cleanupConfirmed: true,
|
|
1227
|
-
selectedZoneName: domain,
|
|
1228
|
-
};
|
|
1229
|
-
if (selectedTunnelId)
|
|
1230
|
-
confirmPayload.selectedTunnelId = selectedTunnelId;
|
|
1231
|
-
return result({
|
|
1232
|
-
status: "awaiting_cleanup_confirmation",
|
|
1233
|
-
message: `Your Cloudflare account has artefacts from prior work that don't belong to the ${domain} setup. Confirm to clean them up before creating the tunnel.`,
|
|
1234
|
-
data: {
|
|
1235
|
-
domain,
|
|
1236
|
-
pollution: {
|
|
1237
|
-
zones: pollutionZonesList.map((z) => ({ name: z.name })),
|
|
1238
|
-
tunnels: pollutionTunnelsList.map((t) => ({ id: t.id, name: t.name })),
|
|
1239
|
-
cnames: pollutionCnamesList,
|
|
1240
|
-
},
|
|
1241
|
-
render: {
|
|
1242
|
-
name: "confirm",
|
|
1243
|
-
data: {
|
|
1244
|
-
title: `Clean up before setting up ${domain}?`,
|
|
1245
|
-
description: `Keeping ${domain}. Everything below will be deleted.`,
|
|
1246
|
-
items,
|
|
1247
|
-
confirmLabel: "Clean up and continue",
|
|
1248
|
-
rejectLabel: "Cancel",
|
|
1249
|
-
confirmMessage: JSON.stringify(confirmPayload),
|
|
1250
|
-
rejectMessage: "I don't want to clean up.",
|
|
1251
|
-
},
|
|
1252
|
-
},
|
|
1253
|
-
},
|
|
1254
|
-
});
|
|
1255
|
-
}
|
|
1256
|
-
// Cleanup confirmed — run the rebuild with explicit intent.
|
|
1257
|
-
log(`cleanup confirmed — cfRebuildCore preserve.zones=[${domain}] preserve.tunnelIds=[${[...preservedTunnelIds].join(",")}]`);
|
|
1258
|
-
try {
|
|
1259
|
-
const rebuild = await cloudflared.cfRebuildCore({
|
|
1260
|
-
preserve: {
|
|
1261
|
-
zones: [domain],
|
|
1262
|
-
tunnelIds: [...preservedTunnelIds],
|
|
1263
|
-
},
|
|
1264
|
-
});
|
|
1265
|
-
if (rebuild.halted) {
|
|
1266
|
-
return result({
|
|
1267
|
-
status: "error",
|
|
1268
|
-
message: `Cleanup halted: ${rebuild.haltReason ?? "unknown reason"}. ${cloudflared.recoveryMessage()}`,
|
|
1269
|
-
});
|
|
1270
|
-
}
|
|
1271
|
-
const failedActions = rebuild.actions.filter((a) => a.result === "failed");
|
|
1272
|
-
if (failedActions.length > 0) {
|
|
1273
|
-
return result({
|
|
1274
|
-
status: "error",
|
|
1275
|
-
message: `Cleanup completed with ${failedActions.length} failed action(s) of ${rebuild.actions.length}. First: ${failedActions[0].detail ?? "(no detail)"}. Run cf-verify to see current state.`,
|
|
1276
|
-
});
|
|
1277
|
-
}
|
|
1278
|
-
log(`cleanup complete actions=${rebuild.actions.length}`);
|
|
1279
|
-
// Refresh tunnels snapshot so label collision checks run against
|
|
1280
|
-
// the post-cleanup account state.
|
|
1281
|
-
try {
|
|
1282
|
-
allTunnels = await cloudflared.listTunnelsOnAccount();
|
|
1283
|
-
}
|
|
1284
|
-
catch (err) {
|
|
1285
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1286
|
-
log(`post-cleanup tunnel refresh failed: ${msg}`);
|
|
1287
|
-
}
|
|
1288
|
-
}
|
|
1289
|
-
catch (err) {
|
|
1290
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1291
|
-
log(`cleanup failed: ${msg}`);
|
|
1292
|
-
return result({
|
|
1293
|
-
status: "error",
|
|
1294
|
-
message: `Could not clean up your Cloudflare account: ${msg}`,
|
|
1295
|
-
});
|
|
1296
|
-
}
|
|
1297
|
-
}
|
|
1298
|
-
// ── Step 4: Labels ──────────────────────────────────────────────
|
|
1299
|
-
// Resolution precedence: args > persisted state hostnames.
|
|
1300
|
-
const resolvedAdminLabel = adminLabel ?? stateAdminLabel;
|
|
1301
|
-
const resolvedPublicLabel = publicLabel ?? statePublicLabel ?? null;
|
|
1302
|
-
if (!resolvedAdminLabel) {
|
|
1303
|
-
log(`ui=tunnel-route-picker rendered tunnelDomain=${domain}`);
|
|
1304
|
-
return result({
|
|
1305
|
-
status: "awaiting_labels",
|
|
1306
|
-
message: `Pick a short name for your admin address on ${domain}. You can also pick one for a public address, or leave it empty.`,
|
|
1307
|
-
data: {
|
|
1308
|
-
domain,
|
|
1309
|
-
render: {
|
|
1310
|
-
name: "tunnel-route-picker",
|
|
1311
|
-
data: {
|
|
1312
|
-
domain,
|
|
1313
|
-
title: "Pick your addresses",
|
|
1314
|
-
description: "Your admin address is required. Public is optional and can be added later.",
|
|
1315
|
-
submitLabel: "Set up addresses",
|
|
1316
|
-
},
|
|
1317
|
-
},
|
|
1318
|
-
},
|
|
1319
|
-
});
|
|
1320
|
-
}
|
|
1321
|
-
// Defense-in-depth label validation (UI already filters, but tool
|
|
1322
|
-
// is authoritative — an agent misroute must fail loudly, not silently).
|
|
1323
|
-
if (!LABEL_RE.test(resolvedAdminLabel)) {
|
|
1324
|
-
log(`invalid admin label: ${resolvedAdminLabel}`);
|
|
1325
|
-
return result({
|
|
1326
|
-
status: "label-taken",
|
|
1327
|
-
message: "Admin label is not a valid DNS label. Pick something that is lowercase letters, numbers, and hyphens.",
|
|
1328
|
-
data: {
|
|
1329
|
-
domain,
|
|
1330
|
-
field: "admin",
|
|
1331
|
-
render: {
|
|
1332
|
-
name: "tunnel-route-picker",
|
|
1333
|
-
data: {
|
|
1334
|
-
domain,
|
|
1335
|
-
title: "Pick your addresses",
|
|
1336
|
-
defaultAdmin: "",
|
|
1337
|
-
defaultPublic: resolvedPublicLabel ?? "",
|
|
1338
|
-
error: { field: "admin", message: "Not a valid address — use lowercase letters, numbers, and hyphens." },
|
|
1339
|
-
submitLabel: "Set up addresses",
|
|
1340
|
-
},
|
|
1341
|
-
},
|
|
1342
|
-
},
|
|
1343
|
-
});
|
|
1344
|
-
}
|
|
1345
|
-
if (resolvedPublicLabel !== null && resolvedPublicLabel !== "" && !LABEL_RE.test(resolvedPublicLabel)) {
|
|
1346
|
-
log(`invalid public label: ${resolvedPublicLabel}`);
|
|
1347
|
-
return result({
|
|
1348
|
-
status: "label-taken",
|
|
1349
|
-
message: "Public label is not a valid DNS label.",
|
|
1350
|
-
data: {
|
|
1351
|
-
domain,
|
|
1352
|
-
field: "public",
|
|
1353
|
-
render: {
|
|
1354
|
-
name: "tunnel-route-picker",
|
|
1355
|
-
data: {
|
|
1356
|
-
domain,
|
|
1357
|
-
defaultAdmin: resolvedAdminLabel,
|
|
1358
|
-
defaultPublic: "",
|
|
1359
|
-
error: { field: "public", message: "Not a valid address." },
|
|
1360
|
-
submitLabel: "Set up addresses",
|
|
1361
|
-
},
|
|
1362
|
-
},
|
|
1363
|
-
},
|
|
1364
|
-
});
|
|
1365
|
-
}
|
|
1366
|
-
if (resolvedPublicLabel && resolvedAdminLabel === resolvedPublicLabel) {
|
|
1367
|
-
log(`admin and public labels identical: ${resolvedAdminLabel}`);
|
|
1368
|
-
return result({
|
|
1369
|
-
status: "label-taken",
|
|
1370
|
-
message: "Admin and public addresses must differ.",
|
|
1371
|
-
data: {
|
|
1372
|
-
domain,
|
|
1373
|
-
field: "public",
|
|
1374
|
-
render: {
|
|
1375
|
-
name: "tunnel-route-picker",
|
|
1376
|
-
data: {
|
|
1377
|
-
domain,
|
|
1378
|
-
defaultAdmin: resolvedAdminLabel,
|
|
1379
|
-
defaultPublic: "",
|
|
1380
|
-
error: { field: "public", message: "Public must differ from admin." },
|
|
1381
|
-
submitLabel: "Set up addresses",
|
|
1382
|
-
},
|
|
1383
|
-
},
|
|
1384
|
-
},
|
|
1385
|
-
});
|
|
1386
|
-
}
|
|
1387
|
-
const adminHostname = `${resolvedAdminLabel}.${domain}`;
|
|
1388
|
-
const publicHostname = resolvedPublicLabel ? `${resolvedPublicLabel}.${domain}` : null;
|
|
1389
|
-
const hostnames = publicHostname ? [adminHostname, publicHostname] : [adminHostname];
|
|
1390
|
-
// Account-zone scope check happens inside routeDnsCli (Step 6) — the
|
|
1391
|
-
// domain was picked from the live account zone list, so by construction
|
|
1392
|
-
// it is on the bound account. No pre-flight needed here.
|
|
1393
|
-
// ── Step 5: Resolve or create tunnel (idempotent on re-entry) ───
|
|
1394
|
-
let tunnelId = existingState?.tunnelId ?? null;
|
|
1395
|
-
let tunnelName = existingState?.tunnelName ?? null;
|
|
1396
|
-
let credentialsPath = existingState?.credentialsPath ?? null;
|
|
1397
|
-
if (!tunnelId) {
|
|
1398
|
-
if (selectedTunnelId && selectedTunnelId !== "__new__") {
|
|
1399
|
-
// User picked an existing tunnel — reuse its identity.
|
|
1400
|
-
const selected = allTunnels.find((t) => t.id === selectedTunnelId);
|
|
1401
|
-
if (!selected) {
|
|
1402
|
-
return result({
|
|
1403
|
-
status: "error",
|
|
1404
|
-
message: "Selected tunnel not found on account.",
|
|
1405
|
-
});
|
|
1406
|
-
}
|
|
1407
|
-
if (!hasBrandCredsFor(selected.id)) {
|
|
1408
|
-
// Cannot reuse without credentials — this tunnel belongs to
|
|
1409
|
-
// another device on the same account. Surface structured error.
|
|
1410
|
-
log(`collision type=tunnel-name label=${resolvedAdminLabel} existingTunnelId=${selected.id}`);
|
|
1411
|
-
return result({
|
|
1412
|
-
status: "tunnel-name-taken",
|
|
1413
|
-
message: `The tunnel "${selected.name}" belongs to another device. Pick a different label for this one.`,
|
|
1414
|
-
data: {
|
|
1415
|
-
suggestedLabel: `${resolvedAdminLabel}2`,
|
|
1416
|
-
existingTunnelId: selected.id,
|
|
1417
|
-
render: {
|
|
1418
|
-
name: "tunnel-route-picker",
|
|
1419
|
-
data: {
|
|
1420
|
-
domain,
|
|
1421
|
-
defaultAdmin: "",
|
|
1422
|
-
defaultPublic: resolvedPublicLabel ?? "",
|
|
1423
|
-
error: { field: "admin", message: `That address is already taken. Pick a different one.` },
|
|
1424
|
-
submitLabel: "Set up addresses",
|
|
1425
|
-
},
|
|
1426
|
-
},
|
|
1427
|
-
},
|
|
1428
|
-
});
|
|
1429
|
-
}
|
|
1430
|
-
tunnelId = selected.id;
|
|
1431
|
-
tunnelName = selected.name;
|
|
1432
|
-
}
|
|
1433
|
-
else {
|
|
1434
|
-
// Create new — tunnel name is derived from admin label + domain.
|
|
1435
|
-
const derivedName = deriveTunnelName(resolvedAdminLabel, domain);
|
|
1436
|
-
log(`branch=new-tunnel zoneName=${domain} tunnelName=${derivedName}`);
|
|
1437
|
-
const accountExisting = allTunnels.find((t) => t.name === derivedName);
|
|
1438
|
-
if (accountExisting && !hasBrandCredsFor(accountExisting.id)) {
|
|
1439
|
-
log(`collision type=tunnel-name label=${resolvedAdminLabel} existingTunnelId=${accountExisting.id}`);
|
|
1440
|
-
return result({
|
|
1441
|
-
status: "tunnel-name-taken",
|
|
1442
|
-
message: `The address ${resolvedAdminLabel}.${domain} is already in use by another device. Pick a different one.`,
|
|
1443
|
-
data: {
|
|
1444
|
-
domain,
|
|
1445
|
-
suggestedLabel: `${resolvedAdminLabel}2`,
|
|
1446
|
-
existingTunnelId: accountExisting.id,
|
|
1447
|
-
field: "admin",
|
|
1448
|
-
render: {
|
|
1449
|
-
name: "tunnel-route-picker",
|
|
1450
|
-
data: {
|
|
1451
|
-
domain,
|
|
1452
|
-
defaultAdmin: "",
|
|
1453
|
-
defaultPublic: resolvedPublicLabel ?? "",
|
|
1454
|
-
error: { field: "admin", message: `That address is already taken — pick a different one.` },
|
|
1455
|
-
submitLabel: "Set up addresses",
|
|
1456
|
-
},
|
|
1457
|
-
},
|
|
1458
|
-
},
|
|
1459
|
-
});
|
|
1460
|
-
}
|
|
1461
|
-
try {
|
|
1462
|
-
const created = cloudflared.createTunnelCli(derivedName);
|
|
1463
|
-
tunnelId = created.tunnelId;
|
|
1464
|
-
tunnelName = created.tunnelName;
|
|
1465
|
-
credentialsPath = created.credentialsPath;
|
|
1466
|
-
}
|
|
1467
|
-
catch (err) {
|
|
1468
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1469
|
-
log(`tunnel creation failed: ${msg}`);
|
|
1470
|
-
return result({ status: "error", message: `Could not create the tunnel: ${msg}` });
|
|
1471
|
-
}
|
|
1472
|
-
}
|
|
1473
|
-
}
|
|
1474
|
-
if (!tunnelId || !tunnelName) {
|
|
1475
|
-
return result({ status: "error", message: "Tunnel identity could not be resolved." });
|
|
1476
|
-
}
|
|
1477
|
-
// Fallback credentials path when reusing a tunnel picked from the UI:
|
|
1478
|
-
// createTunnelCli already copies from ~/.cloudflared to the brand dir,
|
|
1479
|
-
// but that runs only in the create branch. For the "reuse existing"
|
|
1480
|
-
// branch, derive the same brand-dir path here.
|
|
1481
|
-
if (!credentialsPath) {
|
|
1482
|
-
credentialsPath = join(homedir(), cloudflared.loadBrand().configDir, "cloudflared", `${tunnelId}.json`);
|
|
1483
|
-
}
|
|
1484
|
-
// ── Step 6: DNS routing + config (idempotent every call) ────────
|
|
1485
|
-
for (const h of hostnames) {
|
|
1486
|
-
const check = await cloudflared.checkDnsCollision(h, tunnelId);
|
|
1487
|
-
if (check.collision) {
|
|
1488
|
-
const offendingField = h === adminHostname ? "admin" : "public";
|
|
1489
|
-
log(`collision type=label hostname=${h} existingTunnelId=${check.existingTunnelId}`);
|
|
1490
|
-
return result({
|
|
1491
|
-
status: "label-taken",
|
|
1492
|
-
message: `The address ${h} already points to a different tunnel. Pick a different ${offendingField} label.`,
|
|
1493
|
-
data: {
|
|
1494
|
-
domain,
|
|
1495
|
-
field: offendingField,
|
|
1496
|
-
existingTunnelId: check.existingTunnelId ?? undefined,
|
|
1497
|
-
render: {
|
|
1498
|
-
name: "tunnel-route-picker",
|
|
1499
|
-
data: {
|
|
1500
|
-
domain,
|
|
1501
|
-
defaultAdmin: offendingField === "admin" ? "" : resolvedAdminLabel,
|
|
1502
|
-
defaultPublic: offendingField === "public" ? "" : (resolvedPublicLabel ?? ""),
|
|
1503
|
-
error: { field: offendingField, message: `That address is already in use by another device.` },
|
|
1504
|
-
submitLabel: "Set up addresses",
|
|
1505
|
-
},
|
|
1506
|
-
},
|
|
1507
|
-
},
|
|
1508
|
-
});
|
|
1509
|
-
}
|
|
1510
|
-
}
|
|
1511
|
-
const configPath = cloudflared.writeLocalConfig(tunnelId, credentialsPath, hostnames, platformPort);
|
|
1512
|
-
cloudflared.saveTunnelIdentity({
|
|
1513
|
-
tunnelId,
|
|
1514
|
-
tunnelName,
|
|
1515
|
-
domain,
|
|
1516
|
-
configPath,
|
|
1517
|
-
credentialsPath,
|
|
1518
|
-
adminHostname,
|
|
1519
|
-
publicHostname,
|
|
1520
|
-
});
|
|
1521
|
-
for (const h of hostnames) {
|
|
1522
|
-
await cloudflared.routeDnsCli(tunnelId, h);
|
|
1523
|
-
}
|
|
1524
|
-
log(`ui=tunnel-route-picker submitted adminLabel=${resolvedAdminLabel} publicLabel=${resolvedPublicLabel ?? "null"}`);
|
|
1525
|
-
// ── Step 7: Remote auth check ───────────────────────────────────
|
|
1526
|
-
// scrypt hashing on Pi ARM takes 2-5s after form submission —
|
|
1527
|
-
// wait before checking to avoid a false negative on every first attempt
|
|
1528
|
-
log("waiting 3s for scrypt hash before checking remote auth");
|
|
1529
|
-
await new Promise((r) => setTimeout(r, 3000));
|
|
1530
|
-
log("checking remote auth");
|
|
1531
|
-
try {
|
|
1532
|
-
const res = await fetch(`http://127.0.0.1:${platformPort}/api/remote-auth/status`, {
|
|
1533
|
-
signal: AbortSignal.timeout(5000),
|
|
1534
|
-
});
|
|
1535
|
-
const body = (await res.json());
|
|
1536
|
-
if (!body.configured) {
|
|
1537
|
-
const setupUrl = `http://${hostname()}.local:${platformPort}/__remote-auth/setup`;
|
|
1538
|
-
log("remote auth not configured");
|
|
1539
|
-
return result({
|
|
1540
|
-
status: "awaiting_password",
|
|
1541
|
-
message: `Before enabling remote access, you need to set a password that protects your admin interface over the internet.\n\nOpen this URL in your own browser (not the one in the chat) and set a password:\n\`${setupUrl}\`\n\nChoose something strong — at least 8 characters with a number and a special character. Let me know when you've submitted it.`,
|
|
1542
|
-
data: { setupUrl },
|
|
1543
|
-
});
|
|
1544
|
-
}
|
|
1545
|
-
log("remote auth configured");
|
|
1546
|
-
}
|
|
1547
|
-
catch {
|
|
1548
|
-
log("remote auth check failed — platform may not be running");
|
|
1549
|
-
return result({
|
|
1550
|
-
status: "error",
|
|
1551
|
-
message: "Could not verify remote access password — the platform may not be running. Make sure it's started and try again.",
|
|
1552
|
-
});
|
|
1553
|
-
}
|
|
1554
|
-
// ── Step 8: Tunnel enable ───────────────────────────────────────
|
|
1555
|
-
const tunnelState = cloudflared.getPersistedState();
|
|
1556
|
-
if (!tunnelState) {
|
|
1557
|
-
return result({ status: "error", message: "Tunnel state lost — please try again." });
|
|
1558
|
-
}
|
|
1559
|
-
const status = await cloudflared.getStatus(domain);
|
|
1560
|
-
if (!status.running) {
|
|
1561
|
-
log("tunnel not running — starting");
|
|
1562
|
-
try {
|
|
1563
|
-
cloudflared.startTunnel({
|
|
1564
|
-
tunnelId: tunnelState.tunnelId,
|
|
1565
|
-
tunnelName: tunnelState.tunnelName,
|
|
1566
|
-
domain,
|
|
1567
|
-
configPath: tunnelState.configPath,
|
|
1568
|
-
credentialsPath: tunnelState.credentialsPath,
|
|
1569
|
-
});
|
|
1570
|
-
// Wait to confirm it didn't crash immediately
|
|
1571
|
-
for (let i = 0; i < 10; i++) {
|
|
1572
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
1573
|
-
const check = await cloudflared.getStatus(domain);
|
|
1574
|
-
if (!check.running) {
|
|
1575
|
-
const brand = cloudflared.loadBrand();
|
|
1576
|
-
log("tunnel process exited immediately");
|
|
1577
|
-
return result({
|
|
1578
|
-
status: "error",
|
|
1579
|
-
message: `The tunnel started but exited immediately. Check ~/${brand.configDir}/logs/cloudflared.log for details.`,
|
|
1580
|
-
});
|
|
1581
|
-
}
|
|
1582
|
-
}
|
|
1583
|
-
// Verify admin URL is reachable through Cloudflare edge
|
|
1584
|
-
const verifyUrl = `https://${adminHostname}`;
|
|
1585
|
-
let verified = false;
|
|
1586
|
-
let lastStatus = null;
|
|
1587
|
-
for (let attempt = 0; attempt < 6; attempt++) {
|
|
1588
|
-
if (attempt > 0)
|
|
1589
|
-
await new Promise((r) => setTimeout(r, 5000));
|
|
1590
|
-
try {
|
|
1591
|
-
const res = await fetch(verifyUrl, {
|
|
1592
|
-
signal: AbortSignal.timeout(10000),
|
|
1593
|
-
redirect: "manual",
|
|
1594
|
-
});
|
|
1595
|
-
lastStatus = res.status;
|
|
1596
|
-
if (res.status !== 530) {
|
|
1597
|
-
verified = true;
|
|
1598
|
-
log(`verified url=${verifyUrl} status=${res.status}`);
|
|
1599
|
-
break;
|
|
1600
|
-
}
|
|
1601
|
-
}
|
|
1602
|
-
catch {
|
|
1603
|
-
// retry
|
|
1604
|
-
}
|
|
1605
|
-
}
|
|
1606
|
-
if (!verified) {
|
|
1607
|
-
log(`verified url=${verifyUrl} status=${lastStatus ?? "timeout"} result=unreachable`);
|
|
1608
|
-
return result({
|
|
1609
|
-
status: "error",
|
|
1610
|
-
message: `The tunnel is running but the admin URL is not reachable yet. This usually resolves in a few minutes as DNS propagates. Try again shortly.`,
|
|
1611
|
-
});
|
|
1612
|
-
}
|
|
1613
|
-
log("tunnel started and verified");
|
|
1614
|
-
}
|
|
1615
|
-
catch (err) {
|
|
1616
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1617
|
-
log(`tunnel start failed: ${msg}`);
|
|
1618
|
-
return result({ status: "error", message: `Could not start the tunnel: ${msg}` });
|
|
1619
|
-
}
|
|
1620
|
-
}
|
|
1621
|
-
else {
|
|
1622
|
-
log(`tunnel already running (PID ${status.pid})`);
|
|
1623
|
-
}
|
|
1624
|
-
// ── Complete ────────────────────────────────────────────────────
|
|
1625
|
-
const adminUrl = `https://${adminHostname}`;
|
|
1626
|
-
const publicUrl = publicHostname ? `https://${publicHostname}` : undefined;
|
|
1627
|
-
log(`complete — admin: ${adminUrl}${publicUrl ? `, public: ${publicUrl}` : ""}`);
|
|
1628
|
-
const urlLines = publicUrl
|
|
1629
|
-
? `Admin: ${adminUrl}\nPublic: ${publicUrl}`
|
|
1630
|
-
: `Admin: ${adminUrl}`;
|
|
1631
|
-
const reachableNote = publicUrl
|
|
1632
|
-
? "Both URLs are live and reachable."
|
|
1633
|
-
: "The admin URL is live and reachable.";
|
|
1634
|
-
return result({
|
|
1635
|
-
status: "complete",
|
|
1636
|
-
message: `Remote access is set up and verified.\n\n${urlLines}\n\n${reachableNote} The tunnel auto-starts on device reboot.`,
|
|
1637
|
-
data: { adminUrl, publicUrl },
|
|
1638
|
-
});
|
|
1639
|
-
}
|
|
1640
|
-
catch (err) {
|
|
1641
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1642
|
-
log(`unexpected error: ${msg}`);
|
|
1643
|
-
return result({ status: "error", message: `Setup failed: ${msg}` });
|
|
1644
|
-
}
|
|
713
|
+
main().catch((err) => {
|
|
714
|
+
console.error(`[cloudflare-mcp] fatal: ${err}`);
|
|
715
|
+
process.exit(1);
|
|
1645
716
|
});
|
|
1646
|
-
function result(r) {
|
|
1647
|
-
return {
|
|
1648
|
-
content: [{ type: "text", text: JSON.stringify(r, null, 2) }],
|
|
1649
|
-
};
|
|
1650
|
-
}
|
|
1651
|
-
const transport = new StdioServerTransport();
|
|
1652
|
-
await server.connect(transport);
|
|
1653
717
|
//# sourceMappingURL=index.js.map
|