@camstack/addon-tailscale-client 0.1.12

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.
Files changed (43) hide show
  1. package/dist/@mf-types/compiled-types/page/TailscaleClientOverviewPage.d.ts +20 -0
  2. package/dist/@mf-types/compiled-types/page/TailscaleClientOverviewPage.d.ts.map +1 -0
  3. package/dist/@mf-types/compiled-types/page/page.d.ts +8 -0
  4. package/dist/@mf-types/compiled-types/page/page.d.ts.map +1 -0
  5. package/dist/@mf-types/page.d.ts +2 -0
  6. package/dist/@mf-types.d.ts +3 -0
  7. package/dist/@mf-types.zip +0 -0
  8. package/dist/__mfe_internal__addon_tailscale_client_page__loadShare___mf_0_camstack_mf_1_sdk__loadShare__.mjs-bYM9BuS1.mjs +12 -0
  9. package/dist/__mfe_internal__addon_tailscale_client_page__loadShare___mf_0_camstack_mf_1_types__loadShare__.mjs-CtHD1dC0.mjs +12 -0
  10. package/dist/__mfe_internal__addon_tailscale_client_page__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.mjs-fz-lQtUx.mjs +12 -0
  11. package/dist/__mfe_internal__addon_tailscale_client_page__loadShare___mf_0_tanstack_mf_1_react_mf_2_query__loadShare__.mjs-B-3nffMn.mjs +73 -0
  12. package/dist/__mfe_internal__addon_tailscale_client_page__loadShare___mf_0_trpc_mf_1_client__loadShare__.mjs-6CvhJC3f.mjs +42 -0
  13. package/dist/__mfe_internal__addon_tailscale_client_page__loadShare___mf_0_trpc_mf_1_react_mf_2_query__loadShare__.mjs-Sv3rXvki.mjs +46 -0
  14. package/dist/__mfe_internal__addon_tailscale_client_page__loadShare__react__loadShare__.mjs-BBqTAV2L.mjs +56 -0
  15. package/dist/__mfe_internal__addon_tailscale_client_page__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.mjs-BK8BTUon.mjs +18 -0
  16. package/dist/__mfe_internal__addon_tailscale_client_page__loadShare__react_mf_2_dom__loadShare__.mjs-B6pR25zU.mjs +28 -0
  17. package/dist/__mfe_internal__addon_tailscale_client_page__loadShare__react_mf_2_dom_mf_1_client__loadShare__.mjs-kyoamNQ7.mjs +18 -0
  18. package/dist/_stub.js +652 -0
  19. package/dist/_virtual_mf-localSharedImportMap___mfe_internal__addon_tailscale_client_page-PXP_-hRW.mjs +156 -0
  20. package/dist/addon-tailscale-client.css +3 -0
  21. package/dist/client-1J4MstR_.mjs +7592 -0
  22. package/dist/dist-C168hexw.mjs +17192 -0
  23. package/dist/dist-CPnIfsyh.mjs +2229 -0
  24. package/dist/dist-CmoRvaEc.mjs +2483 -0
  25. package/dist/dist-CwyDJZhZ.mjs +16329 -0
  26. package/dist/dist-DNrrMIdr.mjs +662 -0
  27. package/dist/dist-i1I4ldIE.mjs +1260 -0
  28. package/dist/getErrorShape-BPSzUA7W-C2H3tqHP.mjs +189 -0
  29. package/dist/hostInit-KpnzzkeJ.mjs +144 -0
  30. package/dist/index.js +9 -0
  31. package/dist/index.mjs +2 -0
  32. package/dist/jsx-runtime-BmcMHbj3.mjs +22 -0
  33. package/dist/modern-CWdms43F.mjs +2184 -0
  34. package/dist/react-BXkW-3WQ.mjs +293 -0
  35. package/dist/react-dom-BcGsvCWU.mjs +131 -0
  36. package/dist/remoteEntry.js +83 -0
  37. package/dist/rolldown-runtime-DC4cgjXG.mjs +20 -0
  38. package/dist/tailscale.addon.js +633 -0
  39. package/dist/tailscale.addon.js.map +1 -0
  40. package/dist/tailscale.addon.mjs +627 -0
  41. package/dist/tailscale.addon.mjs.map +1 -0
  42. package/dist/virtualExposes-wANYNTM2.mjs +27 -0
  43. package/package.json +94 -0
