@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 @@
1
+ {"version":3,"file":"tailscale.addon.mjs","names":[],"sources":["../src/tailscale-cli.ts","../src/tailscale.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, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'\nimport { promisify } from 'node:util'\n\nconst execFileP = promisify(execFile)\n\n/**\n * Interactive-login handle returned by `TailscaleCli.startInteractiveLogin`.\n *\n * The child `tailscale up` process keeps running while the operator\n * authenticates in their browser; once the control plane accepts the\n * device the CLI self-terminates. The caller can `cancel()` to kill\n * the process if the user abandons the flow.\n */\nexport interface TailscaleLoginHandle {\n /** The verification URL printed by `tailscale up`. */\n readonly loginUrl: string\n /** Kill the underlying `tailscale up` process. Idempotent. */\n cancel(): void\n}\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\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 /**\n * Start an interactive `tailscale up` (no `--auth-key`) and resolve\n * with the verification URL the daemon prints to stdout/stderr.\n *\n * The returned handle keeps the child process alive — it self-exits\n * once the user authenticates in the browser, and `cancel()` kills\n * it if the operator gives up.\n *\n * Times out (and kills the child) when no URL is observed within\n * `timeoutMs` (default 5_000).\n */\n async startInteractiveLogin(input: {\n readonly hostname?: string\n readonly timeoutMs?: number\n } = {}): Promise<TailscaleLoginHandle> {\n const bin = await this.resolveBin()\n const args = ['up']\n if (input.hostname) args.push(`--hostname=${input.hostname}`)\n const timeoutMs = input.timeoutMs ?? 5_000\n\n const child: ChildProcessWithoutNullStreams = spawn(bin, args, { stdio: 'pipe' })\n\n const loginUrlPattern = /https:\\/\\/login\\.tailscale\\.com\\/a\\/[a-z0-9]+/i\n\n return new Promise<TailscaleLoginHandle>((resolve, reject) => {\n let settled = false\n let stderrBuf = ''\n\n const cancel = (): void => {\n if (!child.killed) {\n child.kill()\n }\n }\n\n const timer = setTimeout(() => {\n if (settled) return\n settled = true\n cancel()\n reject(new TailscaleCliError(\n `tailscale up did not print a login URL within ${timeoutMs}ms`,\n stderrBuf,\n ))\n }, timeoutMs)\n\n const onData = (chunk: Buffer): void => {\n if (settled) return\n const text = chunk.toString('utf8')\n stderrBuf += text\n const match = text.match(loginUrlPattern)\n if (match) {\n settled = true\n clearTimeout(timer)\n // Detach the listeners — let the child keep running until\n // the user finishes authenticating in the browser.\n child.stdout.off('data', onData)\n child.stderr.off('data', onData)\n resolve({ loginUrl: match[0], cancel })\n }\n }\n\n child.stdout.on('data', onData)\n child.stderr.on('data', onData)\n child.on('error', (err) => {\n if (settled) return\n settled = true\n clearTimeout(timer)\n reject(new TailscaleCliError(\n `tailscale up failed to spawn: ${err.message}`,\n stderrBuf,\n ))\n })\n child.on('exit', (code) => {\n if (settled) return\n settled = true\n clearTimeout(timer)\n reject(new TailscaleCliError(\n `tailscale up exited (code=${code ?? 'null'}) before printing a login URL`,\n stderrBuf,\n ))\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 async serve(input: { readonly port: number; readonly enabled: boolean }): Promise<void> {\n const bin = await this.resolveBin()\n // `tailscale serve --bg https / http://127.0.0.1:<port>` registers\n // the rule. `--bg off` clears it. We always operate on the\n // root path so the operator's hub-base URL maps cleanly.\n const args = input.enabled\n ? ['serve', '--bg', `http://127.0.0.1:${input.port}`]\n : ['serve', '--bg', 'off']\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: { readonly port: number; readonly enabled: boolean }): Promise<void> {\n const bin = await this.resolveBin()\n const args = input.enabled\n ? ['funnel', '--bg', `http://127.0.0.1:${input.port}`]\n : ['funnel', '--bg', 'off']\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 * Tailscale (client) addon — joins the host to a Tailscale tailnet\n * via the `tailscale` CLI.\n *\n * Capabilities registered:\n * • `mesh-network` — `getStatus` / `join` / `leave` / `listPeers` /\n * `startLogin` / `testConnection`.\n * Mesh endpoint reporting goes through `getStatus().endpoints`.\n * • `addon-pages-source` — exposes the bundled overview page\n * (`/addon/tailscale-client`) that drives the redirect-login flow\n * and renders the peer table.\n *\n * Serve / Funnel ingresses are owned by a SEPARATE addon\n * (`@camstack/addon-tailscale-ingress`) which depends on this one\n * being installed + the host being joined.\n *\n * `tailscaled` itself is NOT bundled — operators install it from\n * tailscale.com/download. Binary missing = a clean error surface.\n *\n * Auto-rejoin (#174):\n * On boot, after the CLI probe, we read the persisted `authKey`\n * (used by the headless / Advanced flow) and check `tailscale status`.\n * If the daemon reports `BackendState !== 'Running'` AND the operator\n * has a non-empty `authKey` stashed, we run `tailscale up --auth-key=…`\n * silently. The retry uses a 5/15/30/60/60 minute backoff capped at\n * 5 attempts; a successful join resets the counter. We never rejoin\n * when `BackendState === 'NoState'` (first-time install: would race\n * the operator) or when a redirect-login is in flight.\n */\nimport type {\n AddonPageDeclaration,\n IAddonPageProvider,\n IMeshNetworkProvider,\n MeshPeer,\n MeshStatus,\n ProviderRegistration,\n} from '@camstack/types'\nimport { addonPagesSourceCapability, BaseAddon, meshNetworkCapability } from '@camstack/types'\n\nimport { TailscaleCli, TailscaleCliError, type TailscaleStatusJson } from './tailscale-cli.js'\n\ninterface TailscaleConfig {\n /** Pre-auth key from admin.tailscale.com → Settings → Keys. Used by\n * `join` and reapplied on boot if the host is not yet joined. */\n readonly authKey: string\n /** Hostname this device advertises in the tailnet. Empty = let the\n * daemon pick from the OS hostname. */\n readonly hostname: string\n}\n\n/**\n * Module-federation page declaration — picked up by the\n * `addon-pages-source` aggregator and surfaced on admin-ui once the\n * federation bundle is built. See `src/page/TailscaleClientOverviewPage.tsx`.\n */\nconst TAILSCALE_OVERVIEW_PAGES: readonly AddonPageDeclaration[] = [\n {\n id: 'tailscale-client',\n label: 'Tailscale',\n icon: 'network',\n path: '/addon/tailscale-client',\n remoteName: 'addon_tailscale_client_page',\n bundle: 'remoteEntry.js',\n },\n] as const\n\n/**\n * Auto-rejoin backoff (#174). Five attempts at 5m → 15m → 30m → 60m → 60m,\n * then we stop retrying until either the operator manually joins (which\n * resets the counter) or the addon is restarted.\n */\nconst AUTO_REJOIN_BACKOFF_MS: readonly number[] = [\n 5 * 60_000,\n 15 * 60_000,\n 30 * 60_000,\n 60 * 60_000,\n 60 * 60_000,\n]\n\nexport class TailscaleClientAddon extends BaseAddon<TailscaleConfig> {\n private cli = new TailscaleCli()\n /** Used by the orchestrator + UI labels. */\n readonly displayName = 'Tailscale Client'\n readonly kind = 'tailscale-client' as const\n\n // ── Auto-rejoin state (#174) ──────────────────────────────────────\n /** Index into AUTO_REJOIN_BACKOFF_MS for the next retry. Resets on success. */\n private rejoinAttempt = 0\n /** Timer handle for the next pending rejoin tick. `null` = idle. */\n private rejoinTimer: ReturnType<typeof setTimeout> | null = null\n /**\n * Set while `startLogin` is in flight. The auto-rejoin loop skips when\n * truthy so a silent `up --auth-key` doesn't collide with the\n * browser-redirect login session.\n */\n private loginInFlight = false\n\n constructor() {\n super({\n authKey: '',\n hostname: '',\n })\n }\n\n protected async onInitialize(): Promise<ProviderRegistration[]> {\n // Best-effort daemon probe at boot so the operator sees a clear\n // error in the logs if `tailscaled` isn't installed yet, rather\n // than a cryptic \"command not found\" on first action.\n let cliReachable = false\n try {\n const v = await this.cli.version()\n cliReachable = true\n this.ctx.logger.info('Tailscale CLI ready', { meta: { version: v } })\n } catch (err) {\n this.ctx.logger.warn('Tailscale CLI not found — install from https://tailscale.com/download', {\n meta: { error: err instanceof Error ? err.message : String(err) },\n tags: { topic: 'tailscale', phase: 'cli-missing' },\n })\n }\n\n // Auto-rejoin on boot (#174). Deferred to the next tick so provider\n // registration completes first — the rejoin attempt logs through the\n // addon's scoped logger which is already wired up at this point.\n if (cliReachable) {\n setImmediate(() => { void this.tryAutoRejoin() })\n }\n\n // Ensure we clean up a pending backoff timer when the addon is torn\n // down (restartAddon, host shutdown, etc.).\n this.ctx.addDisposer(() => {\n if (this.rejoinTimer !== null) {\n clearTimeout(this.rejoinTimer)\n this.rejoinTimer = null\n }\n })\n\n const meshProvider: IMeshNetworkProvider = {\n getStatus: () => this.getStatus(),\n join: ({ authKey, hostname }) => this.join({ authKey, ...(hostname ? { hostname } : {}) }),\n startLogin: ({ hostname }) => this.startLogin({ ...(hostname ? { hostname } : {}) }),\n leave: () => this.leave(),\n listPeers: () => this.listPeers(),\n testConnection: ({ authKey }) => this.testConnection(authKey),\n }\n\n const pagesProvider: IAddonPageProvider = {\n id: 'tailscale-client',\n listPages: () => TAILSCALE_OVERVIEW_PAGES,\n }\n\n return [\n { capability: meshNetworkCapability, provider: meshProvider },\n { capability: addonPagesSourceCapability, provider: pagesProvider },\n ]\n }\n\n // ── mesh-network impl ────────────────────────────────────────────\n\n private async getStatus(): Promise<MeshStatus> {\n try {\n const s = await this.cli.status()\n const joined = s.BackendState === 'Running'\n const meshIp = s.Self?.TailscaleIPs?.[0] ?? ''\n // DNSName comes as \"camstack.tail-abc.ts.net.\" with trailing dot.\n const magicDnsHostname = (s.Self?.DNSName ?? '').replace(/\\.$/, '')\n const peerCount = s.Peer ? Object.keys(s.Peer).length : 0\n const endpoints = this.buildEndpoints(s, meshIp, magicDnsHostname)\n const status: MeshStatus = { joined, meshIp, magicDnsHostname, peerCount, endpoints }\n return status\n } catch (err) {\n return {\n joined: false,\n meshIp: '',\n magicDnsHostname: '',\n peerCount: 0,\n endpoints: [],\n error: err instanceof TailscaleCliError ? err.message : String(err),\n }\n }\n }\n\n private buildEndpoints(\n s: TailscaleStatusJson,\n meshIp: string,\n magicDnsHostname: string,\n ): MeshStatus['endpoints'] {\n const out: MeshStatus['endpoints'][number][] = []\n if (meshIp) {\n out.push({\n id: 'mesh-ipv4',\n label: 'Mesh IPv4',\n scope: 'mesh',\n url: `http://${meshIp}`,\n hostname: meshIp,\n port: 0,\n protocol: 'http',\n })\n }\n if (magicDnsHostname) {\n out.push({\n id: 'magicdns',\n label: 'MagicDNS',\n scope: 'mesh',\n url: `https://${magicDnsHostname}`,\n hostname: magicDnsHostname,\n port: 0,\n protocol: 'https',\n })\n }\n void s // reserved for future status-derived endpoints\n return out\n }\n\n private async join(input: { authKey: string; hostname?: string }): Promise<{ joined: true }> {\n this.ctx.logger.info('tailscale: joining tailnet', {\n meta: { hasHostname: !!input.hostname },\n tags: { topic: 'tailscale', phase: 'join' },\n })\n await this.cli.up({ authKey: input.authKey, ...(input.hostname ? { hostname: input.hostname } : {}) })\n await this.updateGlobalSettings({\n authKey: input.authKey,\n hostname: input.hostname ?? this.config.hostname,\n })\n // Manual join succeeded — reset the auto-rejoin backoff so a later\n // controlled-leave + key-rotation doesn't have to wait an hour.\n this.resetAutoRejoinBackoff()\n this.ctx.logger.info('tailscale: joined', {\n tags: { topic: 'tailscale', phase: 'joined' },\n })\n return { joined: true as const }\n }\n\n /**\n * Spawn `tailscale up` (no `--auth-key`) and return the login URL\n * printed by the daemon. The child process keeps running until the\n * operator authenticates in their browser — at which point it\n * self-terminates. Caller polls `getStatus()` for `joined: true`.\n */\n private async startLogin(input: { hostname?: string }): Promise<{ loginUrl: string }> {\n this.ctx.logger.info('tailscale: starting interactive login', {\n meta: { hasHostname: !!input.hostname },\n tags: { topic: 'tailscale', phase: 'login-start' },\n })\n // Block the silent-rejoin loop while a redirect-login session is\n // open — see the `loginInFlight` gate in `tryAutoRejoin`.\n this.loginInFlight = true\n try {\n const handle = await this.cli.startInteractiveLogin({\n ...(input.hostname ? { hostname: input.hostname } : {}),\n })\n // Persist the optional hostname override so it survives the\n // browser round-trip if the user reloads admin UI before the\n // daemon reports joined.\n if (input.hostname && input.hostname !== this.config.hostname) {\n await this.updateGlobalSettings({\n authKey: this.config.authKey,\n hostname: input.hostname,\n })\n }\n this.ctx.logger.info('tailscale: login URL ready', {\n tags: { topic: 'tailscale', phase: 'login-url' },\n })\n // Clear the inFlight flag once the URL is returned; the page polls\n // `getStatus()` to detect completion, but from this addon's POV\n // the next auto-rejoin tick may safely run if the user abandons.\n this.loginInFlight = false\n return { loginUrl: handle.loginUrl }\n } catch (err) {\n this.loginInFlight = false\n throw err\n }\n }\n\n private async leave(): Promise<{ left: true }> {\n this.ctx.logger.info('tailscale: leaving tailnet', { tags: { topic: 'tailscale', phase: 'leave' } })\n await this.cli.down()\n // Don't clear authKey from config — operator can rejoin without\n // re-pasting.\n return { left: true as const }\n }\n\n /**\n * Test the daemon + auth key WITHOUT committing to a join.\n */\n private async testConnection(authKey?: string): Promise<{\n ok: boolean\n tenant?: string\n daemonVersion?: string\n error?: string\n }> {\n let daemonVersion: string | undefined\n try {\n daemonVersion = await this.cli.version()\n } catch (err) {\n return {\n ok: false,\n error: `tailscaled CLI not reachable: ${err instanceof Error ? err.message : String(err)}. Install Tailscale on the host.`,\n }\n }\n\n let tenant: string | undefined\n try {\n const status = await this.cli.status()\n if (status.MagicDNSSuffix) {\n tenant = status.MagicDNSSuffix\n }\n } catch {\n /* ignore — daemon may not yet be running */\n }\n\n if (authKey !== undefined) {\n const trimmed = authKey.trim()\n if (!trimmed) {\n return { ok: false, ...(tenant ? { tenant } : {}), ...(daemonVersion ? { daemonVersion } : {}), error: 'Auth key is empty.' }\n }\n if (!trimmed.startsWith('tskey-auth-') && !trimmed.startsWith('tskey-client-')) {\n return {\n ok: false,\n ...(tenant ? { tenant } : {}),\n ...(daemonVersion ? { daemonVersion } : {}),\n error: 'Auth key does not look like a Tailscale key (expected prefix `tskey-auth-` or `tskey-client-`).',\n }\n }\n }\n\n return {\n ok: true,\n ...(tenant ? { tenant } : {}),\n ...(daemonVersion ? { daemonVersion } : {}),\n }\n }\n\n private async listPeers(): Promise<{ peers: readonly MeshPeer[] }> {\n const s = await this.cli.status()\n const peers: MeshPeer[] = []\n if (s.Self) {\n peers.push({\n id: s.Self.ID,\n hostname: s.Self.HostName,\n addresses: s.Self.TailscaleIPs ?? [],\n os: s.Self.OS,\n online: s.Self.Online,\n lastSeenMs: Date.now(),\n isSelf: true,\n })\n }\n for (const p of Object.values(s.Peer ?? {})) {\n const lastSeen = p.LastSeen ? Date.parse(p.LastSeen) : 0\n peers.push({\n id: p.ID,\n hostname: p.HostName,\n addresses: p.TailscaleIPs ?? [],\n os: p.OS,\n online: p.Online,\n lastSeenMs: Number.isFinite(lastSeen) ? lastSeen : 0,\n isSelf: false,\n })\n }\n return { peers }\n }\n\n // ── Auto-rejoin loop (#174) ───────────────────────────────────────\n\n /**\n * Single pass of the auto-rejoin probe. Called once on boot and then\n * on the backoff timer. Internal contract:\n * - reads `BackendState` via the CLI\n * - skips when CLI unreachable / NoState / Running / no authKey /\n * login-redirect in flight\n * - otherwise runs `tailscale up --auth-key=<stored>` and resets\n * the backoff index on success\n * - on failure / continued non-Running state schedules the next\n * attempt with the configured backoff\n */\n private async tryAutoRejoin(): Promise<void> {\n if (this.rejoinTimer !== null) {\n // Already scheduled. Should never happen but be defensive.\n return\n }\n if (this.loginInFlight) {\n this.ctx.logger.info('auto-rejoin: skipping — redirect login in flight', {\n tags: { topic: 'tailscale', phase: 'auto-rejoin-skip' },\n })\n this.scheduleNextAutoRejoin()\n return\n }\n if (!this.config.authKey) {\n // Nothing to rejoin with. The operator either hasn't pasted a key\n // yet OR is using the browser-redirect flow — both are valid.\n return\n }\n let state: TailscaleStatusJson['BackendState'] | null\n try {\n const s = await this.cli.status()\n state = s.BackendState\n } catch (err) {\n this.ctx.logger.warn('auto-rejoin: status probe failed — CLI unreachable, skipping', {\n meta: { error: err instanceof Error ? err.message : String(err) },\n tags: { topic: 'tailscale', phase: 'auto-rejoin-cli-missing' },\n })\n // Do NOT schedule a retry when the CLI is missing — the operator\n // needs to install tailscaled first; spamming the timer wouldn't\n // help. The next boot will probe again.\n return\n }\n if (state === 'Running') {\n // Already joined; reset the counter so a later transition out of\n // Running starts at the 5min step.\n this.resetAutoRejoinBackoff()\n return\n }\n if (state === 'NoState') {\n // Daemon hasn't been initialized yet — first-time install.\n // Don't try to inject a key here; the operator should drive the\n // first join from the UI so they see any failure surface.\n this.ctx.logger.info('auto-rejoin: skipping — daemon NoState (first-time install)', {\n tags: { topic: 'tailscale', phase: 'auto-rejoin-no-state' },\n })\n return\n }\n // BackendState is NeedsLogin / NeedsMachineAuth / Starting / Stopped:\n // any of these are safe to retry with the stored key.\n this.ctx.logger.info('auto-rejoin: attempting silent rejoin', {\n meta: { state, attempt: this.rejoinAttempt + 1 },\n tags: { topic: 'tailscale', phase: 'auto-rejoin-attempt' },\n })\n try {\n await this.cli.up({\n authKey: this.config.authKey,\n ...(this.config.hostname ? { hostname: this.config.hostname } : {}),\n })\n this.ctx.logger.info('auto-rejoin: succeeded', {\n tags: { topic: 'tailscale', phase: 'auto-rejoin-ok' },\n })\n this.resetAutoRejoinBackoff()\n } catch (err) {\n this.ctx.logger.warn('auto-rejoin: tailscale up failed — will retry on backoff', {\n meta: { error: err instanceof Error ? err.message : String(err), nextAttempt: this.rejoinAttempt + 1 },\n tags: { topic: 'tailscale', phase: 'auto-rejoin-error' },\n })\n this.scheduleNextAutoRejoin()\n }\n }\n\n /** Reset the backoff index — used after a successful join (manual or auto). */\n private resetAutoRejoinBackoff(): void {\n this.rejoinAttempt = 0\n if (this.rejoinTimer !== null) {\n clearTimeout(this.rejoinTimer)\n this.rejoinTimer = null\n }\n }\n\n /** Queue the next attempt or stop retrying once we hit the cap. */\n private scheduleNextAutoRejoin(): void {\n if (this.rejoinAttempt >= AUTO_REJOIN_BACKOFF_MS.length) {\n this.ctx.logger.warn('auto-rejoin: cap reached — giving up until next boot or manual join', {\n meta: { attempts: this.rejoinAttempt },\n tags: { topic: 'tailscale', phase: 'auto-rejoin-cap-reached' },\n })\n return\n }\n const delay = AUTO_REJOIN_BACKOFF_MS[this.rejoinAttempt] ?? AUTO_REJOIN_BACKOFF_MS[AUTO_REJOIN_BACKOFF_MS.length - 1] ?? 60 * 60_000\n this.rejoinAttempt += 1\n this.rejoinTimer = setTimeout(() => {\n this.rejoinTimer = null\n void this.tryAutoRejoin()\n }, delay)\n }\n\n protected globalSettingsSchema() {\n return this.schema({\n sections: [\n {\n id: 'auth',\n title: 'Tailscale',\n immediate: true,\n description: 'Joins the host to a Tailscale tailnet. Click \"Connect to Tailscale\" to open the Tailscale login page in your browser.',\n fields: [\n {\n type: 'info' as const,\n key: 'tailscaleHelp',\n label: 'Prerequisites',\n format: 'html' as const,\n content:\n 'Install <code>tailscaled</code> from ' +\n '<a href=\"https://tailscale.com/download\">tailscale.com/download</a>.' +\n ' On macOS the GUI app ships the CLI inside the .app bundle.' +\n ' For Serve / Funnel ingress, install ' +\n '<code>@camstack/addon-tailscale-ingress</code> separately.',\n variant: 'info' as const,\n },\n this.field({\n type: 'text',\n key: 'hostname',\n label: 'Device Hostname (optional)',\n description: 'Override the hostname advertised in the tailnet. Empty = use the OS hostname.',\n placeholder: 'camstack-hub',\n }),\n {\n type: 'info' as const,\n key: 'tailscaleConnectHint',\n label: 'Connect',\n format: 'html' as const,\n content:\n 'Use the <strong>Connect to Tailscale</strong> action on the addon page to start the browser-redirect login flow. ' +\n 'The admin UI will open a one-time Tailscale URL in a new tab; the host joins the tailnet once you authenticate.',\n variant: 'info' as const,\n },\n ],\n },\n {\n id: 'auth-advanced',\n title: 'Advanced / Headless',\n style: 'accordion',\n defaultCollapsed: true,\n immediate: true,\n 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).',\n fields: [\n this.field({\n type: 'password',\n key: 'authKey',\n label: 'Pre-auth Key',\n description: 'tskey-auth-* token from the Tailscale admin console. Leave empty when using the browser-redirect flow above.',\n showToggle: true,\n }),\n ],\n },\n ],\n })\n }\n}\n\nexport default TailscaleClientAddon\n"],"mappings":"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"}
@@ -0,0 +1,27 @@
1
+ //#region virtual:mf-exposes:__mfe_internal__addon_tailscale_client_page__remoteEntry_js
2
+ var e = {}, t = /* @__PURE__ */ new Set(), n = Promise.resolve();
3
+ async function r(e) {
4
+ let t = n.then(e, e);
5
+ return n = t.then(() => void 0, () => void 0), t;
6
+ }
7
+ async function i(n) {
8
+ if (typeof document > "u") return;
9
+ let r = e[n] || [];
10
+ await Promise.all(r.map((e) => {
11
+ let n = new URL(e, import.meta.url).href;
12
+ return t.has(n) || (t.add(n), document.querySelector(`link[rel="stylesheet"][data-mf-href="${n}"]`)) ? Promise.resolve() : new Promise((e, t) => {
13
+ let r = document.createElement("link");
14
+ r.rel = "stylesheet", r.href = n, r.setAttribute("data-mf-href", n), r.onload = () => e(), r.onerror = () => t(/* @__PURE__ */ Error(`[Module Federation] Failed to load CSS asset: ${n}`)), document.head.appendChild(r);
15
+ });
16
+ }));
17
+ }
18
+ var a = { "./page": async () => {
19
+ await i("./page");
20
+ let e = await r(() => import("./_stub.js")), t = {};
21
+ return Object.assign(t, e), Object.defineProperty(t, "__esModule", {
22
+ value: !0,
23
+ enumerable: !1
24
+ }), t;
25
+ } };
26
+ //#endregion
27
+ export { a as default };
package/package.json ADDED
@@ -0,0 +1,94 @@
1
+ {
2
+ "name": "@camstack/addon-tailscale-client",
3
+ "version": "0.1.12",
4
+ "description": "Tailscale mesh addon for CamStack — joins the host to a tailnet via the tailscale CLI.",
5
+ "keywords": [
6
+ "camstack",
7
+ "addon",
8
+ "camstack-addon",
9
+ "tailscale",
10
+ "mesh",
11
+ "vpn"
12
+ ],
13
+ "license": "MIT",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/camstack/server"
17
+ },
18
+ "main": "./dist/index.js",
19
+ "module": "./dist/index.mjs",
20
+ "types": "./dist/index.d.ts",
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "import": "./dist/index.mjs",
25
+ "require": "./dist/index.js"
26
+ },
27
+ "./package.json": "./package.json"
28
+ },
29
+ "camstack": {
30
+ "displayName": "Tailscale Client",
31
+ "addons": [
32
+ {
33
+ "id": "tailscale-client",
34
+ "name": "Tailscale Client",
35
+ "version": "1.0.0",
36
+ "entry": "./dist/tailscale.addon.js",
37
+ "execution": {
38
+ "placement": "hub-only",
39
+ "group": "tailscale"
40
+ },
41
+ "capabilities": [
42
+ {
43
+ "name": "mesh-network"
44
+ },
45
+ {
46
+ "name": "addon-pages-source"
47
+ }
48
+ ],
49
+ "pages": [
50
+ {
51
+ "id": "tailscale-client",
52
+ "label": "Tailscale",
53
+ "icon": "network",
54
+ "path": "/addon/tailscale-client",
55
+ "remoteName": "addon_tailscale_client_page",
56
+ "bundle": "remoteEntry.js"
57
+ }
58
+ ]
59
+ }
60
+ ]
61
+ },
62
+ "files": [
63
+ "dist"
64
+ ],
65
+ "scripts": {
66
+ "build": "vite build && vite build -c vite.page.config.ts",
67
+ "build:lib": "vite build",
68
+ "build:page": "vite build -c vite.page.config.ts",
69
+ "dev:page": "vite",
70
+ "typecheck": "tsc --noEmit",
71
+ "publish": "npm publish --access public"
72
+ },
73
+ "peerDependencies": {
74
+ "@camstack/types": "^0.1.0",
75
+ "react": ">=18",
76
+ "react-dom": ">=18"
77
+ },
78
+ "devDependencies": {
79
+ "@camstack/sdk": "*",
80
+ "@camstack/types": "*",
81
+ "@camstack/ui-library": "*",
82
+ "@module-federation/vite": "^1.15.2",
83
+ "@tailwindcss/vite": "^4.2.0",
84
+ "@trpc/client": "^11.16.0",
85
+ "@vitejs/plugin-react": "^4.0.0",
86
+ "lucide-react": "^0.576",
87
+ "react": "^19.0.0",
88
+ "react-dom": "^19.0.0",
89
+ "tailwindcss": "^4.2.0",
90
+ "typescript": "~5.9.0",
91
+ "vite": "^8.0.11",
92
+ "vite-plugin-dts": "^5.0.0"
93
+ }
94
+ }