@stim-cli/server 1.10.0 → 1.10.1
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/README.md +24 -4
- package/dist/{protocol-dn2_TUeH.mjs → protocol-B3k9Ykuu.mjs} +14 -0
- package/dist/protocol.d.mts +11 -2
- package/dist/protocol.mjs +1 -1
- package/dist/protocol.schema.json +20 -0
- package/dist/stim-server.mjs +226 -27
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -22,7 +22,12 @@ stim-server devices revoke <id> # revoke a paired device
|
|
|
22
22
|
|
|
23
23
|
`GET http://127.0.0.1:7787/health` answers requests from this Mac with the
|
|
24
24
|
server's name, versions, protocol, `stimHome`, and the Tailscale state it
|
|
25
|
-
started with.
|
|
25
|
+
started with. While Tailscale runs, it also carries `route`, read from
|
|
26
|
+
`tailscale serve status --json` on each request: `routed` with the HTTPS
|
|
27
|
+
`port` that proxies to the server, `funneled` with the Funnel `ports` that do,
|
|
28
|
+
`missing`, or `unknown` with a `reason`; the last three carry the `port` the
|
|
29
|
+
setup command would use. Stim Desktop uses it to find a running server and
|
|
30
|
+
show its route. A request through
|
|
26
31
|
`tailscale serve`, on a Tailscale address, or with a `Host` other than
|
|
27
32
|
`127.0.0.1` or `localhost` gets HTTP 426, like any other plain HTTP request.
|
|
28
33
|
|
|
@@ -40,12 +45,23 @@ same tailnet or with the Mac shared to the phone's user.
|
|
|
40
45
|
|
|
41
46
|
`stim-server` listens only on `127.0.0.1` and on the Mac's Tailscale
|
|
42
47
|
addresses, never on every interface. Run this once so clients can use
|
|
43
|
-
`wss://<mac>.<tailnet>.ts.net` with a valid certificate:
|
|
48
|
+
`wss://<mac>.<tailnet>.ts.net:7443` with a valid certificate:
|
|
44
49
|
|
|
45
50
|
```bash
|
|
46
|
-
tailscale serve --bg http://127.0.0.1:7787
|
|
51
|
+
tailscale serve --bg --https=7443 http://127.0.0.1:7787
|
|
47
52
|
```
|
|
48
53
|
|
|
54
|
+
The dedicated port 7443 keeps the server tailnet only and leaves port 443 to
|
|
55
|
+
other apps. Do not serve `stim-server` on a port where Tailscale Funnel is on:
|
|
56
|
+
Funnel makes every handler on that port reachable from the public internet.
|
|
57
|
+
|
|
58
|
+
`stim-server` reads `tailscale serve status --json` at start and on every
|
|
59
|
+
`pair`. It uses the HTTPS port whose `/` handler proxies to its loopback port,
|
|
60
|
+
preferring 7443. Without such a route, it assumes 7443 and prints the command
|
|
61
|
+
above, or the next free port when 7443 is taken. It never suggests a Funnel
|
|
62
|
+
port. Any handler, at any path, or TCP forward that reaches the server on a
|
|
63
|
+
Funnel port makes it public, and `pair` refuses.
|
|
64
|
+
|
|
49
65
|
When Tailscale is not running, `stim-server` listens on loopback only and
|
|
50
66
|
says so on stderr. Start Tailscale, then restart `stim-server`.
|
|
51
67
|
|
|
@@ -54,9 +70,13 @@ says so on stderr. Start Tailscale, then restart `stim-server`.
|
|
|
54
70
|
`stim-server pair` prints the JSON that the pairing QR code encodes:
|
|
55
71
|
|
|
56
72
|
```json
|
|
57
|
-
{ "v": 1, "name": "Janic's MacBook Pro", "endpoint": "wss://janics-mbp.tail1234.ts.net", "pairingToken": "..." }
|
|
73
|
+
{ "v": 1, "name": "Janic's MacBook Pro", "endpoint": "wss://janics-mbp.tail1234.ts.net:7443", "pairingToken": "..." }
|
|
58
74
|
```
|
|
59
75
|
|
|
76
|
+
The endpoint names the route's port, and omits it for 443. When a route to
|
|
77
|
+
the server is on a port with Funnel on, `pair` refuses and exits 1 without
|
|
78
|
+
creating a token.
|
|
79
|
+
|
|
60
80
|
The pairing token works once and expires after 5 minutes. A client spends it in
|
|
61
81
|
`hello` and receives a random device token, which it presents on every later
|
|
62
82
|
connection. The server stores only the SHA-256 hash of each token.
|
|
@@ -366,6 +366,20 @@ function protocolJsonSchema() {
|
|
|
366
366
|
}
|
|
367
367
|
}
|
|
368
368
|
},
|
|
369
|
+
{
|
|
370
|
+
type: "object",
|
|
371
|
+
required: [
|
|
372
|
+
"event",
|
|
373
|
+
"subscription",
|
|
374
|
+
"delayed"
|
|
375
|
+
],
|
|
376
|
+
additionalProperties: false,
|
|
377
|
+
properties: {
|
|
378
|
+
event: { const: "frame-delayed" },
|
|
379
|
+
subscription: { type: "string" },
|
|
380
|
+
delayed: { type: "boolean" }
|
|
381
|
+
}
|
|
382
|
+
},
|
|
369
383
|
{
|
|
370
384
|
type: "object",
|
|
371
385
|
required: [
|
package/dist/protocol.d.mts
CHANGED
|
@@ -178,10 +178,19 @@ interface FrameEvent {
|
|
|
178
178
|
/** Base64-encoded image bytes. */
|
|
179
179
|
data: string;
|
|
180
180
|
}
|
|
181
|
-
|
|
181
|
+
/**
|
|
182
|
+
* Captures for a `frames.subscribe` subscription are slow or a timed-out capture is being retried; the
|
|
183
|
+
* client keeps showing its last frame. Followed by `delayed: false` once captures recover.
|
|
184
|
+
*/
|
|
185
|
+
interface FrameDelayedEvent {
|
|
186
|
+
event: "frame-delayed";
|
|
187
|
+
subscription: string;
|
|
188
|
+
delayed: boolean;
|
|
189
|
+
}
|
|
190
|
+
type ServerEvent = StatusEvent | LogsEvent | FrameEvent | FrameDelayedEvent | ErrorEvent;
|
|
182
191
|
type ServerMessage = ServerResponse | ServerEvent;
|
|
183
192
|
type JsonSchema = Record<string, unknown>;
|
|
184
193
|
/** The JSON Schema of every message on the wire, written to `dist/protocol.schema.json` at build time. */
|
|
185
194
|
declare function protocolJsonSchema(): JsonSchema;
|
|
186
195
|
//#endregion
|
|
187
|
-
export { CAPABILITIES, Capability, ClientRequest, DeviceAuth, ERROR_CODES, ErrorCode, ErrorEvent, FrameEvent, FrameTarget, HelloParams, HelloResult, LOG_LEVELS, LOG_SOURCES, LogFilter, LogLevel, LogRecord, LogSource, LogsEvent, LogsQueryResult, MAX_LOG_TAIL, METHODS, Method, Methods, PROTOCOL_SCHEMA_FILE, PROTOCOL_VERSION, PairingAuth, Platform, ProtocolError, RequestId, ServerEvent, ServerMessage, ServerResponse, SettingsResult, StatsResult, StatusEvent, SubscribeResult, UnsubscribeParams, WorkspaceParams, protocolJsonSchema };
|
|
196
|
+
export { CAPABILITIES, Capability, ClientRequest, DeviceAuth, ERROR_CODES, ErrorCode, ErrorEvent, FrameDelayedEvent, FrameEvent, FrameTarget, HelloParams, HelloResult, LOG_LEVELS, LOG_SOURCES, LogFilter, LogLevel, LogRecord, LogSource, LogsEvent, LogsQueryResult, MAX_LOG_TAIL, METHODS, Method, Methods, PROTOCOL_SCHEMA_FILE, PROTOCOL_VERSION, PairingAuth, Platform, ProtocolError, RequestId, ServerEvent, ServerMessage, ServerResponse, SettingsResult, StatsResult, StatusEvent, SubscribeResult, UnsubscribeParams, WorkspaceParams, protocolJsonSchema };
|
package/dist/protocol.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as MAX_LOG_TAIL, c as PROTOCOL_VERSION, i as LOG_SOURCES, l as protocolJsonSchema, n as ERROR_CODES, o as METHODS, r as LOG_LEVELS, s as PROTOCOL_SCHEMA_FILE, t as CAPABILITIES } from "./protocol-
|
|
1
|
+
import { a as MAX_LOG_TAIL, c as PROTOCOL_VERSION, i as LOG_SOURCES, l as protocolJsonSchema, n as ERROR_CODES, o as METHODS, r as LOG_LEVELS, s as PROTOCOL_SCHEMA_FILE, t as CAPABILITIES } from "./protocol-B3k9Ykuu.mjs";
|
|
2
2
|
export { CAPABILITIES, ERROR_CODES, LOG_LEVELS, LOG_SOURCES, MAX_LOG_TAIL, METHODS, PROTOCOL_SCHEMA_FILE, PROTOCOL_VERSION, protocolJsonSchema };
|
|
@@ -632,6 +632,26 @@
|
|
|
632
632
|
}
|
|
633
633
|
}
|
|
634
634
|
},
|
|
635
|
+
{
|
|
636
|
+
"type": "object",
|
|
637
|
+
"required": [
|
|
638
|
+
"event",
|
|
639
|
+
"subscription",
|
|
640
|
+
"delayed"
|
|
641
|
+
],
|
|
642
|
+
"additionalProperties": false,
|
|
643
|
+
"properties": {
|
|
644
|
+
"event": {
|
|
645
|
+
"const": "frame-delayed"
|
|
646
|
+
},
|
|
647
|
+
"subscription": {
|
|
648
|
+
"type": "string"
|
|
649
|
+
},
|
|
650
|
+
"delayed": {
|
|
651
|
+
"type": "boolean"
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
},
|
|
635
655
|
{
|
|
636
656
|
"type": "object",
|
|
637
657
|
"required": [
|
package/dist/stim-server.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { a as MAX_LOG_TAIL, i as LOG_SOURCES, r as LOG_LEVELS, t as CAPABILITIES } from "./protocol-
|
|
2
|
+
import { a as MAX_LOG_TAIL, i as LOG_SOURCES, r as LOG_LEVELS, t as CAPABILITIES } from "./protocol-B3k9Ykuu.mjs";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
import { accessSync, constants, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
|
|
5
5
|
import { homedir, hostname, tmpdir } from "node:os";
|
|
@@ -407,10 +407,15 @@ var FeedPool = class {
|
|
|
407
407
|
};
|
|
408
408
|
//#endregion
|
|
409
409
|
//#region src/frames.ts
|
|
410
|
+
const DEFAULT_FRAME_LIMITS = {
|
|
411
|
+
toolTimeoutMs: 3e4,
|
|
412
|
+
slowCaptureMs: 3e3,
|
|
413
|
+
failureBackoffMs: 3e3,
|
|
414
|
+
maxConsecutiveFailures: 3
|
|
415
|
+
};
|
|
410
416
|
const MIN_INTERVAL_MS = 200;
|
|
411
417
|
const MAX_INTERVAL_MS = 1e3;
|
|
412
418
|
const MAX_CAPTURES = 2;
|
|
413
|
-
const TOOL_TIMEOUT_MS = 1e4;
|
|
414
419
|
const MAX_EDGE = 1280;
|
|
415
420
|
const JPEG_QUALITY = 70;
|
|
416
421
|
function deviceKey(device) {
|
|
@@ -469,7 +474,14 @@ function jpegSize(bytes) {
|
|
|
469
474
|
}
|
|
470
475
|
return null;
|
|
471
476
|
}
|
|
472
|
-
|
|
477
|
+
/** A rejection with `transient: true` is a timeout on a machine that is merely busy, not a broken capturer. */
|
|
478
|
+
function timeoutError(message) {
|
|
479
|
+
return Object.assign(new Error(message), { transient: true });
|
|
480
|
+
}
|
|
481
|
+
function isTransient(error) {
|
|
482
|
+
return error instanceof Error && error.transient === true;
|
|
483
|
+
}
|
|
484
|
+
function runTool(file, args, env, running, timeoutMs) {
|
|
473
485
|
return new Promise((resolve, reject) => {
|
|
474
486
|
const child = spawn(file, args, {
|
|
475
487
|
env,
|
|
@@ -485,8 +497,8 @@ function runTool(file, args, env, running) {
|
|
|
485
497
|
let stderr = "";
|
|
486
498
|
const timer = setTimeout(() => {
|
|
487
499
|
terminate(child);
|
|
488
|
-
reject(
|
|
489
|
-
},
|
|
500
|
+
reject(timeoutError(`${file} ${args[0]} did not finish within ${timeoutMs / 1e3} s.`));
|
|
501
|
+
}, timeoutMs);
|
|
490
502
|
child.stdout.on("data", (chunk) => chunks.push(chunk));
|
|
491
503
|
child.stderr.setEncoding("utf8");
|
|
492
504
|
child.stderr.on("data", (chunk) => {
|
|
@@ -515,7 +527,7 @@ async function stopTools(running, tmp) {
|
|
|
515
527
|
* can be the unlit one and comes back black; `primary` is the panel CoreDevice reports as primary. A simctl
|
|
516
528
|
* that rejects `primary` gets the default display instead.
|
|
517
529
|
*/
|
|
518
|
-
function simulatorCapturer(udid, env) {
|
|
530
|
+
function simulatorCapturer(udid, env, limits) {
|
|
519
531
|
let tmp = null;
|
|
520
532
|
let display = ["--display=primary"];
|
|
521
533
|
let closed = false;
|
|
@@ -528,7 +540,7 @@ function simulatorCapturer(udid, env) {
|
|
|
528
540
|
"--type=jpeg",
|
|
529
541
|
...display,
|
|
530
542
|
output
|
|
531
|
-
], env, running);
|
|
543
|
+
], env, running, limits.toolTimeoutMs);
|
|
532
544
|
return {
|
|
533
545
|
capture: async () => {
|
|
534
546
|
tmp ??= mkdtempSync(join(serverDir(), "frames-"));
|
|
@@ -654,7 +666,7 @@ function screenshotImage(body) {
|
|
|
654
666
|
for (const entry of protoFields(message)) if (entry.field === 4 && "bytes" in entry) return Buffer.from(entry.bytes);
|
|
655
667
|
throw new Error("getScreenshot returned no image.");
|
|
656
668
|
}
|
|
657
|
-
function emulatorCapturer(serial, env) {
|
|
669
|
+
function emulatorCapturer(serial, env, limits) {
|
|
658
670
|
let session = null;
|
|
659
671
|
let tmp = null;
|
|
660
672
|
const running = /* @__PURE__ */ new Set();
|
|
@@ -673,18 +685,26 @@ function emulatorCapturer(serial, env) {
|
|
|
673
685
|
const chunks = [];
|
|
674
686
|
let status;
|
|
675
687
|
let message;
|
|
688
|
+
let timedOut = false;
|
|
676
689
|
const record = (headers) => {
|
|
677
690
|
if (headers["grpc-status"] !== void 0) status = String(headers["grpc-status"]);
|
|
678
691
|
if (headers["grpc-message"] !== void 0) message = String(headers["grpc-message"]);
|
|
679
692
|
};
|
|
680
|
-
request.setTimeout(
|
|
693
|
+
request.setTimeout(limits.toolTimeoutMs, () => {
|
|
694
|
+
timedOut = true;
|
|
695
|
+
request.close();
|
|
696
|
+
});
|
|
681
697
|
request.on("response", record);
|
|
682
698
|
request.on("trailers", record);
|
|
683
699
|
request.on("data", (chunk) => chunks.push(chunk));
|
|
684
|
-
request.on("error", (error) =>
|
|
700
|
+
request.on("error", (error) => {
|
|
701
|
+
const text = `getScreenshot failed on ${serial}: ${error.message}`;
|
|
702
|
+
reject(timedOut ? timeoutError(text) : new Error(text));
|
|
703
|
+
});
|
|
685
704
|
request.on("close", () => {
|
|
686
705
|
if (status === "0") return resolve(Buffer.concat(chunks));
|
|
687
|
-
|
|
706
|
+
const text = `getScreenshot failed on ${serial}: ${message ?? `status ${status ?? "missing"}`}`;
|
|
707
|
+
reject(timedOut ? timeoutError(text) : new Error(text));
|
|
688
708
|
});
|
|
689
709
|
request.end(screenshotRequest());
|
|
690
710
|
});
|
|
@@ -712,7 +732,7 @@ function emulatorCapturer(serial, env) {
|
|
|
712
732
|
input,
|
|
713
733
|
"--out",
|
|
714
734
|
output
|
|
715
|
-
], env, running);
|
|
735
|
+
], env, running, limits.toolTimeoutMs);
|
|
716
736
|
return readFileSync(output);
|
|
717
737
|
}
|
|
718
738
|
};
|
|
@@ -749,18 +769,23 @@ var FrameSource = class {
|
|
|
749
769
|
interval = MIN_INTERVAL_MS;
|
|
750
770
|
timer = null;
|
|
751
771
|
stopped = false;
|
|
772
|
+
delayed = false;
|
|
773
|
+
consecutiveFailures = 0;
|
|
752
774
|
capturer;
|
|
753
775
|
limiter;
|
|
776
|
+
limits;
|
|
754
777
|
ended;
|
|
755
|
-
constructor(capturer, limiter, ended) {
|
|
778
|
+
constructor(capturer, limiter, limits, ended) {
|
|
756
779
|
this.capturer = capturer;
|
|
757
780
|
this.limiter = limiter;
|
|
781
|
+
this.limits = limits;
|
|
758
782
|
this.ended = ended;
|
|
759
783
|
this.tick();
|
|
760
784
|
}
|
|
761
785
|
add(listener) {
|
|
762
786
|
this.listeners.add(listener);
|
|
763
787
|
if (this.last) listener.frame(this.last);
|
|
788
|
+
if (this.delayed) listener.delayed(true);
|
|
764
789
|
return () => {
|
|
765
790
|
if (this.listeners.delete(listener) && this.listeners.size === 0) this.stop();
|
|
766
791
|
};
|
|
@@ -773,6 +798,11 @@ var FrameSource = class {
|
|
|
773
798
|
this.ended();
|
|
774
799
|
return this.capturer.close();
|
|
775
800
|
}
|
|
801
|
+
setDelayed(delayed) {
|
|
802
|
+
if (this.delayed === delayed) return;
|
|
803
|
+
this.delayed = delayed;
|
|
804
|
+
for (const listener of this.listeners) listener.delayed(delayed);
|
|
805
|
+
}
|
|
776
806
|
async tick() {
|
|
777
807
|
this.timer = null;
|
|
778
808
|
let took = 0;
|
|
@@ -797,11 +827,19 @@ var FrameSource = class {
|
|
|
797
827
|
return true;
|
|
798
828
|
});
|
|
799
829
|
if (this.stopped) return;
|
|
830
|
+
this.consecutiveFailures = 0;
|
|
831
|
+
this.setDelayed(took > this.limits.slowCaptureMs);
|
|
800
832
|
if (changed) for (const listener of this.listeners) listener.frame(this.last);
|
|
801
833
|
this.interval = changed ? MIN_INTERVAL_MS : Math.min(this.interval * 2, MAX_INTERVAL_MS);
|
|
802
834
|
this.timer = setTimeout(() => void this.tick(), Math.max(this.interval - took, took));
|
|
803
835
|
} catch (error) {
|
|
804
836
|
if (this.stopped) return;
|
|
837
|
+
if (isTransient(error) && this.consecutiveFailures + 1 < this.limits.maxConsecutiveFailures) {
|
|
838
|
+
this.consecutiveFailures++;
|
|
839
|
+
this.setDelayed(true);
|
|
840
|
+
this.timer = setTimeout(() => void this.tick(), this.limits.failureBackoffMs);
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
805
843
|
const listeners = [...this.listeners];
|
|
806
844
|
this.stop();
|
|
807
845
|
for (const listener of listeners) listener.failed(error.message);
|
|
@@ -818,14 +856,16 @@ var FramePool = class {
|
|
|
818
856
|
sources = /* @__PURE__ */ new Map();
|
|
819
857
|
limiter = new Limiter(MAX_CAPTURES);
|
|
820
858
|
env;
|
|
821
|
-
|
|
859
|
+
limits;
|
|
860
|
+
constructor(env, limits = DEFAULT_FRAME_LIMITS) {
|
|
822
861
|
this.env = env;
|
|
862
|
+
this.limits = limits;
|
|
823
863
|
}
|
|
824
864
|
subscribe(device, listener) {
|
|
825
865
|
const key = deviceKey(device);
|
|
826
866
|
let source = this.sources.get(key);
|
|
827
867
|
if (!source) {
|
|
828
|
-
const created = new FrameSource(device.platform === "ios" ? simulatorCapturer(device.udid, this.env) : emulatorCapturer(device.serial, this.env), this.limiter, () => {
|
|
868
|
+
const created = new FrameSource(device.platform === "ios" ? simulatorCapturer(device.udid, this.env, this.limits) : emulatorCapturer(device.serial, this.env, this.limits), this.limiter, this.limits, () => {
|
|
829
869
|
if (this.sources.get(key) === created) this.sources.delete(key);
|
|
830
870
|
});
|
|
831
871
|
this.sources.set(key, created);
|
|
@@ -914,6 +954,8 @@ var LogBatcher = class {
|
|
|
914
954
|
//#region src/tailscale.ts
|
|
915
955
|
const MAC_APP_BINARY = "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
|
|
916
956
|
const TIMEOUT_MS = 3e3;
|
|
957
|
+
/** The tailnet-only HTTPS port the setup command serves stim-server on. */
|
|
958
|
+
const SERVE_PORT = 7443;
|
|
917
959
|
function executable(path) {
|
|
918
960
|
try {
|
|
919
961
|
accessSync(path, constants.X_OK);
|
|
@@ -967,6 +1009,124 @@ function tailscaleStatus(binary, env) {
|
|
|
967
1009
|
};
|
|
968
1010
|
}
|
|
969
1011
|
}
|
|
1012
|
+
function reaches(address, target, hosts) {
|
|
1013
|
+
if (typeof address !== "string") return null;
|
|
1014
|
+
let url;
|
|
1015
|
+
try {
|
|
1016
|
+
url = new URL(address.includes("://") ? address : `tcp://${address}`);
|
|
1017
|
+
} catch {
|
|
1018
|
+
return null;
|
|
1019
|
+
}
|
|
1020
|
+
return hosts.has(url.hostname) && url.port === String(target) ? url : null;
|
|
1021
|
+
}
|
|
1022
|
+
function portOf(hostPort) {
|
|
1023
|
+
return Number(hostPort.slice(hostPort.lastIndexOf(":") + 1));
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* Reads `tailscale serve status --json`, an ipn.ServeConfig: `TCP` maps a port to `{ HTTPS }` or
|
|
1027
|
+
* `{ TCPForward }`, `Web` maps `<host>:<port>` to `{ Handlers: { <mount>: { Proxy } } }`,
|
|
1028
|
+
* `AllowFunnel` maps `<host>:<port>` to true, and `Foreground` holds the same shape per
|
|
1029
|
+
* foreground session. Any handler or TCP forward that reaches the server on a Funnel port exposes
|
|
1030
|
+
* it; only a `/` HTTP proxy on an HTTPS port is a route a client can use.
|
|
1031
|
+
*/
|
|
1032
|
+
function parseServeStatus(value, target, ips) {
|
|
1033
|
+
if (!isJsonObject(value)) return {
|
|
1034
|
+
state: "unknown",
|
|
1035
|
+
reason: "it printed no serve config",
|
|
1036
|
+
port: SERVE_PORT
|
|
1037
|
+
};
|
|
1038
|
+
const hosts = /* @__PURE__ */ new Set([
|
|
1039
|
+
"127.0.0.1",
|
|
1040
|
+
"localhost",
|
|
1041
|
+
"[::1]",
|
|
1042
|
+
"0.0.0.0",
|
|
1043
|
+
"[::]",
|
|
1044
|
+
...ips.map((ip) => ip.includes(":") ? `[${ip}]` : ip)
|
|
1045
|
+
]);
|
|
1046
|
+
const configs = [value, ...isJsonObject(value.Foreground) ? Object.values(value.Foreground) : []].filter(isJsonObject);
|
|
1047
|
+
const used = /* @__PURE__ */ new Set();
|
|
1048
|
+
const funneled = /* @__PURE__ */ new Set();
|
|
1049
|
+
const reaching = /* @__PURE__ */ new Set();
|
|
1050
|
+
const routes = /* @__PURE__ */ new Set();
|
|
1051
|
+
for (const config of configs) {
|
|
1052
|
+
const tcp = isJsonObject(config.TCP) ? config.TCP : {};
|
|
1053
|
+
for (const [port, listener] of Object.entries(tcp)) {
|
|
1054
|
+
used.add(Number(port));
|
|
1055
|
+
if (isJsonObject(listener) && reaches(listener.TCPForward, target, hosts)) reaching.add(Number(port));
|
|
1056
|
+
}
|
|
1057
|
+
for (const [hostPort, allowed] of Object.entries(isJsonObject(config.AllowFunnel) ? config.AllowFunnel : {})) if (allowed === true) funneled.add(portOf(hostPort));
|
|
1058
|
+
for (const [hostPort, web] of Object.entries(isJsonObject(config.Web) ? config.Web : {})) {
|
|
1059
|
+
const port = portOf(hostPort);
|
|
1060
|
+
const listener = tcp[String(port)];
|
|
1061
|
+
const https = isJsonObject(listener) && listener.HTTPS === true;
|
|
1062
|
+
const handlers = isJsonObject(web) && isJsonObject(web.Handlers) ? web.Handlers : {};
|
|
1063
|
+
for (const [mount, handler] of Object.entries(handlers)) {
|
|
1064
|
+
const url = isJsonObject(handler) ? reaches(handler.Proxy, target, hosts) : null;
|
|
1065
|
+
if (!url) continue;
|
|
1066
|
+
reaching.add(port);
|
|
1067
|
+
if (https && mount === "/" && url.protocol === "http:" && url.pathname === "/") routes.add(port);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
let free = SERVE_PORT;
|
|
1072
|
+
while (used.has(free) || funneled.has(free)) free++;
|
|
1073
|
+
const exposed = [...reaching].filter((port) => funneled.has(port)).toSorted((a, b) => a - b);
|
|
1074
|
+
if (exposed.length) return {
|
|
1075
|
+
state: "funneled",
|
|
1076
|
+
ports: exposed,
|
|
1077
|
+
port: free
|
|
1078
|
+
};
|
|
1079
|
+
const tailnet = [...routes].toSorted((a, b) => a - b);
|
|
1080
|
+
if (tailnet.length) return {
|
|
1081
|
+
state: "routed",
|
|
1082
|
+
port: tailnet.includes(SERVE_PORT) ? SERVE_PORT : tailnet[0]
|
|
1083
|
+
};
|
|
1084
|
+
return {
|
|
1085
|
+
state: "missing",
|
|
1086
|
+
port: free
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
function serveRoute(binary, env, target, ips, timeoutMs = TIMEOUT_MS) {
|
|
1090
|
+
if (!binary) return Promise.resolve({
|
|
1091
|
+
state: "unknown",
|
|
1092
|
+
reason: "the tailscale command was not found",
|
|
1093
|
+
port: SERVE_PORT
|
|
1094
|
+
});
|
|
1095
|
+
return new Promise((resolve) => {
|
|
1096
|
+
execFile(binary, [
|
|
1097
|
+
"serve",
|
|
1098
|
+
"status",
|
|
1099
|
+
"--json"
|
|
1100
|
+
], {
|
|
1101
|
+
env,
|
|
1102
|
+
timeout: timeoutMs,
|
|
1103
|
+
killSignal: "SIGKILL",
|
|
1104
|
+
encoding: "utf8"
|
|
1105
|
+
}, (error, stdout) => {
|
|
1106
|
+
let route;
|
|
1107
|
+
try {
|
|
1108
|
+
route = error ? {
|
|
1109
|
+
state: "unknown",
|
|
1110
|
+
reason: error.killed ? "it timed out" : error.message.split("\n")[0],
|
|
1111
|
+
port: SERVE_PORT
|
|
1112
|
+
} : parseServeStatus(stdout.trim() ? JSON.parse(stdout) : {}, target, ips);
|
|
1113
|
+
} catch {
|
|
1114
|
+
route = {
|
|
1115
|
+
state: "unknown",
|
|
1116
|
+
reason: "it printed output that is not JSON",
|
|
1117
|
+
port: SERVE_PORT
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
resolve(route);
|
|
1121
|
+
});
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
function tailnetEndpoint(dnsName, port) {
|
|
1125
|
+
return port === 443 ? `wss://${dnsName}` : `wss://${dnsName}:${port}`;
|
|
1126
|
+
}
|
|
1127
|
+
function serveCommand(port, target) {
|
|
1128
|
+
return `tailscale serve --bg --https=${port} http://127.0.0.1:${target}`;
|
|
1129
|
+
}
|
|
970
1130
|
/** Reads `tailscale whois --json`: the peer's node (`Node.StableID`, `Node.Name`) and user (`UserProfile.LoginName`). */
|
|
971
1131
|
function parseWhois(value) {
|
|
972
1132
|
if (!isJsonObject(value) || !isJsonObject(value.Node)) return null;
|
|
@@ -1044,6 +1204,7 @@ const STATUS_FEED = {
|
|
|
1044
1204
|
keep: 1,
|
|
1045
1205
|
label: "stim status --watch"
|
|
1046
1206
|
};
|
|
1207
|
+
const HEALTH_ROUTE_TIMEOUT_MS = 1e3;
|
|
1047
1208
|
const COMMAND_LIMITS = {
|
|
1048
1209
|
timeoutMs: 6e4,
|
|
1049
1210
|
maxOutputBytes: 33554432
|
|
@@ -1110,7 +1271,11 @@ async function startServer(options) {
|
|
|
1110
1271
|
const limiter = new FailureLimiter(options.maxAuthFailures ?? 5, options.failureWindowMs ?? 6e4);
|
|
1111
1272
|
const authTimeoutMs = options.authTimeoutMs ?? 5e3;
|
|
1112
1273
|
const feeds = new FeedPool(options.stimCli, options.env);
|
|
1113
|
-
const
|
|
1274
|
+
const frameLimits = {
|
|
1275
|
+
...DEFAULT_FRAME_LIMITS,
|
|
1276
|
+
...options.frameLimits
|
|
1277
|
+
};
|
|
1278
|
+
const frames = new FramePool(options.env, frameLimits);
|
|
1114
1279
|
const running = /* @__PURE__ */ new Set();
|
|
1115
1280
|
const logLimits = {
|
|
1116
1281
|
...LOG_LIMITS,
|
|
@@ -1389,6 +1554,13 @@ async function startServer(options) {
|
|
|
1389
1554
|
pending = frame;
|
|
1390
1555
|
if (!retry) flush();
|
|
1391
1556
|
},
|
|
1557
|
+
delayed: (delayed) => {
|
|
1558
|
+
if (!ended) send(socket, {
|
|
1559
|
+
event: "frame-delayed",
|
|
1560
|
+
subscription,
|
|
1561
|
+
delayed
|
|
1562
|
+
});
|
|
1563
|
+
},
|
|
1392
1564
|
failed: end
|
|
1393
1565
|
};
|
|
1394
1566
|
let unsubscribeStatus = null;
|
|
@@ -1529,6 +1701,14 @@ async function startServer(options) {
|
|
|
1529
1701
|
stimHome: configDir(),
|
|
1530
1702
|
tailscale: healthTailscale(options.tailscaleState)
|
|
1531
1703
|
};
|
|
1704
|
+
const answerHealth = async (response) => {
|
|
1705
|
+
const tailscale = options.tailscaleState;
|
|
1706
|
+
const route = tailscale.state === "running" && tailscale.dnsName ? await serveRoute(options.tailscale, options.env, addresses[0].port, tailscale.ips, HEALTH_ROUTE_TIMEOUT_MS) : void 0;
|
|
1707
|
+
response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({
|
|
1708
|
+
...health,
|
|
1709
|
+
route
|
|
1710
|
+
}));
|
|
1711
|
+
};
|
|
1532
1712
|
const servers = [];
|
|
1533
1713
|
const addresses = [];
|
|
1534
1714
|
const close = async () => {
|
|
@@ -1547,7 +1727,7 @@ async function startServer(options) {
|
|
|
1547
1727
|
for (const host of options.hosts) {
|
|
1548
1728
|
const server = createServer((request, response) => {
|
|
1549
1729
|
if (localHealthRequest(request)) {
|
|
1550
|
-
response
|
|
1730
|
+
answerHealth(response);
|
|
1551
1731
|
return;
|
|
1552
1732
|
}
|
|
1553
1733
|
response.writeHead(426, { "content-type": "text/plain" }).end("stim-server speaks WebSocket only.\n");
|
|
@@ -1590,12 +1770,18 @@ function fail(message) {
|
|
|
1590
1770
|
console.error(`stim-server: ${message}`);
|
|
1591
1771
|
process.exit(1);
|
|
1592
1772
|
}
|
|
1593
|
-
function tailscaleNote(tailscale
|
|
1594
|
-
const remedy = `Remote clients cannot connect until Tailscale runs; then restart stim-server
|
|
1773
|
+
function tailscaleNote(tailscale) {
|
|
1774
|
+
const remedy = `Remote clients cannot connect until Tailscale runs; then restart stim-server, which prints the \`tailscale serve\` command to run once.`;
|
|
1595
1775
|
if (tailscale.state === "not-running") return `Tailscale is not running (${tailscale.backendState}). ${remedy}`;
|
|
1596
1776
|
if (tailscale.state === "unavailable") return `Tailscale is unavailable: ${tailscale.reason}. ${remedy}`;
|
|
1597
1777
|
return null;
|
|
1598
1778
|
}
|
|
1779
|
+
function routeNote(route, port) {
|
|
1780
|
+
if (route.state === "missing") return `No \`tailscale serve\` route reaches port ${port}, so phones cannot connect yet. Run this once to serve it on a tailnet-only port: \`${serveCommand(route.port, port)}\``;
|
|
1781
|
+
if (route.state === "unknown") return `Could not read \`tailscale serve status --json\` (${route.reason}); assuming stim-server is served on port ${route.port}.`;
|
|
1782
|
+
if (route.state === "funneled") return `Tailscale Funnel is on for port ${route.ports.join(", ")}, which proxies to stim-server, so the server is reachable from the public internet. Remove that handler (see \`tailscale serve status\`), then serve stim-server on a tailnet-only port: \`${serveCommand(route.port, port)}\``;
|
|
1783
|
+
return null;
|
|
1784
|
+
}
|
|
1599
1785
|
function macName(tailscale) {
|
|
1600
1786
|
return tailscale.state === "running" && tailscale.hostName || hostname();
|
|
1601
1787
|
}
|
|
@@ -1628,8 +1814,13 @@ async function serve(port) {
|
|
|
1628
1814
|
}
|
|
1629
1815
|
console.log(`stim-server ${pkg.version} (stim ${stim.version}, protocol 1)`);
|
|
1630
1816
|
for (const { host, port: bound } of server.addresses) console.log(`listening on ws://${host.includes(":") ? `[${host}]` : host}:${bound}`);
|
|
1631
|
-
|
|
1632
|
-
|
|
1817
|
+
let note = tailscaleNote(tailscale);
|
|
1818
|
+
if (tailscale.state === "running" && tailscale.dnsName) {
|
|
1819
|
+
const route = await serveRoute(tailscaleBinary, env, port, tailscale.ips);
|
|
1820
|
+
if (route.state !== "funneled") console.log(`tailnet endpoint: ${tailnetEndpoint(tailscale.dnsName, route.port)}`);
|
|
1821
|
+
note = routeNote(route, port);
|
|
1822
|
+
if (route.state === "funneled") note = `${note} Pairing is refused until then.`;
|
|
1823
|
+
}
|
|
1633
1824
|
if (note) console.error(note);
|
|
1634
1825
|
const shutdown = () => {
|
|
1635
1826
|
server.close().then(() => process.exit(0));
|
|
@@ -1638,14 +1829,23 @@ async function serve(port) {
|
|
|
1638
1829
|
process.on("SIGTERM", shutdown);
|
|
1639
1830
|
process.on("SIGHUP", shutdown);
|
|
1640
1831
|
}
|
|
1641
|
-
function pair(port, json) {
|
|
1642
|
-
const
|
|
1832
|
+
async function pair(port, json) {
|
|
1833
|
+
const binary = findTailscale(process.env);
|
|
1834
|
+
const tailscale = tailscaleStatus(binary, process.env);
|
|
1835
|
+
let endpoint = `ws://127.0.0.1:${port}`;
|
|
1836
|
+
let note = tailscaleNote(tailscale);
|
|
1837
|
+
if (note) note = `${note} The endpoint above only works on this Mac.`;
|
|
1838
|
+
if (tailscale.state === "running" && tailscale.dnsName) {
|
|
1839
|
+
const route = await serveRoute(binary, process.env, port, tailscale.ips);
|
|
1840
|
+
if (route.state === "funneled") fail(`refusing to pair. ${routeNote(route, port)}`);
|
|
1841
|
+
endpoint = tailnetEndpoint(tailscale.dnsName, route.port);
|
|
1842
|
+
note = routeNote(route, port);
|
|
1843
|
+
}
|
|
1643
1844
|
const { token, expiresAt } = createPairingToken();
|
|
1644
|
-
const running = tailscale.state === "running";
|
|
1645
1845
|
const payload = {
|
|
1646
1846
|
v: 1,
|
|
1647
1847
|
name: macName(tailscale),
|
|
1648
|
-
endpoint
|
|
1848
|
+
endpoint,
|
|
1649
1849
|
pairingToken: token
|
|
1650
1850
|
};
|
|
1651
1851
|
if (json) return void console.log(JSON.stringify({
|
|
@@ -1654,8 +1854,7 @@ function pair(port, json) {
|
|
|
1654
1854
|
}));
|
|
1655
1855
|
console.log(JSON.stringify(payload));
|
|
1656
1856
|
console.error(`The pairing token is single use and expires at ${expiresAt}.`);
|
|
1657
|
-
|
|
1658
|
-
if (note) console.error(`${note} The endpoint above only works on this Mac.`);
|
|
1857
|
+
if (note) console.error(note);
|
|
1659
1858
|
}
|
|
1660
1859
|
function describe(device) {
|
|
1661
1860
|
const from = device.identity.kind === "local" ? "this Mac" : `${device.identity.nodeName || device.identity.nodeId}${device.identity.user ? ` (${device.identity.user})` : ""}`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stim-cli/server",
|
|
3
|
-
"version": "1.10.
|
|
3
|
+
"version": "1.10.1",
|
|
4
4
|
"description": "Serves Stim status to paired read-only clients over Tailscale.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
"./protocol.schema.json": "./dist/protocol.schema.json"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@stim-cli/core": "^1.10.
|
|
32
|
-
"stim": "1.10.
|
|
31
|
+
"@stim-cli/core": "^1.10.1",
|
|
32
|
+
"stim": "1.10.1",
|
|
33
33
|
"ws": "^8.21.3"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|