@@ -0,0 +1,633 @@
1
+ Object.defineProperties(exports, {
2
+ __esModule: { value: true },
3
+ [Symbol.toStringTag]: { value: "Module" }
4
+ });
5
+ let _camstack_types = require("@camstack/types");
6
+ let node_child_process = require("node:child_process");
7
+ //#region src/tailscale-cli.ts
8
+ /**
9
+ * Thin wrapper around the `tailscale` CLI.
10
+ *
11
+ * Why CLI rather than the local API socket: `tailscaled` exposes its
12
+ * control plane at `/var/run/tailscale/tailscaled.sock` but the wire
13
+ * protocol is undocumented + the Go client is gnarly to port. The
14
+ * `tailscale` CLI is the supported public interface, it covers every
15
+ * action we need (`up`/`down`/`status --json`/`serve`/`funnel`), and
16
+ * its output is stable JSON for `status` + non-fatal stderr for the
17
+ * others.
18
+ *
19
+ * The wrapper:
20
+ * - resolves the binary path once (PATH lookup + macOS GUI install
21
+ * fallback at `/Applications/Tailscale.app/Contents/MacOS/Tailscale`).
22
+ * - exposes Promise-based methods returning typed shapes.
23
+ * - never spawns long-lived processes — every call is one-shot.
24
+ *
25
+ * Operator-side prerequisite: `tailscaled` must be installed +
26
+ * running. The addon surfaces "binary missing" / "daemon not running"
27
+ * errors verbatim so the operator can act.
28
+ */
29
+ var execFileP = (0, require("node:util").promisify)(node_child_process.execFile);
30
+ var TAILSCALE_CANDIDATES = [
31
+ "tailscale",
32
+ "/usr/bin/tailscale",
33
+ "/usr/local/bin/tailscale",
34
+ "/opt/homebrew/bin/tailscale",
35
+ "/Applications/Tailscale.app/Contents/MacOS/Tailscale"
36
+ ];
37
+ var TailscaleCliError = class extends Error {
38
+ stderr;
39
+ constructor(message, stderr = "") {
40
+ super(message);
41
+ this.stderr = stderr;
42
+ this.name = "TailscaleCliError";
43
+ }
44
+ };
45
+ var TailscaleCli = class {
46
+ resolvedBin = null;
47
+ /** Locate the `tailscale` binary once and cache the result. */
48
+ async resolveBin() {
49
+ if (this.resolvedBin) return this.resolvedBin;
50
+ for (const candidate of TAILSCALE_CANDIDATES) try {
51
+ await execFileP(candidate, ["version"], { timeout: 3e3 });
52
+ this.resolvedBin = candidate;
53
+ return candidate;
54
+ } catch {}
55
+ throw new TailscaleCliError("tailscale binary not found — install Tailscale from https://tailscale.com/download");
56
+ }
57
+ async version() {
58
+ const { stdout } = await execFileP(await this.resolveBin(), ["version"], { timeout: 5e3 });
59
+ return stdout.trim().split("\n")[0] ?? "";
60
+ }
61
+ async status() {
62
+ const bin = await this.resolveBin();
63
+ try {
64
+ const { stdout } = await execFileP(bin, ["status", "--json"], { timeout: 1e4 });
65
+ return JSON.parse(stdout);
66
+ } catch (err) {
67
+ const e = err;
68
+ throw new TailscaleCliError(`tailscale status failed: ${e.message}`, e.stderr ?? "");
69
+ }
70
+ }
71
+ /** Bring the daemon up with an auth key. Idempotent — calling
72
+ * while already joined returns immediately. */
73
+ async up(input) {
74
+ const bin = await this.resolveBin();
75
+ const args = [
76
+ "up",
77
+ "--auth-key",
78
+ input.authKey,
79
+ "--reset"
80
+ ];
81
+ if (input.hostname) args.push(`--hostname=${input.hostname}`);
82
+ try {
83
+ await execFileP(bin, args, { timeout: 6e4 });
84
+ } catch (err) {
85
+ const e = err;
86
+ throw new TailscaleCliError(`tailscale up failed: ${e.message}`, e.stderr ?? "");
87
+ }
88
+ }
89
+ /**
90
+ * Start an interactive `tailscale up` (no `--auth-key`) and resolve
91
+ * with the verification URL the daemon prints to stdout/stderr.
92
+ *
93
+ * The returned handle keeps the child process alive — it self-exits
94
+ * once the user authenticates in the browser, and `cancel()` kills
95
+ * it if the operator gives up.
96
+ *
97
+ * Times out (and kills the child) when no URL is observed within
98
+ * `timeoutMs` (default 5_000).
99
+ */
100
+ async startInteractiveLogin(input = {}) {
101
+ const bin = await this.resolveBin();
102
+ const args = ["up"];
103
+ if (input.hostname) args.push(`--hostname=${input.hostname}`);
104
+ const timeoutMs = input.timeoutMs ?? 5e3;
105
+ const child = (0, node_child_process.spawn)(bin, args, { stdio: "pipe" });
106
+ const loginUrlPattern = /https:\/\/login\.tailscale\.com\/a\/[a-z0-9]+/i;
107
+ return new Promise((resolve, reject) => {
108
+ let settled = false;
109
+ let stderrBuf = "";
110
+ const cancel = () => {
111
+ if (!child.killed) child.kill();
112
+ };
113
+ const timer = setTimeout(() => {
114
+ if (settled) return;
115
+ settled = true;
116
+ cancel();
117
+ reject(new TailscaleCliError(`tailscale up did not print a login URL within ${timeoutMs}ms`, stderrBuf));
118
+ }, timeoutMs);
119
+ const onData = (chunk) => {
120
+ if (settled) return;
121
+ const text = chunk.toString("utf8");
122
+ stderrBuf += text;
123
+ const match = text.match(loginUrlPattern);
124
+ if (match) {
125
+ settled = true;
126
+ clearTimeout(timer);
127
+ child.stdout.off("data", onData);
128
+ child.stderr.off("data", onData);
129
+ resolve({
130
+ loginUrl: match[0],
131
+ cancel
132
+ });
133
+ }
134
+ };
135
+ child.stdout.on("data", onData);
136
+ child.stderr.on("data", onData);
137
+ child.on("error", (err) => {
138
+ if (settled) return;
139
+ settled = true;
140
+ clearTimeout(timer);
141
+ reject(new TailscaleCliError(`tailscale up failed to spawn: ${err.message}`, stderrBuf));
142
+ });
143
+ child.on("exit", (code) => {
144
+ if (settled) return;
145
+ settled = true;
146
+ clearTimeout(timer);
147
+ reject(new TailscaleCliError(`tailscale up exited (code=${code ?? "null"}) before printing a login URL`, stderrBuf));
148
+ });
149
+ });
150
+ }
151
+ /** Leave the tailnet. After this the host's `100.x` address is
152
+ * released until the next `up`. */
153
+ async down() {
154
+ const bin = await this.resolveBin();
155
+ try {
156
+ await execFileP(bin, ["down"], { timeout: 15e3 });
157
+ } catch (err) {
158
+ const e = err;
159
+ throw new TailscaleCliError(`tailscale down failed: ${e.message}`, e.stderr ?? "");
160
+ }
161
+ }
162
+ /** `tailscale serve` — exposes a local port to peers in the same
163
+ * tailnet over HTTPS with auto-issued cert. Mutating call; the
164
+ * daemon persists the rule across restarts. */
165
+ async serve(input) {
166
+ const bin = await this.resolveBin();
167
+ const args = input.enabled ? [
168
+ "serve",
169
+ "--bg",
170
+ `http://127.0.0.1:${input.port}`
171
+ ] : [
172
+ "serve",
173
+ "--bg",
174
+ "off"
175
+ ];
176
+ try {
177
+ await execFileP(bin, args, { timeout: 15e3 });
178
+ } catch (err) {
179
+ const e = err;
180
+ throw new TailscaleCliError(`tailscale serve failed: ${e.message}`, e.stderr ?? "");
181
+ }
182
+ }
183
+ /** `tailscale funnel` — exposes a local port to the open internet
184
+ * via Tailscale's edge. Requires Funnel ACL grant in the tailnet
185
+ * policy. Same shape as `serve`. */
186
+ async funnel(input) {
187
+ const bin = await this.resolveBin();
188
+ const args = input.enabled ? [
189
+ "funnel",
190
+ "--bg",
191
+ `http://127.0.0.1:${input.port}`
192
+ ] : [
193
+ "funnel",
194
+ "--bg",
195
+ "off"
196
+ ];
197
+ try {
198
+ await execFileP(bin, args, { timeout: 15e3 });
199
+ } catch (err) {
200
+ const e = err;
201
+ throw new TailscaleCliError(`tailscale funnel failed: ${e.message}`, e.stderr ?? "");
202
+ }
203
+ }
204
+ };
205
+ //#endregion
206
+ //#region src/tailscale.addon.ts
207
+ /**
208
+ * Module-federation page declaration — picked up by the
209
+ * `addon-pages-source` aggregator and surfaced on admin-ui once the
210
+ * federation bundle is built. See `src/page/TailscaleClientOverviewPage.tsx`.
211
+ */
212
+ var TAILSCALE_OVERVIEW_PAGES = [{
213
+ id: "tailscale-client",
214
+ label: "Tailscale",
215
+ icon: "network",
216
+ path: "/addon/tailscale-client",
217
+ remoteName: "addon_tailscale_client_page",
218
+ bundle: "remoteEntry.js"
219
+ }];
220
+ /**
221
+ * Auto-rejoin backoff (#174). Five attempts at 5m → 15m → 30m → 60m → 60m,
222
+ * then we stop retrying until either the operator manually joins (which
223
+ * resets the counter) or the addon is restarted.
224
+ */
225
+ var AUTO_REJOIN_BACKOFF_MS = [
226
+ 5 * 6e4,
227
+ 15 * 6e4,
228
+ 30 * 6e4,
229
+ 60 * 6e4,
230
+ 60 * 6e4
231
+ ];
232
+ var TailscaleClientAddon = class extends _camstack_types.BaseAddon {
233
+ cli = new TailscaleCli();
234
+ /** Used by the orchestrator + UI labels. */
235
+ displayName = "Tailscale Client";
236
+ kind = "tailscale-client";
237
+ /** Index into AUTO_REJOIN_BACKOFF_MS for the next retry. Resets on success. */
238
+ rejoinAttempt = 0;
239
+ /** Timer handle for the next pending rejoin tick. `null` = idle. */
240
+ rejoinTimer = null;
241
+ /**
242
+ * Set while `startLogin` is in flight. The auto-rejoin loop skips when
243
+ * truthy so a silent `up --auth-key` doesn't collide with the
244
+ * browser-redirect login session.
245
+ */
246
+ loginInFlight = false;
247
+ constructor() {
248
+ super({
249
+ authKey: "",
250
+ hostname: ""
251
+ });
252
+ }
253
+ async onInitialize() {
254
+ let cliReachable = false;
255
+ try {
256
+ const v = await this.cli.version();
257
+ cliReachable = true;
258
+ this.ctx.logger.info("Tailscale CLI ready", { meta: { version: v } });
259
+ } catch (err) {
260
+ this.ctx.logger.warn("Tailscale CLI not found — install from https://tailscale.com/download", {
261
+ meta: { error: err instanceof Error ? err.message : String(err) },
262
+ tags: {
263
+ topic: "tailscale",
264
+ phase: "cli-missing"
265
+ }
266
+ });
267
+ }
268
+ if (cliReachable) setImmediate(() => {
269
+ this.tryAutoRejoin();
270
+ });
271
+ this.ctx.addDisposer(() => {
272
+ if (this.rejoinTimer !== null) {
273
+ clearTimeout(this.rejoinTimer);
274
+ this.rejoinTimer = null;
275
+ }
276
+ });
277
+ return [{
278
+ capability: _camstack_types.meshNetworkCapability,
279
+ provider: {
280
+ getStatus: () => this.getStatus(),
281
+ join: ({ authKey, hostname }) => this.join({
282
+ authKey,
283
+ ...hostname ? { hostname } : {}
284
+ }),
285
+ startLogin: ({ hostname }) => this.startLogin({ ...hostname ? { hostname } : {} }),
286
+ leave: () => this.leave(),
287
+ listPeers: () => this.listPeers(),
288
+ testConnection: ({ authKey }) => this.testConnection(authKey)
289
+ }
290
+ }, {
291
+ capability: _camstack_types.addonPagesSourceCapability,
292
+ provider: {
293
+ id: "tailscale-client",
294
+ listPages: () => TAILSCALE_OVERVIEW_PAGES
295
+ }
296
+ }];
297
+ }
298
+ async getStatus() {
299
+ try {
300
+ const s = await this.cli.status();
301
+ const joined = s.BackendState === "Running";
302
+ const meshIp = s.Self?.TailscaleIPs?.[0] ?? "";
303
+ const magicDnsHostname = (s.Self?.DNSName ?? "").replace(/\.$/, "");
304
+ return {
305
+ joined,
306
+ meshIp,
307
+ magicDnsHostname,
308
+ peerCount: s.Peer ? Object.keys(s.Peer).length : 0,
309
+ endpoints: this.buildEndpoints(s, meshIp, magicDnsHostname)
310
+ };
311
+ } catch (err) {
312
+ return {
313
+ joined: false,
314
+ meshIp: "",
315
+ magicDnsHostname: "",
316
+ peerCount: 0,
317
+ endpoints: [],
318
+ error: err instanceof TailscaleCliError ? err.message : String(err)
319
+ };
320
+ }
321
+ }
322
+ buildEndpoints(s, meshIp, magicDnsHostname) {
323
+ const out = [];
324
+ if (meshIp) out.push({
325
+ id: "mesh-ipv4",
326
+ label: "Mesh IPv4",
327
+ scope: "mesh",
328
+ url: `http://${meshIp}`,
329
+ hostname: meshIp,
330
+ port: 0,
331
+ protocol: "http"
332
+ });
333
+ if (magicDnsHostname) out.push({
334
+ id: "magicdns",
335
+ label: "MagicDNS",
336
+ scope: "mesh",
337
+ url: `https://${magicDnsHostname}`,
338
+ hostname: magicDnsHostname,
339
+ port: 0,
340
+ protocol: "https"
341
+ });
342
+ return out;
343
+ }
344
+ async join(input) {
345
+ this.ctx.logger.info("tailscale: joining tailnet", {
346
+ meta: { hasHostname: !!input.hostname },
347
+ tags: {
348
+ topic: "tailscale",
349
+ phase: "join"
350
+ }
351
+ });
352
+ await this.cli.up({
353
+ authKey: input.authKey,
354
+ ...input.hostname ? { hostname: input.hostname } : {}
355
+ });
356
+ await this.updateGlobalSettings({
357
+ authKey: input.authKey,
358
+ hostname: input.hostname ?? this.config.hostname
359
+ });
360
+ this.resetAutoRejoinBackoff();
361
+ this.ctx.logger.info("tailscale: joined", { tags: {
362
+ topic: "tailscale",
363
+ phase: "joined"
364
+ } });
365
+ return { joined: true };
366
+ }
367
+ /**
368
+ * Spawn `tailscale up` (no `--auth-key`) and return the login URL
369
+ * printed by the daemon. The child process keeps running until the
370
+ * operator authenticates in their browser — at which point it
371
+ * self-terminates. Caller polls `getStatus()` for `joined: true`.
372
+ */
373
+ async startLogin(input) {
374
+ this.ctx.logger.info("tailscale: starting interactive login", {
375
+ meta: { hasHostname: !!input.hostname },
376
+ tags: {
377
+ topic: "tailscale",
378
+ phase: "login-start"
379
+ }
380
+ });
381
+ this.loginInFlight = true;
382
+ try {
383
+ const handle = await this.cli.startInteractiveLogin({ ...input.hostname ? { hostname: input.hostname } : {} });
384
+ if (input.hostname && input.hostname !== this.config.hostname) await this.updateGlobalSettings({
385
+ authKey: this.config.authKey,
386
+ hostname: input.hostname
387
+ });
388
+ this.ctx.logger.info("tailscale: login URL ready", { tags: {
389
+ topic: "tailscale",
390
+ phase: "login-url"
391
+ } });
392
+ this.loginInFlight = false;
393
+ return { loginUrl: handle.loginUrl };
394
+ } catch (err) {
395
+ this.loginInFlight = false;
396
+ throw err;
397
+ }
398
+ }
399
+ async leave() {
400
+ this.ctx.logger.info("tailscale: leaving tailnet", { tags: {
401
+ topic: "tailscale",
402
+ phase: "leave"
403
+ } });
404
+ await this.cli.down();
405
+ return { left: true };
406
+ }
407
+ /**
408
+ * Test the daemon + auth key WITHOUT committing to a join.
409
+ */
410
+ async testConnection(authKey) {
411
+ let daemonVersion;
412
+ try {
413
+ daemonVersion = await this.cli.version();
414
+ } catch (err) {
415
+ return {
416
+ ok: false,
417
+ error: `tailscaled CLI not reachable: ${err instanceof Error ? err.message : String(err)}. Install Tailscale on the host.`
418
+ };
419
+ }
420
+ let tenant;
421
+ try {
422
+ const status = await this.cli.status();
423
+ if (status.MagicDNSSuffix) tenant = status.MagicDNSSuffix;
424
+ } catch {}
425
+ if (authKey !== void 0) {
426
+ const trimmed = authKey.trim();
427
+ if (!trimmed) return {
428
+ ok: false,
429
+ ...tenant ? { tenant } : {},
430
+ ...daemonVersion ? { daemonVersion } : {},
431
+ error: "Auth key is empty."
432
+ };
433
+ if (!trimmed.startsWith("tskey-auth-") && !trimmed.startsWith("tskey-client-")) return {
434
+ ok: false,
435
+ ...tenant ? { tenant } : {},
436
+ ...daemonVersion ? { daemonVersion } : {},
437
+ error: "Auth key does not look like a Tailscale key (expected prefix `tskey-auth-` or `tskey-client-`)."
438
+ };
439
+ }
440
+ return {
441
+ ok: true,
442
+ ...tenant ? { tenant } : {},
443
+ ...daemonVersion ? { daemonVersion } : {}
444
+ };
445
+ }
446
+ async listPeers() {
447
+ const s = await this.cli.status();
448
+ const peers = [];
449
+ if (s.Self) peers.push({
450
+ id: s.Self.ID,
451
+ hostname: s.Self.HostName,
452
+ addresses: s.Self.TailscaleIPs ?? [],
453
+ os: s.Self.OS,
454
+ online: s.Self.Online,
455
+ lastSeenMs: Date.now(),
456
+ isSelf: true
457
+ });
458
+ for (const p of Object.values(s.Peer ?? {})) {
459
+ const lastSeen = p.LastSeen ? Date.parse(p.LastSeen) : 0;
460
+ peers.push({
461
+ id: p.ID,
462
+ hostname: p.HostName,
463
+ addresses: p.TailscaleIPs ?? [],
464
+ os: p.OS,
465
+ online: p.Online,
466
+ lastSeenMs: Number.isFinite(lastSeen) ? lastSeen : 0,
467
+ isSelf: false
468
+ });
469
+ }
470
+ return { peers };
471
+ }
472
+ /**
473
+ * Single pass of the auto-rejoin probe. Called once on boot and then
474
+ * on the backoff timer. Internal contract:
475
+ * - reads `BackendState` via the CLI
476
+ * - skips when CLI unreachable / NoState / Running / no authKey /
477
+ * login-redirect in flight
478
+ * - otherwise runs `tailscale up --auth-key=<stored>` and resets
479
+ * the backoff index on success
480
+ * - on failure / continued non-Running state schedules the next
481
+ * attempt with the configured backoff
482
+ */
483
+ async tryAutoRejoin() {
484
+ if (this.rejoinTimer !== null) return;
485
+ if (this.loginInFlight) {
486
+ this.ctx.logger.info("auto-rejoin: skipping — redirect login in flight", { tags: {
487
+ topic: "tailscale",
488
+ phase: "auto-rejoin-skip"
489
+ } });
490
+ this.scheduleNextAutoRejoin();
491
+ return;
492
+ }
493
+ if (!this.config.authKey) return;
494
+ let state;
495
+ try {
496
+ state = (await this.cli.status()).BackendState;
497
+ } catch (err) {
498
+ this.ctx.logger.warn("auto-rejoin: status probe failed — CLI unreachable, skipping", {
499
+ meta: { error: err instanceof Error ? err.message : String(err) },
500
+ tags: {
501
+ topic: "tailscale",
502
+ phase: "auto-rejoin-cli-missing"
503
+ }
504
+ });
505
+ return;
506
+ }
507
+ if (state === "Running") {
508
+ this.resetAutoRejoinBackoff();
509
+ return;
510
+ }
511
+ if (state === "NoState") {
512
+ this.ctx.logger.info("auto-rejoin: skipping — daemon NoState (first-time install)", { tags: {
513
+ topic: "tailscale",
514
+ phase: "auto-rejoin-no-state"
515
+ } });
516
+ return;
517
+ }
518
+ this.ctx.logger.info("auto-rejoin: attempting silent rejoin", {
519
+ meta: {
520
+ state,
521
+ attempt: this.rejoinAttempt + 1
522
+ },
523
+ tags: {
524
+ topic: "tailscale",
525
+ phase: "auto-rejoin-attempt"
526
+ }
527
+ });
528
+ try {
529
+ await this.cli.up({
530
+ authKey: this.config.authKey,
531
+ ...this.config.hostname ? { hostname: this.config.hostname } : {}
532
+ });
533
+ this.ctx.logger.info("auto-rejoin: succeeded", { tags: {
534
+ topic: "tailscale",
535
+ phase: "auto-rejoin-ok"
536
+ } });
537
+ this.resetAutoRejoinBackoff();
538
+ } catch (err) {
539
+ this.ctx.logger.warn("auto-rejoin: tailscale up failed — will retry on backoff", {
540
+ meta: {
541
+ error: err instanceof Error ? err.message : String(err),
542
+ nextAttempt: this.rejoinAttempt + 1
543
+ },
544
+ tags: {
545
+ topic: "tailscale",
546
+ phase: "auto-rejoin-error"
547
+ }
548
+ });
549
+ this.scheduleNextAutoRejoin();
550
+ }
551
+ }
552
+ /** Reset the backoff index — used after a successful join (manual or auto). */
553
+ resetAutoRejoinBackoff() {
554
+ this.rejoinAttempt = 0;
555
+ if (this.rejoinTimer !== null) {
556
+ clearTimeout(this.rejoinTimer);
557
+ this.rejoinTimer = null;
558
+ }
559
+ }
560
+ /** Queue the next attempt or stop retrying once we hit the cap. */
561
+ scheduleNextAutoRejoin() {
562
+ if (this.rejoinAttempt >= AUTO_REJOIN_BACKOFF_MS.length) {
563
+ this.ctx.logger.warn("auto-rejoin: cap reached — giving up until next boot or manual join", {
564
+ meta: { attempts: this.rejoinAttempt },
565
+ tags: {
566
+ topic: "tailscale",
567
+ phase: "auto-rejoin-cap-reached"
568
+ }
569
+ });
570
+ return;
571
+ }
572
+ const delay = AUTO_REJOIN_BACKOFF_MS[this.rejoinAttempt] ?? AUTO_REJOIN_BACKOFF_MS[AUTO_REJOIN_BACKOFF_MS.length - 1] ?? 60 * 6e4;
573
+ this.rejoinAttempt += 1;
574
+ this.rejoinTimer = setTimeout(() => {
575
+ this.rejoinTimer = null;
576
+ this.tryAutoRejoin();
577
+ }, delay);
578
+ }
579
+ globalSettingsSchema() {
580
+ return this.schema({ sections: [{
581
+ id: "auth",
582
+ title: "Tailscale",
583
+ immediate: true,
584
+ description: "Joins the host to a Tailscale tailnet. Click \"Connect to Tailscale\" to open the Tailscale login page in your browser.",
585
+ fields: [
586
+ {
587
+ type: "info",
588
+ key: "tailscaleHelp",
589
+ label: "Prerequisites",
590
+ format: "html",
591
+ content: "Install <code>tailscaled</code> from <a href=\"https://tailscale.com/download\">tailscale.com/download</a>. On macOS the GUI app ships the CLI inside the .app bundle. For Serve / Funnel ingress, install <code>@camstack/addon-tailscale-ingress</code> separately.",
592
+ variant: "info"
593
+ },
594
+ this.field({
595
+ type: "text",
596
+ key: "hostname",
597
+ label: "Device Hostname (optional)",
598
+ description: "Override the hostname advertised in the tailnet. Empty = use the OS hostname.",
599
+ placeholder: "camstack-hub"
600
+ }),
601
+ {
602
+ type: "info",
603
+ key: "tailscaleConnectHint",
604
+ label: "Connect",
605
+ format: "html",
606
+ content: "Use the <strong>Connect to Tailscale</strong> action on the addon page to start the browser-redirect login flow. The admin UI will open a one-time Tailscale URL in a new tab; the host joins the tailnet once you authenticate.",
607
+ variant: "info"
608
+ }
609
+ ]
610
+ }, {
611
+ id: "auth-advanced",
612
+ title: "Advanced / Headless",
613
+ style: "accordion",
614
+ defaultCollapsed: true,
615
+ immediate: true,
616
+ description: "Use a pre-generated auth key from admin.tailscale.com → Settings → Keys for headless / CI flows where opening a browser is not possible. When set, the addon also auto-rejoins on boot if the daemon dropped offline (5/15/30/60/60 min backoff).",
617
+ fields: [this.field({
618
+ type: "password",
619
+ key: "authKey",
620
+ label: "Pre-auth Key",
621
+ description: "tskey-auth-* token from the Tailscale admin console. Leave empty when using the browser-redirect flow above.",
622
+ showToggle: true
623
+ })]
624
+ }] });
625
+ }
626
+ };
627
+ //#endregion
628
+ exports.TailscaleCli = TailscaleCli;
629
+ exports.TailscaleCliError = TailscaleCliError;
630
+ exports.TailscaleClientAddon = TailscaleClientAddon;
631
+ exports.default = TailscaleClientAddon;
632
+
633
+ //# sourceMappingURL=tailscale.addon.js.map