@camstack/addon-tailscale-ingress 0.1.1

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/index.js ADDED
@@ -0,0 +1,7 @@
1
+ Object.defineProperties(exports, {
2
+ __esModule: { value: true },
3
+ [Symbol.toStringTag]: { value: "Module" }
4
+ });
5
+ const require_tailscale_ingress_addon = require("./tailscale-ingress.addon.js");
6
+ exports.TailscaleIngressAddon = require_tailscale_ingress_addon.TailscaleIngressAddon;
7
+ exports.default = require_tailscale_ingress_addon.TailscaleIngressAddon;
package/dist/index.mjs ADDED
@@ -0,0 +1,2 @@
1
+ import { TailscaleIngressAddon } from "./tailscale-ingress.addon.mjs";
2
+ export { TailscaleIngressAddon, TailscaleIngressAddon as default };
@@ -0,0 +1,545 @@
1
+ Object.defineProperties(exports, {
2
+ __esModule: { value: true },
3
+ [Symbol.toStringTag]: { value: "Module" }
4
+ });
5
+ let _camstack_types = require("@camstack/types");
6
+ let node_crypto = require("node:crypto");
7
+ let node_child_process = require("node:child_process");
8
+ //#region src/tailscale-cli.ts
9
+ /**
10
+ * Thin wrapper around the `tailscale` CLI.
11
+ *
12
+ * Why CLI rather than the local API socket: `tailscaled` exposes its
13
+ * control plane at `/var/run/tailscale/tailscaled.sock` but the wire
14
+ * protocol is undocumented + the Go client is gnarly to port. The
15
+ * `tailscale` CLI is the supported public interface, it covers every
16
+ * action we need (`up`/`down`/`status --json`/`serve`/`funnel`), and
17
+ * its output is stable JSON for `status` + non-fatal stderr for the
18
+ * others.
19
+ *
20
+ * The wrapper:
21
+ * - resolves the binary path once (PATH lookup + macOS GUI install
22
+ * fallback at `/Applications/Tailscale.app/Contents/MacOS/Tailscale`).
23
+ * - exposes Promise-based methods returning typed shapes.
24
+ * - never spawns long-lived processes — every call is one-shot.
25
+ *
26
+ * Operator-side prerequisite: `tailscaled` must be installed +
27
+ * running. The addon surfaces "binary missing" / "daemon not running"
28
+ * errors verbatim so the operator can act.
29
+ */
30
+ var execFileP = (0, require("node:util").promisify)(node_child_process.execFile);
31
+ var TAILSCALE_CANDIDATES = [
32
+ "tailscale",
33
+ "/usr/bin/tailscale",
34
+ "/usr/local/bin/tailscale",
35
+ "/opt/homebrew/bin/tailscale",
36
+ "/Applications/Tailscale.app/Contents/MacOS/Tailscale"
37
+ ];
38
+ var TailscaleCliError = class extends Error {
39
+ stderr;
40
+ constructor(message, stderr = "") {
41
+ super(message);
42
+ this.stderr = stderr;
43
+ this.name = "TailscaleCliError";
44
+ }
45
+ };
46
+ var TailscaleCli = class {
47
+ resolvedBin = null;
48
+ /** Locate the `tailscale` binary once and cache the result. */
49
+ async resolveBin() {
50
+ if (this.resolvedBin) return this.resolvedBin;
51
+ for (const candidate of TAILSCALE_CANDIDATES) try {
52
+ await execFileP(candidate, ["version"], { timeout: 3e3 });
53
+ this.resolvedBin = candidate;
54
+ return candidate;
55
+ } catch {}
56
+ throw new TailscaleCliError("tailscale binary not found — install Tailscale from https://tailscale.com/download");
57
+ }
58
+ async version() {
59
+ const { stdout } = await execFileP(await this.resolveBin(), ["version"], { timeout: 5e3 });
60
+ return stdout.trim().split("\n")[0] ?? "";
61
+ }
62
+ async status() {
63
+ const bin = await this.resolveBin();
64
+ try {
65
+ const { stdout } = await execFileP(bin, ["status", "--json"], { timeout: 1e4 });
66
+ return JSON.parse(stdout);
67
+ } catch (err) {
68
+ const e = err;
69
+ throw new TailscaleCliError(`tailscale status failed: ${e.message}`, e.stderr ?? "");
70
+ }
71
+ }
72
+ /** Bring the daemon up with an auth key. Idempotent — calling
73
+ * while already joined returns immediately. */
74
+ async up(input) {
75
+ const bin = await this.resolveBin();
76
+ const args = [
77
+ "up",
78
+ "--auth-key",
79
+ input.authKey,
80
+ "--reset"
81
+ ];
82
+ if (input.hostname) args.push(`--hostname=${input.hostname}`);
83
+ try {
84
+ await execFileP(bin, args, { timeout: 6e4 });
85
+ } catch (err) {
86
+ const e = err;
87
+ throw new TailscaleCliError(`tailscale up failed: ${e.message}`, e.stderr ?? "");
88
+ }
89
+ }
90
+ /** Leave the tailnet. After this the host's `100.x` address is
91
+ * released until the next `up`. */
92
+ async down() {
93
+ const bin = await this.resolveBin();
94
+ try {
95
+ await execFileP(bin, ["down"], { timeout: 15e3 });
96
+ } catch (err) {
97
+ const e = err;
98
+ throw new TailscaleCliError(`tailscale down failed: ${e.message}`, e.stderr ?? "");
99
+ }
100
+ }
101
+ /** `tailscale serve` — exposes a local port to peers in the same
102
+ * tailnet over HTTPS with auto-issued cert. Mutating call; the
103
+ * daemon persists the rule across restarts.
104
+ *
105
+ * When `targetPath` is provided we pass `--set-path=<path>` so
106
+ * multiple serve rules can co-exist under different prefixes
107
+ * without overwriting each other. */
108
+ async serve(input) {
109
+ const bin = await this.resolveBin();
110
+ const args = this.buildIngressArgs("serve", input);
111
+ try {
112
+ await execFileP(bin, args, { timeout: 15e3 });
113
+ } catch (err) {
114
+ const e = err;
115
+ throw new TailscaleCliError(`tailscale serve failed: ${e.message}`, e.stderr ?? "");
116
+ }
117
+ }
118
+ /** `tailscale funnel` — exposes a local port to the open internet
119
+ * via Tailscale's edge. Requires Funnel ACL grant in the tailnet
120
+ * policy. Same shape as `serve`. */
121
+ async funnel(input) {
122
+ const bin = await this.resolveBin();
123
+ const args = this.buildIngressArgs("funnel", input);
124
+ try {
125
+ await execFileP(bin, args, { timeout: 15e3 });
126
+ } catch (err) {
127
+ const e = err;
128
+ throw new TailscaleCliError(`tailscale funnel failed: ${e.message}`, e.stderr ?? "");
129
+ }
130
+ }
131
+ /**
132
+ * Single source of truth for the serve/funnel arg shape. The CLI
133
+ * accepts the same flag layout for both verbs, so we share the
134
+ * builder. `--set-path` is only emitted when the operator picks a
135
+ * non-root mount.
136
+ */
137
+ buildIngressArgs(verb, input) {
138
+ const path = (input.targetPath ?? "").trim();
139
+ if (!input.enabled) {
140
+ const out = [verb, "--bg"];
141
+ if (path && path !== "/") out.push(`--set-path=${path}`);
142
+ out.push("off");
143
+ return out;
144
+ }
145
+ const out = [verb, "--bg"];
146
+ if (path && path !== "/") out.push(`--set-path=${path}`);
147
+ out.push(`http://127.0.0.1:${input.port}`);
148
+ return out;
149
+ }
150
+ };
151
+ //#endregion
152
+ //#region src/tailscale-ingress.addon.ts
153
+ /**
154
+ * Tailscale Ingress addon — manages `tailscale serve` (in-tailnet
155
+ * HTTPS) and `tailscale funnel` (public HTTPS) ingresses.
156
+ *
157
+ * Depends on `@camstack/addon-tailscale-client` being installed and
158
+ * the host being joined to a tailnet. The mesh readiness check goes
159
+ * through the `mesh-network` cap so we don't have to import the client
160
+ * package directly.
161
+ *
162
+ * Registers the `network-access` collection cap so the Remote Access
163
+ * page can toggle the public Funnel ingress alongside other ingress
164
+ * providers (cloudflare-tunnel, ngrok, …).
165
+ *
166
+ * Per-ingress feature flags (#175, #176, #177):
167
+ * • `autoStart` — when set, the addon auto-runs `start()` once the
168
+ * mesh-network cap reports `joined: true` (boot or later state
169
+ * change). Errors do NOT crash the addon — they're logged + surfaced
170
+ * via `getStatus().error`.
171
+ * • `ingresses[]` — array of `{ mode, sourcePort, targetPath? }`
172
+ * entries. Each entry maps to one `tailscale serve` / `funnel` CLI
173
+ * invocation. Lets a single addon expose e.g. the hub on Serve AND
174
+ * a Funnel-exposed status page concurrently.
175
+ * • Graceful disposer — registered via `ctx.addDisposer(...)` so it
176
+ * survives addon-disable AND addon-uninstall. Runs `off` for every
177
+ * active ingress; if the daemon is unreachable we log + skip rather
178
+ * than zombieing the addon.
179
+ */
180
+ var DEFAULT_CONFIG = {
181
+ autoStart: false,
182
+ ingresses: []
183
+ };
184
+ var TailscaleIngressAddon = class extends _camstack_types.BaseAddon {
185
+ cli = new TailscaleCli();
186
+ /**
187
+ * Map of "live" entries the addon has called `enabled: true` for. Used
188
+ * by stop()/disposer to know which `off` calls to issue. Keyed by
189
+ * entry id (mode-port-path). The value retains the entry shape so a
190
+ * mid-flight config change can't break the cleanup contract.
191
+ */
192
+ activeEntries = /* @__PURE__ */ new Map();
193
+ /** Set once disposer fires so re-entrant stop() calls are no-ops. */
194
+ disposed = false;
195
+ constructor() {
196
+ super({ ...DEFAULT_CONFIG });
197
+ }
198
+ async onInitialize() {
199
+ let cliReachable = false;
200
+ try {
201
+ await this.cli.version();
202
+ cliReachable = true;
203
+ } catch (err) {
204
+ this.ctx.logger.warn("tailscale-ingress: Tailscale CLI not found", { meta: { error: err instanceof Error ? err.message : String(err) } });
205
+ }
206
+ const provider = {
207
+ start: () => this.start(),
208
+ stop: () => this.stop(),
209
+ getEndpoint: () => this.getPrimaryEndpoint(),
210
+ getStatus: () => this.getStatus(),
211
+ listEndpoints: () => this.listEndpoints()
212
+ };
213
+ this.ctx.addDisposer(() => this.gracefulShutdown());
214
+ if (cliReachable && this.config.autoStart) setImmediate(() => {
215
+ this.autoStartIfReady();
216
+ });
217
+ if (this.config.autoStart) this.watchCapability("mesh-network", { onReady: () => {
218
+ this.autoStartIfReady().catch((err) => {
219
+ this.ctx.logger.warn("tailscale-ingress: auto-start retry after mesh-ready failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
220
+ });
221
+ } });
222
+ return [{
223
+ capability: _camstack_types.networkAccessCapability,
224
+ provider
225
+ }];
226
+ }
227
+ /**
228
+ * Single-shot bring-up driven by either boot or a mesh-ready event.
229
+ * Idempotent — start() is itself idempotent w.r.t. already-running
230
+ * CLI rules, and the active entries map dedupes across calls.
231
+ */
232
+ async autoStartIfReady() {
233
+ if (this.config.ingresses.length === 0) {
234
+ this.ctx.logger.info("tailscale-ingress: autoStart on, but no ingresses configured — skipping", { tags: {
235
+ topic: "tailscale-ingress",
236
+ phase: "autostart-empty"
237
+ } });
238
+ return;
239
+ }
240
+ let joined;
241
+ try {
242
+ joined = (await this.fetchMeshStatus()).joined;
243
+ } catch {
244
+ joined = false;
245
+ }
246
+ if (!joined) {
247
+ this.ctx.logger.info("tailscale-ingress: autoStart waiting for mesh to be joined", { tags: {
248
+ topic: "tailscale-ingress",
249
+ phase: "autostart-mesh-not-joined"
250
+ } });
251
+ return;
252
+ }
253
+ try {
254
+ await this.start();
255
+ this.ctx.logger.info("tailscale-ingress: auto-started successfully", {
256
+ meta: { ingressCount: this.config.ingresses.length },
257
+ tags: {
258
+ topic: "tailscale-ingress",
259
+ phase: "autostart-ok"
260
+ }
261
+ });
262
+ } catch (err) {
263
+ this.ctx.logger.warn("tailscale-ingress: auto-start failed (will be retried on next mesh-ready event)", {
264
+ meta: { error: err instanceof Error ? err.message : String(err) },
265
+ tags: {
266
+ topic: "tailscale-ingress",
267
+ phase: "autostart-error"
268
+ }
269
+ });
270
+ }
271
+ }
272
+ async start() {
273
+ if (this.disposed) throw new Error("tailscale-ingress: addon shut down — cannot start");
274
+ await this.requireClientJoined();
275
+ if (this.config.ingresses.length === 0) throw new Error("tailscale-ingress: no ingresses configured — add at least one row in the addon settings.");
276
+ const errors = [];
277
+ for (const entry of this.config.ingresses) try {
278
+ await this.applyIngressRule(entry, true);
279
+ const id = entryId(entry);
280
+ this.activeEntries.set(id, entry);
281
+ } catch (err) {
282
+ const msg = err instanceof Error ? err.message : String(err);
283
+ errors.push(`${entry.mode}:${entry.sourcePort}${entry.targetPath || ""} — ${msg}`);
284
+ }
285
+ if (this.activeEntries.size === 0) throw new Error(`tailscale-ingress: every ingress failed to start: ${errors.join("; ")}`);
286
+ const primary = (await this.resolveEndpoints())[0];
287
+ if (!primary) throw new Error("tailscale-ingress: CLI accepted the ingress but no MagicDNS hostname yet — has the host fully joined the tailnet?");
288
+ this.ctx.eventBus?.emit({
289
+ id: (0, node_crypto.randomUUID)(),
290
+ timestamp: /* @__PURE__ */ new Date(),
291
+ source: {
292
+ type: "addon",
293
+ id: this.ctx.id
294
+ },
295
+ category: _camstack_types.EventCategory.NetworkTunnelStarted,
296
+ data: { url: primary.url }
297
+ });
298
+ if (errors.length > 0) this.ctx.logger.warn("tailscale-ingress: started with partial failures", {
299
+ meta: {
300
+ errors,
301
+ succeeded: this.activeEntries.size
302
+ },
303
+ tags: {
304
+ topic: "tailscale-ingress",
305
+ phase: "start-partial"
306
+ }
307
+ });
308
+ return {
309
+ url: primary.url,
310
+ hostname: primary.hostname,
311
+ port: primary.port,
312
+ protocol: primary.protocol
313
+ };
314
+ }
315
+ async stop() {
316
+ const snapshot = Array.from(this.activeEntries.values());
317
+ this.activeEntries.clear();
318
+ for (const entry of snapshot) try {
319
+ await this.applyIngressRule(entry, false);
320
+ } catch (err) {
321
+ this.ctx.logger.warn("tailscale-ingress: failed to stop entry — will retry on next disposer call", {
322
+ meta: {
323
+ entry,
324
+ error: err instanceof Error ? err.message : String(err)
325
+ },
326
+ tags: {
327
+ topic: "tailscale-ingress",
328
+ phase: "stop-error"
329
+ }
330
+ });
331
+ }
332
+ this.ctx.eventBus?.emit({
333
+ id: (0, node_crypto.randomUUID)(),
334
+ timestamp: /* @__PURE__ */ new Date(),
335
+ source: {
336
+ type: "addon",
337
+ id: this.ctx.id
338
+ },
339
+ category: _camstack_types.EventCategory.NetworkTunnelStopped,
340
+ data: {}
341
+ });
342
+ }
343
+ /**
344
+ * Runs from `ctx.addDisposer(...)` on every teardown path:
345
+ * - addon disable / uninstall
346
+ * - restartAddon
347
+ * - full server shutdown
348
+ *
349
+ * Strategy: walk every active entry and ask the daemon to clear the
350
+ * matching rule. If the daemon is unreachable we log the failure and
351
+ * proceed — leaving the addon zombied with mesh ingress is worse than
352
+ * a stale rule the operator can clean up by hand.
353
+ */
354
+ async gracefulShutdown() {
355
+ if (this.disposed) return;
356
+ this.disposed = true;
357
+ const snapshot = Array.from(this.activeEntries.values());
358
+ if (snapshot.length === 0) return;
359
+ try {
360
+ await this.cli.version();
361
+ } catch (err) {
362
+ this.ctx.logger.warn("tailscale-ingress: disposer can't reach tailscale CLI — leaving rules in place", {
363
+ meta: {
364
+ error: err instanceof Error ? err.message : String(err),
365
+ remainingEntries: snapshot.length
366
+ },
367
+ tags: {
368
+ topic: "tailscale-ingress",
369
+ phase: "dispose-cli-missing"
370
+ }
371
+ });
372
+ this.activeEntries.clear();
373
+ return;
374
+ }
375
+ for (const entry of snapshot) try {
376
+ await this.applyIngressRule(entry, false);
377
+ } catch (err) {
378
+ this.ctx.logger.warn("tailscale-ingress: disposer failed to clear entry — continuing", {
379
+ meta: {
380
+ entry,
381
+ error: err instanceof Error ? err.message : String(err)
382
+ },
383
+ tags: {
384
+ topic: "tailscale-ingress",
385
+ phase: "dispose-entry-error"
386
+ }
387
+ });
388
+ }
389
+ this.activeEntries.clear();
390
+ }
391
+ async getPrimaryEndpoint() {
392
+ const primary = (await this.resolveEndpoints())[0];
393
+ if (!primary) return null;
394
+ return {
395
+ url: primary.url,
396
+ hostname: primary.hostname,
397
+ port: primary.port,
398
+ protocol: primary.protocol
399
+ };
400
+ }
401
+ async getStatus() {
402
+ try {
403
+ const endpoint = await this.getPrimaryEndpoint();
404
+ return {
405
+ connected: this.activeEntries.size > 0 && endpoint !== null,
406
+ endpoint
407
+ };
408
+ } catch (err) {
409
+ return {
410
+ connected: false,
411
+ endpoint: null,
412
+ error: err instanceof TailscaleCliError ? err.message : String(err)
413
+ };
414
+ }
415
+ }
416
+ async listEndpoints() {
417
+ return this.resolveEndpoints();
418
+ }
419
+ async applyIngressRule(entry, enabled) {
420
+ const opts = {
421
+ port: entry.sourcePort,
422
+ enabled,
423
+ ...entry.targetPath && entry.targetPath !== "/" ? { targetPath: entry.targetPath } : {}
424
+ };
425
+ if (entry.mode === "funnel") await this.cli.funnel(opts);
426
+ else await this.cli.serve(opts);
427
+ }
428
+ /**
429
+ * Build the canonical endpoint list from the active entries map +
430
+ * the mesh-network MagicDNS hostname. Cold-start safe: when the host
431
+ * isn't joined yet we return an empty list rather than a half-formed
432
+ * URL with no hostname.
433
+ */
434
+ async resolveEndpoints() {
435
+ if (this.activeEntries.size === 0) return [];
436
+ const meshStatus = await this.fetchMeshStatus().catch(() => null);
437
+ if (!meshStatus || !meshStatus.joined) return [];
438
+ const host = meshStatus.magicDnsHostname;
439
+ if (!host) return [];
440
+ const out = [];
441
+ for (const entry of this.activeEntries.values()) {
442
+ const path = entry.targetPath && entry.targetPath !== "/" ? entry.targetPath : "";
443
+ out.push({
444
+ id: entryId(entry),
445
+ label: `${entry.mode === "funnel" ? "Funnel" : "Serve"} :${entry.sourcePort}${path}`,
446
+ mode: entry.mode,
447
+ sourcePort: entry.sourcePort,
448
+ url: `https://${host}${path}`,
449
+ hostname: host,
450
+ port: 443,
451
+ protocol: "https"
452
+ });
453
+ }
454
+ return out;
455
+ }
456
+ /**
457
+ * Wait for `mesh-network` (the tailscale-client provider) to be
458
+ * mounted AND report joined. Throws an actionable error otherwise.
459
+ */
460
+ async requireClientJoined() {
461
+ try {
462
+ await this.ctx.acquireCapability("mesh-network", void 0, { timeoutMs: 3e4 });
463
+ } catch (err) {
464
+ throw new Error(`tailscale-ingress: cap 'mesh-network' is not available — is @camstack/addon-tailscale-client installed and enabled? (${err instanceof Error ? err.message : String(err)})`, { cause: err });
465
+ }
466
+ if (!(await this.fetchMeshStatus()).joined) throw new Error("tailscale-ingress: tailnet not joined — run Join on the Tailscale Client addon first.");
467
+ }
468
+ async fetchMeshStatus() {
469
+ const api = this.ctx.api;
470
+ if (!api.meshNetwork?.getStatus?.query) throw new Error("tailscale-ingress: mesh-network cap not exposed on the api surface");
471
+ return await api.meshNetwork.getStatus.query();
472
+ }
473
+ globalSettingsSchema() {
474
+ return this.schema({ sections: [{
475
+ id: "tailscale-ingress",
476
+ title: "Tailscale Ingress",
477
+ description: "Exposes the hub via Tailscale Serve (in-tailnet HTTPS) or Funnel (public). Add one row per ingress — Serve + Funnel can run concurrently on different ports / paths. Requires the Tailscale Client addon to have the host joined first.",
478
+ columns: 1,
479
+ fields: [this.field({
480
+ type: "boolean",
481
+ key: "autoStart",
482
+ label: "Start on boot",
483
+ description: "Bring every configured ingress up automatically once the host is joined to the tailnet.",
484
+ default: DEFAULT_CONFIG.autoStart
485
+ }), {
486
+ type: "editable-array",
487
+ key: "ingresses",
488
+ label: "Ingresses",
489
+ description: "Each row is a single `tailscale serve` or `tailscale funnel` invocation.",
490
+ emptyMessage: "No ingresses configured.",
491
+ addLabel: "Add ingress",
492
+ rowTitleTemplate: "{mode} :{sourcePort}{targetPath}",
493
+ defaultItem: {
494
+ mode: "serve",
495
+ sourcePort: 4e3,
496
+ targetPath: ""
497
+ },
498
+ itemFields: [
499
+ {
500
+ type: "select",
501
+ key: "mode",
502
+ label: "Mode",
503
+ description: "Serve = peers on your tailnet. Funnel = open internet (requires Funnel grant in tailnet ACL).",
504
+ options: [{
505
+ value: "serve",
506
+ label: "Serve (tailnet HTTPS)"
507
+ }, {
508
+ value: "funnel",
509
+ label: "Funnel (public HTTPS)"
510
+ }],
511
+ default: "serve"
512
+ },
513
+ {
514
+ type: "number",
515
+ key: "sourcePort",
516
+ label: "Local port",
517
+ description: "Local hub port the ingress should forward to (e.g. 4000).",
518
+ default: 4e3
519
+ },
520
+ {
521
+ type: "text",
522
+ key: "targetPath",
523
+ label: "Mount path (optional)",
524
+ description: "Public-side path prefix. Leave empty / set \"/\" for root.",
525
+ placeholder: "/"
526
+ }
527
+ ]
528
+ }]
529
+ }] });
530
+ }
531
+ async onConfigChanged() {
532
+ if (this.activeEntries.size === 0) return;
533
+ await this.stop();
534
+ if (this.config.autoStart) await this.autoStartIfReady();
535
+ }
536
+ };
537
+ function entryId(entry) {
538
+ const path = entry.targetPath && entry.targetPath !== "/" ? entry.targetPath : "";
539
+ return `${entry.mode}-${entry.sourcePort}${path}`;
540
+ }
541
+ //#endregion
542
+ exports.TailscaleIngressAddon = TailscaleIngressAddon;
543
+ exports.default = TailscaleIngressAddon;
544
+
545
+ //# sourceMappingURL=tailscale-ingress.addon.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tailscale-ingress.addon.js","names":[],"sources":["../src/tailscale-cli.ts","../src/tailscale-ingress.addon.ts"],"sourcesContent":["/**\n * Thin wrapper around the `tailscale` CLI.\n *\n * Why CLI rather than the local API socket: `tailscaled` exposes its\n * control plane at `/var/run/tailscale/tailscaled.sock` but the wire\n * protocol is undocumented + the Go client is gnarly to port. The\n * `tailscale` CLI is the supported public interface, it covers every\n * action we need (`up`/`down`/`status --json`/`serve`/`funnel`), and\n * its output is stable JSON for `status` + non-fatal stderr for the\n * others.\n *\n * The wrapper:\n * - resolves the binary path once (PATH lookup + macOS GUI install\n * fallback at `/Applications/Tailscale.app/Contents/MacOS/Tailscale`).\n * - exposes Promise-based methods returning typed shapes.\n * - never spawns long-lived processes — every call is one-shot.\n *\n * Operator-side prerequisite: `tailscaled` must be installed +\n * running. The addon surfaces \"binary missing\" / \"daemon not running\"\n * errors verbatim so the operator can act.\n */\nimport { execFile } from 'node:child_process'\nimport { promisify } from 'node:util'\n\nconst execFileP = promisify(execFile)\n\nconst TAILSCALE_CANDIDATES = [\n 'tailscale',\n '/usr/bin/tailscale',\n '/usr/local/bin/tailscale',\n '/opt/homebrew/bin/tailscale',\n // macOS GUI install ships the CLI inside the .app bundle.\n '/Applications/Tailscale.app/Contents/MacOS/Tailscale',\n]\n\nexport class TailscaleCliError extends Error {\n constructor(message: string, public readonly stderr: string = '') {\n super(message)\n this.name = 'TailscaleCliError'\n }\n}\n\n/** Subset of `tailscale status --json` we actually use. */\nexport interface TailscaleStatusJson {\n readonly BackendState: 'NoState' | 'NeedsLogin' | 'NeedsMachineAuth' | 'Starting' | 'Running' | 'Stopped'\n readonly Self?: {\n readonly ID: string\n readonly HostName: string\n readonly DNSName: string // e.g. \"camstack.tail-abc.ts.net.\"\n readonly TailscaleIPs?: readonly string[]\n readonly Online: boolean\n readonly OS: string\n }\n readonly Peer?: Record<string, {\n readonly ID: string\n readonly HostName: string\n readonly DNSName: string\n readonly TailscaleIPs?: readonly string[]\n readonly Online: boolean\n readonly OS: string\n readonly LastSeen: string // ISO 8601\n }>\n readonly MagicDNSSuffix?: string\n readonly CurrentTailnet?: { readonly Name?: string; readonly MagicDNSSuffix?: string }\n}\n\n/**\n * Options for the per-ingress serve/funnel CLI calls. `targetPath` is\n * the public-facing mount path the operator wants the rule to live at\n * (default `/`). Tailscale's `serve` syntax accepts an optional\n * `--set-path=<path>` flag to attach the rule to a non-root prefix —\n * we drive it through this option.\n */\nexport interface IngressRuleOptions {\n readonly port: number\n readonly enabled: boolean\n readonly targetPath?: string\n}\n\nexport class TailscaleCli {\n private resolvedBin: string | null = null\n\n /** Locate the `tailscale` binary once and cache the result. */\n private async resolveBin(): Promise<string> {\n if (this.resolvedBin) return this.resolvedBin\n for (const candidate of TAILSCALE_CANDIDATES) {\n try {\n await execFileP(candidate, ['version'], { timeout: 3_000 })\n this.resolvedBin = candidate\n return candidate\n } catch {\n // try next\n }\n }\n throw new TailscaleCliError(\n 'tailscale binary not found — install Tailscale from https://tailscale.com/download',\n )\n }\n\n async version(): Promise<string> {\n const bin = await this.resolveBin()\n const { stdout } = await execFileP(bin, ['version'], { timeout: 5_000 })\n return stdout.trim().split('\\n')[0] ?? ''\n }\n\n async status(): Promise<TailscaleStatusJson> {\n const bin = await this.resolveBin()\n try {\n const { stdout } = await execFileP(bin, ['status', '--json'], { timeout: 10_000 })\n return JSON.parse(stdout) as TailscaleStatusJson\n } catch (err) {\n const e = err as { stderr?: string; message: string }\n throw new TailscaleCliError(\n `tailscale status failed: ${e.message}`,\n e.stderr ?? '',\n )\n }\n }\n\n /** Bring the daemon up with an auth key. Idempotent — calling\n * while already joined returns immediately. */\n async up(input: { readonly authKey: string; readonly hostname?: string }): Promise<void> {\n const bin = await this.resolveBin()\n const args = ['up', '--auth-key', input.authKey, '--reset']\n if (input.hostname) args.push(`--hostname=${input.hostname}`)\n try {\n await execFileP(bin, args, { timeout: 60_000 })\n } catch (err) {\n const e = err as { stderr?: string; message: string }\n throw new TailscaleCliError(\n `tailscale up failed: ${e.message}`,\n e.stderr ?? '',\n )\n }\n }\n\n /** Leave the tailnet. After this the host's `100.x` address is\n * released until the next `up`. */\n async down(): Promise<void> {\n const bin = await this.resolveBin()\n try {\n await execFileP(bin, ['down'], { timeout: 15_000 })\n } catch (err) {\n const e = err as { stderr?: string; message: string }\n throw new TailscaleCliError(\n `tailscale down failed: ${e.message}`,\n e.stderr ?? '',\n )\n }\n }\n\n /** `tailscale serve` — exposes a local port to peers in the same\n * tailnet over HTTPS with auto-issued cert. Mutating call; the\n * daemon persists the rule across restarts.\n *\n * When `targetPath` is provided we pass `--set-path=<path>` so\n * multiple serve rules can co-exist under different prefixes\n * without overwriting each other. */\n async serve(input: IngressRuleOptions): Promise<void> {\n const bin = await this.resolveBin()\n const args = this.buildIngressArgs('serve', input)\n try {\n await execFileP(bin, args, { timeout: 15_000 })\n } catch (err) {\n const e = err as { stderr?: string; message: string }\n throw new TailscaleCliError(\n `tailscale serve failed: ${e.message}`,\n e.stderr ?? '',\n )\n }\n }\n\n /** `tailscale funnel` — exposes a local port to the open internet\n * via Tailscale's edge. Requires Funnel ACL grant in the tailnet\n * policy. Same shape as `serve`. */\n async funnel(input: IngressRuleOptions): Promise<void> {\n const bin = await this.resolveBin()\n const args = this.buildIngressArgs('funnel', input)\n try {\n await execFileP(bin, args, { timeout: 15_000 })\n } catch (err) {\n const e = err as { stderr?: string; message: string }\n throw new TailscaleCliError(\n `tailscale funnel failed: ${e.message}`,\n e.stderr ?? '',\n )\n }\n }\n\n /**\n * Single source of truth for the serve/funnel arg shape. The CLI\n * accepts the same flag layout for both verbs, so we share the\n * builder. `--set-path` is only emitted when the operator picks a\n * non-root mount.\n */\n private buildIngressArgs(verb: 'serve' | 'funnel', input: IngressRuleOptions): readonly string[] {\n const path = (input.targetPath ?? '').trim()\n if (!input.enabled) {\n const out: string[] = [verb, '--bg']\n if (path && path !== '/') out.push(`--set-path=${path}`)\n out.push('off')\n return out\n }\n const out: string[] = [verb, '--bg']\n if (path && path !== '/') out.push(`--set-path=${path}`)\n out.push(`http://127.0.0.1:${input.port}`)\n return out\n }\n}\n","/**\n * Tailscale Ingress addon — manages `tailscale serve` (in-tailnet\n * HTTPS) and `tailscale funnel` (public HTTPS) ingresses.\n *\n * Depends on `@camstack/addon-tailscale-client` being installed and\n * the host being joined to a tailnet. The mesh readiness check goes\n * through the `mesh-network` cap so we don't have to import the client\n * package directly.\n *\n * Registers the `network-access` collection cap so the Remote Access\n * page can toggle the public Funnel ingress alongside other ingress\n * providers (cloudflare-tunnel, ngrok, …).\n *\n * Per-ingress feature flags (#175, #176, #177):\n * • `autoStart` — when set, the addon auto-runs `start()` once the\n * mesh-network cap reports `joined: true` (boot or later state\n * change). Errors do NOT crash the addon — they're logged + surfaced\n * via `getStatus().error`.\n * • `ingresses[]` — array of `{ mode, sourcePort, targetPath? }`\n * entries. Each entry maps to one `tailscale serve` / `funnel` CLI\n * invocation. Lets a single addon expose e.g. the hub on Serve AND\n * a Funnel-exposed status page concurrently.\n * • Graceful disposer — registered via `ctx.addDisposer(...)` so it\n * survives addon-disable AND addon-uninstall. Runs `off` for every\n * active ingress; if the daemon is unreachable we log + skip rather\n * than zombieing the addon.\n */\nimport {\n BaseAddon,\n EventCategory,\n networkAccessCapability,\n type ConfigField,\n type InferProvider,\n type ProviderRegistration,\n} from '@camstack/types'\n\ntype NetworkAccessProvider = InferProvider<typeof networkAccessCapability>\nimport { randomUUID } from 'node:crypto'\n\nimport { TailscaleCli, TailscaleCliError } from './tailscale-cli.js'\n\n// ── Settings shape (#176) ───────────────────────────────────────────\n\ntype IngressMode = 'serve' | 'funnel'\n\n/**\n * Single ingress entry. The CLI invocation is one-to-one with an entry:\n * the addon manages independent `tailscale serve|funnel` rules per row.\n * Identity is `{mode}-{sourcePort}-{normalisedTargetPath}` — used as the\n * endpoint id and the cleanup key.\n */\ninterface IngressEntry {\n readonly mode: IngressMode\n readonly sourcePort: number\n /** Public-side mount path. Empty / `/` = root. */\n readonly targetPath: string\n}\n\ninterface TailscaleIngressConfig {\n /** When true AND mesh-network is joined, auto-run `start()` on boot. */\n readonly autoStart: boolean\n /** All configured ingress rules. */\n readonly ingresses: readonly IngressEntry[]\n}\n\nconst DEFAULT_CONFIG: TailscaleIngressConfig = {\n autoStart: false,\n ingresses: [],\n}\n\ninterface ResolvedEndpoint {\n readonly id: string\n readonly label: string\n readonly mode: IngressMode\n readonly sourcePort: number\n readonly url: string\n readonly hostname: string\n readonly port: 443\n readonly protocol: 'https'\n}\n\nexport class TailscaleIngressAddon extends BaseAddon<TailscaleIngressConfig> {\n private cli = new TailscaleCli()\n /**\n * Map of \"live\" entries the addon has called `enabled: true` for. Used\n * by stop()/disposer to know which `off` calls to issue. Keyed by\n * entry id (mode-port-path). The value retains the entry shape so a\n * mid-flight config change can't break the cleanup contract.\n */\n private activeEntries = new Map<string, IngressEntry>()\n /** Set once disposer fires so re-entrant stop() calls are no-ops. */\n private disposed = false\n\n constructor() {\n super({ ...DEFAULT_CONFIG })\n }\n\n protected async onInitialize(): Promise<ProviderRegistration[]> {\n // Sanity-probe the CLI early so the operator sees a clear error\n // when the daemon isn't installed.\n let cliReachable = false\n try {\n await this.cli.version()\n cliReachable = true\n } catch (err) {\n this.ctx.logger.warn('tailscale-ingress: Tailscale CLI not found', {\n meta: { error: err instanceof Error ? err.message : String(err) },\n })\n }\n\n const provider: NetworkAccessProvider = {\n start: () => this.start(),\n stop: () => this.stop(),\n getEndpoint: () => this.getPrimaryEndpoint(),\n getStatus: () => this.getStatus(),\n listEndpoints: () => this.listEndpoints(),\n }\n\n // Graceful shutdown disposer (#177) — runs on:\n // • addon disable / uninstall (kernel calls `addon.shutdown()`)\n // • restartAddon (kernel calls shutdown + re-init)\n // • full server shutdown\n // We don't try to be clever about \"uninstall vs disable\" — for\n // active ingresses the right cleanup is identical (turn rules off).\n // Disposers run before `_ctx` is nulled, so logging still works.\n this.ctx.addDisposer(() => this.gracefulShutdown())\n\n // Auto-start (#175) — only when the CLI is reachable AND the\n // operator opted in. We don't crash the addon on failure here:\n // start() catches its own errors when invoked through this path.\n if (cliReachable && this.config.autoStart) {\n // Defer to the next tick so provider registration completes\n // first. Otherwise `requireClientJoined` may race the cap-ready\n // event for `mesh-network` on cold boot.\n setImmediate(() => { void this.autoStartIfReady() })\n }\n\n // Subscribe to mesh-network readiness transitions so a delayed\n // `joined: true` (operator clicks Connect after we booted) also\n // triggers auto-start without requiring a server restart.\n if (this.config.autoStart) {\n // `watchCapability` is a BaseAddon helper that wraps the\n // `system.ready-state` subscription with type narrowing. We only\n // care about the `ready` transition — when mesh-network goes\n // down we want operator-visible feedback (next getStatus call\n // returns connected: false) but no automatic teardown of rules,\n // which would race the daemon's own reconnect path.\n this.watchCapability('mesh-network', {\n onReady: () => {\n // Best-effort: silently no-op when auto-start already ran.\n void this.autoStartIfReady().catch((err) => {\n this.ctx.logger.warn('tailscale-ingress: auto-start retry after mesh-ready failed', {\n meta: { error: err instanceof Error ? err.message : String(err) },\n })\n })\n },\n })\n }\n\n return [{ capability: networkAccessCapability, provider }]\n }\n\n // ── #175 — autoStart helper ─────────────────────────────────────────\n\n /**\n * Single-shot bring-up driven by either boot or a mesh-ready event.\n * Idempotent — start() is itself idempotent w.r.t. already-running\n * CLI rules, and the active entries map dedupes across calls.\n */\n private async autoStartIfReady(): Promise<void> {\n if (this.config.ingresses.length === 0) {\n this.ctx.logger.info('tailscale-ingress: autoStart on, but no ingresses configured — skipping', {\n tags: { topic: 'tailscale-ingress', phase: 'autostart-empty' },\n })\n return\n }\n // We re-probe mesh-network status via the cap rather than just\n // trusting the readiness event — readiness is binary (ready/down)\n // but the actual mesh may be NeedsLogin / NeedsMachineAuth / etc.\n let joined: boolean\n try {\n const status = await this.fetchMeshStatus()\n joined = status.joined\n } catch {\n // Treat as not joined; don't spam the log on every retry tick.\n joined = false\n }\n if (!joined) {\n this.ctx.logger.info('tailscale-ingress: autoStart waiting for mesh to be joined', {\n tags: { topic: 'tailscale-ingress', phase: 'autostart-mesh-not-joined' },\n })\n return\n }\n try {\n await this.start()\n this.ctx.logger.info('tailscale-ingress: auto-started successfully', {\n meta: { ingressCount: this.config.ingresses.length },\n tags: { topic: 'tailscale-ingress', phase: 'autostart-ok' },\n })\n } catch (err) {\n // Don't crash — surface the error through the next getStatus().\n this.ctx.logger.warn('tailscale-ingress: auto-start failed (will be retried on next mesh-ready event)', {\n meta: { error: err instanceof Error ? err.message : String(err) },\n tags: { topic: 'tailscale-ingress', phase: 'autostart-error' },\n })\n }\n }\n\n // ── #176 — multi-ingress lifecycle ──────────────────────────────────\n\n private async start(): Promise<{ url: string; hostname: string; port: number; protocol: 'http' | 'https' }> {\n if (this.disposed) {\n throw new Error('tailscale-ingress: addon shut down — cannot start')\n }\n await this.requireClientJoined()\n\n if (this.config.ingresses.length === 0) {\n throw new Error('tailscale-ingress: no ingresses configured — add at least one row in the addon settings.')\n }\n\n // Apply each ingress rule. We collect errors but don't short-circuit\n // — if entry #2 fails, entry #1 stays up (and is also recorded so\n // a later stop() cleans it up).\n const errors: string[] = []\n for (const entry of this.config.ingresses) {\n try {\n await this.applyIngressRule(entry, true)\n const id = entryId(entry)\n this.activeEntries.set(id, entry)\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err)\n errors.push(`${entry.mode}:${entry.sourcePort}${entry.targetPath || ''} — ${msg}`)\n }\n }\n\n if (this.activeEntries.size === 0) {\n throw new Error(`tailscale-ingress: every ingress failed to start: ${errors.join('; ')}`)\n }\n\n const endpoints = await this.resolveEndpoints()\n const primary = endpoints[0]\n if (!primary) {\n throw new Error('tailscale-ingress: CLI accepted the ingress but no MagicDNS hostname yet — has the host fully joined the tailnet?')\n }\n\n this.ctx.eventBus?.emit({\n id: randomUUID(),\n timestamp: new Date(),\n source: { type: 'addon', id: this.ctx.id },\n category: EventCategory.NetworkTunnelStarted,\n data: { url: primary.url },\n })\n\n if (errors.length > 0) {\n this.ctx.logger.warn('tailscale-ingress: started with partial failures', {\n meta: { errors, succeeded: this.activeEntries.size },\n tags: { topic: 'tailscale-ingress', phase: 'start-partial' },\n })\n }\n\n return {\n url: primary.url,\n hostname: primary.hostname,\n port: primary.port,\n protocol: primary.protocol,\n }\n }\n\n private async stop(): Promise<void> {\n // Snapshot then clear so re-entrant calls don't double-issue `off`.\n const snapshot = Array.from(this.activeEntries.values())\n this.activeEntries.clear()\n for (const entry of snapshot) {\n try {\n await this.applyIngressRule(entry, false)\n } catch (err) {\n // Log but don't throw — `stop()` must always succeed from the\n // operator's POV. A failed off-call is recoverable on the next\n // boot via the disposer or a manual CLI cleanup.\n this.ctx.logger.warn('tailscale-ingress: failed to stop entry — will retry on next disposer call', {\n meta: {\n entry,\n error: err instanceof Error ? err.message : String(err),\n },\n tags: { topic: 'tailscale-ingress', phase: 'stop-error' },\n })\n }\n }\n this.ctx.eventBus?.emit({\n id: randomUUID(),\n timestamp: new Date(),\n source: { type: 'addon', id: this.ctx.id },\n category: EventCategory.NetworkTunnelStopped,\n data: {},\n })\n }\n\n // ── #177 — graceful disposer ────────────────────────────────────────\n\n /**\n * Runs from `ctx.addDisposer(...)` on every teardown path:\n * - addon disable / uninstall\n * - restartAddon\n * - full server shutdown\n *\n * Strategy: walk every active entry and ask the daemon to clear the\n * matching rule. If the daemon is unreachable we log the failure and\n * proceed — leaving the addon zombied with mesh ingress is worse than\n * a stale rule the operator can clean up by hand.\n */\n private async gracefulShutdown(): Promise<void> {\n if (this.disposed) return\n this.disposed = true\n const snapshot = Array.from(this.activeEntries.values())\n if (snapshot.length === 0) return\n\n // Probe the daemon first — a clean \"CLI missing\" path lets us log\n // the operator-actionable error once instead of N times.\n try {\n await this.cli.version()\n } catch (err) {\n this.ctx.logger.warn('tailscale-ingress: disposer can\\'t reach tailscale CLI — leaving rules in place', {\n meta: {\n error: err instanceof Error ? err.message : String(err),\n remainingEntries: snapshot.length,\n },\n tags: { topic: 'tailscale-ingress', phase: 'dispose-cli-missing' },\n })\n this.activeEntries.clear()\n return\n }\n\n for (const entry of snapshot) {\n try {\n await this.applyIngressRule(entry, false)\n } catch (err) {\n this.ctx.logger.warn('tailscale-ingress: disposer failed to clear entry — continuing', {\n meta: {\n entry,\n error: err instanceof Error ? err.message : String(err),\n },\n tags: { topic: 'tailscale-ingress', phase: 'dispose-entry-error' },\n })\n }\n }\n this.activeEntries.clear()\n }\n\n // ── network-access provider impl ───────────────────────────────────\n\n private async getPrimaryEndpoint(): Promise<\n { url: string; hostname: string; port: number; protocol: 'http' | 'https' } | null\n > {\n const all = await this.resolveEndpoints()\n const primary = all[0]\n if (!primary) return null\n return {\n url: primary.url,\n hostname: primary.hostname,\n port: primary.port,\n protocol: primary.protocol,\n }\n }\n\n private async getStatus(): Promise<{\n connected: boolean\n endpoint: { url: string; hostname: string; port: number; protocol: 'http' | 'https' } | null\n error?: string\n }> {\n try {\n const endpoint = await this.getPrimaryEndpoint()\n return {\n connected: this.activeEntries.size > 0 && endpoint !== null,\n endpoint,\n }\n } catch (err) {\n return {\n connected: false,\n endpoint: null,\n error: err instanceof TailscaleCliError ? err.message : String(err),\n }\n }\n }\n\n private async listEndpoints(): Promise<ReadonlyArray<{\n id: string\n label: string\n url: string\n hostname: string\n port: number\n protocol: 'http' | 'https'\n mode?: string\n sourcePort?: number\n }>> {\n return this.resolveEndpoints()\n }\n\n // ── Helpers ─────────────────────────────────────────────────────────\n\n private async applyIngressRule(entry: IngressEntry, enabled: boolean): Promise<void> {\n const opts: { port: number; enabled: boolean; targetPath?: string } = {\n port: entry.sourcePort,\n enabled,\n ...(entry.targetPath && entry.targetPath !== '/' ? { targetPath: entry.targetPath } : {}),\n }\n if (entry.mode === 'funnel') {\n await this.cli.funnel(opts)\n } else {\n await this.cli.serve(opts)\n }\n }\n\n /**\n * Build the canonical endpoint list from the active entries map +\n * the mesh-network MagicDNS hostname. Cold-start safe: when the host\n * isn't joined yet we return an empty list rather than a half-formed\n * URL with no hostname.\n */\n private async resolveEndpoints(): Promise<readonly ResolvedEndpoint[]> {\n if (this.activeEntries.size === 0) return []\n const meshStatus = await this.fetchMeshStatus().catch(() => null)\n if (!meshStatus || !meshStatus.joined) return []\n const host = meshStatus.magicDnsHostname\n if (!host) return []\n const out: ResolvedEndpoint[] = []\n for (const entry of this.activeEntries.values()) {\n const path = entry.targetPath && entry.targetPath !== '/' ? entry.targetPath : ''\n out.push({\n id: entryId(entry),\n label: `${entry.mode === 'funnel' ? 'Funnel' : 'Serve'} :${entry.sourcePort}${path}`,\n mode: entry.mode,\n sourcePort: entry.sourcePort,\n url: `https://${host}${path}`,\n hostname: host,\n port: 443,\n protocol: 'https',\n })\n }\n return out\n }\n\n /**\n * Wait for `mesh-network` (the tailscale-client provider) to be\n * mounted AND report joined. Throws an actionable error otherwise.\n */\n private async requireClientJoined(): Promise<void> {\n try {\n await this.ctx.acquireCapability('mesh-network', undefined, { timeoutMs: 30_000 })\n } catch (err) {\n throw new Error(\n `tailscale-ingress: cap 'mesh-network' is not available — is @camstack/addon-tailscale-client installed and enabled? (${err instanceof Error ? err.message : String(err)})`,\n { cause: err },\n )\n }\n const status = await this.fetchMeshStatus()\n if (!status.joined) {\n throw new Error('tailscale-ingress: tailnet not joined — run Join on the Tailscale Client addon first.')\n }\n }\n\n private async fetchMeshStatus(): Promise<{ joined: boolean; magicDnsHostname: string; error?: string }> {\n const api = this.ctx.api as unknown as {\n meshNetwork?: {\n getStatus: {\n query: (input?: { addonId?: string; nodeId?: string }) => Promise<{\n joined: boolean\n magicDnsHostname: string\n error?: string\n }>\n }\n }\n }\n if (!api.meshNetwork?.getStatus?.query) {\n throw new Error('tailscale-ingress: mesh-network cap not exposed on the api surface')\n }\n return await api.meshNetwork.getStatus.query()\n }\n\n protected globalSettingsSchema() {\n const ingressItemFields: readonly ConfigField[] = [\n {\n type: 'select',\n key: 'mode',\n label: 'Mode',\n description: 'Serve = peers on your tailnet. Funnel = open internet (requires Funnel grant in tailnet ACL).',\n options: [\n { value: 'serve', label: 'Serve (tailnet HTTPS)' },\n { value: 'funnel', label: 'Funnel (public HTTPS)' },\n ],\n default: 'serve',\n },\n {\n type: 'number',\n key: 'sourcePort',\n label: 'Local port',\n description: 'Local hub port the ingress should forward to (e.g. 4000).',\n default: 4000,\n },\n {\n type: 'text',\n key: 'targetPath',\n label: 'Mount path (optional)',\n description: 'Public-side path prefix. Leave empty / set \"/\" for root.',\n placeholder: '/',\n },\n ]\n return this.schema({\n sections: [\n {\n id: 'tailscale-ingress',\n title: 'Tailscale Ingress',\n description:\n 'Exposes the hub via Tailscale Serve (in-tailnet HTTPS) or Funnel (public). Add one row per ingress — Serve + Funnel can run concurrently on different ports / paths. Requires the Tailscale Client addon to have the host joined first.',\n columns: 1,\n fields: [\n this.field({\n type: 'boolean',\n key: 'autoStart',\n label: 'Start on boot',\n description: 'Bring every configured ingress up automatically once the host is joined to the tailnet.',\n default: DEFAULT_CONFIG.autoStart,\n }),\n {\n type: 'editable-array',\n key: 'ingresses',\n label: 'Ingresses',\n description: 'Each row is a single `tailscale serve` or `tailscale funnel` invocation.',\n emptyMessage: 'No ingresses configured.',\n addLabel: 'Add ingress',\n rowTitleTemplate: '{mode} :{sourcePort}{targetPath}',\n defaultItem: {\n mode: 'serve',\n sourcePort: 4000,\n targetPath: '',\n },\n itemFields: ingressItemFields,\n },\n ],\n },\n ],\n })\n }\n\n protected async onConfigChanged(): Promise<void> {\n // When ingresses change while running, reconcile the daemon state.\n // For simplicity we tear everything down and re-apply on the next\n // start() — operators control re-bring-up via the Remote Access\n // Start button (or the autoStart flag).\n if (this.activeEntries.size === 0) return\n await this.stop()\n if (this.config.autoStart) {\n await this.autoStartIfReady()\n }\n }\n}\n\nfunction entryId(entry: IngressEntry): string {\n const path = entry.targetPath && entry.targetPath !== '/' ? entry.targetPath : ''\n return `${entry.mode}-${entry.sourcePort}${path}`\n}\n\nexport default TailscaleIngressAddon\n"],"mappings":"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"}
@@ -0,0 +1,541 @@
1
+ import { BaseAddon, EventCategory, networkAccessCapability } from "@camstack/types";
2
+ import { randomUUID } from "node:crypto";
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ //#region src/tailscale-cli.ts
6
+ /**
7
+ * Thin wrapper around the `tailscale` CLI.
8
+ *
9
+ * Why CLI rather than the local API socket: `tailscaled` exposes its
10
+ * control plane at `/var/run/tailscale/tailscaled.sock` but the wire
11
+ * protocol is undocumented + the Go client is gnarly to port. The
12
+ * `tailscale` CLI is the supported public interface, it covers every
13
+ * action we need (`up`/`down`/`status --json`/`serve`/`funnel`), and
14
+ * its output is stable JSON for `status` + non-fatal stderr for the
15
+ * others.
16
+ *
17
+ * The wrapper:
18
+ * - resolves the binary path once (PATH lookup + macOS GUI install
19
+ * fallback at `/Applications/Tailscale.app/Contents/MacOS/Tailscale`).
20
+ * - exposes Promise-based methods returning typed shapes.
21
+ * - never spawns long-lived processes — every call is one-shot.
22
+ *
23
+ * Operator-side prerequisite: `tailscaled` must be installed +
24
+ * running. The addon surfaces "binary missing" / "daemon not running"
25
+ * errors verbatim so the operator can act.
26
+ */
27
+ var execFileP = promisify(execFile);
28
+ var TAILSCALE_CANDIDATES = [
29
+ "tailscale",
30
+ "/usr/bin/tailscale",
31
+ "/usr/local/bin/tailscale",
32
+ "/opt/homebrew/bin/tailscale",
33
+ "/Applications/Tailscale.app/Contents/MacOS/Tailscale"
34
+ ];
35
+ var TailscaleCliError = class extends Error {
36
+ stderr;
37
+ constructor(message, stderr = "") {
38
+ super(message);
39
+ this.stderr = stderr;
40
+ this.name = "TailscaleCliError";
41
+ }
42
+ };
43
+ var TailscaleCli = class {
44
+ resolvedBin = null;
45
+ /** Locate the `tailscale` binary once and cache the result. */
46
+ async resolveBin() {
47
+ if (this.resolvedBin) return this.resolvedBin;
48
+ for (const candidate of TAILSCALE_CANDIDATES) try {
49
+ await execFileP(candidate, ["version"], { timeout: 3e3 });
50
+ this.resolvedBin = candidate;
51
+ return candidate;
52
+ } catch {}
53
+ throw new TailscaleCliError("tailscale binary not found — install Tailscale from https://tailscale.com/download");
54
+ }
55
+ async version() {
56
+ const { stdout } = await execFileP(await this.resolveBin(), ["version"], { timeout: 5e3 });
57
+ return stdout.trim().split("\n")[0] ?? "";
58
+ }
59
+ async status() {
60
+ const bin = await this.resolveBin();
61
+ try {
62
+ const { stdout } = await execFileP(bin, ["status", "--json"], { timeout: 1e4 });
63
+ return JSON.parse(stdout);
64
+ } catch (err) {
65
+ const e = err;
66
+ throw new TailscaleCliError(`tailscale status failed: ${e.message}`, e.stderr ?? "");
67
+ }
68
+ }
69
+ /** Bring the daemon up with an auth key. Idempotent — calling
70
+ * while already joined returns immediately. */
71
+ async up(input) {
72
+ const bin = await this.resolveBin();
73
+ const args = [
74
+ "up",
75
+ "--auth-key",
76
+ input.authKey,
77
+ "--reset"
78
+ ];
79
+ if (input.hostname) args.push(`--hostname=${input.hostname}`);
80
+ try {
81
+ await execFileP(bin, args, { timeout: 6e4 });
82
+ } catch (err) {
83
+ const e = err;
84
+ throw new TailscaleCliError(`tailscale up failed: ${e.message}`, e.stderr ?? "");
85
+ }
86
+ }
87
+ /** Leave the tailnet. After this the host's `100.x` address is
88
+ * released until the next `up`. */
89
+ async down() {
90
+ const bin = await this.resolveBin();
91
+ try {
92
+ await execFileP(bin, ["down"], { timeout: 15e3 });
93
+ } catch (err) {
94
+ const e = err;
95
+ throw new TailscaleCliError(`tailscale down failed: ${e.message}`, e.stderr ?? "");
96
+ }
97
+ }
98
+ /** `tailscale serve` — exposes a local port to peers in the same
99
+ * tailnet over HTTPS with auto-issued cert. Mutating call; the
100
+ * daemon persists the rule across restarts.
101
+ *
102
+ * When `targetPath` is provided we pass `--set-path=<path>` so
103
+ * multiple serve rules can co-exist under different prefixes
104
+ * without overwriting each other. */
105
+ async serve(input) {
106
+ const bin = await this.resolveBin();
107
+ const args = this.buildIngressArgs("serve", input);
108
+ try {
109
+ await execFileP(bin, args, { timeout: 15e3 });
110
+ } catch (err) {
111
+ const e = err;
112
+ throw new TailscaleCliError(`tailscale serve failed: ${e.message}`, e.stderr ?? "");
113
+ }
114
+ }
115
+ /** `tailscale funnel` — exposes a local port to the open internet
116
+ * via Tailscale's edge. Requires Funnel ACL grant in the tailnet
117
+ * policy. Same shape as `serve`. */
118
+ async funnel(input) {
119
+ const bin = await this.resolveBin();
120
+ const args = this.buildIngressArgs("funnel", input);
121
+ try {
122
+ await execFileP(bin, args, { timeout: 15e3 });
123
+ } catch (err) {
124
+ const e = err;
125
+ throw new TailscaleCliError(`tailscale funnel failed: ${e.message}`, e.stderr ?? "");
126
+ }
127
+ }
128
+ /**
129
+ * Single source of truth for the serve/funnel arg shape. The CLI
130
+ * accepts the same flag layout for both verbs, so we share the
131
+ * builder. `--set-path` is only emitted when the operator picks a
132
+ * non-root mount.
133
+ */
134
+ buildIngressArgs(verb, input) {
135
+ const path = (input.targetPath ?? "").trim();
136
+ if (!input.enabled) {
137
+ const out = [verb, "--bg"];
138
+ if (path && path !== "/") out.push(`--set-path=${path}`);
139
+ out.push("off");
140
+ return out;
141
+ }
142
+ const out = [verb, "--bg"];
143
+ if (path && path !== "/") out.push(`--set-path=${path}`);
144
+ out.push(`http://127.0.0.1:${input.port}`);
145
+ return out;
146
+ }
147
+ };
148
+ //#endregion
149
+ //#region src/tailscale-ingress.addon.ts
150
+ /**
151
+ * Tailscale Ingress addon — manages `tailscale serve` (in-tailnet
152
+ * HTTPS) and `tailscale funnel` (public HTTPS) ingresses.
153
+ *
154
+ * Depends on `@camstack/addon-tailscale-client` being installed and
155
+ * the host being joined to a tailnet. The mesh readiness check goes
156
+ * through the `mesh-network` cap so we don't have to import the client
157
+ * package directly.
158
+ *
159
+ * Registers the `network-access` collection cap so the Remote Access
160
+ * page can toggle the public Funnel ingress alongside other ingress
161
+ * providers (cloudflare-tunnel, ngrok, …).
162
+ *
163
+ * Per-ingress feature flags (#175, #176, #177):
164
+ * • `autoStart` — when set, the addon auto-runs `start()` once the
165
+ * mesh-network cap reports `joined: true` (boot or later state
166
+ * change). Errors do NOT crash the addon — they're logged + surfaced
167
+ * via `getStatus().error`.
168
+ * • `ingresses[]` — array of `{ mode, sourcePort, targetPath? }`
169
+ * entries. Each entry maps to one `tailscale serve` / `funnel` CLI
170
+ * invocation. Lets a single addon expose e.g. the hub on Serve AND
171
+ * a Funnel-exposed status page concurrently.
172
+ * • Graceful disposer — registered via `ctx.addDisposer(...)` so it
173
+ * survives addon-disable AND addon-uninstall. Runs `off` for every
174
+ * active ingress; if the daemon is unreachable we log + skip rather
175
+ * than zombieing the addon.
176
+ */
177
+ var DEFAULT_CONFIG = {
178
+ autoStart: false,
179
+ ingresses: []
180
+ };
181
+ var TailscaleIngressAddon = class extends BaseAddon {
182
+ cli = new TailscaleCli();
183
+ /**
184
+ * Map of "live" entries the addon has called `enabled: true` for. Used
185
+ * by stop()/disposer to know which `off` calls to issue. Keyed by
186
+ * entry id (mode-port-path). The value retains the entry shape so a
187
+ * mid-flight config change can't break the cleanup contract.
188
+ */
189
+ activeEntries = /* @__PURE__ */ new Map();
190
+ /** Set once disposer fires so re-entrant stop() calls are no-ops. */
191
+ disposed = false;
192
+ constructor() {
193
+ super({ ...DEFAULT_CONFIG });
194
+ }
195
+ async onInitialize() {
196
+ let cliReachable = false;
197
+ try {
198
+ await this.cli.version();
199
+ cliReachable = true;
200
+ } catch (err) {
201
+ this.ctx.logger.warn("tailscale-ingress: Tailscale CLI not found", { meta: { error: err instanceof Error ? err.message : String(err) } });
202
+ }
203
+ const provider = {
204
+ start: () => this.start(),
205
+ stop: () => this.stop(),
206
+ getEndpoint: () => this.getPrimaryEndpoint(),
207
+ getStatus: () => this.getStatus(),
208
+ listEndpoints: () => this.listEndpoints()
209
+ };
210
+ this.ctx.addDisposer(() => this.gracefulShutdown());
211
+ if (cliReachable && this.config.autoStart) setImmediate(() => {
212
+ this.autoStartIfReady();
213
+ });
214
+ if (this.config.autoStart) this.watchCapability("mesh-network", { onReady: () => {
215
+ this.autoStartIfReady().catch((err) => {
216
+ this.ctx.logger.warn("tailscale-ingress: auto-start retry after mesh-ready failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
217
+ });
218
+ } });
219
+ return [{
220
+ capability: networkAccessCapability,
221
+ provider
222
+ }];
223
+ }
224
+ /**
225
+ * Single-shot bring-up driven by either boot or a mesh-ready event.
226
+ * Idempotent — start() is itself idempotent w.r.t. already-running
227
+ * CLI rules, and the active entries map dedupes across calls.
228
+ */
229
+ async autoStartIfReady() {
230
+ if (this.config.ingresses.length === 0) {
231
+ this.ctx.logger.info("tailscale-ingress: autoStart on, but no ingresses configured — skipping", { tags: {
232
+ topic: "tailscale-ingress",
233
+ phase: "autostart-empty"
234
+ } });
235
+ return;
236
+ }
237
+ let joined;
238
+ try {
239
+ joined = (await this.fetchMeshStatus()).joined;
240
+ } catch {
241
+ joined = false;
242
+ }
243
+ if (!joined) {
244
+ this.ctx.logger.info("tailscale-ingress: autoStart waiting for mesh to be joined", { tags: {
245
+ topic: "tailscale-ingress",
246
+ phase: "autostart-mesh-not-joined"
247
+ } });
248
+ return;
249
+ }
250
+ try {
251
+ await this.start();
252
+ this.ctx.logger.info("tailscale-ingress: auto-started successfully", {
253
+ meta: { ingressCount: this.config.ingresses.length },
254
+ tags: {
255
+ topic: "tailscale-ingress",
256
+ phase: "autostart-ok"
257
+ }
258
+ });
259
+ } catch (err) {
260
+ this.ctx.logger.warn("tailscale-ingress: auto-start failed (will be retried on next mesh-ready event)", {
261
+ meta: { error: err instanceof Error ? err.message : String(err) },
262
+ tags: {
263
+ topic: "tailscale-ingress",
264
+ phase: "autostart-error"
265
+ }
266
+ });
267
+ }
268
+ }
269
+ async start() {
270
+ if (this.disposed) throw new Error("tailscale-ingress: addon shut down — cannot start");
271
+ await this.requireClientJoined();
272
+ if (this.config.ingresses.length === 0) throw new Error("tailscale-ingress: no ingresses configured — add at least one row in the addon settings.");
273
+ const errors = [];
274
+ for (const entry of this.config.ingresses) try {
275
+ await this.applyIngressRule(entry, true);
276
+ const id = entryId(entry);
277
+ this.activeEntries.set(id, entry);
278
+ } catch (err) {
279
+ const msg = err instanceof Error ? err.message : String(err);
280
+ errors.push(`${entry.mode}:${entry.sourcePort}${entry.targetPath || ""} — ${msg}`);
281
+ }
282
+ if (this.activeEntries.size === 0) throw new Error(`tailscale-ingress: every ingress failed to start: ${errors.join("; ")}`);
283
+ const primary = (await this.resolveEndpoints())[0];
284
+ if (!primary) throw new Error("tailscale-ingress: CLI accepted the ingress but no MagicDNS hostname yet — has the host fully joined the tailnet?");
285
+ this.ctx.eventBus?.emit({
286
+ id: randomUUID(),
287
+ timestamp: /* @__PURE__ */ new Date(),
288
+ source: {
289
+ type: "addon",
290
+ id: this.ctx.id
291
+ },
292
+ category: EventCategory.NetworkTunnelStarted,
293
+ data: { url: primary.url }
294
+ });
295
+ if (errors.length > 0) this.ctx.logger.warn("tailscale-ingress: started with partial failures", {
296
+ meta: {
297
+ errors,
298
+ succeeded: this.activeEntries.size
299
+ },
300
+ tags: {
301
+ topic: "tailscale-ingress",
302
+ phase: "start-partial"
303
+ }
304
+ });
305
+ return {
306
+ url: primary.url,
307
+ hostname: primary.hostname,
308
+ port: primary.port,
309
+ protocol: primary.protocol
310
+ };
311
+ }
312
+ async stop() {
313
+ const snapshot = Array.from(this.activeEntries.values());
314
+ this.activeEntries.clear();
315
+ for (const entry of snapshot) try {
316
+ await this.applyIngressRule(entry, false);
317
+ } catch (err) {
318
+ this.ctx.logger.warn("tailscale-ingress: failed to stop entry — will retry on next disposer call", {
319
+ meta: {
320
+ entry,
321
+ error: err instanceof Error ? err.message : String(err)
322
+ },
323
+ tags: {
324
+ topic: "tailscale-ingress",
325
+ phase: "stop-error"
326
+ }
327
+ });
328
+ }
329
+ this.ctx.eventBus?.emit({
330
+ id: randomUUID(),
331
+ timestamp: /* @__PURE__ */ new Date(),
332
+ source: {
333
+ type: "addon",
334
+ id: this.ctx.id
335
+ },
336
+ category: EventCategory.NetworkTunnelStopped,
337
+ data: {}
338
+ });
339
+ }
340
+ /**
341
+ * Runs from `ctx.addDisposer(...)` on every teardown path:
342
+ * - addon disable / uninstall
343
+ * - restartAddon
344
+ * - full server shutdown
345
+ *
346
+ * Strategy: walk every active entry and ask the daemon to clear the
347
+ * matching rule. If the daemon is unreachable we log the failure and
348
+ * proceed — leaving the addon zombied with mesh ingress is worse than
349
+ * a stale rule the operator can clean up by hand.
350
+ */
351
+ async gracefulShutdown() {
352
+ if (this.disposed) return;
353
+ this.disposed = true;
354
+ const snapshot = Array.from(this.activeEntries.values());
355
+ if (snapshot.length === 0) return;
356
+ try {
357
+ await this.cli.version();
358
+ } catch (err) {
359
+ this.ctx.logger.warn("tailscale-ingress: disposer can't reach tailscale CLI — leaving rules in place", {
360
+ meta: {
361
+ error: err instanceof Error ? err.message : String(err),
362
+ remainingEntries: snapshot.length
363
+ },
364
+ tags: {
365
+ topic: "tailscale-ingress",
366
+ phase: "dispose-cli-missing"
367
+ }
368
+ });
369
+ this.activeEntries.clear();
370
+ return;
371
+ }
372
+ for (const entry of snapshot) try {
373
+ await this.applyIngressRule(entry, false);
374
+ } catch (err) {
375
+ this.ctx.logger.warn("tailscale-ingress: disposer failed to clear entry — continuing", {
376
+ meta: {
377
+ entry,
378
+ error: err instanceof Error ? err.message : String(err)
379
+ },
380
+ tags: {
381
+ topic: "tailscale-ingress",
382
+ phase: "dispose-entry-error"
383
+ }
384
+ });
385
+ }
386
+ this.activeEntries.clear();
387
+ }
388
+ async getPrimaryEndpoint() {
389
+ const primary = (await this.resolveEndpoints())[0];
390
+ if (!primary) return null;
391
+ return {
392
+ url: primary.url,
393
+ hostname: primary.hostname,
394
+ port: primary.port,
395
+ protocol: primary.protocol
396
+ };
397
+ }
398
+ async getStatus() {
399
+ try {
400
+ const endpoint = await this.getPrimaryEndpoint();
401
+ return {
402
+ connected: this.activeEntries.size > 0 && endpoint !== null,
403
+ endpoint
404
+ };
405
+ } catch (err) {
406
+ return {
407
+ connected: false,
408
+ endpoint: null,
409
+ error: err instanceof TailscaleCliError ? err.message : String(err)
410
+ };
411
+ }
412
+ }
413
+ async listEndpoints() {
414
+ return this.resolveEndpoints();
415
+ }
416
+ async applyIngressRule(entry, enabled) {
417
+ const opts = {
418
+ port: entry.sourcePort,
419
+ enabled,
420
+ ...entry.targetPath && entry.targetPath !== "/" ? { targetPath: entry.targetPath } : {}
421
+ };
422
+ if (entry.mode === "funnel") await this.cli.funnel(opts);
423
+ else await this.cli.serve(opts);
424
+ }
425
+ /**
426
+ * Build the canonical endpoint list from the active entries map +
427
+ * the mesh-network MagicDNS hostname. Cold-start safe: when the host
428
+ * isn't joined yet we return an empty list rather than a half-formed
429
+ * URL with no hostname.
430
+ */
431
+ async resolveEndpoints() {
432
+ if (this.activeEntries.size === 0) return [];
433
+ const meshStatus = await this.fetchMeshStatus().catch(() => null);
434
+ if (!meshStatus || !meshStatus.joined) return [];
435
+ const host = meshStatus.magicDnsHostname;
436
+ if (!host) return [];
437
+ const out = [];
438
+ for (const entry of this.activeEntries.values()) {
439
+ const path = entry.targetPath && entry.targetPath !== "/" ? entry.targetPath : "";
440
+ out.push({
441
+ id: entryId(entry),
442
+ label: `${entry.mode === "funnel" ? "Funnel" : "Serve"} :${entry.sourcePort}${path}`,
443
+ mode: entry.mode,
444
+ sourcePort: entry.sourcePort,
445
+ url: `https://${host}${path}`,
446
+ hostname: host,
447
+ port: 443,
448
+ protocol: "https"
449
+ });
450
+ }
451
+ return out;
452
+ }
453
+ /**
454
+ * Wait for `mesh-network` (the tailscale-client provider) to be
455
+ * mounted AND report joined. Throws an actionable error otherwise.
456
+ */
457
+ async requireClientJoined() {
458
+ try {
459
+ await this.ctx.acquireCapability("mesh-network", void 0, { timeoutMs: 3e4 });
460
+ } catch (err) {
461
+ throw new Error(`tailscale-ingress: cap 'mesh-network' is not available — is @camstack/addon-tailscale-client installed and enabled? (${err instanceof Error ? err.message : String(err)})`, { cause: err });
462
+ }
463
+ if (!(await this.fetchMeshStatus()).joined) throw new Error("tailscale-ingress: tailnet not joined — run Join on the Tailscale Client addon first.");
464
+ }
465
+ async fetchMeshStatus() {
466
+ const api = this.ctx.api;
467
+ if (!api.meshNetwork?.getStatus?.query) throw new Error("tailscale-ingress: mesh-network cap not exposed on the api surface");
468
+ return await api.meshNetwork.getStatus.query();
469
+ }
470
+ globalSettingsSchema() {
471
+ return this.schema({ sections: [{
472
+ id: "tailscale-ingress",
473
+ title: "Tailscale Ingress",
474
+ description: "Exposes the hub via Tailscale Serve (in-tailnet HTTPS) or Funnel (public). Add one row per ingress — Serve + Funnel can run concurrently on different ports / paths. Requires the Tailscale Client addon to have the host joined first.",
475
+ columns: 1,
476
+ fields: [this.field({
477
+ type: "boolean",
478
+ key: "autoStart",
479
+ label: "Start on boot",
480
+ description: "Bring every configured ingress up automatically once the host is joined to the tailnet.",
481
+ default: DEFAULT_CONFIG.autoStart
482
+ }), {
483
+ type: "editable-array",
484
+ key: "ingresses",
485
+ label: "Ingresses",
486
+ description: "Each row is a single `tailscale serve` or `tailscale funnel` invocation.",
487
+ emptyMessage: "No ingresses configured.",
488
+ addLabel: "Add ingress",
489
+ rowTitleTemplate: "{mode} :{sourcePort}{targetPath}",
490
+ defaultItem: {
491
+ mode: "serve",
492
+ sourcePort: 4e3,
493
+ targetPath: ""
494
+ },
495
+ itemFields: [
496
+ {
497
+ type: "select",
498
+ key: "mode",
499
+ label: "Mode",
500
+ description: "Serve = peers on your tailnet. Funnel = open internet (requires Funnel grant in tailnet ACL).",
501
+ options: [{
502
+ value: "serve",
503
+ label: "Serve (tailnet HTTPS)"
504
+ }, {
505
+ value: "funnel",
506
+ label: "Funnel (public HTTPS)"
507
+ }],
508
+ default: "serve"
509
+ },
510
+ {
511
+ type: "number",
512
+ key: "sourcePort",
513
+ label: "Local port",
514
+ description: "Local hub port the ingress should forward to (e.g. 4000).",
515
+ default: 4e3
516
+ },
517
+ {
518
+ type: "text",
519
+ key: "targetPath",
520
+ label: "Mount path (optional)",
521
+ description: "Public-side path prefix. Leave empty / set \"/\" for root.",
522
+ placeholder: "/"
523
+ }
524
+ ]
525
+ }]
526
+ }] });
527
+ }
528
+ async onConfigChanged() {
529
+ if (this.activeEntries.size === 0) return;
530
+ await this.stop();
531
+ if (this.config.autoStart) await this.autoStartIfReady();
532
+ }
533
+ };
534
+ function entryId(entry) {
535
+ const path = entry.targetPath && entry.targetPath !== "/" ? entry.targetPath : "";
536
+ return `${entry.mode}-${entry.sourcePort}${path}`;
537
+ }
538
+ //#endregion
539
+ export { TailscaleIngressAddon, TailscaleIngressAddon as default };
540
+
541
+ //# sourceMappingURL=tailscale-ingress.addon.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tailscale-ingress.addon.mjs","names":[],"sources":["../src/tailscale-cli.ts","../src/tailscale-ingress.addon.ts"],"sourcesContent":["/**\n * Thin wrapper around the `tailscale` CLI.\n *\n * Why CLI rather than the local API socket: `tailscaled` exposes its\n * control plane at `/var/run/tailscale/tailscaled.sock` but the wire\n * protocol is undocumented + the Go client is gnarly to port. The\n * `tailscale` CLI is the supported public interface, it covers every\n * action we need (`up`/`down`/`status --json`/`serve`/`funnel`), and\n * its output is stable JSON for `status` + non-fatal stderr for the\n * others.\n *\n * The wrapper:\n * - resolves the binary path once (PATH lookup + macOS GUI install\n * fallback at `/Applications/Tailscale.app/Contents/MacOS/Tailscale`).\n * - exposes Promise-based methods returning typed shapes.\n * - never spawns long-lived processes — every call is one-shot.\n *\n * Operator-side prerequisite: `tailscaled` must be installed +\n * running. The addon surfaces \"binary missing\" / \"daemon not running\"\n * errors verbatim so the operator can act.\n */\nimport { execFile } from 'node:child_process'\nimport { promisify } from 'node:util'\n\nconst execFileP = promisify(execFile)\n\nconst TAILSCALE_CANDIDATES = [\n 'tailscale',\n '/usr/bin/tailscale',\n '/usr/local/bin/tailscale',\n '/opt/homebrew/bin/tailscale',\n // macOS GUI install ships the CLI inside the .app bundle.\n '/Applications/Tailscale.app/Contents/MacOS/Tailscale',\n]\n\nexport class TailscaleCliError extends Error {\n constructor(message: string, public readonly stderr: string = '') {\n super(message)\n this.name = 'TailscaleCliError'\n }\n}\n\n/** Subset of `tailscale status --json` we actually use. */\nexport interface TailscaleStatusJson {\n readonly BackendState: 'NoState' | 'NeedsLogin' | 'NeedsMachineAuth' | 'Starting' | 'Running' | 'Stopped'\n readonly Self?: {\n readonly ID: string\n readonly HostName: string\n readonly DNSName: string // e.g. \"camstack.tail-abc.ts.net.\"\n readonly TailscaleIPs?: readonly string[]\n readonly Online: boolean\n readonly OS: string\n }\n readonly Peer?: Record<string, {\n readonly ID: string\n readonly HostName: string\n readonly DNSName: string\n readonly TailscaleIPs?: readonly string[]\n readonly Online: boolean\n readonly OS: string\n readonly LastSeen: string // ISO 8601\n }>\n readonly MagicDNSSuffix?: string\n readonly CurrentTailnet?: { readonly Name?: string; readonly MagicDNSSuffix?: string }\n}\n\n/**\n * Options for the per-ingress serve/funnel CLI calls. `targetPath` is\n * the public-facing mount path the operator wants the rule to live at\n * (default `/`). Tailscale's `serve` syntax accepts an optional\n * `--set-path=<path>` flag to attach the rule to a non-root prefix —\n * we drive it through this option.\n */\nexport interface IngressRuleOptions {\n readonly port: number\n readonly enabled: boolean\n readonly targetPath?: string\n}\n\nexport class TailscaleCli {\n private resolvedBin: string | null = null\n\n /** Locate the `tailscale` binary once and cache the result. */\n private async resolveBin(): Promise<string> {\n if (this.resolvedBin) return this.resolvedBin\n for (const candidate of TAILSCALE_CANDIDATES) {\n try {\n await execFileP(candidate, ['version'], { timeout: 3_000 })\n this.resolvedBin = candidate\n return candidate\n } catch {\n // try next\n }\n }\n throw new TailscaleCliError(\n 'tailscale binary not found — install Tailscale from https://tailscale.com/download',\n )\n }\n\n async version(): Promise<string> {\n const bin = await this.resolveBin()\n const { stdout } = await execFileP(bin, ['version'], { timeout: 5_000 })\n return stdout.trim().split('\\n')[0] ?? ''\n }\n\n async status(): Promise<TailscaleStatusJson> {\n const bin = await this.resolveBin()\n try {\n const { stdout } = await execFileP(bin, ['status', '--json'], { timeout: 10_000 })\n return JSON.parse(stdout) as TailscaleStatusJson\n } catch (err) {\n const e = err as { stderr?: string; message: string }\n throw new TailscaleCliError(\n `tailscale status failed: ${e.message}`,\n e.stderr ?? '',\n )\n }\n }\n\n /** Bring the daemon up with an auth key. Idempotent — calling\n * while already joined returns immediately. */\n async up(input: { readonly authKey: string; readonly hostname?: string }): Promise<void> {\n const bin = await this.resolveBin()\n const args = ['up', '--auth-key', input.authKey, '--reset']\n if (input.hostname) args.push(`--hostname=${input.hostname}`)\n try {\n await execFileP(bin, args, { timeout: 60_000 })\n } catch (err) {\n const e = err as { stderr?: string; message: string }\n throw new TailscaleCliError(\n `tailscale up failed: ${e.message}`,\n e.stderr ?? '',\n )\n }\n }\n\n /** Leave the tailnet. After this the host's `100.x` address is\n * released until the next `up`. */\n async down(): Promise<void> {\n const bin = await this.resolveBin()\n try {\n await execFileP(bin, ['down'], { timeout: 15_000 })\n } catch (err) {\n const e = err as { stderr?: string; message: string }\n throw new TailscaleCliError(\n `tailscale down failed: ${e.message}`,\n e.stderr ?? '',\n )\n }\n }\n\n /** `tailscale serve` — exposes a local port to peers in the same\n * tailnet over HTTPS with auto-issued cert. Mutating call; the\n * daemon persists the rule across restarts.\n *\n * When `targetPath` is provided we pass `--set-path=<path>` so\n * multiple serve rules can co-exist under different prefixes\n * without overwriting each other. */\n async serve(input: IngressRuleOptions): Promise<void> {\n const bin = await this.resolveBin()\n const args = this.buildIngressArgs('serve', input)\n try {\n await execFileP(bin, args, { timeout: 15_000 })\n } catch (err) {\n const e = err as { stderr?: string; message: string }\n throw new TailscaleCliError(\n `tailscale serve failed: ${e.message}`,\n e.stderr ?? '',\n )\n }\n }\n\n /** `tailscale funnel` — exposes a local port to the open internet\n * via Tailscale's edge. Requires Funnel ACL grant in the tailnet\n * policy. Same shape as `serve`. */\n async funnel(input: IngressRuleOptions): Promise<void> {\n const bin = await this.resolveBin()\n const args = this.buildIngressArgs('funnel', input)\n try {\n await execFileP(bin, args, { timeout: 15_000 })\n } catch (err) {\n const e = err as { stderr?: string; message: string }\n throw new TailscaleCliError(\n `tailscale funnel failed: ${e.message}`,\n e.stderr ?? '',\n )\n }\n }\n\n /**\n * Single source of truth for the serve/funnel arg shape. The CLI\n * accepts the same flag layout for both verbs, so we share the\n * builder. `--set-path` is only emitted when the operator picks a\n * non-root mount.\n */\n private buildIngressArgs(verb: 'serve' | 'funnel', input: IngressRuleOptions): readonly string[] {\n const path = (input.targetPath ?? '').trim()\n if (!input.enabled) {\n const out: string[] = [verb, '--bg']\n if (path && path !== '/') out.push(`--set-path=${path}`)\n out.push('off')\n return out\n }\n const out: string[] = [verb, '--bg']\n if (path && path !== '/') out.push(`--set-path=${path}`)\n out.push(`http://127.0.0.1:${input.port}`)\n return out\n }\n}\n","/**\n * Tailscale Ingress addon — manages `tailscale serve` (in-tailnet\n * HTTPS) and `tailscale funnel` (public HTTPS) ingresses.\n *\n * Depends on `@camstack/addon-tailscale-client` being installed and\n * the host being joined to a tailnet. The mesh readiness check goes\n * through the `mesh-network` cap so we don't have to import the client\n * package directly.\n *\n * Registers the `network-access` collection cap so the Remote Access\n * page can toggle the public Funnel ingress alongside other ingress\n * providers (cloudflare-tunnel, ngrok, …).\n *\n * Per-ingress feature flags (#175, #176, #177):\n * • `autoStart` — when set, the addon auto-runs `start()` once the\n * mesh-network cap reports `joined: true` (boot or later state\n * change). Errors do NOT crash the addon — they're logged + surfaced\n * via `getStatus().error`.\n * • `ingresses[]` — array of `{ mode, sourcePort, targetPath? }`\n * entries. Each entry maps to one `tailscale serve` / `funnel` CLI\n * invocation. Lets a single addon expose e.g. the hub on Serve AND\n * a Funnel-exposed status page concurrently.\n * • Graceful disposer — registered via `ctx.addDisposer(...)` so it\n * survives addon-disable AND addon-uninstall. Runs `off` for every\n * active ingress; if the daemon is unreachable we log + skip rather\n * than zombieing the addon.\n */\nimport {\n BaseAddon,\n EventCategory,\n networkAccessCapability,\n type ConfigField,\n type InferProvider,\n type ProviderRegistration,\n} from '@camstack/types'\n\ntype NetworkAccessProvider = InferProvider<typeof networkAccessCapability>\nimport { randomUUID } from 'node:crypto'\n\nimport { TailscaleCli, TailscaleCliError } from './tailscale-cli.js'\n\n// ── Settings shape (#176) ───────────────────────────────────────────\n\ntype IngressMode = 'serve' | 'funnel'\n\n/**\n * Single ingress entry. The CLI invocation is one-to-one with an entry:\n * the addon manages independent `tailscale serve|funnel` rules per row.\n * Identity is `{mode}-{sourcePort}-{normalisedTargetPath}` — used as the\n * endpoint id and the cleanup key.\n */\ninterface IngressEntry {\n readonly mode: IngressMode\n readonly sourcePort: number\n /** Public-side mount path. Empty / `/` = root. */\n readonly targetPath: string\n}\n\ninterface TailscaleIngressConfig {\n /** When true AND mesh-network is joined, auto-run `start()` on boot. */\n readonly autoStart: boolean\n /** All configured ingress rules. */\n readonly ingresses: readonly IngressEntry[]\n}\n\nconst DEFAULT_CONFIG: TailscaleIngressConfig = {\n autoStart: false,\n ingresses: [],\n}\n\ninterface ResolvedEndpoint {\n readonly id: string\n readonly label: string\n readonly mode: IngressMode\n readonly sourcePort: number\n readonly url: string\n readonly hostname: string\n readonly port: 443\n readonly protocol: 'https'\n}\n\nexport class TailscaleIngressAddon extends BaseAddon<TailscaleIngressConfig> {\n private cli = new TailscaleCli()\n /**\n * Map of \"live\" entries the addon has called `enabled: true` for. Used\n * by stop()/disposer to know which `off` calls to issue. Keyed by\n * entry id (mode-port-path). The value retains the entry shape so a\n * mid-flight config change can't break the cleanup contract.\n */\n private activeEntries = new Map<string, IngressEntry>()\n /** Set once disposer fires so re-entrant stop() calls are no-ops. */\n private disposed = false\n\n constructor() {\n super({ ...DEFAULT_CONFIG })\n }\n\n protected async onInitialize(): Promise<ProviderRegistration[]> {\n // Sanity-probe the CLI early so the operator sees a clear error\n // when the daemon isn't installed.\n let cliReachable = false\n try {\n await this.cli.version()\n cliReachable = true\n } catch (err) {\n this.ctx.logger.warn('tailscale-ingress: Tailscale CLI not found', {\n meta: { error: err instanceof Error ? err.message : String(err) },\n })\n }\n\n const provider: NetworkAccessProvider = {\n start: () => this.start(),\n stop: () => this.stop(),\n getEndpoint: () => this.getPrimaryEndpoint(),\n getStatus: () => this.getStatus(),\n listEndpoints: () => this.listEndpoints(),\n }\n\n // Graceful shutdown disposer (#177) — runs on:\n // • addon disable / uninstall (kernel calls `addon.shutdown()`)\n // • restartAddon (kernel calls shutdown + re-init)\n // • full server shutdown\n // We don't try to be clever about \"uninstall vs disable\" — for\n // active ingresses the right cleanup is identical (turn rules off).\n // Disposers run before `_ctx` is nulled, so logging still works.\n this.ctx.addDisposer(() => this.gracefulShutdown())\n\n // Auto-start (#175) — only when the CLI is reachable AND the\n // operator opted in. We don't crash the addon on failure here:\n // start() catches its own errors when invoked through this path.\n if (cliReachable && this.config.autoStart) {\n // Defer to the next tick so provider registration completes\n // first. Otherwise `requireClientJoined` may race the cap-ready\n // event for `mesh-network` on cold boot.\n setImmediate(() => { void this.autoStartIfReady() })\n }\n\n // Subscribe to mesh-network readiness transitions so a delayed\n // `joined: true` (operator clicks Connect after we booted) also\n // triggers auto-start without requiring a server restart.\n if (this.config.autoStart) {\n // `watchCapability` is a BaseAddon helper that wraps the\n // `system.ready-state` subscription with type narrowing. We only\n // care about the `ready` transition — when mesh-network goes\n // down we want operator-visible feedback (next getStatus call\n // returns connected: false) but no automatic teardown of rules,\n // which would race the daemon's own reconnect path.\n this.watchCapability('mesh-network', {\n onReady: () => {\n // Best-effort: silently no-op when auto-start already ran.\n void this.autoStartIfReady().catch((err) => {\n this.ctx.logger.warn('tailscale-ingress: auto-start retry after mesh-ready failed', {\n meta: { error: err instanceof Error ? err.message : String(err) },\n })\n })\n },\n })\n }\n\n return [{ capability: networkAccessCapability, provider }]\n }\n\n // ── #175 — autoStart helper ─────────────────────────────────────────\n\n /**\n * Single-shot bring-up driven by either boot or a mesh-ready event.\n * Idempotent — start() is itself idempotent w.r.t. already-running\n * CLI rules, and the active entries map dedupes across calls.\n */\n private async autoStartIfReady(): Promise<void> {\n if (this.config.ingresses.length === 0) {\n this.ctx.logger.info('tailscale-ingress: autoStart on, but no ingresses configured — skipping', {\n tags: { topic: 'tailscale-ingress', phase: 'autostart-empty' },\n })\n return\n }\n // We re-probe mesh-network status via the cap rather than just\n // trusting the readiness event — readiness is binary (ready/down)\n // but the actual mesh may be NeedsLogin / NeedsMachineAuth / etc.\n let joined: boolean\n try {\n const status = await this.fetchMeshStatus()\n joined = status.joined\n } catch {\n // Treat as not joined; don't spam the log on every retry tick.\n joined = false\n }\n if (!joined) {\n this.ctx.logger.info('tailscale-ingress: autoStart waiting for mesh to be joined', {\n tags: { topic: 'tailscale-ingress', phase: 'autostart-mesh-not-joined' },\n })\n return\n }\n try {\n await this.start()\n this.ctx.logger.info('tailscale-ingress: auto-started successfully', {\n meta: { ingressCount: this.config.ingresses.length },\n tags: { topic: 'tailscale-ingress', phase: 'autostart-ok' },\n })\n } catch (err) {\n // Don't crash — surface the error through the next getStatus().\n this.ctx.logger.warn('tailscale-ingress: auto-start failed (will be retried on next mesh-ready event)', {\n meta: { error: err instanceof Error ? err.message : String(err) },\n tags: { topic: 'tailscale-ingress', phase: 'autostart-error' },\n })\n }\n }\n\n // ── #176 — multi-ingress lifecycle ──────────────────────────────────\n\n private async start(): Promise<{ url: string; hostname: string; port: number; protocol: 'http' | 'https' }> {\n if (this.disposed) {\n throw new Error('tailscale-ingress: addon shut down — cannot start')\n }\n await this.requireClientJoined()\n\n if (this.config.ingresses.length === 0) {\n throw new Error('tailscale-ingress: no ingresses configured — add at least one row in the addon settings.')\n }\n\n // Apply each ingress rule. We collect errors but don't short-circuit\n // — if entry #2 fails, entry #1 stays up (and is also recorded so\n // a later stop() cleans it up).\n const errors: string[] = []\n for (const entry of this.config.ingresses) {\n try {\n await this.applyIngressRule(entry, true)\n const id = entryId(entry)\n this.activeEntries.set(id, entry)\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err)\n errors.push(`${entry.mode}:${entry.sourcePort}${entry.targetPath || ''} — ${msg}`)\n }\n }\n\n if (this.activeEntries.size === 0) {\n throw new Error(`tailscale-ingress: every ingress failed to start: ${errors.join('; ')}`)\n }\n\n const endpoints = await this.resolveEndpoints()\n const primary = endpoints[0]\n if (!primary) {\n throw new Error('tailscale-ingress: CLI accepted the ingress but no MagicDNS hostname yet — has the host fully joined the tailnet?')\n }\n\n this.ctx.eventBus?.emit({\n id: randomUUID(),\n timestamp: new Date(),\n source: { type: 'addon', id: this.ctx.id },\n category: EventCategory.NetworkTunnelStarted,\n data: { url: primary.url },\n })\n\n if (errors.length > 0) {\n this.ctx.logger.warn('tailscale-ingress: started with partial failures', {\n meta: { errors, succeeded: this.activeEntries.size },\n tags: { topic: 'tailscale-ingress', phase: 'start-partial' },\n })\n }\n\n return {\n url: primary.url,\n hostname: primary.hostname,\n port: primary.port,\n protocol: primary.protocol,\n }\n }\n\n private async stop(): Promise<void> {\n // Snapshot then clear so re-entrant calls don't double-issue `off`.\n const snapshot = Array.from(this.activeEntries.values())\n this.activeEntries.clear()\n for (const entry of snapshot) {\n try {\n await this.applyIngressRule(entry, false)\n } catch (err) {\n // Log but don't throw — `stop()` must always succeed from the\n // operator's POV. A failed off-call is recoverable on the next\n // boot via the disposer or a manual CLI cleanup.\n this.ctx.logger.warn('tailscale-ingress: failed to stop entry — will retry on next disposer call', {\n meta: {\n entry,\n error: err instanceof Error ? err.message : String(err),\n },\n tags: { topic: 'tailscale-ingress', phase: 'stop-error' },\n })\n }\n }\n this.ctx.eventBus?.emit({\n id: randomUUID(),\n timestamp: new Date(),\n source: { type: 'addon', id: this.ctx.id },\n category: EventCategory.NetworkTunnelStopped,\n data: {},\n })\n }\n\n // ── #177 — graceful disposer ────────────────────────────────────────\n\n /**\n * Runs from `ctx.addDisposer(...)` on every teardown path:\n * - addon disable / uninstall\n * - restartAddon\n * - full server shutdown\n *\n * Strategy: walk every active entry and ask the daemon to clear the\n * matching rule. If the daemon is unreachable we log the failure and\n * proceed — leaving the addon zombied with mesh ingress is worse than\n * a stale rule the operator can clean up by hand.\n */\n private async gracefulShutdown(): Promise<void> {\n if (this.disposed) return\n this.disposed = true\n const snapshot = Array.from(this.activeEntries.values())\n if (snapshot.length === 0) return\n\n // Probe the daemon first — a clean \"CLI missing\" path lets us log\n // the operator-actionable error once instead of N times.\n try {\n await this.cli.version()\n } catch (err) {\n this.ctx.logger.warn('tailscale-ingress: disposer can\\'t reach tailscale CLI — leaving rules in place', {\n meta: {\n error: err instanceof Error ? err.message : String(err),\n remainingEntries: snapshot.length,\n },\n tags: { topic: 'tailscale-ingress', phase: 'dispose-cli-missing' },\n })\n this.activeEntries.clear()\n return\n }\n\n for (const entry of snapshot) {\n try {\n await this.applyIngressRule(entry, false)\n } catch (err) {\n this.ctx.logger.warn('tailscale-ingress: disposer failed to clear entry — continuing', {\n meta: {\n entry,\n error: err instanceof Error ? err.message : String(err),\n },\n tags: { topic: 'tailscale-ingress', phase: 'dispose-entry-error' },\n })\n }\n }\n this.activeEntries.clear()\n }\n\n // ── network-access provider impl ───────────────────────────────────\n\n private async getPrimaryEndpoint(): Promise<\n { url: string; hostname: string; port: number; protocol: 'http' | 'https' } | null\n > {\n const all = await this.resolveEndpoints()\n const primary = all[0]\n if (!primary) return null\n return {\n url: primary.url,\n hostname: primary.hostname,\n port: primary.port,\n protocol: primary.protocol,\n }\n }\n\n private async getStatus(): Promise<{\n connected: boolean\n endpoint: { url: string; hostname: string; port: number; protocol: 'http' | 'https' } | null\n error?: string\n }> {\n try {\n const endpoint = await this.getPrimaryEndpoint()\n return {\n connected: this.activeEntries.size > 0 && endpoint !== null,\n endpoint,\n }\n } catch (err) {\n return {\n connected: false,\n endpoint: null,\n error: err instanceof TailscaleCliError ? err.message : String(err),\n }\n }\n }\n\n private async listEndpoints(): Promise<ReadonlyArray<{\n id: string\n label: string\n url: string\n hostname: string\n port: number\n protocol: 'http' | 'https'\n mode?: string\n sourcePort?: number\n }>> {\n return this.resolveEndpoints()\n }\n\n // ── Helpers ─────────────────────────────────────────────────────────\n\n private async applyIngressRule(entry: IngressEntry, enabled: boolean): Promise<void> {\n const opts: { port: number; enabled: boolean; targetPath?: string } = {\n port: entry.sourcePort,\n enabled,\n ...(entry.targetPath && entry.targetPath !== '/' ? { targetPath: entry.targetPath } : {}),\n }\n if (entry.mode === 'funnel') {\n await this.cli.funnel(opts)\n } else {\n await this.cli.serve(opts)\n }\n }\n\n /**\n * Build the canonical endpoint list from the active entries map +\n * the mesh-network MagicDNS hostname. Cold-start safe: when the host\n * isn't joined yet we return an empty list rather than a half-formed\n * URL with no hostname.\n */\n private async resolveEndpoints(): Promise<readonly ResolvedEndpoint[]> {\n if (this.activeEntries.size === 0) return []\n const meshStatus = await this.fetchMeshStatus().catch(() => null)\n if (!meshStatus || !meshStatus.joined) return []\n const host = meshStatus.magicDnsHostname\n if (!host) return []\n const out: ResolvedEndpoint[] = []\n for (const entry of this.activeEntries.values()) {\n const path = entry.targetPath && entry.targetPath !== '/' ? entry.targetPath : ''\n out.push({\n id: entryId(entry),\n label: `${entry.mode === 'funnel' ? 'Funnel' : 'Serve'} :${entry.sourcePort}${path}`,\n mode: entry.mode,\n sourcePort: entry.sourcePort,\n url: `https://${host}${path}`,\n hostname: host,\n port: 443,\n protocol: 'https',\n })\n }\n return out\n }\n\n /**\n * Wait for `mesh-network` (the tailscale-client provider) to be\n * mounted AND report joined. Throws an actionable error otherwise.\n */\n private async requireClientJoined(): Promise<void> {\n try {\n await this.ctx.acquireCapability('mesh-network', undefined, { timeoutMs: 30_000 })\n } catch (err) {\n throw new Error(\n `tailscale-ingress: cap 'mesh-network' is not available — is @camstack/addon-tailscale-client installed and enabled? (${err instanceof Error ? err.message : String(err)})`,\n { cause: err },\n )\n }\n const status = await this.fetchMeshStatus()\n if (!status.joined) {\n throw new Error('tailscale-ingress: tailnet not joined — run Join on the Tailscale Client addon first.')\n }\n }\n\n private async fetchMeshStatus(): Promise<{ joined: boolean; magicDnsHostname: string; error?: string }> {\n const api = this.ctx.api as unknown as {\n meshNetwork?: {\n getStatus: {\n query: (input?: { addonId?: string; nodeId?: string }) => Promise<{\n joined: boolean\n magicDnsHostname: string\n error?: string\n }>\n }\n }\n }\n if (!api.meshNetwork?.getStatus?.query) {\n throw new Error('tailscale-ingress: mesh-network cap not exposed on the api surface')\n }\n return await api.meshNetwork.getStatus.query()\n }\n\n protected globalSettingsSchema() {\n const ingressItemFields: readonly ConfigField[] = [\n {\n type: 'select',\n key: 'mode',\n label: 'Mode',\n description: 'Serve = peers on your tailnet. Funnel = open internet (requires Funnel grant in tailnet ACL).',\n options: [\n { value: 'serve', label: 'Serve (tailnet HTTPS)' },\n { value: 'funnel', label: 'Funnel (public HTTPS)' },\n ],\n default: 'serve',\n },\n {\n type: 'number',\n key: 'sourcePort',\n label: 'Local port',\n description: 'Local hub port the ingress should forward to (e.g. 4000).',\n default: 4000,\n },\n {\n type: 'text',\n key: 'targetPath',\n label: 'Mount path (optional)',\n description: 'Public-side path prefix. Leave empty / set \"/\" for root.',\n placeholder: '/',\n },\n ]\n return this.schema({\n sections: [\n {\n id: 'tailscale-ingress',\n title: 'Tailscale Ingress',\n description:\n 'Exposes the hub via Tailscale Serve (in-tailnet HTTPS) or Funnel (public). Add one row per ingress — Serve + Funnel can run concurrently on different ports / paths. Requires the Tailscale Client addon to have the host joined first.',\n columns: 1,\n fields: [\n this.field({\n type: 'boolean',\n key: 'autoStart',\n label: 'Start on boot',\n description: 'Bring every configured ingress up automatically once the host is joined to the tailnet.',\n default: DEFAULT_CONFIG.autoStart,\n }),\n {\n type: 'editable-array',\n key: 'ingresses',\n label: 'Ingresses',\n description: 'Each row is a single `tailscale serve` or `tailscale funnel` invocation.',\n emptyMessage: 'No ingresses configured.',\n addLabel: 'Add ingress',\n rowTitleTemplate: '{mode} :{sourcePort}{targetPath}',\n defaultItem: {\n mode: 'serve',\n sourcePort: 4000,\n targetPath: '',\n },\n itemFields: ingressItemFields,\n },\n ],\n },\n ],\n })\n }\n\n protected async onConfigChanged(): Promise<void> {\n // When ingresses change while running, reconcile the daemon state.\n // For simplicity we tear everything down and re-apply on the next\n // start() — operators control re-bring-up via the Remote Access\n // Start button (or the autoStart flag).\n if (this.activeEntries.size === 0) return\n await this.stop()\n if (this.config.autoStart) {\n await this.autoStartIfReady()\n }\n }\n}\n\nfunction entryId(entry: IngressEntry): string {\n const path = entry.targetPath && entry.targetPath !== '/' ? entry.targetPath : ''\n return `${entry.mode}-${entry.sourcePort}${path}`\n}\n\nexport default TailscaleIngressAddon\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@camstack/addon-tailscale-ingress",
3
+ "version": "0.1.1",
4
+ "description": "Tailscale Serve / Funnel ingress addon for CamStack — exposes the hub via tailnet HTTPS or public Funnel.",
5
+ "keywords": [
6
+ "camstack",
7
+ "addon",
8
+ "camstack-addon",
9
+ "tailscale",
10
+ "funnel",
11
+ "serve",
12
+ "ingress"
13
+ ],
14
+ "license": "MIT",
15
+ "main": "./dist/index.js",
16
+ "module": "./dist/index.mjs",
17
+ "types": "./dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "import": "./dist/index.mjs",
22
+ "require": "./dist/index.js"
23
+ },
24
+ "./package.json": "./package.json"
25
+ },
26
+ "camstack": {
27
+ "displayName": "Tailscale Ingress",
28
+ "addons": [
29
+ {
30
+ "id": "tailscale-ingress",
31
+ "name": "Tailscale Ingress",
32
+ "version": "1.0.0",
33
+ "description": "Exposes the CamStack hub on the tailnet (Serve) or the public internet (Funnel). Requires `@camstack/addon-tailscale-client` to be installed and joined first.",
34
+ "entry": "./dist/tailscale-ingress.addon.js",
35
+ "execution": {
36
+ "placement": "hub-only",
37
+ "group": "tailscale-ingress"
38
+ },
39
+ "capabilities": [
40
+ {
41
+ "name": "network-access"
42
+ }
43
+ ]
44
+ }
45
+ ]
46
+ },
47
+ "files": [
48
+ "dist"
49
+ ],
50
+ "scripts": {
51
+ "build": "vite build",
52
+ "typecheck": "tsc --noEmit"
53
+ },
54
+ "peerDependencies": {
55
+ "@camstack/types": "^0.1.0"
56
+ },
57
+ "devDependencies": {
58
+ "@camstack/types": "*",
59
+ "typescript": "~5.9.0",
60
+ "vite": "^8.0.11",
61
+ "vite-plugin-dts": "^5.0.0"
62
+ }
63
+ }