@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,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
|
+
}
|