@stim-cli/transport-tailscale 0.0.0 → 1.21.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Janic Duplessis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,7 @@
1
1
  # @stim-cli/transport-tailscale
2
2
 
3
- Placeholder. Install a version published with the other Stim packages.
3
+ The Tailscale transport for stim-server. It serves stim-server on a tailnet-only `tailscale serve` HTTPS route, never
4
+ on Funnel, identifies each peer with `tailscale whois`, and listens on the machine's Tailscale addresses while Tailscale
5
+ runs. It implements the [`@stim-cli/transport`](../transport/README.md) contract.
6
+
7
+ stim-server depends on it and loads it at start; it needs no configuration.
@@ -0,0 +1,92 @@
1
+ import { TRANSPORT_API_VERSION, Transport, TransportInput } from "@stim-cli/transport";
2
+ import { ServeRoute } from "@stim-cli/core/protocol";
3
+ //#region src/tailscale.d.ts
4
+ interface TailnetIdentity {
5
+ kind: "tailnet";
6
+ nodeId: string;
7
+ nodeName: string;
8
+ user: string;
9
+ }
10
+ type TailscaleState = {
11
+ state: "running";
12
+ ips: string[];
13
+ dnsName: string | null;
14
+ hostName: string | null;
15
+ } | {
16
+ state: "not-running";
17
+ backendState: string;
18
+ } | {
19
+ state: "unavailable";
20
+ reason: string;
21
+ };
22
+ declare function findTailscale(env: NodeJS.ProcessEnv, platform?: NodeJS.Platform, isExecutable?: (path: string) => boolean): string | null;
23
+ type TailscaleInstall = "app" | "cli" | "none";
24
+ declare function tailscaleInstall(env: NodeJS.ProcessEnv, isExecutable?: (path: string) => boolean): TailscaleInstall;
25
+ declare function readRawTailscaleStatus(binary: string | null, env: NodeJS.ProcessEnv): Promise<unknown>;
26
+ declare function readTailscaleStatus(binary: string | null, env: NodeJS.ProcessEnv, timeoutMs?: number): Promise<TailscaleState>;
27
+ declare function serveRoute(binary: string | null, env: NodeJS.ProcessEnv, target: number, ips: string[], timeoutMs?: number): Promise<ServeRoute>;
28
+ declare function tailnetEndpoint(dnsName: string, port: number): string;
29
+ declare function serveCommand(port: number, target: number): string;
30
+ //#endregion
31
+ //#region src/tailscale-monitor.d.ts
32
+ interface TailscaleSnapshot {
33
+ binary: string | null;
34
+ state: TailscaleState;
35
+ }
36
+ interface TailscaleMonitor {
37
+ current(): TailscaleSnapshot;
38
+ onChange(listener: (snapshot: TailscaleSnapshot, previous: TailscaleSnapshot) => void): () => void;
39
+ stop(): void;
40
+ }
41
+ interface TailscaleMonitorOptions {
42
+ env: NodeJS.ProcessEnv;
43
+ initial: TailscaleSnapshot;
44
+ find?: (env: NodeJS.ProcessEnv) => string | null;
45
+ read?: (binary: string | null, env: NodeJS.ProcessEnv) => Promise<TailscaleState>;
46
+ backoffMs?: number;
47
+ maxMs?: number;
48
+ }
49
+ declare function watchTailscale(options: TailscaleMonitorOptions): TailscaleMonitor;
50
+ //#endregion
51
+ //#region src/serve-plan.d.ts
52
+ interface ServeRecord {
53
+ port: number;
54
+ /** Whether `service install` created the `tailscale serve` route, so `uninstall` removes it. */
55
+ created: boolean;
56
+ }
57
+ /**
58
+ * What `install --serve` does about the node's `tailscale serve` config: refuse a Funnel route or an unreadable
59
+ * config, keep a route that already reaches the port (remembering whether an earlier install created it), or
60
+ * create one.
61
+ */
62
+ declare function planServe(route: ServeRoute, target: number, previous: ServeRecord | null): {
63
+ record: ServeRecord;
64
+ create: boolean;
65
+ } | {
66
+ refusal: string;
67
+ };
68
+ //#endregion
69
+ //#region index.d.ts
70
+ declare const apiVersion: typeof TRANSPORT_API_VERSION;
71
+ declare const id = "tailscale";
72
+ /**
73
+ * The Tailscale transport, plus what stim-server still reads from Tailscale directly: the snapshot behind the
74
+ * `tailscale` and `route` fields of `/health`, the raw status that pins paired machines, and `route.setup`.
75
+ */
76
+ interface TailscaleTransport extends Transport {
77
+ id: typeof id;
78
+ snapshot(): TailscaleSnapshot;
79
+ monitor: TailscaleMonitor | null;
80
+ rawStatus(): Promise<unknown>;
81
+ serveRoute(target: number, timeoutMs?: number): Promise<ServeRoute>;
82
+ setupRoute(target: number): Promise<ServeRoute>;
83
+ }
84
+ declare function isTailscaleTransport(transport: Transport): transport is TailscaleTransport;
85
+ declare function tailscaleTransport({ env, snapshot, monitor }: {
86
+ env: NodeJS.ProcessEnv;
87
+ snapshot: TailscaleSnapshot;
88
+ monitor?: TailscaleMonitor | null;
89
+ }): TailscaleTransport;
90
+ declare function createTransport({ env }: TransportInput): Promise<TailscaleTransport>;
91
+ //#endregion
92
+ export { type ServeRecord, type TailnetIdentity, type TailscaleInstall, type TailscaleMonitor, type TailscaleSnapshot, type TailscaleState, TailscaleTransport, apiVersion, createTransport, findTailscale, id, isTailscaleTransport, planServe, readRawTailscaleStatus, readTailscaleStatus, serveCommand, serveRoute, tailnetEndpoint, tailscaleInstall, tailscaleTransport, watchTailscale };
package/dist/index.mjs ADDED
@@ -0,0 +1,459 @@
1
+ import { isIP } from "node:net";
2
+ import { TRANSPORT_API_VERSION } from "@stim-cli/transport";
3
+ import { execFile } from "node:child_process";
4
+ import { accessSync, constants } from "node:fs";
5
+ import { delimiter, join, win32 } from "node:path";
6
+ import { isJsonObject } from "@stim-cli/core/state";
7
+ //#region src/serve-plan.ts
8
+ /**
9
+ * What `install --serve` does about the node's `tailscale serve` config: refuse a Funnel route or an unreadable
10
+ * config, keep a route that already reaches the port (remembering whether an earlier install created it), or
11
+ * create one.
12
+ */
13
+ function planServe(route, target, previous) {
14
+ if (route.state === "funneled") return { refusal: `refusing to serve. Tailscale Funnel is on for port ${route.ports.join(", ")}, which proxies to port ${target}, so it is reachable from the public internet. Remove that handler (see \`tailscale serve status\`), then run install again.` };
15
+ if (route.state === "unknown") return { refusal: `could not read \`tailscale serve status --json\` (${route.reason}); not changing the route.` };
16
+ if (route.state === "routed") return {
17
+ record: {
18
+ port: route.port,
19
+ created: previous?.port === route.port && previous.created
20
+ },
21
+ create: false
22
+ };
23
+ return {
24
+ record: {
25
+ port: route.port,
26
+ created: true
27
+ },
28
+ create: true
29
+ };
30
+ }
31
+ //#endregion
32
+ //#region src/tailscale.ts
33
+ const MAC_APP_BINARY = "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
34
+ const TIMEOUT_MS = 3e3;
35
+ /** The tailnet-only HTTPS port the setup command serves stim-server on. */
36
+ const SERVE_PORT = 7443;
37
+ function executable(path) {
38
+ try {
39
+ accessSync(path, constants.X_OK);
40
+ return true;
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+ function findTailscale(env, platform = process.platform, isExecutable = executable) {
46
+ const name = platform === "win32" ? "tailscale.exe" : "tailscale";
47
+ for (const dir of (env.PATH ?? "").split(delimiter)) if (dir && isExecutable(join(dir, name))) return join(dir, name);
48
+ const installed = platform === "win32" ? win32.join(env.ProgramFiles ?? "C:\\Program Files", "Tailscale", "tailscale.exe") : MAC_APP_BINARY;
49
+ return isExecutable(installed) ? installed : null;
50
+ }
51
+ function tailscaleInstall(env, isExecutable = executable) {
52
+ if (isExecutable(MAC_APP_BINARY)) return "app";
53
+ return (env.PATH ?? "").split(delimiter).some((dir) => dir && isExecutable(join(dir, "tailscale"))) ? "cli" : "none";
54
+ }
55
+ function parseTailscaleStatus(value) {
56
+ if (!isJsonObject(value) || typeof value.BackendState !== "string") return {
57
+ state: "unavailable",
58
+ reason: "`tailscale status --json` printed no BackendState"
59
+ };
60
+ if (value.BackendState !== "Running") return {
61
+ state: "not-running",
62
+ backendState: value.BackendState
63
+ };
64
+ const self = isJsonObject(value.Self) ? value.Self : {};
65
+ return {
66
+ state: "running",
67
+ ips: Array.isArray(value.TailscaleIPs) ? value.TailscaleIPs.filter((ip) => typeof ip === "string") : [],
68
+ dnsName: typeof self.DNSName === "string" && self.DNSName ? self.DNSName.replace(/\.$/, "") : null,
69
+ hostName: typeof self.HostName === "string" && self.HostName ? self.HostName : null
70
+ };
71
+ }
72
+ function readRawTailscaleStatus(binary, env) {
73
+ if (!binary) return Promise.resolve(null);
74
+ return new Promise((resolve) => {
75
+ execFile(binary, ["status", "--json"], {
76
+ env,
77
+ timeout: TIMEOUT_MS,
78
+ killSignal: "SIGKILL",
79
+ encoding: "utf8"
80
+ }, (error, stdout) => {
81
+ if (error) return resolve(null);
82
+ try {
83
+ const status = JSON.parse(stdout);
84
+ return resolve(isJsonObject(status) && status.BackendState === "Running" ? status : null);
85
+ } catch {
86
+ resolve(null);
87
+ }
88
+ });
89
+ });
90
+ }
91
+ function readTailscaleStatus(binary, env, timeoutMs = TIMEOUT_MS) {
92
+ if (!binary) return Promise.resolve({
93
+ state: "unavailable",
94
+ reason: "the tailscale command was not found"
95
+ });
96
+ return new Promise((resolve) => {
97
+ execFile(binary, ["status", "--json"], {
98
+ env,
99
+ timeout: timeoutMs,
100
+ killSignal: "SIGKILL",
101
+ encoding: "utf8"
102
+ }, (error, stdout) => {
103
+ if (error) return resolve({
104
+ state: "unavailable",
105
+ reason: `\`tailscale status --json\` failed: ${error.killed ? "it timed out" : error.message.split("\n")[0]}`
106
+ });
107
+ try {
108
+ resolve(parseTailscaleStatus(JSON.parse(stdout)));
109
+ } catch {
110
+ resolve({
111
+ state: "unavailable",
112
+ reason: "`tailscale status --json` printed output that is not JSON"
113
+ });
114
+ }
115
+ });
116
+ });
117
+ }
118
+ function reaches(address, target, hosts) {
119
+ if (typeof address !== "string") return null;
120
+ let url;
121
+ try {
122
+ url = new URL(address.includes("://") ? address : `tcp://${address}`);
123
+ } catch {
124
+ return null;
125
+ }
126
+ return hosts.has(url.hostname) && url.port === String(target) ? url : null;
127
+ }
128
+ function portOf(hostPort) {
129
+ return Number(hostPort.slice(hostPort.lastIndexOf(":") + 1));
130
+ }
131
+ /**
132
+ * Reads `tailscale serve status --json`, an ipn.ServeConfig: `TCP` maps a port to `{ HTTPS }` or
133
+ * `{ TCPForward }`, `Web` maps `<host>:<port>` to `{ Handlers: { <mount>: { Proxy } } }`,
134
+ * `AllowFunnel` maps `<host>:<port>` to true, and `Foreground` holds the same shape per
135
+ * foreground session. Any handler or TCP forward that reaches the server on a Funnel port exposes
136
+ * it; only a `/` HTTP proxy on an HTTPS port is a route a client can use.
137
+ */
138
+ function parseServeStatus(value, target, ips) {
139
+ if (!isJsonObject(value)) return {
140
+ state: "unknown",
141
+ reason: "it printed no serve config",
142
+ port: SERVE_PORT
143
+ };
144
+ if (Object.keys(value).length && ![
145
+ "TCP",
146
+ "Web",
147
+ "AllowFunnel",
148
+ "Foreground"
149
+ ].some((field) => field in value)) return {
150
+ state: "unknown",
151
+ reason: "it printed an unrecognized serve config",
152
+ port: SERVE_PORT
153
+ };
154
+ if ([
155
+ "TCP",
156
+ "Web",
157
+ "AllowFunnel",
158
+ "Foreground"
159
+ ].some((field) => value[field] !== void 0 && !isJsonObject(value[field]))) return {
160
+ state: "unknown",
161
+ reason: "it printed a malformed serve config",
162
+ port: SERVE_PORT
163
+ };
164
+ const hosts = /* @__PURE__ */ new Set([
165
+ "127.0.0.1",
166
+ "localhost",
167
+ "[::1]",
168
+ "0.0.0.0",
169
+ "[::]",
170
+ ...ips.map((ip) => ip.includes(":") ? `[${ip}]` : ip)
171
+ ]);
172
+ const configs = [value, ...isJsonObject(value.Foreground) ? Object.values(value.Foreground) : []].filter(isJsonObject);
173
+ if (isJsonObject(value.Foreground) && configs.length !== 1 + Object.keys(value.Foreground).length) return {
174
+ state: "unknown",
175
+ reason: "it printed a malformed foreground config",
176
+ port: SERVE_PORT
177
+ };
178
+ const used = /* @__PURE__ */ new Set();
179
+ const funneled = /* @__PURE__ */ new Set();
180
+ const reaching = /* @__PURE__ */ new Set();
181
+ const routes = /* @__PURE__ */ new Set();
182
+ for (const config of configs) {
183
+ if ([
184
+ "TCP",
185
+ "Web",
186
+ "AllowFunnel"
187
+ ].some((field) => config[field] !== void 0 && !isJsonObject(config[field]))) return {
188
+ state: "unknown",
189
+ reason: "it printed a malformed serve config",
190
+ port: SERVE_PORT
191
+ };
192
+ const tcp = isJsonObject(config.TCP) ? config.TCP : {};
193
+ for (const [port, listener] of Object.entries(tcp)) {
194
+ used.add(Number(port));
195
+ if (isJsonObject(listener) && reaches(listener.TCPForward, target, hosts)) reaching.add(Number(port));
196
+ }
197
+ for (const [hostPort, allowed] of Object.entries(isJsonObject(config.AllowFunnel) ? config.AllowFunnel : {})) if (allowed === true) funneled.add(portOf(hostPort));
198
+ for (const [hostPort, web] of Object.entries(isJsonObject(config.Web) ? config.Web : {})) {
199
+ const port = portOf(hostPort);
200
+ const listener = tcp[String(port)];
201
+ const https = isJsonObject(listener) && listener.HTTPS === true;
202
+ const handlers = isJsonObject(web) && isJsonObject(web.Handlers) ? web.Handlers : {};
203
+ for (const [mount, handler] of Object.entries(handlers)) {
204
+ const url = isJsonObject(handler) ? reaches(handler.Proxy, target, hosts) : null;
205
+ if (!url) continue;
206
+ reaching.add(port);
207
+ if (https && mount === "/" && url.protocol === "http:" && url.pathname === "/") routes.add(port);
208
+ }
209
+ }
210
+ }
211
+ let free = SERVE_PORT;
212
+ while (used.has(free) || funneled.has(free)) free++;
213
+ const exposed = [...reaching].filter((port) => funneled.has(port)).toSorted((a, b) => a - b);
214
+ if (exposed.length) return {
215
+ state: "funneled",
216
+ ports: exposed,
217
+ port: free
218
+ };
219
+ const tailnet = [...routes].toSorted((a, b) => a - b);
220
+ if (tailnet.length) return {
221
+ state: "routed",
222
+ port: tailnet.includes(SERVE_PORT) ? SERVE_PORT : tailnet[0]
223
+ };
224
+ return {
225
+ state: "missing",
226
+ port: free
227
+ };
228
+ }
229
+ function serveRoute(binary, env, target, ips, timeoutMs = TIMEOUT_MS) {
230
+ if (!binary) return Promise.resolve({
231
+ state: "unknown",
232
+ reason: "the tailscale command was not found",
233
+ port: SERVE_PORT
234
+ });
235
+ return new Promise((resolve) => {
236
+ execFile(binary, [
237
+ "serve",
238
+ "status",
239
+ "--json"
240
+ ], {
241
+ env,
242
+ timeout: timeoutMs,
243
+ killSignal: "SIGKILL",
244
+ encoding: "utf8"
245
+ }, (error, stdout) => {
246
+ let route;
247
+ try {
248
+ route = error ? {
249
+ state: "unknown",
250
+ reason: error.killed ? "it timed out" : error.message.split("\n")[0],
251
+ port: SERVE_PORT
252
+ } : parseServeStatus(stdout.trim() ? JSON.parse(stdout) : {}, target, ips);
253
+ } catch {
254
+ route = {
255
+ state: "unknown",
256
+ reason: "it printed output that is not JSON",
257
+ port: SERVE_PORT
258
+ };
259
+ }
260
+ resolve(route);
261
+ });
262
+ });
263
+ }
264
+ function tailnetEndpoint(dnsName, port) {
265
+ return port === 443 ? `wss://${dnsName}` : `wss://${dnsName}:${port}`;
266
+ }
267
+ function serveCommand(port, target) {
268
+ return `tailscale serve --bg --https=${port} http://127.0.0.1:${target}`;
269
+ }
270
+ async function setupServeRoute(binary, env, target, state) {
271
+ if (!binary || state.state !== "running" || !state.dnsName) throw new Error("Start Tailscale on this machine before setting up the phone connection.");
272
+ const route = await serveRoute(binary, env, target, state.ips);
273
+ const plan = planServe(route, target, null);
274
+ if ("refusal" in plan) throw new Error(plan.refusal.replace("run install again", "retry setup"));
275
+ if (!plan.create) return route;
276
+ await new Promise((resolve, reject) => {
277
+ execFile(binary, [
278
+ "serve",
279
+ "--bg",
280
+ `--https=${plan.record.port}`,
281
+ `http://127.0.0.1:${target}`
282
+ ], {
283
+ env,
284
+ timeout: 1e4,
285
+ killSignal: "SIGKILL",
286
+ encoding: "utf8"
287
+ }, (error, stdout, stderr) => {
288
+ if (!error) return resolve();
289
+ const detail = [stdout.trim(), stderr.trim()].filter(Boolean).join("\n");
290
+ reject(/* @__PURE__ */ new Error(`Phone connection setup ${error.killed ? "timed out" : "failed"}. ${detail || error.message}\nIf Tailscale asks to enable HTTPS, approve its browser setup and try again.`));
291
+ });
292
+ });
293
+ const verified = await serveRoute(binary, env, target, state.ips);
294
+ if (verified.state !== "routed") throw new Error(`Phone connection could not be verified (${verified.state === "unknown" ? verified.reason : verified.state}). Check Tailscale and try again.`);
295
+ return verified;
296
+ }
297
+ /** Reads `tailscale whois --json`: the peer's node (`Node.StableID`, `Node.Name`) and user (`UserProfile.LoginName`). */
298
+ function parseWhois(value) {
299
+ if (!isJsonObject(value) || !isJsonObject(value.Node)) return null;
300
+ const { StableID, Name } = value.Node;
301
+ const login = isJsonObject(value.UserProfile) ? value.UserProfile.LoginName : void 0;
302
+ if (typeof StableID !== "string" || !StableID) return null;
303
+ return {
304
+ kind: "tailnet",
305
+ nodeId: StableID,
306
+ nodeName: typeof Name === "string" ? Name.replace(/\.$/, "") : "",
307
+ user: typeof login === "string" ? login : ""
308
+ };
309
+ }
310
+ function whois(binary, env, ip) {
311
+ if (!binary) return Promise.resolve(null);
312
+ return new Promise((resolve) => {
313
+ execFile(binary, [
314
+ "whois",
315
+ "--json",
316
+ ip
317
+ ], {
318
+ env,
319
+ timeout: TIMEOUT_MS,
320
+ encoding: "utf8"
321
+ }, (error, stdout) => {
322
+ if (error) return resolve(null);
323
+ try {
324
+ resolve(parseWhois(JSON.parse(stdout)));
325
+ } catch {
326
+ resolve(null);
327
+ }
328
+ });
329
+ });
330
+ }
331
+ //#endregion
332
+ //#region src/tailscale-monitor.ts
333
+ const STATUS_TIMEOUT_MS = 1e4;
334
+ const MISSES_BEFORE_DOWN = 3;
335
+ function sameState(a, b) {
336
+ if (a.state !== b.state) return false;
337
+ if (a.state === "running" && b.state === "running") return a.dnsName === b.dnsName && a.hostName === b.hostName && a.ips.length === b.ips.length && a.ips.every((ip, i) => ip === b.ips[i]);
338
+ if (a.state === "not-running" && b.state === "not-running") return a.backendState === b.backendState;
339
+ return true;
340
+ }
341
+ function watchTailscale(options) {
342
+ const find = options.find ?? findTailscale;
343
+ const read = options.read ?? ((binary, env) => readTailscaleStatus(binary, env, STATUS_TIMEOUT_MS));
344
+ const backoffMs = options.backoffMs ?? 2e3;
345
+ const maxMs = options.maxMs ?? 3e4;
346
+ const listeners = /* @__PURE__ */ new Set();
347
+ let snapshot = options.initial;
348
+ let timer = null;
349
+ let stopped = false;
350
+ let misses = 0;
351
+ let silent = 0;
352
+ const schedule = () => {
353
+ if (stopped) return;
354
+ const wait = snapshot.state.state === "running" && silent === 0 ? maxMs : Math.min(backoffMs * 2 ** Math.max(misses, silent), maxMs);
355
+ timer = setTimeout(() => void check(), wait);
356
+ timer.unref();
357
+ };
358
+ async function check() {
359
+ try {
360
+ const binary = snapshot.binary ?? find(options.env);
361
+ const state = await read(binary, options.env);
362
+ if (stopped) return;
363
+ if (state.state === "unavailable" && snapshot.state.state === "running" && ++silent < MISSES_BEFORE_DOWN) return;
364
+ silent = 0;
365
+ misses = state.state === "running" ? 0 : misses + 1;
366
+ if (binary !== snapshot.binary || !sameState(state, snapshot.state)) {
367
+ const previous = snapshot;
368
+ snapshot = {
369
+ binary,
370
+ state
371
+ };
372
+ for (const listener of listeners) listener(snapshot, previous);
373
+ }
374
+ } catch (error) {
375
+ console.error(`stim-server: checking Tailscale failed: ${error.message}`);
376
+ } finally {
377
+ schedule();
378
+ }
379
+ }
380
+ schedule();
381
+ return {
382
+ current: () => snapshot,
383
+ onChange(listener) {
384
+ listeners.add(listener);
385
+ return () => listeners.delete(listener);
386
+ },
387
+ stop() {
388
+ stopped = true;
389
+ if (timer) clearTimeout(timer);
390
+ }
391
+ };
392
+ }
393
+ //#endregion
394
+ //#region index.ts
395
+ const apiVersion = TRANSPORT_API_VERSION;
396
+ const id = "tailscale";
397
+ function isTailscaleTransport(transport) {
398
+ return transport.id === "tailscale" && "snapshot" in transport && typeof transport.snapshot === "function";
399
+ }
400
+ function tailscaleTransport({ env, snapshot, monitor = null }) {
401
+ const now = () => monitor?.current() ?? snapshot;
402
+ return {
403
+ id,
404
+ traits: {
405
+ privateNetwork: true,
406
+ tls: "end-to-end",
407
+ peerIdentity: true
408
+ },
409
+ monitor,
410
+ snapshot: now,
411
+ ...monitor && {
412
+ bindAddresses() {
413
+ const { state } = monitor.current();
414
+ return state.state === "running" ? state.ips : [];
415
+ },
416
+ watch: (listener) => monitor.onChange(() => listener())
417
+ },
418
+ async identify({ address }) {
419
+ if (!isIP(address)) return null;
420
+ const identity = await whois(now().binary, env, address);
421
+ return identity && {
422
+ id: identity.nodeId,
423
+ name: identity.nodeName,
424
+ user: identity.user
425
+ };
426
+ },
427
+ rawStatus: () => readRawTailscaleStatus(now().binary, env),
428
+ serveRoute(target, timeoutMs) {
429
+ const { binary, state } = now();
430
+ return serveRoute(binary, env, target, state.state === "running" ? state.ips : [], timeoutMs);
431
+ },
432
+ setupRoute(target) {
433
+ const { binary, state } = now();
434
+ return setupServeRoute(binary, env, target, state);
435
+ },
436
+ close: () => monitor?.stop()
437
+ };
438
+ }
439
+ async function createTransport({ env }) {
440
+ const binary = findTailscale(env);
441
+ const state = await readTailscaleStatus(binary, env);
442
+ const monitor = watchTailscale({
443
+ env,
444
+ initial: {
445
+ binary,
446
+ state
447
+ }
448
+ });
449
+ return tailscaleTransport({
450
+ env,
451
+ snapshot: {
452
+ binary,
453
+ state
454
+ },
455
+ monitor
456
+ });
457
+ }
458
+ //#endregion
459
+ export { apiVersion, createTransport, findTailscale, id, isTailscaleTransport, planServe, readRawTailscaleStatus, readTailscaleStatus, serveCommand, serveRoute, tailnetEndpoint, tailscaleInstall, tailscaleTransport, watchTailscale };
package/package.json CHANGED
@@ -1,11 +1,36 @@
1
1
  {
2
2
  "name": "@stim-cli/transport-tailscale",
3
- "version": "0.0.0",
4
- "description": "Placeholder. The first real version is published by the Stim release workflow.",
3
+ "version": "1.21.0",
4
+ "description": "Tailscale transport for stim-server: tailnet-only serve route, whois identity and peer pinning.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/appandflow/stim.git",
9
9
  "directory": "packages/transport-tailscale"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "type": "module",
17
+ "main": "dist/index.mjs",
18
+ "types": "dist/index.d.mts",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.mts",
22
+ "module-sync": "./dist/index.mjs",
23
+ "default": "./dist/index.mjs"
24
+ }
25
+ },
26
+ "dependencies": {
27
+ "@stim-cli/core": "^1.21.0",
28
+ "@stim-cli/transport": "^1.21.0"
29
+ },
30
+ "engines": {
31
+ "node": ">=22.12.0"
32
+ },
33
+ "scripts": {
34
+ "build": "tsdown"
10
35
  }
11
- }
36
+ }