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