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