pi-straw-boss 0.33.0 → 0.33.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/package.json +1 -1
- package/src/control.ts +12 -5
- package/src/devices.ts +2 -1
- package/src/gui.ts +3 -3
- package/src/host.ts +7 -2
- package/src/remote.ts +9 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-straw-boss",
|
|
3
|
-
"version": "0.33.
|
|
3
|
+
"version": "0.33.1",
|
|
4
4
|
"description": "Straw Boss coordination for pi in Herdr: sessions join a tab's net by choice, the first joiner is its main agent, a workspace tower opens on demand with a live monitoring dashboard, and boss_broadcast reaches the rest",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/control.ts
CHANGED
|
@@ -35,7 +35,7 @@ export { ENDING };
|
|
|
35
35
|
|
|
36
36
|
export interface Reply { status: number; body: unknown }
|
|
37
37
|
/** `answers.target` reads the net afresh, so the binding and the dialog are checked again for every answer. */
|
|
38
|
-
export interface ControlDeps { lookup(key: string): Found | undefined; call?: HerdrCall; csrf: string; canonical(file?: string): string | undefined; pair?(): { url: string; rows: string[] } | undefined; remoteOn?(): Promise<void>; remoteOff?(): Promise<void>; paired?: { on(): boolean; list(): unknown; unpair(id: string): boolean; unpairAll(): number }; spawn?(parts: string[], json: Record<string, unknown>): Promise<Reply>; close?(kind: string, json: Record<string, unknown>): Promise<Reply>; dismiss?(key: unknown): Promise<boolean>; dismissAll?(): Promise<number>; stop?(asked: Asked): void; mirrors?: Pick<ReturnType<typeof createMirrors>, "toggle" | "fresh">; secret?(file: string): Promise<boolean>; answers?: Pick<Hub, "replay" | "ended" | "send" | "probe" | "commands"> & { target(key: string): Promise<Target | undefined> } }
|
|
38
|
+
export interface ControlDeps { lookup(key: string): Found | undefined; call?: HerdrCall; csrf: string; canonical(file?: string): string | undefined; pair?(): { url: string; rows: string[] } | undefined; remoteOn?(): Promise<void>; remoteOff?(): Promise<void>; paired?: { on(): boolean; list(): unknown; unpair(id: string): boolean; unpairAll(): number; unsaved(): string | undefined }; saveFailed?(reason: string): void; spawn?(parts: string[], json: Record<string, unknown>): Promise<Reply>; close?(kind: string, json: Record<string, unknown>): Promise<Reply>; dismiss?(key: unknown): Promise<boolean>; dismissAll?(): Promise<number>; stop?(asked: Asked): void; mirrors?: Pick<ReturnType<typeof createMirrors>, "toggle" | "fresh">; secret?(file: string): Promise<boolean>; answers?: Pick<Hub, "replay" | "ended" | "send" | "probe" | "commands"> & { target(key: string): Promise<Target | undefined> } }
|
|
39
39
|
|
|
40
40
|
const no = (status: number, error: string, reason: string): Reply => ({ status, body: { error, reason } });
|
|
41
41
|
const same = (a: string, b: string) => a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
@@ -76,8 +76,13 @@ export function checkText(text: unknown): string | Reply {
|
|
|
76
76
|
return typed;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
export function createControl({ lookup, call, csrf, canonical, pair, remoteOn, remoteOff, paired, spawn, close, dismiss, dismissAll, stop, mirrors, secret = async () => true, answers }: ControlDeps) {
|
|
79
|
+
export function createControl({ lookup, call, csrf, canonical, pair, remoteOn, remoteOff, paired, saveFailed, spawn, close, dismiss, dismissAll, stop, mirrors, secret = async () => true, answers }: ControlDeps) {
|
|
80
80
|
const busy = new Set<string>();
|
|
81
|
+
// A change to the paired devices or the remote wish that could not be written to paired.json took effect in memory only: say so (500), to the page and the session.
|
|
82
|
+
const lost = (): Reply | undefined => {
|
|
83
|
+
const reason = paired?.unsaved();
|
|
84
|
+
return reason ? (saveFailed?.(reason), no(500, "save_failed", reason)) : undefined;
|
|
85
|
+
};
|
|
81
86
|
const pad = createKeys({ fresh: (id, key, pane) => mirrors?.fresh(id, key, pane) ?? false, secret, answerable: async (key) => Boolean(await answers?.target(key)), probe: async (key) => answers?.probe(key) });
|
|
82
87
|
/** The origin checks, then the session checks, then one Herdr call. `parts` is the path after api/: sessions/<key>/<action>, remote/pair, or server/stop. */
|
|
83
88
|
return async function handle(req: IncomingMessage, origin: string, [scope, key = "", action = ""]: string[]): Promise<Reply> {
|
|
@@ -102,16 +107,18 @@ export function createControl({ lookup, call, csrf, canonical, pair, remoteOn, r
|
|
|
102
107
|
if (scope === "remote" && key === "on" && !action) {
|
|
103
108
|
if (!remoteOn) return no(503, "unavailable", "This server cannot turn remote access on.");
|
|
104
109
|
const failed = await remoteOn().then(() => undefined, (error: Error) => error.message);
|
|
110
|
+
const unsaved = !failed && lost();
|
|
111
|
+
if (unsaved) return unsaved;
|
|
105
112
|
const paired = !failed && pair?.();
|
|
106
113
|
return paired ? { status: 200, body: paired } : no(502, "remote_failed", failed ?? "Remote access did not turn on.");
|
|
107
114
|
}
|
|
108
115
|
if (scope === "remote" && !action && ["devices", "unpair", "unpair-all", "off"].includes(key)) {
|
|
109
116
|
if (!paired) return no(503, "unavailable", "This server keeps no paired devices.");
|
|
110
117
|
if (key === "devices") return { status: 200, body: { on: paired.on(), devices: paired.list() } };
|
|
111
|
-
if (key === "off") return remoteOff ? remoteOff().then(() => ({ status: 200, body: {} }
|
|
112
|
-
if (key === "unpair-all") return { status: 200, body: { unpaired: paired.unpairAll()
|
|
118
|
+
if (key === "off") return remoteOff ? remoteOff().then(() => lost() ?? { status: 200, body: {} }, (error: Error) => no(502, "remote_failed", error.message)) : no(503, "unavailable", "This server cannot turn remote access off.");
|
|
119
|
+
if (key === "unpair-all") return ((n) => lost() ?? { status: 200, body: { unpaired: n } })(paired.unpairAll());
|
|
113
120
|
const { id } = read.json;
|
|
114
|
-
return typeof id !== "string" || !id || id.length > 64 ? no(400, "bad_request", "Name the device to unpair.") : paired.unpair(id) ? { status: 200, body: {} } : no(404, "not_found", "No such paired device.");
|
|
121
|
+
return typeof id !== "string" || !id || id.length > 64 ? no(400, "bad_request", "Name the device to unpair.") : paired.unpair(id) ? (lost() ?? { status: 200, body: {} }) : no(404, "not_found", "No such paired device.");
|
|
115
122
|
}
|
|
116
123
|
if (scope === "remote") {
|
|
117
124
|
const pairing = key === "pair" && !action ? pair?.() : undefined;
|
package/src/devices.ts
CHANGED
|
@@ -27,11 +27,12 @@ export function createDevices(file?: string) {
|
|
|
27
27
|
if (saved?.host && saved.login) identity = { host: String(saved.host), login: String(saved.login) };
|
|
28
28
|
// An older file (0.32.1 and earlier) kept only `sessions` (hash to expiry) and was written only while remote was on, so one with devices and no wish says remote was on.
|
|
29
29
|
const old = Object.entries(saved?.sessions ?? {}).map(([h, until]): [string, Record<string, unknown>] => [h, { until }]);
|
|
30
|
-
wanted =
|
|
30
|
+
wanted = saved?.wanted === true;
|
|
31
31
|
for (const [h, d] of [...old, ...Object.entries<Record<string, unknown>>(saved?.devices ?? {})]) {
|
|
32
32
|
const until = num(d?.until, 0);
|
|
33
33
|
if (/^[0-9a-f]{64}$/.test(h) && until > now) devices.set(h, { until, label: typeof d.label === "string" ? d.label.slice(0, 80) : "", paired: num(d.paired, 0), seen: num(d.seen, 0) });
|
|
34
34
|
}
|
|
35
|
+
if (typeof saved?.wanted !== "boolean") wanted = devices.size > 0; // the older file: only a session still valid says remote was on
|
|
35
36
|
} catch {} // none saved
|
|
36
37
|
// A file that cannot be written (a full disk, a directory that went away) must not throw out of the request that changed the state: the
|
|
37
38
|
// state stays in memory for this server's life, and `error` says what failed until a save works again.
|
package/src/gui.ts
CHANGED
|
@@ -44,11 +44,11 @@ export interface Gui { url: string; port: number; remotePort: number; token: str
|
|
|
44
44
|
/** `net` is the registry behind the net reading (the allow list of session keys); without it no session can be watched. `call` reaches Herdr for input. */
|
|
45
45
|
/** `hub` carries answers to the dialogs bridges relay (dialogs.ts); it runs exactly as long as the server. */
|
|
46
46
|
/** `onRemote` and `onRemoteOff` turn remote access on and off from the page, in the session that runs the server. `onStop` takes another session's stop request (requestStop), so the owner also takes its remote entry down; without it the server stops itself. */
|
|
47
|
-
export interface GuiOptions { read(): Promise<Net>; net?: { lookup(key: string): Found | undefined; workspace(id: string): Ref[]; dismiss?(key: string): boolean; dismissAll?(): number }; call?: HerdrCall; hub?: Hub; dir?: string; paired?: string; port?: number; remotePort?: number; token?: string; csrf?: string; pollMs?: number; pingMs?: number; onStop?(asked: Asked): void; onRemote?(): Promise<void>; onRemoteOff?(): Promise<void>; spawn?: ControlDeps["spawn"]; close?: ControlDeps["close"] }
|
|
47
|
+
export interface GuiOptions { read(): Promise<Net>; net?: { lookup(key: string): Found | undefined; workspace(id: string): Ref[]; dismiss?(key: string): boolean; dismissAll?(): number }; call?: HerdrCall; hub?: Hub; dir?: string; paired?: string; port?: number; remotePort?: number; token?: string; csrf?: string; pollMs?: number; pingMs?: number; onStop?(asked: Asked): void; onRemote?(): Promise<void>; onRemoteOff?(): Promise<void>; onSaveFailed?(reason: string): void; spawn?: ControlDeps["spawn"]; close?: ControlDeps["close"] }
|
|
48
48
|
export interface Asked { by: string; take: boolean }
|
|
49
49
|
|
|
50
50
|
/** Starts the server. A preferred `port` and `remotePort` (with the `token` and `csrf`) are reused when free, so a restarted server keeps the URL and the page an open tab already has. */
|
|
51
|
-
export async function startGui({ read, net, call, hub, dir = BUNDLE, paired, port = 0, remotePort = 0, token = randomBytes(16).toString("hex"), csrf = randomBytes(16).toString("hex"), pollMs = 2000, pingMs = 15_000, onStop, onRemote, onRemoteOff, spawn, close }: GuiOptions): Promise<Gui> {
|
|
51
|
+
export async function startGui({ read, net, call, hub, dir = BUNDLE, paired, port = 0, remotePort = 0, token = randomBytes(16).toString("hex"), csrf = randomBytes(16).toString("hex"), pollMs = 2000, pingMs = 15_000, onStop, onRemote, onRemoteOff, onSaveFailed, spawn, close }: GuiOptions): Promise<Gui> {
|
|
52
52
|
try {
|
|
53
53
|
if (!statSync(join(dir, "index.html")).isFile()) throw new Error();
|
|
54
54
|
} catch {
|
|
@@ -70,7 +70,7 @@ export async function startGui({ read, net, call, hub, dir = BUNDLE, paired, por
|
|
|
70
70
|
if (n) await tick();
|
|
71
71
|
return n;
|
|
72
72
|
};
|
|
73
|
-
const control = createControl({ lookup: (key) => net?.lookup(key), call, csrf, canonical, pair, remoteOn: onRemote, remoteOff: onRemoteOff, paired: { on: () => Boolean(access.remote), ...access.devices }, spawn, close: close && (async (kind, json) => (await tick(), close(kind, json))), dismiss: async (key) => typeof key === "string" && Boolean(net?.dismiss?.(key)) && (await tick(), true), dismissAll, mirrors, stop, secret: waitsForSecret, answers: hub && { replay: hub.replay, ended: hub.ended, send: hub.send, probe: hub.probe, commands: hub.commands, target: async (key) => (await tick(), hub.target(key)) } });
|
|
73
|
+
const control = createControl({ lookup: (key) => net?.lookup(key), call, csrf, canonical, pair, remoteOn: onRemote, remoteOff: onRemoteOff, paired: { on: () => Boolean(access.remote), ...access.devices, unsaved: access.unsaved }, saveFailed: onSaveFailed, spawn, close: close && (async (kind, json) => (await tick(), close(kind, json))), dismiss: async (key) => typeof key === "string" && Boolean(net?.dismiss?.(key)) && (await tick(), true), dismissAll, mirrors, stop, secret: waitsForSecret, answers: hub && { replay: hub.replay, ended: hub.ended, send: hub.send, probe: hub.probe, commands: hub.commands, target: async (key) => (await tick(), hub.target(key)) } });
|
|
74
74
|
let last: string | undefined, problem: string | undefined, inflight: Promise<void> | undefined;
|
|
75
75
|
let poll: ReturnType<typeof setInterval> | undefined, beat: ReturnType<typeof setInterval> | undefined;
|
|
76
76
|
const send = (to: ServerResponse, data: string, event?: string) => to.writableEnded || to.write(`${event ? `event: ${event}\n` : ""}data: ${data}\n\n`); // a reading in flight at stop ends after the streams
|
package/src/host.ts
CHANGED
|
@@ -26,6 +26,7 @@ export function createHost({ pi, cfg, s, guiDir, me, tell }: Kit, { bridge, conn
|
|
|
26
26
|
}
|
|
27
27
|
return (s.c ?? s.viewer!).listSessions();
|
|
28
28
|
};
|
|
29
|
+
const unsaved = (gui: Gui) => ((reason) => reason && tell(reason, "warning"))(gui.access.unsaved()); // a paired.json that could not be saved: what was just changed is lost at a restart
|
|
29
30
|
const label = () => `${me() || "unknown"} (pane ${cfg.env.HERDR_PANE_ID})`;
|
|
30
31
|
// This session's server stops, its remote entry first: /boss gui stop here, another session's stop or takeover (`asked`), or the session's end.
|
|
31
32
|
const stopOwn = async (asked?: { by: string; take: boolean }) => {
|
|
@@ -61,7 +62,7 @@ export function createHost({ pi, cfg, s, guiDir, me, tell }: Kit, { bridge, conn
|
|
|
61
62
|
const share = (take = false, manual = false, was = false) =>
|
|
62
63
|
(opening ??= (async () => {
|
|
63
64
|
if (s.gui) return { url: s.gui.url, owner: label(), mine: s.gui };
|
|
64
|
-
const start = (prefer: { port?: number; remotePort?: number; token?: string; csrf?: string }, net = netSource({ run, world, sessions, hub: (s.hub = s.hub?.me === me() ? s.hub : createHub(pi, me(), bridge)) })) => startGui({ read: net.read, net, call, hub: s.hub, dir: bundle, paired: join(guiDir, "paired.json"), ...prefer, onStop: (asked) => void stopOwn(asked), onRemote: async () => tell((await switchRemote(s.gui!, true, guiDir, ts)).text), onRemoteOff: async () => tell((await switchRemote(s.gui!, false, guiDir, ts)).text), spawn: createSpawn({ run, sleep, call, live: async (id) => (await sessions()).some((x) => x.id === id), sessionAt: async (pane) => (await sessions()).find((x) => x.herdrLocation?.paneId === pane)?.id }), close: createCloser({ run, sleep, me, sessions, ask: async (key, keepPane) => s.hub?.close(key, keepPane) }) });
|
|
65
|
+
const start = (prefer: { port?: number; remotePort?: number; token?: string; csrf?: string }, net = netSource({ run, world, sessions, hub: (s.hub = s.hub?.me === me() ? s.hub : createHub(pi, me(), bridge)) })) => startGui({ read: net.read, net, call, hub: s.hub, dir: bundle, paired: join(guiDir, "paired.json"), ...prefer, onStop: (asked) => void stopOwn(asked), onSaveFailed: (reason) => tell(reason, "warning"), onRemote: async () => tell((await switchRemote(s.gui!, true, guiDir, ts)).text), onRemoteOff: async () => tell((await switchRemote(s.gui!, false, guiDir, ts)).text), spawn: createSpawn({ run, sleep, call, live: async (id) => (await sessions()).some((x) => x.id === id), sessionAt: async (pane) => (await sessions()).find((x) => x.herdrLocation?.paneId === pane)?.id }), close: createCloser({ run, sleep, me, sessions, ask: async (key, keepPane) => s.hub?.close(key, keepPane) }) });
|
|
65
66
|
const shared = await sharedGui(guiDir, [label(), me()], start, sleep, { version, take });
|
|
66
67
|
if ((s.gui = shared.mine) && !manual) await resume(s.gui, was);
|
|
67
68
|
return shared;
|
|
@@ -85,10 +86,14 @@ export function createHost({ pi, cfg, s, guiDir, me, tell }: Kit, { bridge, conn
|
|
|
85
86
|
const { url, owner, stale } = await share(mode.startsWith("remote"), mode.startsWith("remote"));
|
|
86
87
|
if (mode.startsWith("remote") && !s.gui) return tell(older(owner));
|
|
87
88
|
if (stale) tell(older(owner), "warning");
|
|
88
|
-
if (mode === "remote off")
|
|
89
|
+
if (mode === "remote off") {
|
|
90
|
+
tell((await switchRemote(s.gui!, false, guiDir, ts)).text);
|
|
91
|
+
return void unsaved(s.gui!);
|
|
92
|
+
}
|
|
89
93
|
if (mode === "remote") {
|
|
90
94
|
const { text, lines, link } = await switchRemote(s.gui!, true, guiDir, ts), block = (active: boolean) => pi.events.emit("herdr:blocked", { active, label: "Showing the remote pairing QR code" });
|
|
91
95
|
tell(text);
|
|
96
|
+
unsaved(s.gui!);
|
|
92
97
|
return void (lines && link && s.ui && (await showLines(s.ui, lines, { block, keys: pairingKeys(link, copy, openUrl) })));
|
|
93
98
|
}
|
|
94
99
|
tell(`Straw net GUI: ${url}`);
|
package/src/remote.ts
CHANGED
|
@@ -57,7 +57,9 @@ async function served(run: Run, host: string) {
|
|
|
57
57
|
const s = parse<Serve>(await run(["serve", "status", "--json"]), "serve status"), held = s.TCP?.[PORT] !== undefined;
|
|
58
58
|
const handlers = Object.entries(s.Web?.[host]?.Handlers ?? {}), target = held && s.TCP?.[PORT]?.HTTPS && handlers.length === 1 && handlers[0]![0] === "/" ? handlers[0]![1].Proxy : undefined;
|
|
59
59
|
const elsewhere = Object.keys(s.Web ?? {}).some((h) => h !== host && h.endsWith(`:${PORT}`)) || Object.values(s.Foreground ?? {}).some((f) => f.TCP?.[PORT] !== undefined);
|
|
60
|
-
|
|
60
|
+
// Any host's "/" handler on 8443, to tell an entry that is gone from one under another host (an identity change).
|
|
61
|
+
const anywhere = Object.entries(s.Web ?? {}).filter(([h]) => h.endsWith(`:${PORT}`)).flatMap(([, w]) => Object.entries(w.Handlers ?? {}).filter(([p]) => p === "/").map(([, x]) => x.Proxy));
|
|
62
|
+
return { target, anywhere, foreign: elsewhere || (held && !target), funnel: Boolean(s.AllowFunnel?.[host]) };
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
/** Points 8443 at `target`, when 8443 is free or ours (`target` itself or one of `stale`); otherwise, or when Funnel is on there, it refuses and touches nothing. */
|
|
@@ -74,8 +76,9 @@ export async function serveOn(run: Run, host: string, target: string, stale: (st
|
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
/** Removes the 8443 entry only while it is still ours (only "/", proxying to one of `targets`). */
|
|
77
|
-
export async function serveOff(run: Run, host: string, targets: (string | undefined)[]) {
|
|
79
|
+
export async function serveOff(run: Run, host: string, targets: (string | undefined)[], elsewhere?: { remains: boolean }) {
|
|
78
80
|
const now = await served(run, host);
|
|
81
|
+
if (elsewhere) elsewhere.remains = now.anywhere.some((t) => t !== undefined && targets.includes(t));
|
|
79
82
|
if (now.foreign || !now.target || !targets.includes(now.target)) return false;
|
|
80
83
|
await run(["serve", `--https=${PORT}`, "off"]);
|
|
81
84
|
return true;
|
|
@@ -96,6 +99,8 @@ export function createAccess(token: string, ended: (device?: string) => void = (
|
|
|
96
99
|
get remembered() { return remote ?? devices.identity; },
|
|
97
100
|
/** Why paired.json could not be written last time, or undefined; the devices are then kept in memory only. */
|
|
98
101
|
get saveError() { return devices.error; },
|
|
102
|
+
/** What a failed save of paired.json means (the change holds until the server stops), or undefined while the file is current. */
|
|
103
|
+
unsaved(): string | undefined { return devices.error && `paired.json could not be saved (${devices.error}); the change holds until the server stops, and a restart loses it`; },
|
|
99
104
|
devices: {
|
|
100
105
|
list: devices.list,
|
|
101
106
|
unpair(id: string) {
|
|
@@ -205,8 +210,8 @@ export async function switchRemote(gui: { port: number; remotePort: number; toke
|
|
|
205
210
|
gui.access.disable(stop);
|
|
206
211
|
const id = was ?? (stale ? await identity(run).catch(() => undefined) : undefined);
|
|
207
212
|
if (!id) return { text: "Remote access is off", changed: false };
|
|
208
|
-
const removed = await serveOff(run, id.host, [was && target, stale, old]);
|
|
209
|
-
rmSync(file, { force: true });
|
|
213
|
+
const left = { remains: false }, removed = await serveOff(run, id.host, [was && target, stale, old], left);
|
|
214
|
+
if (removed || !left.remains) rmSync(file, { force: true }); // an entry of ours under another host (the identity changed) is still to remove: its record stays
|
|
210
215
|
return { text: removed ? `Remote access is off; the Tailscale serve entry on ${PORT} is removed` : `Remote access is off; the ${PORT} entry no longer pointed here, so it was left alone`, changed: true };
|
|
211
216
|
}
|
|
212
217
|
const id = await identity(run);
|