@camstack/addon-cloudflare-tunnel 0.1.14 → 0.1.15

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.
@@ -1,79 +1,1130 @@
1
- "use strict";
2
- var __defProp = Object.defineProperty;
3
- var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
- var __getOwnPropNames = Object.getOwnPropertyNames;
5
- var __hasOwnProp = Object.prototype.hasOwnProperty;
6
- var __export = (target, all) => {
7
- for (var name in all)
8
- __defProp(target, name, { get: all[name], enumerable: true });
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
- var __copyProps = (to, from, except, desc) => {
11
- if (from && typeof from === "object" || typeof from === "function") {
12
- for (let key of __getOwnPropNames(from))
13
- if (!__hasOwnProp.call(to, key) && key !== except)
14
- __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
- }
16
- return to;
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 __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
-
20
- // src/cloudflare-tunnel.addon.ts
21
- var cloudflare_tunnel_addon_exports = {};
22
- __export(cloudflare_tunnel_addon_exports, {
23
- CloudflareTunnelAddon: () => CloudflareTunnelAddon
24
- });
25
- module.exports = __toCommonJS(cloudflare_tunnel_addon_exports);
26
- var import_types = require("@camstack/types");
27
- var CloudflareTunnelAddon = class extends import_types.BaseAddon {
28
- constructor() {
29
- super({ mode: "quick", namedTunnelToken: "", localPort: 3e3 });
30
- }
31
- async onInitialize() {
32
- this.ctx.logger.info("Cloudflare Tunnel addon initialized");
33
- return [{ capability: import_types.networkAccessCapability, provider: this }];
34
- }
35
- globalSettingsSchema() {
36
- return this.schema({
37
- sections: [{
38
- id: "tunnel",
39
- title: "Tunnel Settings",
40
- fields: [
41
- this.field({
42
- type: "select",
43
- key: "mode",
44
- label: "Tunnel Mode",
45
- description: "Quick mode creates a temporary public URL; Named mode uses a persistent named tunnel.",
46
- default: "quick",
47
- options: [
48
- { value: "quick", label: "Quick Tunnel", description: "Temporary public URL, no Cloudflare account required" },
49
- { value: "named", label: "Named Tunnel", description: "Persistent tunnel using a Cloudflare tunnel token" }
50
- ]
51
- }),
52
- this.field({
53
- type: "password",
54
- key: "namedTunnelToken",
55
- label: "Tunnel Token",
56
- description: "Token from your Cloudflare Zero Trust dashboard (required for Named Tunnel mode)",
57
- showToggle: true,
58
- showWhen: { field: "mode", equals: "named" }
59
- }),
60
- this.field({
61
- type: "number",
62
- key: "localPort",
63
- label: "Local Port",
64
- description: "The local port that the tunnel will expose publicly",
65
- min: 1,
66
- max: 65535,
67
- step: 1,
68
- default: 3e3
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
- // Annotate the CommonJS export names for ESM import in node:
76
- 0 && (module.exports = {
77
- CloudflareTunnelAddon
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