@rubytech/create-realagent 1.0.619 → 1.0.621
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 +181 -1033
- package/payload/platform/plugins/cloudflare/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts +117 -261
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts.map +1 -1
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.js +379 -903
- 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 +70 -76
- package/payload/platform/plugins/cloudflare/skills/setup-tunnel/SKILL.md +34 -82
- package/payload/platform/plugins/docs/PLUGIN.md +1 -1
- package/payload/platform/plugins/docs/references/cloudflare.md +21 -30
- package/payload/platform/templates/agents/admin/IDENTITY.md +8 -0
- package/payload/platform/templates/agents/public/IDENTITY.md +8 -0
- package/payload/platform/templates/specialists/agents/personal-assistant.md +9 -9
- package/payload/server/server.js +85 -9
- package/payload/platform/plugins/cloudflare/mcp/__tests__/auth-binding.test.ts +0 -195
|
@@ -3,21 +3,82 @@ 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
|
});
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// tunnel-status trailing guidance — deterministic 1:1 mapping from status
|
|
14
|
+
// enum → single prescribed recovery sentence.
|
|
15
|
+
//
|
|
16
|
+
// The trailing string is a prompt for the agent's next turn. Task 541 closed
|
|
17
|
+
// the recurrence loop where a "check in the browser" sentence primed a 44-
|
|
18
|
+
// tool-call dashboard cascade. The mapping here is exhaustive for every
|
|
19
|
+
// reachable `unhealthyReason`; there is no generic fallback branch. Each
|
|
20
|
+
// sentence names exactly one next action so the agent cannot fork on it.
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
const BOUND_ACCOUNT_MISMATCH_SENTENCE = (hostname) => `Sign in to Cloudflare at dash.cloudflare.com in the account that owns ${hostname} — ` +
|
|
23
|
+
`the account with the little orange cloud next to it in your browser tab — then tell me ` +
|
|
24
|
+
`and I'll restart the tunnel login.`;
|
|
25
|
+
function buildTunnelStatusGuidance(status) {
|
|
26
|
+
if (!status.unhealthyReason)
|
|
27
|
+
return "";
|
|
28
|
+
if (status.unhealthyReason === "not-running") {
|
|
29
|
+
return `\n\nThe tunnel process is not running. Ask the user if they want me to enable it.`;
|
|
30
|
+
}
|
|
31
|
+
if (status.unhealthyReason === "no-tunnel-configured") {
|
|
32
|
+
return `\n\nNo tunnel is configured on this laptop yet. Run tunnel-create to set one up.`;
|
|
33
|
+
}
|
|
34
|
+
if (status.unhealthyReason === "bound-account-does-not-own-hostname") {
|
|
35
|
+
// Use the first configured hostname as the anchor — matches how the
|
|
36
|
+
// operator thinks about the setup. If configuredHostnames is empty
|
|
37
|
+
// (unreachable here because the enum is only set when probes ran)
|
|
38
|
+
// fall back to domain.
|
|
39
|
+
const anchor = status.configuredHostnames[0] ??
|
|
40
|
+
status.persistedHostnames[0] ??
|
|
41
|
+
status.domain ??
|
|
42
|
+
"your domain";
|
|
43
|
+
return `\n\n${BOUND_ACCOUNT_MISMATCH_SENTENCE(anchor)}`;
|
|
44
|
+
}
|
|
45
|
+
if (status.unhealthyReason === "hostname-probes-failed") {
|
|
46
|
+
// Dominant per-hostname mode drives the sentence. All failing probes
|
|
47
|
+
// share one mode in the expected failure classes — when they diverge,
|
|
48
|
+
// the first failure wins (the per-hostname list is already printed in
|
|
49
|
+
// the JSON above; the sentence names the one action the agent takes).
|
|
50
|
+
const firstFailure = status.probes.find((p) => p.failureMode !== "ok");
|
|
51
|
+
const mode = firstFailure?.failureMode ?? "edge-unreachable";
|
|
52
|
+
if (mode === "tunnel-not-matched" || mode === "cname-points-elsewhere") {
|
|
53
|
+
return `\n\nDNS is pointing at a different tunnel. I'll re-point it.`;
|
|
54
|
+
}
|
|
55
|
+
if (mode === "dns-missing") {
|
|
56
|
+
return `\n\nDNS is not set up yet. I'll create it.`;
|
|
57
|
+
}
|
|
58
|
+
// edge-unreachable — transient Cloudflare edge or connectivity; the
|
|
59
|
+
// agent should not immediately re-probe or open the dashboard.
|
|
60
|
+
return `\n\nThe tunnel is not reachable through Cloudflare right now. This usually clears in a minute.`;
|
|
61
|
+
}
|
|
62
|
+
// Exhaustiveness guard — a new enum value must be added to this switch
|
|
63
|
+
// before it ever appears in a tool result. Returning empty string would
|
|
64
|
+
// let a novel failure mode silently emit JSON-only output. The bare
|
|
65
|
+
// assignment (no `as never` cast) is the point: TypeScript narrows
|
|
66
|
+
// `status.unhealthyReason` to `never` after every branch above returns,
|
|
67
|
+
// so a new `TunnelUnhealthyReason` value introduced without a branch
|
|
68
|
+
// here fails at compile time.
|
|
69
|
+
const _exhaustive = status.unhealthyReason;
|
|
70
|
+
console.error(`[cloudflare:tunnel-status:unknown-reason] reason=${String(_exhaustive)}`);
|
|
71
|
+
return "";
|
|
72
|
+
}
|
|
14
73
|
// ===================================================================
|
|
15
|
-
// Authentication — cert.pem only
|
|
74
|
+
// Authentication — OAuth cert.pem only
|
|
16
75
|
//
|
|
17
|
-
// The plugin recognises exactly one
|
|
18
|
-
// cert.pem was bound to via
|
|
19
|
-
//
|
|
20
|
-
//
|
|
76
|
+
// The plugin recognises exactly one identity: the Cloudflare account
|
|
77
|
+
// cert.pem was bound to via `cloudflared tunnel login`. No API token
|
|
78
|
+
// handling, no SDK, no read of account state by any code path. The
|
|
79
|
+
// operator's logged-in Cloudflare dashboard session is the source of
|
|
80
|
+
// truth for which domains exist and who owns them; the agent's role
|
|
81
|
+
// is to instruct the operator where to click.
|
|
21
82
|
// ===================================================================
|
|
22
83
|
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
84
|
force: z
|
|
@@ -26,26 +87,22 @@ server.tool("tunnel-login", "Authenticate with Cloudflare via `cloudflared tunne
|
|
|
26
87
|
.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
88
|
}, async ({ force }) => {
|
|
28
89
|
try {
|
|
29
|
-
// Force: delete cert.pem (both paths) and binding before restarting
|
|
30
90
|
if (force) {
|
|
31
91
|
console.error("[tunnel-login] force=true — clearing cert.pem and account binding before re-auth");
|
|
32
92
|
cloudflared.resetAuth();
|
|
33
93
|
}
|
|
34
94
|
const auth = cloudflared.validateAuth();
|
|
35
|
-
// Phase: Already bound — cert + binding present and accountIds agree
|
|
36
95
|
if (auth.bound) {
|
|
37
|
-
console.error(`[tunnel-login]
|
|
96
|
+
console.error(`[cloudflare:tunnel-login:complete] accountId=${auth.boundAccountId}`);
|
|
38
97
|
return {
|
|
39
98
|
content: [
|
|
40
99
|
{
|
|
41
100
|
type: "text",
|
|
42
|
-
text: `
|
|
101
|
+
text: `Sign-in complete.`,
|
|
43
102
|
},
|
|
44
103
|
],
|
|
45
104
|
};
|
|
46
105
|
}
|
|
47
|
-
// Phase: cert.pem exists — derive credentials and either materialize
|
|
48
|
-
// or reconcile the binding.
|
|
49
106
|
if (auth.hasCert) {
|
|
50
107
|
const creds = cloudflared.parseCertPem();
|
|
51
108
|
if (!creds) {
|
|
@@ -53,210 +110,138 @@ server.tool("tunnel-login", "Authenticate with Cloudflare via `cloudflared tunne
|
|
|
53
110
|
content: [
|
|
54
111
|
{
|
|
55
112
|
type: "text",
|
|
56
|
-
text: `
|
|
113
|
+
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
114
|
},
|
|
58
115
|
],
|
|
59
116
|
isError: true,
|
|
60
117
|
};
|
|
61
118
|
}
|
|
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
119
|
if (!auth.hasBinding) {
|
|
66
|
-
|
|
120
|
+
cloudflared.materializeBinding("migration");
|
|
121
|
+
console.error(`[cloudflare:tunnel-login:complete] accountId=${auth.certAccountId} source=migration`);
|
|
67
122
|
return {
|
|
68
123
|
content: [
|
|
69
124
|
{
|
|
70
125
|
type: "text",
|
|
71
|
-
text: `
|
|
126
|
+
text: `Sign-in complete.`,
|
|
72
127
|
},
|
|
73
128
|
],
|
|
74
129
|
};
|
|
75
130
|
}
|
|
76
|
-
// Drift: cert says account X, binding says account Y — refuse with
|
|
77
|
-
// recovery instruction (force=true will clear both).
|
|
78
131
|
if (auth.certAccountId !== auth.boundAccountId) {
|
|
79
132
|
return {
|
|
80
133
|
content: [
|
|
81
134
|
{
|
|
82
135
|
type: "text",
|
|
83
|
-
text: `
|
|
136
|
+
text: `The Cloudflare sign-in on this laptop has changed since it was first set up. ${cloudflared.recoveryMessage()}`,
|
|
84
137
|
},
|
|
85
138
|
],
|
|
86
139
|
isError: true,
|
|
87
140
|
};
|
|
88
141
|
}
|
|
89
142
|
}
|
|
90
|
-
|
|
91
|
-
const activeLogin = cloudflared.getActiveLogin();
|
|
92
|
-
if (activeLogin) {
|
|
143
|
+
if (!cloudflared.isInstalled()) {
|
|
93
144
|
return {
|
|
94
145
|
content: [
|
|
95
146
|
{
|
|
96
147
|
type: "text",
|
|
97
|
-
text:
|
|
148
|
+
text: "cloudflared is not installed. Run tunnel-install first.",
|
|
98
149
|
},
|
|
99
150
|
],
|
|
151
|
+
isError: true,
|
|
100
152
|
};
|
|
101
153
|
}
|
|
102
|
-
//
|
|
103
|
-
|
|
154
|
+
// Three-state login machine. `complete` is already handled above via
|
|
155
|
+
// `auth.bound` / migration path, so here we branch on in-progress vs.
|
|
156
|
+
// the two restart cases (log-visible failure, PID-dead without cert).
|
|
157
|
+
const loginStatus = cloudflared.getLoginStatus();
|
|
158
|
+
if (loginStatus.state === "failed") {
|
|
159
|
+
console.error(`[cloudflare:tunnel-login:failed] reason=${loginStatus.reason} ` +
|
|
160
|
+
`pid=${loginStatus.loginState?.pid ?? "none"} marker=${JSON.stringify(loginStatus.marker)}`);
|
|
161
|
+
cloudflared.resetAuth();
|
|
162
|
+
const restarted = await cloudflared.tunnelLogin();
|
|
104
163
|
return {
|
|
105
164
|
content: [
|
|
106
165
|
{
|
|
107
166
|
type: "text",
|
|
108
|
-
text:
|
|
167
|
+
text: `Sign-in failed — the browser didn't open on the laptop. Restarting.\n\n` +
|
|
168
|
+
`Sign-in URL: ${restarted.authUrl}\n\n` +
|
|
169
|
+
`Ask the user to open the URL, pick the Cloudflare account they want this laptop signed into, and click Authorize. ` +
|
|
170
|
+
`Call tunnel-login again once they confirm — it will detect the completed sign-in automatically.`,
|
|
109
171
|
},
|
|
110
172
|
],
|
|
111
|
-
isError: true,
|
|
112
173
|
};
|
|
113
174
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
content: [
|
|
120
|
-
{
|
|
121
|
-
type: "text",
|
|
122
|
-
text: `Cloudflare login started${alreadyNote}. Open this URL to authorize:\n\n${result.authUrl}\n\nThe user must open this URL and click Authorize. After authorization, call tunnel-login again — it will detect the authorization and derive API credentials automatically.`,
|
|
123
|
-
},
|
|
124
|
-
],
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
catch (err) {
|
|
128
|
-
return {
|
|
129
|
-
content: [
|
|
130
|
-
{
|
|
131
|
-
type: "text",
|
|
132
|
-
text: `Login failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
133
|
-
},
|
|
134
|
-
],
|
|
135
|
-
isError: true,
|
|
136
|
-
};
|
|
137
|
-
}
|
|
138
|
-
});
|
|
139
|
-
server.tool("cf-zone-status", "List all zones (domains) on the Cloudflare account with their activation status and assigned nameservers.", {}, async () => {
|
|
140
|
-
try {
|
|
141
|
-
const zones = await cloudflared.listZones();
|
|
142
|
-
if (zones.length === 0) {
|
|
175
|
+
if (loginStatus.state === "exited-no-cert") {
|
|
176
|
+
console.error(`[cloudflare:tunnel-login:failed] reason=login-process-exited-without-cert ` +
|
|
177
|
+
`pid=${loginStatus.loginState?.pid ?? "none"}`);
|
|
178
|
+
cloudflared.resetAuth();
|
|
179
|
+
const restarted = await cloudflared.tunnelLogin();
|
|
143
180
|
return {
|
|
144
181
|
content: [
|
|
145
182
|
{
|
|
146
183
|
type: "text",
|
|
147
|
-
text:
|
|
184
|
+
text: `Sign-in ended without saving the cert. Restarting.\n\n` +
|
|
185
|
+
`Sign-in URL: ${restarted.authUrl}\n\n` +
|
|
186
|
+
`Ask the user to open the URL, pick the Cloudflare account they want this laptop signed into, and click Authorize. ` +
|
|
187
|
+
`Call tunnel-login again once they confirm — it will detect the completed sign-in automatically.`,
|
|
148
188
|
},
|
|
149
189
|
],
|
|
150
190
|
};
|
|
151
191
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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`);
|
|
192
|
+
if (loginStatus.state === "in-progress") {
|
|
193
|
+
console.error(`[cloudflare:tunnel-login:waiting] pid=${loginStatus.loginState.pid} authUrl=${loginStatus.loginState.authUrl}`);
|
|
194
|
+
return {
|
|
195
|
+
content: [
|
|
196
|
+
{
|
|
197
|
+
type: "text",
|
|
198
|
+
text: `Sign-in in progress — a browser window is already open (started ${new Date(loginStatus.loginState.startedAt).toISOString()}).\n\n` +
|
|
199
|
+
`Sign-in URL: ${loginStatus.loginState.authUrl}\n\n` +
|
|
200
|
+
`Ask the user to finish the sign-in in their browser and click Authorize. ` +
|
|
201
|
+
`Call tunnel-login again once the user confirms — it will detect the completed sign-in automatically.`,
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
// state === "idle" — spawn fresh.
|
|
207
|
+
const result = await cloudflared.tunnelLogin();
|
|
188
208
|
return {
|
|
189
209
|
content: [
|
|
190
210
|
{
|
|
191
211
|
type: "text",
|
|
192
|
-
text: `
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
? "The domain is active on Cloudflare."
|
|
196
|
-
: `Current status: ${result.status}.`}`,
|
|
212
|
+
text: `Cloudflare sign-in started. Open this URL to authorize:\n\n${result.authUrl}\n\n` +
|
|
213
|
+
`Ask the user to open the URL, pick the Cloudflare account they want this laptop signed into, and click Authorize. ` +
|
|
214
|
+
`Call tunnel-login again once they confirm — it will detect the completed sign-in automatically.`,
|
|
197
215
|
},
|
|
198
216
|
],
|
|
199
217
|
};
|
|
200
218
|
}
|
|
201
219
|
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
220
|
return {
|
|
213
221
|
content: [
|
|
214
222
|
{
|
|
215
223
|
type: "text",
|
|
216
|
-
text: `
|
|
217
|
-
`If this looks like a permissions error, the cert.pem account may not own ` +
|
|
218
|
-
`this brand's zones. ${cloudflared.recoveryMessage()}`,
|
|
224
|
+
text: `Sign-in failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
219
225
|
},
|
|
220
226
|
],
|
|
221
227
|
isError: true,
|
|
222
228
|
};
|
|
223
229
|
}
|
|
224
230
|
});
|
|
225
|
-
|
|
226
|
-
// Cloudflare Tunnel tools
|
|
227
|
-
// ===================================================================
|
|
228
|
-
server.tool("tunnel-status", "Full Cloudflare Tunnel status: installed, version, token valid, running, domain, hostnames.", {
|
|
231
|
+
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
232
|
domain: z
|
|
230
233
|
.string()
|
|
231
234
|
.optional()
|
|
232
|
-
.describe("The bare domain (e.g. 'maxy.bot'). If omitted,
|
|
235
|
+
.describe("The bare domain (e.g. 'maxy.bot'). If omitted, derived from persisted state."),
|
|
233
236
|
}, async ({ domain }) => {
|
|
234
237
|
try {
|
|
235
238
|
const status = await cloudflared.getStatus(domain);
|
|
236
|
-
|
|
237
|
-
let dnsWarning = "";
|
|
238
|
-
if (status.hostnames.length > 0) {
|
|
239
|
-
const dns = await import("node:dns/promises");
|
|
240
|
-
const resolver = new dns.Resolver();
|
|
241
|
-
resolver.setServers(["8.8.8.8", "1.1.1.1"]);
|
|
242
|
-
const checks = [];
|
|
243
|
-
for (const h of status.hostnames) {
|
|
244
|
-
try {
|
|
245
|
-
await resolver.resolve4(h);
|
|
246
|
-
}
|
|
247
|
-
catch {
|
|
248
|
-
checks.push(h);
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
if (checks.length > 0) {
|
|
252
|
-
dnsWarning = `\n\nDNS NOT RESOLVING: ${checks.join(", ")} do not resolve (checked via Google DNS 8.8.8.8). Possible causes: (1) CNAME records not created — run tunnel-create to create them; (2) nameservers not pointing to Cloudflare — check cf-zone-status; (3) DNS propagation in progress — wait a few minutes and re-check.`;
|
|
253
|
-
}
|
|
254
|
-
}
|
|
239
|
+
const guidance = buildTunnelStatusGuidance(status);
|
|
255
240
|
return {
|
|
256
241
|
content: [
|
|
257
242
|
{
|
|
258
243
|
type: "text",
|
|
259
|
-
text: JSON.stringify(status, null, 2) +
|
|
244
|
+
text: JSON.stringify(status, null, 2) + guidance,
|
|
260
245
|
},
|
|
261
246
|
],
|
|
262
247
|
};
|
|
@@ -317,7 +302,7 @@ server.tool("tunnel-install", "Install the cloudflared binary if not already pre
|
|
|
317
302
|
};
|
|
318
303
|
}
|
|
319
304
|
});
|
|
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.
|
|
305
|
+
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 domain is not on the signed-in Cloudflare account. Idempotent — reuses existing tunnel if name matches.", {
|
|
321
306
|
domain: z
|
|
322
307
|
.string()
|
|
323
308
|
.describe("The bare domain (e.g. 'maxy.bot')."),
|
|
@@ -335,7 +320,6 @@ server.tool("tunnel-create", "Create a Cloudflare Tunnel and route DNS for the s
|
|
|
335
320
|
.optional()
|
|
336
321
|
.describe("Human-readable tunnel name (default: derived from domain)"),
|
|
337
322
|
}, async ({ domain, adminSubdomain, publicSubdomain, tunnelName }) => {
|
|
338
|
-
// Validate: admin and public subdomains must differ
|
|
339
323
|
if (publicSubdomain && adminSubdomain === publicSubdomain) {
|
|
340
324
|
return {
|
|
341
325
|
content: [
|
|
@@ -348,35 +332,27 @@ server.tool("tunnel-create", "Create a Cloudflare Tunnel and route DNS for the s
|
|
|
348
332
|
};
|
|
349
333
|
}
|
|
350
334
|
try {
|
|
335
|
+
const auth = cloudflared.validateAuth();
|
|
336
|
+
if (!auth.bound) {
|
|
337
|
+
return {
|
|
338
|
+
content: [
|
|
339
|
+
{
|
|
340
|
+
type: "text",
|
|
341
|
+
text: `REFUSED: this laptop is not signed into Cloudflare yet. ${cloudflared.recoveryMessage()}`,
|
|
342
|
+
},
|
|
343
|
+
],
|
|
344
|
+
isError: true,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
351
347
|
const name = tunnelName ?? domain.replace(/\./g, "-");
|
|
352
348
|
const platformPort = parseInt(process.env.PLATFORM_PORT ?? "19200", 10);
|
|
353
|
-
// Build hostname list from subdomains
|
|
354
349
|
const adminHostname = `${adminSubdomain}.${domain}`;
|
|
355
350
|
const publicHostname = publicSubdomain ? `${publicSubdomain}.${domain}` : null;
|
|
356
351
|
const hostnames = publicHostname ? [adminHostname, publicHostname] : [adminHostname];
|
|
357
352
|
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
353
|
// Step 1: Create tunnel via CLI (idempotent — reuses if name exists)
|
|
362
354
|
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
|
|
355
|
+
// Step 2: Write local config.yml with ingress rules
|
|
380
356
|
const configPath = cloudflared.writeLocalConfig(tunnel.tunnelId, tunnel.credentialsPath, hostnames, platformPort);
|
|
381
357
|
// Persist tunnel identity with actual hostnames
|
|
382
358
|
cloudflared.saveTunnelIdentity({
|
|
@@ -388,21 +364,19 @@ server.tool("tunnel-create", "Create a Cloudflare Tunnel and route DNS for the s
|
|
|
388
364
|
adminHostname,
|
|
389
365
|
publicHostname,
|
|
390
366
|
});
|
|
391
|
-
// Step
|
|
367
|
+
// Step 3: Route DNS via CLI for each hostname (pre-flight + post-flight in routeDnsCli)
|
|
392
368
|
const dnsResults = [];
|
|
393
369
|
for (const h of hostnames) {
|
|
394
370
|
const route = await cloudflared.routeDnsCli(tunnel.tunnelId, h);
|
|
395
371
|
dnsResults.push({
|
|
396
372
|
hostname: h,
|
|
397
373
|
fqdn: route.fqdn,
|
|
398
|
-
zone: route.zone,
|
|
399
374
|
note: route.created ? "created" : "already existed",
|
|
400
375
|
});
|
|
401
376
|
}
|
|
402
|
-
// Step
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
resolver.setServers(["8.8.8.8", "1.1.1.1"]);
|
|
377
|
+
// Step 4: Verify hostnames resolve via public DNS
|
|
378
|
+
const resolver = new Resolver();
|
|
379
|
+
resolver.setServers(["1.1.1.1", "8.8.8.8"]);
|
|
406
380
|
const dnsWarnings = [];
|
|
407
381
|
for (const h of hostnames) {
|
|
408
382
|
try {
|
|
@@ -414,16 +388,16 @@ server.tool("tunnel-create", "Create a Cloudflare Tunnel and route DNS for the s
|
|
|
414
388
|
}
|
|
415
389
|
let dnsNote = "";
|
|
416
390
|
if (dnsWarnings.length > 0) {
|
|
417
|
-
dnsNote = `\n\nDNS WARNING: ${dnsWarnings.join(", ")} did not resolve via
|
|
391
|
+
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
392
|
}
|
|
419
393
|
const dnsLines = dnsResults
|
|
420
|
-
.map((r) => `
|
|
394
|
+
.map((r) => ` ${r.hostname} → this tunnel (${r.note})`)
|
|
421
395
|
.join("\n");
|
|
422
396
|
return {
|
|
423
397
|
content: [
|
|
424
398
|
{
|
|
425
399
|
type: "text",
|
|
426
|
-
text: `Tunnel created.\n\n
|
|
400
|
+
text: `Tunnel created.\n\n Name: ${tunnel.tunnelName}\n${dnsLines}\n Config: ${configPath}\n\nUse tunnel-enable to start the tunnel.${dnsNote}`,
|
|
427
401
|
},
|
|
428
402
|
],
|
|
429
403
|
};
|
|
@@ -451,7 +425,6 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
|
|
|
451
425
|
domain: z.string().optional().describe("The bare domain (e.g. 'maxy.bot'). If omitted, reads from persisted tunnel state."),
|
|
452
426
|
}, async ({ tunnelId: tunnelIdParam, domain: domainParam }) => {
|
|
453
427
|
try {
|
|
454
|
-
// Resolve tunnelId and domain from params or persisted state
|
|
455
428
|
const tunnelId = tunnelIdParam ?? cloudflared.getPersistedTunnelId();
|
|
456
429
|
const domain = domainParam ?? cloudflared.getPersistedDomain();
|
|
457
430
|
if (!tunnelId) {
|
|
@@ -459,7 +432,7 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
|
|
|
459
432
|
content: [
|
|
460
433
|
{
|
|
461
434
|
type: "text",
|
|
462
|
-
text: "No tunnel
|
|
435
|
+
text: "No tunnel found on this laptop. Run tunnel-create first.",
|
|
463
436
|
},
|
|
464
437
|
],
|
|
465
438
|
isError: true,
|
|
@@ -470,30 +443,28 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
|
|
|
470
443
|
content: [
|
|
471
444
|
{
|
|
472
445
|
type: "text",
|
|
473
|
-
text: "No domain
|
|
446
|
+
text: "No domain recorded for this tunnel. Run tunnel-create first to set one up with a domain.",
|
|
474
447
|
},
|
|
475
448
|
],
|
|
476
449
|
isError: true,
|
|
477
450
|
};
|
|
478
451
|
}
|
|
479
|
-
// Read actual hostnames from persisted state (backward compat: derives from domain)
|
|
480
452
|
const hostnames = cloudflared.getPersistedHostnames();
|
|
481
453
|
if (hostnames.length === 0) {
|
|
482
454
|
return {
|
|
483
455
|
content: [
|
|
484
456
|
{
|
|
485
457
|
type: "text",
|
|
486
|
-
text: "No
|
|
458
|
+
text: "No addresses recorded for this tunnel. Run tunnel-create first.",
|
|
487
459
|
},
|
|
488
460
|
],
|
|
489
461
|
isError: true,
|
|
490
462
|
};
|
|
491
463
|
}
|
|
492
464
|
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"]);
|
|
465
|
+
// GATE 1: Check if actual hostnames resolve via public DNS
|
|
466
|
+
const resolver = new Resolver();
|
|
467
|
+
resolver.setServers(["1.1.1.1", "8.8.8.8"]);
|
|
497
468
|
const unresolvedSubs = [];
|
|
498
469
|
for (const h of hostnames) {
|
|
499
470
|
try {
|
|
@@ -508,7 +479,7 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
|
|
|
508
479
|
content: [
|
|
509
480
|
{
|
|
510
481
|
type: "text",
|
|
511
|
-
text: `REFUSED: ${unresolvedSubs.join(", ")} do not resolve
|
|
482
|
+
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
483
|
},
|
|
513
484
|
],
|
|
514
485
|
isError: true,
|
|
@@ -517,7 +488,7 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
|
|
|
517
488
|
const brand = cloudflared.loadBrand();
|
|
518
489
|
const platformPort = parseInt(process.env.PLATFORM_PORT ?? "19200", 10);
|
|
519
490
|
console.error(`[tunnel-enable] using PLATFORM_PORT=${platformPort}`);
|
|
520
|
-
// GATE 2: Remote auth must be configured
|
|
491
|
+
// GATE 2: Remote auth must be configured
|
|
521
492
|
try {
|
|
522
493
|
const res = await fetch(`http://127.0.0.1:${platformPort}/api/remote-auth/status`, {
|
|
523
494
|
signal: AbortSignal.timeout(5000),
|
|
@@ -546,23 +517,20 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
|
|
|
546
517
|
isError: true,
|
|
547
518
|
};
|
|
548
519
|
}
|
|
549
|
-
// GATE 3:
|
|
550
|
-
// We use validateAuth (non-throwing) so we can produce a uniform
|
|
551
|
-
// refusal message regardless of which condition failed.
|
|
520
|
+
// GATE 3: cert.pem + binding
|
|
552
521
|
const enableAuth = cloudflared.validateAuth();
|
|
553
522
|
if (!enableAuth.bound) {
|
|
554
|
-
const missing = !enableAuth.hasCert ? "cert.pem" : !enableAuth.hasBinding ? "account binding" : "matching cert/binding";
|
|
555
523
|
return {
|
|
556
524
|
content: [
|
|
557
525
|
{
|
|
558
526
|
type: "text",
|
|
559
|
-
text: `REFUSED: Cannot start tunnel —
|
|
527
|
+
text: `REFUSED: Cannot start tunnel — this laptop is not signed into Cloudflare or the sign-in has drifted. ${cloudflared.recoveryMessage()}`,
|
|
560
528
|
},
|
|
561
529
|
],
|
|
562
530
|
isError: true,
|
|
563
531
|
};
|
|
564
532
|
}
|
|
565
|
-
// GATE 4: config.yml must exist
|
|
533
|
+
// GATE 4: config.yml must exist
|
|
566
534
|
const tunnelState = cloudflared.getPersistedState();
|
|
567
535
|
const configPath = tunnelState?.configPath;
|
|
568
536
|
const credentialsPath = tunnelState?.credentialsPath;
|
|
@@ -578,7 +546,6 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
|
|
|
578
546
|
isError: true,
|
|
579
547
|
};
|
|
580
548
|
}
|
|
581
|
-
// Start the tunnel daemon — config.yml + cert.pem, deterministic
|
|
582
549
|
cloudflared.startTunnel({
|
|
583
550
|
tunnelId,
|
|
584
551
|
tunnelName,
|
|
@@ -586,7 +553,6 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
|
|
|
586
553
|
configPath,
|
|
587
554
|
credentialsPath: credentialsPath ?? "",
|
|
588
555
|
});
|
|
589
|
-
// Wait up to 5 seconds to verify the process didn't immediately crash
|
|
590
556
|
const logHint = `~/${brand.configDir}/logs/cloudflared.log`;
|
|
591
557
|
for (let i = 0; i < 10; i++) {
|
|
592
558
|
await new Promise((r) => setTimeout(r, 500));
|
|
@@ -604,9 +570,7 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
|
|
|
604
570
|
}
|
|
605
571
|
}
|
|
606
572
|
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
|
|
573
|
+
const verifyUrl = `https://${hostnames[0]}`;
|
|
610
574
|
let verified = false;
|
|
611
575
|
console.error(`[tunnel-enable] verifying url=${verifyUrl}`);
|
|
612
576
|
for (let attempt = 0; attempt < 6; attempt++) {
|
|
@@ -632,20 +596,19 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
|
|
|
632
596
|
content: [
|
|
633
597
|
{
|
|
634
598
|
type: "text",
|
|
635
|
-
text: `Tunnel process is running (PID ${status.pid}) but ${verifyUrl} returned
|
|
599
|
+
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
600
|
},
|
|
637
601
|
],
|
|
638
602
|
isError: true,
|
|
639
603
|
};
|
|
640
604
|
}
|
|
641
|
-
// Build URL list for output
|
|
642
605
|
const urlLines = hostnames.map((h, i) => {
|
|
643
606
|
const label = i === 0 ? "Admin" : "Public";
|
|
644
607
|
return ` ${label}: https://${h}`;
|
|
645
608
|
}).join("\n");
|
|
646
609
|
const reachableNote = hostnames.length > 1
|
|
647
|
-
? "All URLs are reachable through Cloudflare
|
|
648
|
-
: "The admin URL is reachable through Cloudflare
|
|
610
|
+
? "All URLs are reachable through Cloudflare."
|
|
611
|
+
: "The admin URL is reachable through Cloudflare.";
|
|
649
612
|
return {
|
|
650
613
|
content: [
|
|
651
614
|
{
|
|
@@ -697,40 +660,25 @@ server.tool("tunnel-disable", "Stop the Cloudflare Tunnel process. Config is pre
|
|
|
697
660
|
};
|
|
698
661
|
}
|
|
699
662
|
});
|
|
700
|
-
server.tool("tunnel-add-hostname", "Add an
|
|
663
|
+
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
664
|
hostname: z
|
|
702
665
|
.string()
|
|
703
|
-
.describe("The
|
|
666
|
+
.describe("The address to add (e.g. 'maxy.chat'). The domain must be on the Cloudflare account this laptop is signed into."),
|
|
704
667
|
tunnelId: z
|
|
705
668
|
.string()
|
|
706
669
|
.describe("The tunnel UUID. Get this from tunnel-status."),
|
|
707
670
|
}, async ({ hostname, tunnelId }) => {
|
|
708
671
|
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
|
|
672
|
+
const route = await cloudflared.routeDnsCli(tunnelId, hostname);
|
|
719
673
|
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";
|
|
674
|
+
const routeNote = route.created
|
|
675
|
+
? "created"
|
|
676
|
+
: "already existed";
|
|
729
677
|
return {
|
|
730
678
|
content: [
|
|
731
679
|
{
|
|
732
680
|
type: "text",
|
|
733
|
-
text: `
|
|
681
|
+
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
682
|
},
|
|
735
683
|
],
|
|
736
684
|
};
|
|
@@ -746,7 +694,7 @@ server.tool("tunnel-add-hostname", "Add an alias hostname to an existing Cloudfl
|
|
|
746
694
|
content: [
|
|
747
695
|
{
|
|
748
696
|
type: "text",
|
|
749
|
-
text: `Failed to add
|
|
697
|
+
text: `Failed to add address: ${err instanceof Error ? err.message : String(err)}`,
|
|
750
698
|
},
|
|
751
699
|
],
|
|
752
700
|
isError: true,
|
|
@@ -764,12 +712,11 @@ server.tool("dns-lookup", "Resolve DNS records for a hostname. Replaces dig/nslo
|
|
|
764
712
|
nameserver: z
|
|
765
713
|
.string()
|
|
766
714
|
.optional()
|
|
767
|
-
.describe("Custom nameserver IP (default:
|
|
715
|
+
.describe("Custom nameserver IP (default: Cloudflare 1.1.1.1 + Google 8.8.8.8)"),
|
|
768
716
|
}, async ({ hostname, type, nameserver }) => {
|
|
769
717
|
try {
|
|
770
|
-
const
|
|
771
|
-
|
|
772
|
-
resolver.setServers(nameserver ? [nameserver] : ["8.8.8.8", "1.1.1.1"]);
|
|
718
|
+
const resolver = new Resolver();
|
|
719
|
+
resolver.setServers(nameserver ? [nameserver] : ["1.1.1.1", "8.8.8.8"]);
|
|
773
720
|
const recordType = type ?? "A";
|
|
774
721
|
const results = {
|
|
775
722
|
hostname,
|
|
@@ -791,15 +738,13 @@ server.tool("dns-lookup", "Resolve DNS records for a hostname. Replaces dig/nslo
|
|
|
791
738
|
catch (cnameErr) {
|
|
792
739
|
const cnameCode = cnameErr.code;
|
|
793
740
|
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
741
|
try {
|
|
797
742
|
const aRecords = await resolver.resolve4(hostname);
|
|
798
|
-
results.note = "CNAME lookup returned ENODATA — record is likely
|
|
743
|
+
results.note = "CNAME lookup returned ENODATA — record is likely flattened to A";
|
|
799
744
|
results.addresses = aRecords;
|
|
800
745
|
}
|
|
801
746
|
catch {
|
|
802
|
-
throw cnameErr;
|
|
747
|
+
throw cnameErr;
|
|
803
748
|
}
|
|
804
749
|
}
|
|
805
750
|
else {
|
|
@@ -845,809 +790,12 @@ server.tool("dns-lookup", "Resolve DNS records for a hostname. Replaces dig/nslo
|
|
|
845
790
|
};
|
|
846
791
|
}
|
|
847
792
|
});
|
|
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, "-");
|
|
793
|
+
async function main() {
|
|
794
|
+
const transport = new StdioServerTransport();
|
|
795
|
+
await server.connect(transport);
|
|
912
796
|
}
|
|
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
|
-
}
|
|
797
|
+
main().catch((err) => {
|
|
798
|
+
console.error(`[cloudflare-mcp] fatal: ${err}`);
|
|
799
|
+
process.exit(1);
|
|
1645
800
|
});
|
|
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
801
|
//# sourceMappingURL=index.js.map
|