@camstack/addon-cloudflare-tunnel 0.1.14 → 0.1.16
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/dist/cloudflare-tunnel.addon.js +1125 -74
- package/dist/cloudflare-tunnel.addon.js.map +1 -1
- package/dist/cloudflare-tunnel.addon.mjs +1124 -5
- package/dist/cloudflare-tunnel.addon.mjs.map +1 -1
- package/dist/index.js +6 -162
- package/dist/index.mjs +2 -87
- package/package.json +5 -3
- package/dist/chunk-VHOC5TFB.mjs +0 -55
- package/dist/chunk-VHOC5TFB.mjs.map +0 -1
- package/dist/cloudflare-tunnel.addon.d.mts +0 -19
- package/dist/cloudflare-tunnel.addon.d.ts +0 -19
- package/dist/index.d.mts +0 -25
- package/dist/index.d.ts +0 -25
- package/dist/index.js.map +0 -1
- package/dist/index.mjs.map +0 -1
|
@@ -1,79 +1,1130 @@
|
|
|
1
|
-
"
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let node_crypto = require("node:crypto");
|
|
3
|
+
let node_child_process = require("node:child_process");
|
|
4
|
+
let _camstack_types = require("@camstack/types");
|
|
5
|
+
let zod = require("zod");
|
|
6
|
+
//#region src/cloudflare-tunnel.ts
|
|
7
|
+
/**
|
|
8
|
+
* Direct child_process.spawn() driver for the `cloudflared` binary.
|
|
9
|
+
*
|
|
10
|
+
* Why no IProcessManager: `IKernelServices` doesn't expose one to addons,
|
|
11
|
+
* and ProcessConfig doesn't have an output-callback hook. We need stdout
|
|
12
|
+
* to live-parse the Quick-tunnel public URL, so spawn directly.
|
|
13
|
+
*
|
|
14
|
+
* Lifecycle:
|
|
15
|
+
* - start() spawns the binary, attaches stdout/stderr line forwarders
|
|
16
|
+
* (every line → `this.logger.info` with `tags.topic='tunnel'`),
|
|
17
|
+
* watches for the `https://*.trycloudflare.com` line on Quick mode,
|
|
18
|
+
* and updates `this.endpoint.url` when found.
|
|
19
|
+
* - stop() sends SIGTERM, escalates to SIGKILL after 5s.
|
|
20
|
+
* - Auto-restart on unexpected exit, capped at 5 retries.
|
|
21
|
+
*/
|
|
22
|
+
var QUICK_URL_REGEX = /\bhttps:\/\/[a-z0-9-]+\.trycloudflare\.com\b/i;
|
|
23
|
+
var CloudflareTunnelService = class CloudflareTunnelService {
|
|
24
|
+
id = "cloudflare-tunnel";
|
|
25
|
+
type = "cloudflare";
|
|
26
|
+
endpoint = null;
|
|
27
|
+
lastError;
|
|
28
|
+
child = null;
|
|
29
|
+
restartCount = 0;
|
|
30
|
+
intentionalStop = false;
|
|
31
|
+
static MAX_RESTARTS = 5;
|
|
32
|
+
static STOP_GRACE_MS = 5e3;
|
|
33
|
+
constructor(config, logger, eventBus) {
|
|
34
|
+
this.config = config;
|
|
35
|
+
this.logger = logger;
|
|
36
|
+
this.eventBus = eventBus;
|
|
37
|
+
}
|
|
38
|
+
async start() {
|
|
39
|
+
this.logger.info("Starting Cloudflare tunnel", {
|
|
40
|
+
meta: {
|
|
41
|
+
mode: this.config.mode,
|
|
42
|
+
localPort: this.config.localPort
|
|
43
|
+
},
|
|
44
|
+
tags: {
|
|
45
|
+
topic: "tunnel",
|
|
46
|
+
phase: "starting"
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
if (this.config.mode === "custom") {
|
|
50
|
+
if (!this.config.customTunnelToken) {
|
|
51
|
+
const err = /* @__PURE__ */ new Error("Custom tunnel not configured — run \"Enable\" from settings first");
|
|
52
|
+
this.logger.error(err.message, { tags: {
|
|
53
|
+
topic: "tunnel",
|
|
54
|
+
phase: "config-error"
|
|
55
|
+
} });
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
if (!this.config.customHostname) {
|
|
59
|
+
const err = /* @__PURE__ */ new Error("Custom tunnel missing public hostname — re-run \"Enable\"");
|
|
60
|
+
this.logger.error(err.message, { tags: {
|
|
61
|
+
topic: "tunnel",
|
|
62
|
+
phase: "config-error"
|
|
63
|
+
} });
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (this.child !== null) {
|
|
68
|
+
this.logger.warn("Cloudflare tunnel already running — refusing to spawn a duplicate", { tags: {
|
|
69
|
+
topic: "tunnel",
|
|
70
|
+
phase: "already-running"
|
|
71
|
+
} });
|
|
72
|
+
if (this.endpoint) return this.endpoint;
|
|
73
|
+
}
|
|
74
|
+
this.intentionalStop = false;
|
|
75
|
+
this.restartCount = 0;
|
|
76
|
+
this.lastError = void 0;
|
|
77
|
+
this.spawnChild();
|
|
78
|
+
const placeholderHost = this.config.mode === "custom" ? this.config.customHostname : "pending.trycloudflare.com";
|
|
79
|
+
this.endpoint = {
|
|
80
|
+
url: `https://${placeholderHost}`,
|
|
81
|
+
hostname: placeholderHost,
|
|
82
|
+
port: 443,
|
|
83
|
+
protocol: "https"
|
|
84
|
+
};
|
|
85
|
+
this.eventBus.emit({
|
|
86
|
+
id: (0, node_crypto.randomUUID)(),
|
|
87
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
88
|
+
source: {
|
|
89
|
+
type: "addon",
|
|
90
|
+
id: "cloudflare-tunnel"
|
|
91
|
+
},
|
|
92
|
+
category: _camstack_types.EventCategory.NetworkTunnelStarted,
|
|
93
|
+
data: { url: this.endpoint.url }
|
|
94
|
+
});
|
|
95
|
+
return this.endpoint;
|
|
96
|
+
}
|
|
97
|
+
async stop() {
|
|
98
|
+
this.logger.info("Stopping Cloudflare tunnel", {
|
|
99
|
+
meta: {
|
|
100
|
+
hadProcess: this.child !== null,
|
|
101
|
+
hadEndpoint: this.endpoint !== null
|
|
102
|
+
},
|
|
103
|
+
tags: {
|
|
104
|
+
topic: "tunnel",
|
|
105
|
+
phase: "stopping"
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
this.intentionalStop = true;
|
|
109
|
+
if (this.child !== null) {
|
|
110
|
+
const child = this.child;
|
|
111
|
+
this.child = null;
|
|
112
|
+
try {
|
|
113
|
+
child.kill("SIGTERM");
|
|
114
|
+
const killTimer = setTimeout(() => {
|
|
115
|
+
if (!child.killed) {
|
|
116
|
+
this.logger.warn("cloudflared did not exit within grace — SIGKILL", { tags: {
|
|
117
|
+
topic: "tunnel",
|
|
118
|
+
phase: "force-kill"
|
|
119
|
+
} });
|
|
120
|
+
try {
|
|
121
|
+
child.kill("SIGKILL");
|
|
122
|
+
} catch {}
|
|
123
|
+
}
|
|
124
|
+
}, CloudflareTunnelService.STOP_GRACE_MS);
|
|
125
|
+
await new Promise((resolve) => {
|
|
126
|
+
child.once("exit", () => {
|
|
127
|
+
clearTimeout(killTimer);
|
|
128
|
+
resolve();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
} catch (err) {
|
|
132
|
+
this.logger.warn("cloudflared process stop reported an error", {
|
|
133
|
+
meta: { error: err instanceof Error ? err.message : String(err) },
|
|
134
|
+
tags: {
|
|
135
|
+
topic: "tunnel",
|
|
136
|
+
phase: "stop-error"
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
this.endpoint = null;
|
|
142
|
+
this.logger.info("Cloudflare tunnel stopped", { tags: {
|
|
143
|
+
topic: "tunnel",
|
|
144
|
+
phase: "stopped"
|
|
145
|
+
} });
|
|
146
|
+
this.eventBus.emit({
|
|
147
|
+
id: (0, node_crypto.randomUUID)(),
|
|
148
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
149
|
+
source: {
|
|
150
|
+
type: "addon",
|
|
151
|
+
id: "cloudflare-tunnel"
|
|
152
|
+
},
|
|
153
|
+
category: _camstack_types.EventCategory.NetworkTunnelStopped,
|
|
154
|
+
data: {}
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
getEndpoint() {
|
|
158
|
+
return this.endpoint;
|
|
159
|
+
}
|
|
160
|
+
getStatus() {
|
|
161
|
+
const status = {
|
|
162
|
+
connected: this.endpoint !== null,
|
|
163
|
+
endpoint: this.endpoint
|
|
164
|
+
};
|
|
165
|
+
if (this.lastError !== void 0) status.error = this.lastError;
|
|
166
|
+
return status;
|
|
167
|
+
}
|
|
168
|
+
spawnChild() {
|
|
169
|
+
const args = this.config.mode === "quick" ? [
|
|
170
|
+
"tunnel",
|
|
171
|
+
"--url",
|
|
172
|
+
`http://${this.config.localHost || "127.0.0.1"}:${this.config.localPort}`
|
|
173
|
+
] : [
|
|
174
|
+
"tunnel",
|
|
175
|
+
"run",
|
|
176
|
+
"--token",
|
|
177
|
+
this.config.customTunnelToken
|
|
178
|
+
];
|
|
179
|
+
let child;
|
|
180
|
+
try {
|
|
181
|
+
child = (0, node_child_process.spawn)("cloudflared", args, { stdio: [
|
|
182
|
+
"ignore",
|
|
183
|
+
"pipe",
|
|
184
|
+
"pipe"
|
|
185
|
+
] });
|
|
186
|
+
} catch (err) {
|
|
187
|
+
this.logger.error("Failed to spawn cloudflared (binary missing?)", {
|
|
188
|
+
meta: { error: err instanceof Error ? err.message : String(err) },
|
|
189
|
+
tags: {
|
|
190
|
+
topic: "tunnel",
|
|
191
|
+
phase: "spawn-error"
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
throw err;
|
|
195
|
+
}
|
|
196
|
+
this.child = child;
|
|
197
|
+
this.logger.info("cloudflared spawned", {
|
|
198
|
+
meta: {
|
|
199
|
+
pid: child.pid,
|
|
200
|
+
args: args.join(" ")
|
|
201
|
+
},
|
|
202
|
+
tags: {
|
|
203
|
+
topic: "tunnel",
|
|
204
|
+
phase: "spawned"
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
const forwardLines = (stream, level) => {
|
|
208
|
+
let buf = "";
|
|
209
|
+
stream.setEncoding("utf8");
|
|
210
|
+
stream.on("data", (chunk) => {
|
|
211
|
+
buf += chunk;
|
|
212
|
+
const lines = buf.split("\n");
|
|
213
|
+
buf = lines.pop() ?? "";
|
|
214
|
+
for (const line of lines) {
|
|
215
|
+
if (!line.trim()) continue;
|
|
216
|
+
if (this.config.mode === "quick") {
|
|
217
|
+
const match = line.match(QUICK_URL_REGEX);
|
|
218
|
+
if (match && this.endpoint && this.endpoint.url !== match[0]) {
|
|
219
|
+
try {
|
|
220
|
+
const parsed = new URL(match[0]);
|
|
221
|
+
this.endpoint = {
|
|
222
|
+
url: match[0],
|
|
223
|
+
hostname: parsed.hostname,
|
|
224
|
+
port: parsed.port ? Number(parsed.port) : parsed.protocol === "https:" ? 443 : 80,
|
|
225
|
+
protocol: parsed.protocol === "https:" ? "https" : "http"
|
|
226
|
+
};
|
|
227
|
+
} catch {
|
|
228
|
+
this.endpoint = {
|
|
229
|
+
...this.endpoint,
|
|
230
|
+
url: match[0]
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
this.logger.info("Quick tunnel URL ready", {
|
|
234
|
+
meta: { url: match[0] },
|
|
235
|
+
tags: {
|
|
236
|
+
topic: "tunnel",
|
|
237
|
+
phase: "quick-url-ready"
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
this.eventBus.emit({
|
|
241
|
+
id: (0, node_crypto.randomUUID)(),
|
|
242
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
243
|
+
source: {
|
|
244
|
+
type: "addon",
|
|
245
|
+
id: "cloudflare-tunnel"
|
|
246
|
+
},
|
|
247
|
+
category: _camstack_types.EventCategory.NetworkTunnelStarted,
|
|
248
|
+
data: {
|
|
249
|
+
url: match[0],
|
|
250
|
+
updated: true
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (level === "warn") this.logger.warn(line, { tags: {
|
|
256
|
+
topic: "tunnel",
|
|
257
|
+
stream: "stderr"
|
|
258
|
+
} });
|
|
259
|
+
else this.logger.info(line, { tags: {
|
|
260
|
+
topic: "tunnel",
|
|
261
|
+
stream: "stdout"
|
|
262
|
+
} });
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
};
|
|
266
|
+
forwardLines(child.stdout, "info");
|
|
267
|
+
forwardLines(child.stderr, "info");
|
|
268
|
+
child.on("exit", (code, signal) => {
|
|
269
|
+
this.logger.info("cloudflared exited", {
|
|
270
|
+
meta: {
|
|
271
|
+
code,
|
|
272
|
+
signal,
|
|
273
|
+
intentional: this.intentionalStop
|
|
274
|
+
},
|
|
275
|
+
tags: {
|
|
276
|
+
topic: "tunnel",
|
|
277
|
+
phase: "exited"
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
if (this.child === child) this.child = null;
|
|
281
|
+
if (!this.intentionalStop && this.restartCount < CloudflareTunnelService.MAX_RESTARTS) {
|
|
282
|
+
this.restartCount++;
|
|
283
|
+
const backoffMs = Math.min(1e3 * Math.pow(2, this.restartCount), 3e4);
|
|
284
|
+
this.logger.warn("cloudflared crashed — restarting with backoff", {
|
|
285
|
+
meta: {
|
|
286
|
+
attempt: this.restartCount,
|
|
287
|
+
backoffMs
|
|
288
|
+
},
|
|
289
|
+
tags: {
|
|
290
|
+
topic: "tunnel",
|
|
291
|
+
phase: "restarting"
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
setTimeout(() => {
|
|
295
|
+
if (!this.intentionalStop) try {
|
|
296
|
+
this.spawnChild();
|
|
297
|
+
} catch (err) {
|
|
298
|
+
this.logger.error("cloudflared restart failed", {
|
|
299
|
+
meta: { error: err instanceof Error ? err.message : String(err) },
|
|
300
|
+
tags: {
|
|
301
|
+
topic: "tunnel",
|
|
302
|
+
phase: "restart-error"
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}, backoffMs);
|
|
307
|
+
} else if (!this.intentionalStop) {
|
|
308
|
+
this.logger.error("cloudflared crashed too many times — giving up", {
|
|
309
|
+
meta: { restartCount: this.restartCount },
|
|
310
|
+
tags: {
|
|
311
|
+
topic: "tunnel",
|
|
312
|
+
phase: "gave-up"
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
this.endpoint = null;
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
child.on("error", (err) => {
|
|
319
|
+
this.logger.error("cloudflared process error", {
|
|
320
|
+
meta: { error: err.message },
|
|
321
|
+
tags: {
|
|
322
|
+
topic: "tunnel",
|
|
323
|
+
phase: "process-error"
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
}
|
|
9
328
|
};
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
329
|
+
//#endregion
|
|
330
|
+
//#region src/cloudflare-api.ts
|
|
331
|
+
/**
|
|
332
|
+
* Cloudflare API client — Tunnel + DNS Zones operations needed by the
|
|
333
|
+
* Custom-tunnel flow.
|
|
334
|
+
*
|
|
335
|
+
* Auth: user-pasted API token (created once in the Cloudflare dashboard
|
|
336
|
+
* with `Account.Cloudflare Tunnel:Edit` + `Zone.DNS:Edit` scopes).
|
|
337
|
+
* The token is treated as opaque and never logged.
|
|
338
|
+
*
|
|
339
|
+
* Endpoints used:
|
|
340
|
+
* - GET /accounts — discover account id
|
|
341
|
+
* - GET /zones — list zones (interactive picker)
|
|
342
|
+
* - POST /accounts/{accountId}/cfd_tunnel — create tunnel
|
|
343
|
+
* - DELETE /accounts/{accountId}/cfd_tunnel/{id} — delete tunnel
|
|
344
|
+
* - PUT /accounts/{accountId}/cfd_tunnel/{id}/configurations — set ingress
|
|
345
|
+
* - POST /zones/{zoneId}/dns_records — create CNAME
|
|
346
|
+
* - DELETE /zones/{zoneId}/dns_records/{id} — cleanup
|
|
347
|
+
*
|
|
348
|
+
* The wrappers throw `CloudflareApiError` with the `code` + first `message`
|
|
349
|
+
* from the response envelope so callers can surface actionable errors
|
|
350
|
+
* (e.g. "Invalid API token" vs "Insufficient permissions").
|
|
351
|
+
*/
|
|
352
|
+
var API_BASE = "https://api.cloudflare.com/client/v4";
|
|
353
|
+
var CloudflareApiError = class extends Error {
|
|
354
|
+
constructor(status, code, message, raw) {
|
|
355
|
+
super(message);
|
|
356
|
+
this.status = status;
|
|
357
|
+
this.code = code;
|
|
358
|
+
this.raw = raw;
|
|
359
|
+
this.name = "CloudflareApiError";
|
|
360
|
+
}
|
|
17
361
|
};
|
|
18
|
-
var
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
362
|
+
var CloudflareApi = class {
|
|
363
|
+
constructor(token) {
|
|
364
|
+
this.token = token;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Resolve the account behind the token.
|
|
368
|
+
*
|
|
369
|
+
* Cloudflare's `GET /accounts` requires `User:Read` (or a multi-account
|
|
370
|
+
* token); a token scoped narrowly to `Account → Cloudflare Tunnel:Edit
|
|
371
|
+
* + Zone → DNS:Edit` on a single account returns an empty list. We
|
|
372
|
+
* fall back to listing zones and reading `zone.account` — zones always
|
|
373
|
+
* carry account metadata so this works regardless of token scopes.
|
|
374
|
+
*/
|
|
375
|
+
async getAccount() {
|
|
376
|
+
try {
|
|
377
|
+
const accounts = await this.req("GET", "/accounts");
|
|
378
|
+
if (accounts.length > 0) return accounts[0];
|
|
379
|
+
} catch (err) {
|
|
380
|
+
if (!(err instanceof CloudflareApiError) || err.status !== 403) throw err;
|
|
381
|
+
}
|
|
382
|
+
const zones = await this.listZones();
|
|
383
|
+
for (const z of zones) if (z.account?.id) return {
|
|
384
|
+
id: z.account.id,
|
|
385
|
+
name: z.account.name ?? z.account.id
|
|
386
|
+
};
|
|
387
|
+
throw new CloudflareApiError(404, void 0, "Token cannot reach any account — make sure it has Account → Cloudflare Tunnel:Edit AND Zone → DNS:Edit");
|
|
388
|
+
}
|
|
389
|
+
/** List every zone the token can reach. */
|
|
390
|
+
async listZones() {
|
|
391
|
+
return this.req("GET", "/zones?per_page=50");
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Look up an existing tunnel by name. Returns the first non-deleted
|
|
395
|
+
* match. Used by `enableCustom` to make tunnel creation idempotent —
|
|
396
|
+
* a previous attempt that crashed mid-flow may have left a
|
|
397
|
+
* 'camstack' tunnel behind; this lets us reuse / delete-and-recreate
|
|
398
|
+
* rather than failing with "tunnel with this name already exists".
|
|
399
|
+
*/
|
|
400
|
+
async findTunnelByName(accountId, name) {
|
|
401
|
+
const list = await this.req("GET", `/accounts/${accountId}/cfd_tunnel?name=${encodeURIComponent(name)}&is_deleted=false`);
|
|
402
|
+
return list.length > 0 ? list[0] : null;
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Cloudflare's `GET /accounts/{id}/cfd_tunnel/{id}/token` returns the
|
|
406
|
+
* connector JWT for an existing tunnel. We use it when reusing a
|
|
407
|
+
* previously-created tunnel — the create-tunnel response embeds the
|
|
408
|
+
* token, but lookups by name only return metadata.
|
|
409
|
+
*/
|
|
410
|
+
async getTunnelToken(accountId, tunnelId) {
|
|
411
|
+
return this.req("GET", `/accounts/${accountId}/cfd_tunnel/${tunnelId}/token`);
|
|
412
|
+
}
|
|
413
|
+
async createTunnel(accountId, name) {
|
|
414
|
+
return this.req("POST", `/accounts/${accountId}/cfd_tunnel`, {
|
|
415
|
+
name,
|
|
416
|
+
tunnel_secret: Buffer.from((0, node_crypto.randomUUID)() + (0, node_crypto.randomUUID)()).toString("base64"),
|
|
417
|
+
config_src: "cloudflare"
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
async deleteTunnel(accountId, tunnelId) {
|
|
421
|
+
await this.req("DELETE", `/accounts/${accountId}/cfd_tunnel/${tunnelId}`);
|
|
422
|
+
}
|
|
423
|
+
async putTunnelConfiguration(accountId, tunnelId, ingress) {
|
|
424
|
+
await this.req("PUT", `/accounts/${accountId}/cfd_tunnel/${tunnelId}/configurations`, { config: { ingress } });
|
|
425
|
+
}
|
|
426
|
+
async createDnsRecord(zoneId, record) {
|
|
427
|
+
return this.req("POST", `/zones/${zoneId}/dns_records`, {
|
|
428
|
+
type: record.type,
|
|
429
|
+
name: record.name,
|
|
430
|
+
content: record.content,
|
|
431
|
+
proxied: record.proxied ?? true,
|
|
432
|
+
ttl: 1
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
async deleteDnsRecord(zoneId, recordId) {
|
|
436
|
+
await this.req("DELETE", `/zones/${zoneId}/dns_records/${recordId}`);
|
|
437
|
+
}
|
|
438
|
+
async req(method, path, body) {
|
|
439
|
+
const res = await fetch(`${API_BASE}${path}`, {
|
|
440
|
+
method,
|
|
441
|
+
headers: {
|
|
442
|
+
Authorization: `Bearer ${this.token}`,
|
|
443
|
+
"Content-Type": "application/json"
|
|
444
|
+
},
|
|
445
|
+
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
446
|
+
});
|
|
447
|
+
const text = await res.text();
|
|
448
|
+
let parsed;
|
|
449
|
+
try {
|
|
450
|
+
parsed = text ? JSON.parse(text) : void 0;
|
|
451
|
+
} catch {
|
|
452
|
+
parsed = void 0;
|
|
453
|
+
}
|
|
454
|
+
if (!res.ok || !parsed?.success) {
|
|
455
|
+
const firstError = parsed?.errors?.[0];
|
|
456
|
+
throw new CloudflareApiError(res.status, firstError?.code, firstError?.message ?? `Cloudflare API ${method} ${path} failed (${res.status})`, parsed);
|
|
457
|
+
}
|
|
458
|
+
return parsed.result;
|
|
459
|
+
}
|
|
74
460
|
};
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
461
|
+
//#endregion
|
|
462
|
+
//#region src/cloudflare-actions.ts
|
|
463
|
+
/**
|
|
464
|
+
* Cloudflare Tunnel — customActions catalog.
|
|
465
|
+
*
|
|
466
|
+
* Exposes the server-managed flow as discrete steps that the admin-ui
|
|
467
|
+
* settings form drives via `api.addons.custom.{query,mutate}({ addonId,
|
|
468
|
+
* action, input })`:
|
|
469
|
+
*
|
|
470
|
+
* validateToken — probe the API token, return account name + id
|
|
471
|
+
* (used by the settings form as an inline "Connect"
|
|
472
|
+
* button; populates the zone picker on success).
|
|
473
|
+
*
|
|
474
|
+
* listZones — return the zones the token can reach
|
|
475
|
+
* (drives the interactive "Domain" select in Custom
|
|
476
|
+
* mode; this is the option-source for the new
|
|
477
|
+
* `addon-action-select` form-builder field type).
|
|
478
|
+
*
|
|
479
|
+
* enableCustom — server-managed setup: create the tunnel via the
|
|
480
|
+
* Cloudflare API, write the public CNAME, persist the
|
|
481
|
+
* tunnel JWT, and switch the addon into 'custom' mode
|
|
482
|
+
* so subsequent starts pick the persisted token.
|
|
483
|
+
*
|
|
484
|
+
* disableCustom — tear down: best-effort delete the DNS record + the
|
|
485
|
+
* tunnel via the API and reset persisted state to the
|
|
486
|
+
* Quick-tunnel default.
|
|
487
|
+
*
|
|
488
|
+
* Quick mode needs NO actions — `start`/`stop` on the network-access cap
|
|
489
|
+
* is enough (cloudflared invents the public URL).
|
|
490
|
+
*/
|
|
491
|
+
var ZoneSchema = zod.z.object({
|
|
492
|
+
id: zod.z.string(),
|
|
493
|
+
name: zod.z.string(),
|
|
494
|
+
status: zod.z.string()
|
|
495
|
+
});
|
|
496
|
+
var cloudflareTunnelActions = (0, _camstack_types.defineCustomActions)({
|
|
497
|
+
validateToken: (0, _camstack_types.customAction)(zod.z.object({ token: zod.z.string().min(20) }), zod.z.object({
|
|
498
|
+
ok: zod.z.literal(true),
|
|
499
|
+
accountId: zod.z.string(),
|
|
500
|
+
accountName: zod.z.string()
|
|
501
|
+
}), { kind: "mutation" }),
|
|
502
|
+
listZones: (0, _camstack_types.customAction)(zod.z.object({ token: zod.z.string().min(20) }), zod.z.object({ zones: zod.z.array(ZoneSchema).readonly() }), { kind: "mutation" }),
|
|
503
|
+
enableCustom: (0, _camstack_types.customAction)(zod.z.object({
|
|
504
|
+
token: zod.z.string().min(20),
|
|
505
|
+
zoneId: zod.z.string().min(1),
|
|
506
|
+
hostname: zod.z.string().min(1),
|
|
507
|
+
localPort: zod.z.number().int().min(1).max(65535)
|
|
508
|
+
}), zod.z.object({
|
|
509
|
+
ok: zod.z.literal(true),
|
|
510
|
+
tunnelId: zod.z.string(),
|
|
511
|
+
hostname: zod.z.string()
|
|
512
|
+
}), { kind: "mutation" }),
|
|
513
|
+
disableCustom: (0, _camstack_types.customAction)(zod.z.object({}).optional(), zod.z.object({ ok: zod.z.literal(true) }), { kind: "mutation" }),
|
|
514
|
+
/**
|
|
515
|
+
* Bridge to the hub-only `local-network` cap so the settings UI can
|
|
516
|
+
* populate an `addon-action-select` field with operator-pinnable
|
|
517
|
+
* candidate addresses for the tunnel ingress. Returned shape matches
|
|
518
|
+
* what the form-builder's `addon-action-select` expects:
|
|
519
|
+
* `{ addresses: [{ value, label, description }] }`.
|
|
520
|
+
*/
|
|
521
|
+
listLocalAddresses: (0, _camstack_types.customAction)(zod.z.object({}).optional(), zod.z.object({ addresses: zod.z.array(zod.z.object({
|
|
522
|
+
value: zod.z.string(),
|
|
523
|
+
label: zod.z.string(),
|
|
524
|
+
description: zod.z.string()
|
|
525
|
+
})).readonly() }))
|
|
78
526
|
});
|
|
527
|
+
//#endregion
|
|
528
|
+
//#region src/cloudflare-tunnel.addon.ts
|
|
529
|
+
/**
|
|
530
|
+
* Cloudflare Tunnel — exposes CamStack via Cloudflare's network.
|
|
531
|
+
*
|
|
532
|
+
* Two flows, both driven by a single `start()` button on the Remote
|
|
533
|
+
* Access page:
|
|
534
|
+
*
|
|
535
|
+
* • Quick (`mode='quick'`) — `start()` spawns `cloudflared tunnel --url
|
|
536
|
+
* http://localhost:<port>`; cloudflared invents a random
|
|
537
|
+
* `*.trycloudflare.com` URL and we surface it back to the operator.
|
|
538
|
+
*
|
|
539
|
+
* • Custom (`mode='custom'`) — operator fills API token + zone +
|
|
540
|
+
* hostname in settings, then hits `start()`. The addon (server-side)
|
|
541
|
+
* auto-provisions: creates / reuses the tunnel, writes the CNAME,
|
|
542
|
+
* persists the JWT, then spawns `cloudflared tunnel run --token <jwt>`.
|
|
543
|
+
* Subsequent starts skip the provisioning step (token is cached).
|
|
544
|
+
*
|
|
545
|
+
* `listZones` is exposed as a customAction so the settings form's
|
|
546
|
+
* `addon-action-select` zone picker can populate options live.
|
|
547
|
+
*/
|
|
548
|
+
var CloudflareTunnelAddon = class extends _camstack_types.BaseAddon {
|
|
549
|
+
service = null;
|
|
550
|
+
constructor() {
|
|
551
|
+
super({
|
|
552
|
+
mode: "quick",
|
|
553
|
+
localPort: 0,
|
|
554
|
+
localHost: "",
|
|
555
|
+
customApiToken: "",
|
|
556
|
+
customAccountId: "",
|
|
557
|
+
customTunnelId: "",
|
|
558
|
+
customTunnelToken: "",
|
|
559
|
+
customZoneId: "",
|
|
560
|
+
customZoneName: "",
|
|
561
|
+
customDnsRecordId: "",
|
|
562
|
+
customHostname: "camstack"
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Resolve the local hub HTTP port the tunnel must front. Order:
|
|
567
|
+
* 1. Explicit `localPort` in addon config (> 0) — operator override.
|
|
568
|
+
* 2. `CAMSTACK_PORT` env (set by the bootstrap config manager).
|
|
569
|
+
* 3. `PORT` env (legacy / .env fallback).
|
|
570
|
+
* 4. 4000 — current CamStack default.
|
|
571
|
+
*/
|
|
572
|
+
resolveLocalPort() {
|
|
573
|
+
if (this.config.localPort && this.config.localPort > 0) return this.config.localPort;
|
|
574
|
+
const camstack = Number.parseInt(process.env["CAMSTACK_PORT"] ?? "", 10);
|
|
575
|
+
if (Number.isFinite(camstack) && camstack > 0) return camstack;
|
|
576
|
+
const port = Number.parseInt(process.env["PORT"] ?? "", 10);
|
|
577
|
+
if (Number.isFinite(port) && port > 0) return port;
|
|
578
|
+
return 4e3;
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Ask the `local-network` cap for the preferred LAN address. Fallback
|
|
582
|
+
* is `127.0.0.1` when the cap is unreachable / not yet mounted.
|
|
583
|
+
* Cached for the duration of one provisioning call.
|
|
584
|
+
*/
|
|
585
|
+
async resolveLocalHost() {
|
|
586
|
+
try {
|
|
587
|
+
const preferred = await this.ctx.api.localNetwork?.getPreferred?.query();
|
|
588
|
+
if (preferred?.address) return preferred.address;
|
|
589
|
+
} catch (err) {
|
|
590
|
+
this.ctx.logger.warn("local-network getPreferred failed — falling back to localhost", {
|
|
591
|
+
meta: { error: err instanceof Error ? err.message : String(err) },
|
|
592
|
+
tags: {
|
|
593
|
+
topic: "tunnel",
|
|
594
|
+
phase: "localhost-fallback"
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
return "127.0.0.1";
|
|
599
|
+
}
|
|
600
|
+
async onInitialize() {
|
|
601
|
+
const localHost = await this.resolveLocalHost();
|
|
602
|
+
this.service = new CloudflareTunnelService({
|
|
603
|
+
...this.config,
|
|
604
|
+
localPort: this.resolveLocalPort(),
|
|
605
|
+
localHost
|
|
606
|
+
}, this.ctx.logger, this.ctx.eventBus);
|
|
607
|
+
this.ctx.logger.info("Cloudflare Tunnel addon initialized", { meta: {
|
|
608
|
+
mode: this.config.mode,
|
|
609
|
+
hasCustomToken: !!this.config.customTunnelToken
|
|
610
|
+
} });
|
|
611
|
+
return {
|
|
612
|
+
providers: [{
|
|
613
|
+
capability: _camstack_types.networkAccessCapability,
|
|
614
|
+
provider: {
|
|
615
|
+
start: () => this.handleStart(),
|
|
616
|
+
stop: () => this.requireService().stop(),
|
|
617
|
+
getStatus: () => this.requireService().getStatus()
|
|
618
|
+
}
|
|
619
|
+
}],
|
|
620
|
+
customActions: cloudflareTunnelActions,
|
|
621
|
+
actionHandlers: {
|
|
622
|
+
validateToken: async (input) => this.validateToken(input),
|
|
623
|
+
listZones: async (input) => this.listZones(input),
|
|
624
|
+
enableCustom: async (input) => this.enableCustom(input),
|
|
625
|
+
disableCustom: async () => this.disableCustom(),
|
|
626
|
+
listLocalAddresses: async () => this.listLocalAddresses()
|
|
627
|
+
}
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
async onShutdown() {
|
|
631
|
+
if (this.service) {
|
|
632
|
+
await this.service.stop().catch(() => void 0);
|
|
633
|
+
this.service = null;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
async onConfigChanged() {
|
|
637
|
+
const wasRunning = this.service?.getStatus().connected === true;
|
|
638
|
+
if (this.service) await this.service.stop().catch(() => void 0);
|
|
639
|
+
const localHost = await this.resolveLocalHost();
|
|
640
|
+
this.service = new CloudflareTunnelService({
|
|
641
|
+
...this.config,
|
|
642
|
+
localPort: this.resolveLocalPort(),
|
|
643
|
+
localHost
|
|
644
|
+
}, this.ctx.logger, this.ctx.eventBus);
|
|
645
|
+
if (wasRunning) {
|
|
646
|
+
this.ctx.logger.info("config changed while running — re-spawning tunnel", { tags: {
|
|
647
|
+
topic: "tunnel",
|
|
648
|
+
phase: "config-reload"
|
|
649
|
+
} });
|
|
650
|
+
try {
|
|
651
|
+
await this.handleStart();
|
|
652
|
+
} catch (err) {
|
|
653
|
+
this.ctx.logger.error("config-reload restart failed", {
|
|
654
|
+
meta: { error: err instanceof Error ? err.message : String(err) },
|
|
655
|
+
tags: {
|
|
656
|
+
topic: "tunnel",
|
|
657
|
+
phase: "config-reload-error"
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Start the tunnel, provisioning custom mode first if needed. This is
|
|
665
|
+
* the single button the orchestrator (Remote Access page) calls; the
|
|
666
|
+
* addon decides whether the underlying spawn is quick or token-based.
|
|
667
|
+
*/
|
|
668
|
+
async handleStart() {
|
|
669
|
+
if (this.config.mode === "custom" && (!this.config.customTunnelToken || !this.config.customHostname.includes("."))) {
|
|
670
|
+
this.ctx.logger.info("start: custom mode needs (re)provisioning", {
|
|
671
|
+
meta: {
|
|
672
|
+
hasToken: !!this.config.customTunnelToken,
|
|
673
|
+
hostname: this.config.customHostname
|
|
674
|
+
},
|
|
675
|
+
tags: {
|
|
676
|
+
topic: "tunnel",
|
|
677
|
+
phase: "auto-provision"
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
const token = this.config.customApiToken;
|
|
681
|
+
const zoneId = this.config.customZoneId;
|
|
682
|
+
const hostname = this.config.customHostname;
|
|
683
|
+
if (!token || !zoneId || !hostname) {
|
|
684
|
+
const missing = [
|
|
685
|
+
!token && "API token",
|
|
686
|
+
!zoneId && "zone",
|
|
687
|
+
!hostname && "hostname"
|
|
688
|
+
].filter(Boolean).join(", ");
|
|
689
|
+
const err = /* @__PURE__ */ new Error(`Custom tunnel needs: ${missing}. Fill the settings above first.`);
|
|
690
|
+
this.ctx.logger.error("start: custom mode config incomplete", {
|
|
691
|
+
meta: { missing },
|
|
692
|
+
tags: {
|
|
693
|
+
topic: "tunnel",
|
|
694
|
+
phase: "auto-provision-incomplete"
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
throw err;
|
|
698
|
+
}
|
|
699
|
+
await this.enableCustom({
|
|
700
|
+
token,
|
|
701
|
+
zoneId,
|
|
702
|
+
hostname,
|
|
703
|
+
localPort: this.resolveLocalPort()
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
return this.requireService().start();
|
|
707
|
+
}
|
|
708
|
+
requireService() {
|
|
709
|
+
if (!this.service) throw new Error("Cloudflare tunnel service not initialized");
|
|
710
|
+
return this.service;
|
|
711
|
+
}
|
|
712
|
+
async validateToken(input) {
|
|
713
|
+
this.ctx.logger.info("validateToken: probing API token", { tags: {
|
|
714
|
+
topic: "tunnel",
|
|
715
|
+
phase: "validate-token"
|
|
716
|
+
} });
|
|
717
|
+
const api = new CloudflareApi(input.token);
|
|
718
|
+
try {
|
|
719
|
+
const account = await api.getAccount();
|
|
720
|
+
this.ctx.logger.info("validateToken: token OK", {
|
|
721
|
+
meta: {
|
|
722
|
+
accountId: account.id,
|
|
723
|
+
accountName: account.name
|
|
724
|
+
},
|
|
725
|
+
tags: {
|
|
726
|
+
topic: "tunnel",
|
|
727
|
+
phase: "validate-token-ok"
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
return {
|
|
731
|
+
ok: true,
|
|
732
|
+
accountId: account.id,
|
|
733
|
+
accountName: account.name
|
|
734
|
+
};
|
|
735
|
+
} catch (err) {
|
|
736
|
+
this.ctx.logger.error("validateToken: probe failed", {
|
|
737
|
+
meta: { error: err instanceof Error ? err.message : String(err) },
|
|
738
|
+
tags: {
|
|
739
|
+
topic: "tunnel",
|
|
740
|
+
phase: "validate-token-error"
|
|
741
|
+
}
|
|
742
|
+
});
|
|
743
|
+
throw err;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Surface the local-network cap's interface list to the form-builder
|
|
748
|
+
* `addon-action-select` field so the operator can pin which address
|
|
749
|
+
* the tunnel ingress should target (Docker sidecar, multi-NIC host,
|
|
750
|
+
* etc). Empty `value` represents "auto — fall back to
|
|
751
|
+
* local-network.getPreferred()".
|
|
752
|
+
*/
|
|
753
|
+
async listLocalAddresses() {
|
|
754
|
+
try {
|
|
755
|
+
return { addresses: [{
|
|
756
|
+
value: "",
|
|
757
|
+
label: "Auto (use local-network preferred)",
|
|
758
|
+
description: "re-evaluated on every start"
|
|
759
|
+
}, ...((await this.ctx.api.localNetwork?.list?.query())?.interfaces ?? []).filter((i) => !i.internal && !i.address.startsWith("169.254.") && i.family === "IPv4").map((i) => ({
|
|
760
|
+
value: i.address,
|
|
761
|
+
label: `${i.name} — ${i.address}`,
|
|
762
|
+
description: `${i.kind}${i.preferred ? " · auto-preferred" : ""}`
|
|
763
|
+
}))] };
|
|
764
|
+
} catch (err) {
|
|
765
|
+
this.ctx.logger.warn("listLocalAddresses: local-network cap unreachable", {
|
|
766
|
+
meta: { error: err instanceof Error ? err.message : String(err) },
|
|
767
|
+
tags: {
|
|
768
|
+
topic: "tunnel",
|
|
769
|
+
phase: "list-local-addresses-error"
|
|
770
|
+
}
|
|
771
|
+
});
|
|
772
|
+
return { addresses: [{
|
|
773
|
+
value: "",
|
|
774
|
+
label: "Auto (use local-network preferred)",
|
|
775
|
+
description: "fallback"
|
|
776
|
+
}] };
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
async listZones(input) {
|
|
780
|
+
this.ctx.logger.info("listZones: fetching zones", { tags: {
|
|
781
|
+
topic: "tunnel",
|
|
782
|
+
phase: "list-zones"
|
|
783
|
+
} });
|
|
784
|
+
const api = new CloudflareApi(input.token);
|
|
785
|
+
try {
|
|
786
|
+
const zones = await api.listZones();
|
|
787
|
+
this.ctx.logger.info("listZones: returned zones", {
|
|
788
|
+
meta: { count: zones.length },
|
|
789
|
+
tags: {
|
|
790
|
+
topic: "tunnel",
|
|
791
|
+
phase: "list-zones-ok"
|
|
792
|
+
}
|
|
793
|
+
});
|
|
794
|
+
return { zones: zones.map((z) => ({
|
|
795
|
+
id: z.id,
|
|
796
|
+
name: z.name,
|
|
797
|
+
status: z.status
|
|
798
|
+
})) };
|
|
799
|
+
} catch (err) {
|
|
800
|
+
this.ctx.logger.error("listZones: fetch failed", {
|
|
801
|
+
meta: { error: err instanceof Error ? err.message : String(err) },
|
|
802
|
+
tags: {
|
|
803
|
+
topic: "tunnel",
|
|
804
|
+
phase: "list-zones-error"
|
|
805
|
+
}
|
|
806
|
+
});
|
|
807
|
+
throw err;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* Server-managed custom-tunnel provisioning. Idempotent — reuses an
|
|
812
|
+
* existing 'camstack' tunnel by name and treats "DNS record already
|
|
813
|
+
* exists" as success. Persists tunnel id + JWT + DNS record id back
|
|
814
|
+
* into the addon config so subsequent `start()` calls skip provisioning.
|
|
815
|
+
*
|
|
816
|
+
* Kept exposed as a customAction (rather than purely internal) for
|
|
817
|
+
* scripting / manual re-provisioning from external clients; the UI
|
|
818
|
+
* now drives provisioning via the `start()` Start button instead.
|
|
819
|
+
*/
|
|
820
|
+
async enableCustom(input) {
|
|
821
|
+
this.ctx.logger.info("enableCustom: starting", {
|
|
822
|
+
meta: {
|
|
823
|
+
hostname: input.hostname,
|
|
824
|
+
zoneId: input.zoneId,
|
|
825
|
+
localPort: input.localPort
|
|
826
|
+
},
|
|
827
|
+
tags: {
|
|
828
|
+
topic: "tunnel",
|
|
829
|
+
phase: "enable-start"
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
const api = new CloudflareApi(input.token);
|
|
833
|
+
try {
|
|
834
|
+
const zone = (await api.listZones()).find((z) => z.id === input.zoneId);
|
|
835
|
+
if (!zone) throw new Error(`Selected zone ${input.zoneId} is not visible to this token`);
|
|
836
|
+
const subdomain = input.hostname.trim();
|
|
837
|
+
const subBare = subdomain.endsWith(`.${zone.name}`) ? subdomain.slice(0, -1 - zone.name.length) : subdomain;
|
|
838
|
+
const fqdn = subBare === "@" || subBare === "" ? zone.name : `${subBare}.${zone.name}`;
|
|
839
|
+
this.ctx.logger.info("enableCustom: resolved FQDN", {
|
|
840
|
+
meta: {
|
|
841
|
+
subBare,
|
|
842
|
+
zoneName: zone.name,
|
|
843
|
+
fqdn
|
|
844
|
+
},
|
|
845
|
+
tags: {
|
|
846
|
+
topic: "tunnel",
|
|
847
|
+
phase: "enable-fqdn"
|
|
848
|
+
}
|
|
849
|
+
});
|
|
850
|
+
const account = await api.getAccount();
|
|
851
|
+
this.ctx.logger.info("enableCustom: account resolved", {
|
|
852
|
+
meta: {
|
|
853
|
+
accountId: account.id,
|
|
854
|
+
accountName: account.name
|
|
855
|
+
},
|
|
856
|
+
tags: {
|
|
857
|
+
topic: "tunnel",
|
|
858
|
+
phase: "enable-account"
|
|
859
|
+
}
|
|
860
|
+
});
|
|
861
|
+
if (this.config.customDnsRecordId && this.config.customZoneId && this.config.customHostname && this.config.customHostname.toLowerCase() !== fqdn.toLowerCase()) {
|
|
862
|
+
this.ctx.logger.info("enableCustom: removing previous DNS record (hostname changed)", {
|
|
863
|
+
meta: {
|
|
864
|
+
from: this.config.customHostname,
|
|
865
|
+
to: input.hostname
|
|
866
|
+
},
|
|
867
|
+
tags: {
|
|
868
|
+
topic: "tunnel",
|
|
869
|
+
phase: "enable-dns-cleanup"
|
|
870
|
+
}
|
|
871
|
+
});
|
|
872
|
+
try {
|
|
873
|
+
await api.deleteDnsRecord(this.config.customZoneId, this.config.customDnsRecordId);
|
|
874
|
+
} catch (err) {
|
|
875
|
+
this.ctx.logger.warn("enableCustom: previous DNS cleanup failed (continuing)", {
|
|
876
|
+
meta: { error: err instanceof Error ? err.message : String(err) },
|
|
877
|
+
tags: {
|
|
878
|
+
topic: "tunnel",
|
|
879
|
+
phase: "enable-dns-cleanup-failed"
|
|
880
|
+
}
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
const TUNNEL_NAME = "camstack";
|
|
885
|
+
let tunnel = await api.findTunnelByName(account.id, TUNNEL_NAME);
|
|
886
|
+
if (tunnel) {
|
|
887
|
+
this.ctx.logger.info("enableCustom: reusing existing tunnel by name", {
|
|
888
|
+
meta: {
|
|
889
|
+
tunnelId: tunnel.id,
|
|
890
|
+
name: tunnel.name
|
|
891
|
+
},
|
|
892
|
+
tags: {
|
|
893
|
+
topic: "tunnel",
|
|
894
|
+
phase: "enable-tunnel-reused"
|
|
895
|
+
}
|
|
896
|
+
});
|
|
897
|
+
const token = await api.getTunnelToken(account.id, tunnel.id);
|
|
898
|
+
tunnel = {
|
|
899
|
+
...tunnel,
|
|
900
|
+
token
|
|
901
|
+
};
|
|
902
|
+
} else {
|
|
903
|
+
tunnel = await api.createTunnel(account.id, TUNNEL_NAME);
|
|
904
|
+
this.ctx.logger.info("enableCustom: tunnel created", {
|
|
905
|
+
meta: {
|
|
906
|
+
tunnelId: tunnel.id,
|
|
907
|
+
name: tunnel.name
|
|
908
|
+
},
|
|
909
|
+
tags: {
|
|
910
|
+
topic: "tunnel",
|
|
911
|
+
phase: "enable-tunnel-created"
|
|
912
|
+
}
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
const localHost = await this.resolveLocalHost();
|
|
916
|
+
await api.putTunnelConfiguration(account.id, tunnel.id, [{
|
|
917
|
+
hostname: fqdn,
|
|
918
|
+
service: `http://${localHost}:${input.localPort}`
|
|
919
|
+
}, { service: "http_status:404" }]);
|
|
920
|
+
this.ctx.logger.info("enableCustom: tunnel ingress configured", {
|
|
921
|
+
meta: {
|
|
922
|
+
hostname: fqdn,
|
|
923
|
+
localHost,
|
|
924
|
+
localPort: input.localPort
|
|
925
|
+
},
|
|
926
|
+
tags: {
|
|
927
|
+
topic: "tunnel",
|
|
928
|
+
phase: "enable-ingress"
|
|
929
|
+
}
|
|
930
|
+
});
|
|
931
|
+
let dns;
|
|
932
|
+
try {
|
|
933
|
+
dns = await api.createDnsRecord(input.zoneId, {
|
|
934
|
+
type: "CNAME",
|
|
935
|
+
name: fqdn,
|
|
936
|
+
content: `${tunnel.id}.cfargotunnel.com`,
|
|
937
|
+
proxied: true
|
|
938
|
+
});
|
|
939
|
+
this.ctx.logger.info("enableCustom: DNS record created", {
|
|
940
|
+
meta: {
|
|
941
|
+
recordId: dns.id,
|
|
942
|
+
hostname: fqdn
|
|
943
|
+
},
|
|
944
|
+
tags: {
|
|
945
|
+
topic: "tunnel",
|
|
946
|
+
phase: "enable-dns-created"
|
|
947
|
+
}
|
|
948
|
+
});
|
|
949
|
+
} catch (err) {
|
|
950
|
+
if (err instanceof Error && /already exists/i.test(err.message)) {
|
|
951
|
+
this.ctx.logger.warn("enableCustom: DNS record already exists — assuming it points at this tunnel", {
|
|
952
|
+
meta: { hostname: fqdn },
|
|
953
|
+
tags: {
|
|
954
|
+
topic: "tunnel",
|
|
955
|
+
phase: "enable-dns-existing"
|
|
956
|
+
}
|
|
957
|
+
});
|
|
958
|
+
dns = {
|
|
959
|
+
id: "",
|
|
960
|
+
name: fqdn,
|
|
961
|
+
content: "",
|
|
962
|
+
type: "CNAME"
|
|
963
|
+
};
|
|
964
|
+
} else throw err;
|
|
965
|
+
}
|
|
966
|
+
const patch = {
|
|
967
|
+
mode: "custom",
|
|
968
|
+
localPort: input.localPort,
|
|
969
|
+
customAccountId: account.id,
|
|
970
|
+
customTunnelId: tunnel.id,
|
|
971
|
+
customTunnelToken: tunnel.token,
|
|
972
|
+
customZoneId: input.zoneId,
|
|
973
|
+
customZoneName: zone.name,
|
|
974
|
+
customDnsRecordId: dns.id,
|
|
975
|
+
customHostname: fqdn
|
|
976
|
+
};
|
|
977
|
+
await this.updateGlobalSettings(patch);
|
|
978
|
+
this.ctx.logger.info("enableCustom: complete", {
|
|
979
|
+
meta: {
|
|
980
|
+
tunnelId: tunnel.id,
|
|
981
|
+
hostname: fqdn
|
|
982
|
+
},
|
|
983
|
+
tags: {
|
|
984
|
+
topic: "tunnel",
|
|
985
|
+
phase: "enable-complete"
|
|
986
|
+
}
|
|
987
|
+
});
|
|
988
|
+
return {
|
|
989
|
+
ok: true,
|
|
990
|
+
tunnelId: tunnel.id,
|
|
991
|
+
hostname: fqdn
|
|
992
|
+
};
|
|
993
|
+
} catch (err) {
|
|
994
|
+
this.ctx.logger.error("enableCustom: failed", {
|
|
995
|
+
meta: { error: err instanceof Error ? err.message : String(err) },
|
|
996
|
+
tags: {
|
|
997
|
+
topic: "tunnel",
|
|
998
|
+
phase: "enable-error"
|
|
999
|
+
}
|
|
1000
|
+
});
|
|
1001
|
+
throw err;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
async disableCustom() {
|
|
1005
|
+
const reset = {
|
|
1006
|
+
mode: "quick",
|
|
1007
|
+
localHost: "",
|
|
1008
|
+
customAccountId: "",
|
|
1009
|
+
customTunnelId: "",
|
|
1010
|
+
customTunnelToken: "",
|
|
1011
|
+
customZoneId: "",
|
|
1012
|
+
customZoneName: "",
|
|
1013
|
+
customDnsRecordId: "",
|
|
1014
|
+
customHostname: "camstack",
|
|
1015
|
+
customApiToken: ""
|
|
1016
|
+
};
|
|
1017
|
+
this.ctx.logger.info("disableCustom: reset to quick mode", { tags: {
|
|
1018
|
+
topic: "tunnel",
|
|
1019
|
+
phase: "disable"
|
|
1020
|
+
} });
|
|
1021
|
+
await this.updateGlobalSettings(reset);
|
|
1022
|
+
return { ok: true };
|
|
1023
|
+
}
|
|
1024
|
+
globalSettingsSchema() {
|
|
1025
|
+
return this.schema({ sections: [{
|
|
1026
|
+
id: "mode",
|
|
1027
|
+
title: "Tunnel Mode",
|
|
1028
|
+
immediate: true,
|
|
1029
|
+
fields: [this.field({
|
|
1030
|
+
type: "select",
|
|
1031
|
+
key: "mode",
|
|
1032
|
+
label: "Mode",
|
|
1033
|
+
description: "Quick: random *.trycloudflare.com URL, no account. Custom: server-managed named tunnel on your own domain (requires a Cloudflare account + API token). Hit Start on the Remote Access page to launch — Custom mode auto-provisions on first start. The local hub port is auto-detected from CAMSTACK_PORT / PORT env.",
|
|
1034
|
+
default: "quick",
|
|
1035
|
+
options: [{
|
|
1036
|
+
value: "quick",
|
|
1037
|
+
label: "Quick Tunnel",
|
|
1038
|
+
description: "Temporary public URL, no account required"
|
|
1039
|
+
}, {
|
|
1040
|
+
value: "custom",
|
|
1041
|
+
label: "Custom Domain",
|
|
1042
|
+
description: "Persistent tunnel on your own zone"
|
|
1043
|
+
}]
|
|
1044
|
+
}), {
|
|
1045
|
+
type: "addon-action-select",
|
|
1046
|
+
key: "localHost",
|
|
1047
|
+
label: "Local Address",
|
|
1048
|
+
description: "Which local host address cloudflared should forward traffic to. \"Auto\" follows the local-network preferred interface — pin a specific address only when running multi-NIC or Docker sidecar setups.",
|
|
1049
|
+
default: "",
|
|
1050
|
+
addonId: "cloudflare-tunnel",
|
|
1051
|
+
action: "listLocalAddresses",
|
|
1052
|
+
mapOption: {
|
|
1053
|
+
value: "value",
|
|
1054
|
+
label: "label",
|
|
1055
|
+
description: "description"
|
|
1056
|
+
},
|
|
1057
|
+
emptyResultsMessage: "No local addresses available — fix the local-network cap first."
|
|
1058
|
+
}]
|
|
1059
|
+
}, {
|
|
1060
|
+
id: "custom",
|
|
1061
|
+
title: "Custom Domain Setup",
|
|
1062
|
+
immediate: true,
|
|
1063
|
+
fields: [
|
|
1064
|
+
{
|
|
1065
|
+
type: "info",
|
|
1066
|
+
key: "customHelp",
|
|
1067
|
+
label: "How to get an API token",
|
|
1068
|
+
format: "html",
|
|
1069
|
+
content: "Create a token at <a href=\"https://dash.cloudflare.com/profile/api-tokens\">dash.cloudflare.com/profile/api-tokens</a>:<ul><li>Click <strong>Create Token</strong> → use the <em>Custom token</em> template.</li><li>Permissions: <code>Account → Cloudflare Tunnel → Edit</code> AND <code>Zone → DNS → Edit</code>.</li><li>Account Resources: <code>Include → your account</code>. Zone Resources: <code>Include → the zone</code> you want.</li><li>Continue → Create → copy the token, paste it below.</li></ul>",
|
|
1070
|
+
variant: "info",
|
|
1071
|
+
showWhen: {
|
|
1072
|
+
field: "mode",
|
|
1073
|
+
equals: "custom"
|
|
1074
|
+
}
|
|
1075
|
+
},
|
|
1076
|
+
this.field({
|
|
1077
|
+
type: "password",
|
|
1078
|
+
key: "customApiToken",
|
|
1079
|
+
label: "Cloudflare API Token",
|
|
1080
|
+
description: "Used to provision + manage the tunnel.",
|
|
1081
|
+
showToggle: true,
|
|
1082
|
+
showWhen: {
|
|
1083
|
+
field: "mode",
|
|
1084
|
+
equals: "custom"
|
|
1085
|
+
}
|
|
1086
|
+
}),
|
|
1087
|
+
{
|
|
1088
|
+
type: "addon-action-select",
|
|
1089
|
+
key: "customZoneId",
|
|
1090
|
+
label: "Zone",
|
|
1091
|
+
description: "Pick the Cloudflare zone (root domain) the tunnel will sit on.",
|
|
1092
|
+
addonId: "cloudflare-tunnel",
|
|
1093
|
+
action: "listZones",
|
|
1094
|
+
paramsFromForm: { token: "customApiToken" },
|
|
1095
|
+
refreshOn: ["customApiToken"],
|
|
1096
|
+
mapOption: {
|
|
1097
|
+
value: "id",
|
|
1098
|
+
label: "name",
|
|
1099
|
+
description: "status"
|
|
1100
|
+
},
|
|
1101
|
+
emptyParamsMessage: "Paste your API token above first.",
|
|
1102
|
+
emptyResultsMessage: "Token has no zones with DNS:Edit permission.",
|
|
1103
|
+
showWhen: {
|
|
1104
|
+
field: "mode",
|
|
1105
|
+
equals: "custom"
|
|
1106
|
+
}
|
|
1107
|
+
},
|
|
1108
|
+
this.field({
|
|
1109
|
+
type: "text",
|
|
1110
|
+
key: "customHostname",
|
|
1111
|
+
label: "Sub-domain",
|
|
1112
|
+
description: "Sub-domain to prepend to the selected zone (e.g. \"camstack\" → camstack.yourdomain.com). Use \"@\" or empty for the apex.",
|
|
1113
|
+
default: "camstack",
|
|
1114
|
+
placeholder: "camstack",
|
|
1115
|
+
showWhen: {
|
|
1116
|
+
field: "mode",
|
|
1117
|
+
equals: "custom"
|
|
1118
|
+
}
|
|
1119
|
+
})
|
|
1120
|
+
]
|
|
1121
|
+
}] });
|
|
1122
|
+
}
|
|
1123
|
+
};
|
|
1124
|
+
//#endregion
|
|
1125
|
+
exports.CloudflareTunnelAddon = CloudflareTunnelAddon;
|
|
1126
|
+
exports.CloudflareTunnelService = CloudflareTunnelService;
|
|
1127
|
+
exports.cloudflareTunnelActions = cloudflareTunnelActions;
|
|
1128
|
+
exports.customActions = cloudflareTunnelActions;
|
|
1129
|
+
|
|
79
1130
|
//# sourceMappingURL=cloudflare-tunnel.addon.js.map
|