@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.
- package/dist/@mf-types/compiled-types/page/TailscaleClientOverviewPage.d.ts +20 -0
- package/dist/@mf-types/compiled-types/page/TailscaleClientOverviewPage.d.ts.map +1 -0
- package/dist/@mf-types/compiled-types/page/page.d.ts +8 -0
- package/dist/@mf-types/compiled-types/page/page.d.ts.map +1 -0
- package/dist/@mf-types/page.d.ts +2 -0
- package/dist/@mf-types.d.ts +3 -0
- package/dist/@mf-types.zip +0 -0
- package/dist/__mfe_internal__addon_tailscale_client_page__loadShare___mf_0_camstack_mf_1_sdk__loadShare__.mjs-bYM9BuS1.mjs +12 -0
- package/dist/__mfe_internal__addon_tailscale_client_page__loadShare___mf_0_camstack_mf_1_types__loadShare__.mjs-CtHD1dC0.mjs +12 -0
- 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
- 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
- package/dist/__mfe_internal__addon_tailscale_client_page__loadShare___mf_0_trpc_mf_1_client__loadShare__.mjs-6CvhJC3f.mjs +42 -0
- package/dist/__mfe_internal__addon_tailscale_client_page__loadShare___mf_0_trpc_mf_1_react_mf_2_query__loadShare__.mjs-Sv3rXvki.mjs +46 -0
- package/dist/__mfe_internal__addon_tailscale_client_page__loadShare__react__loadShare__.mjs-BBqTAV2L.mjs +56 -0
- package/dist/__mfe_internal__addon_tailscale_client_page__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.mjs-BK8BTUon.mjs +18 -0
- package/dist/__mfe_internal__addon_tailscale_client_page__loadShare__react_mf_2_dom__loadShare__.mjs-B6pR25zU.mjs +28 -0
- package/dist/__mfe_internal__addon_tailscale_client_page__loadShare__react_mf_2_dom_mf_1_client__loadShare__.mjs-kyoamNQ7.mjs +18 -0
- package/dist/_stub.js +652 -0
- package/dist/_virtual_mf-localSharedImportMap___mfe_internal__addon_tailscale_client_page-PXP_-hRW.mjs +156 -0
- package/dist/addon-tailscale-client.css +3 -0
- package/dist/client-1J4MstR_.mjs +7592 -0
- package/dist/dist-C168hexw.mjs +17192 -0
- package/dist/dist-CPnIfsyh.mjs +2229 -0
- package/dist/dist-CmoRvaEc.mjs +2483 -0
- package/dist/dist-CwyDJZhZ.mjs +16329 -0
- package/dist/dist-DNrrMIdr.mjs +662 -0
- package/dist/dist-i1I4ldIE.mjs +1260 -0
- package/dist/getErrorShape-BPSzUA7W-C2H3tqHP.mjs +189 -0
- package/dist/hostInit-KpnzzkeJ.mjs +144 -0
- package/dist/index.js +9 -0
- package/dist/index.mjs +2 -0
- package/dist/jsx-runtime-BmcMHbj3.mjs +22 -0
- package/dist/modern-CWdms43F.mjs +2184 -0
- package/dist/react-BXkW-3WQ.mjs +293 -0
- package/dist/react-dom-BcGsvCWU.mjs +131 -0
- package/dist/remoteEntry.js +83 -0
- package/dist/rolldown-runtime-DC4cgjXG.mjs +20 -0
- package/dist/tailscale.addon.js +633 -0
- package/dist/tailscale.addon.js.map +1 -0
- package/dist/tailscale.addon.mjs +627 -0
- package/dist/tailscale.addon.mjs.map +1 -0
- package/dist/virtualExposes-wANYNTM2.mjs +27 -0
- 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
|