rechrome 1.47.0 → 1.48.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/README.md +4 -0
- package/e2e.js +2 -2
- package/e2e.ts +2 -2
- package/extension-token.js +37 -1
- package/extension-token.ts +37 -1
- package/fill-secret.js +542 -40
- package/fill-secret.ts +293 -33
- package/listeners.js +9 -5
- package/listeners.ts +10 -4
- package/package.json +9 -2
- package/rechrome.js +96 -21
- package/rechrome.ts +96 -21
- package/record.js +281 -0
- package/record.ts +251 -0
- package/rtc-channel.js +303 -0
- package/rtc-channel.ts +130 -0
- package/secret.js +15 -10
- package/secret.ts +2 -70
- package/serve.js +137 -13
- package/serve.ts +137 -13
- package/shares.js +49 -12
- package/shares.ts +39 -6
- package/token.js +88 -0
- package/token.ts +74 -0
- package/webrtc.js +92 -85
- package/webrtc.ts +6 -128
package/serve.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { receiveUpload, readRequestJson } from "./scoped-upload.js";
|
|
2
2
|
import { readListeners, listenerAddress, authorizeProfileRequest, canReadProfileFile, profileOutputPrefix, normalizePrefix, resolveAllowedProfile, shareRtcListeners, rtcListeners, type Listener } from "./listeners.js";
|
|
3
|
-
import { WEBRTC, authorizeCredential, credentialForBearer, readShares, redeemInvite, shareProblem, updateShares, webrtcCredentials, type Credential, type ShareConfig } from "./shares.js";
|
|
3
|
+
import { WEBRTC, authorizeCredential, credentialForBearer, inviteState, readShares, redeemInvite, shareProblem, shareSessions, updateShares, webrtcCredentials, type Credential, type ShareConfig } from "./shares.js";
|
|
4
4
|
import { bearerOf, safeEqual } from "./secret.js";
|
|
5
5
|
import { WebrtcHost } from "./webrtc.js";
|
|
6
6
|
import { file } from "bun";
|
|
7
7
|
import { createHash, randomBytes, X509Certificate } from "crypto";
|
|
8
|
-
import { mkdirSync, lstatSync, statSync, chmodSync, unlinkSync, accessSync, readdirSync, realpathSync, existsSync, readFileSync, constants as fsConstants } from "fs";
|
|
8
|
+
import { mkdirSync, lstatSync, statSync, chmodSync, unlinkSync, accessSync, readdirSync, realpathSync, existsSync, readFileSync, renameSync, constants as fsConstants } from "fs";
|
|
9
9
|
import { join, resolve, relative, isAbsolute, sep } from "path";
|
|
10
10
|
import { tmpdir } from "os";
|
|
11
11
|
import {
|
|
@@ -24,7 +24,8 @@ import {
|
|
|
24
24
|
checkTailscaleServe,
|
|
25
25
|
chromeDataAccessHint,
|
|
26
26
|
} from "./rechrome.js";
|
|
27
|
-
import { SecretMasker, parseReadCookieWire, readCookieOnSession, parseReadStorageWire, readStorageOnSession, parseReadSecretWire, readSecretOnSession, parseFillSecretWire, fillSecretOnSession, passwordValuesOnSession, hasSnapshot, maskPasswordLines, withholdSnapshots } from "./fill-secret.js";
|
|
27
|
+
import { SecretMasker, runCodeOnSession, parseReadCookieWire, readCookieOnSession, parseReadStorageWire, readStorageOnSession, parseReadSecretWire, readSecretOnSession, parseReadHeadersWire, readHeadersOnSession, readHeadersEvidence, parseFillSecretWire, fillSecretOnSession, passwordValuesOnSession, hasSnapshot, maskPasswordLines, withholdSnapshots } from "./fill-secret.js";
|
|
28
|
+
import { parseRecordArgs, buildCaptureChunkCode, resolveFfmpeg, resolveFormat, recordSink, Recording, freeName, RECORD_CHUNK_MS, RECORD_MAX_SECONDS, type CaptureChunk, type RecordingSummary } from "./record.js";
|
|
28
29
|
|
|
29
30
|
const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
|
|
30
31
|
const CERT_RENEW_THRESHOLD_DAYS = 7;
|
|
@@ -105,6 +106,34 @@ const deniedFiles = new Set<string>();
|
|
|
105
106
|
// keeps them on the host instead (opt-in).
|
|
106
107
|
const pendingDownloads = new Map<string, ReturnType<typeof setTimeout>>();
|
|
107
108
|
const DOWNLOAD_PICKUP_MS = 10 * 60_000;
|
|
109
|
+
// Per-request idle limit for POSTs (Bun's default is 10 s): past the client's 70 s plus the longest timed recording.
|
|
110
|
+
export const RUN_IDLE_SECONDS = 70 + RECORD_MAX_SECONDS + 20;
|
|
111
|
+
// rech record start/stop: the recording in progress (or finished by --max-seconds and not yet
|
|
112
|
+
// collected) per namespaced session. serve owns the capture loop; see record.js.
|
|
113
|
+
type RecordingEntry = { rec: Recording; dir: string; outputPrefix: string; listener: string; keep: boolean; done?: RecordingSummary; expire?: ReturnType<typeof setTimeout> };
|
|
114
|
+
const recordings = new Map<string, RecordingEntry>();
|
|
115
|
+
|
|
116
|
+
/** Rename a finished recording to its final name and hand it to the client like a download. */
|
|
117
|
+
function deliverRecording(entry: RecordingEntry, s: RecordingSummary, filename: string | undefined): { stdout: string; files: string[] } {
|
|
118
|
+
// Nothing captured is the real story (no tab, session gone); ffmpeg's "no stream" only follows from it.
|
|
119
|
+
if (!s.frames || !existsSync(s.out)) {
|
|
120
|
+
try { unlinkSync(s.out); } catch {}
|
|
121
|
+
const why = s.error?.split("\n")[0];
|
|
122
|
+
throw new Error(`record: nothing was captured${why ? ` (${why})` : ""}${why?.includes("rech open") ? "" : "; is a tab open? run \`rech open <url>\` first"}`);
|
|
123
|
+
}
|
|
124
|
+
if (s.encodeError) { try { unlinkSync(s.out); } catch {} throw new Error(s.encodeError); }
|
|
125
|
+
// Never over an existing file: `--filename clip.mp4` twice keeps both (clip.mp4, clip-2.mp4).
|
|
126
|
+
const out = filename ? join(entry.dir, freeName(filename, n => existsSync(join(entry.dir, n)))) : s.out.replace(/\/\.(recording-[^/]+)\.part$/, "/$1");
|
|
127
|
+
if (out !== s.out) renameSync(s.out, out);
|
|
128
|
+
log(`record: ${JSON.stringify({ listener: entry.listener, name: relative(entry.dir, out), frames: s.frames, captured: s.captured, fps: s.fps, format: s.format, size: statSync(out).size, kept: entry.keep, ...(s.error ? { ended: s.error.split("\n")[0] } : {}) })}`);
|
|
129
|
+
if (!entry.keep) {
|
|
130
|
+
const real = realpathSync(out);
|
|
131
|
+
clearTimeout(pendingDownloads.get(real));
|
|
132
|
+
pendingDownloads.set(real, setTimeout(() => { pendingDownloads.delete(real); try { unlinkSync(real); } catch {} }, DOWNLOAD_PICKUP_MS));
|
|
133
|
+
}
|
|
134
|
+
const note = s.error ? ` — capture ended early: ${s.error.split("\n")[0]}` : "";
|
|
135
|
+
return { stdout: `record: saved ${relative(entry.dir, out)} (${s.format}, ${s.seconds.toFixed(1)} s at ${s.fps} fps, ${s.captured} screenshots)${note}\n`, files: [(entry.outputPrefix + relative(entry.dir, out)).replaceAll("\\", "/")] };
|
|
136
|
+
}
|
|
108
137
|
// playwright-cli's line for a finished download (tools/backend/response.js).
|
|
109
138
|
const DOWNLOADED_LINE = /^- Downloaded file (.+?) to "(.+)"$/gm;
|
|
110
139
|
// Where the CLI saves downloads: its output dir under the session's cwd (context.js outputDir).
|
|
@@ -701,6 +730,16 @@ export async function serve() {
|
|
|
701
730
|
}).catch(error => log(`could not record device use: ${error}`));
|
|
702
731
|
};
|
|
703
732
|
|
|
733
|
+
const profileInfo = async (profiles: string[] | "*") => {
|
|
734
|
+
const registry = await readTokenRegistry().catch(() => ({} as Awaited<ReturnType<typeof readTokenRegistry>>));
|
|
735
|
+
const cache = await readChromeProfileCache().catch(() => null) ?? {};
|
|
736
|
+
const keys = profiles === "*" ? Object.keys(registry) : profiles;
|
|
737
|
+
return keys.map(k => ({ key: k, name: cache[registry[k]?.profileDir ?? ""]?.name ?? "", registered: !!registry[k] }));
|
|
738
|
+
};
|
|
739
|
+
const liveSessions = () => {
|
|
740
|
+
try { return new Set(readdirSync(tmpSocketRoot()).filter(f => f.endsWith(".sock")).map(f => f.slice(0, -5))); } catch { return new Set<string>(); }
|
|
741
|
+
};
|
|
742
|
+
|
|
704
743
|
const handle = async (req: Request, initialListener: Listener, remoteAddr: () => string): Promise<Response> => {
|
|
705
744
|
let listener = initialListener;
|
|
706
745
|
const reqUrl = new URL(req.url);
|
|
@@ -735,7 +774,8 @@ export async function serve() {
|
|
|
735
774
|
const { name } = await req.json().catch(() => ({})) as { name?: string };
|
|
736
775
|
try {
|
|
737
776
|
const device = await updateShares(config => redeemInvite(config, cred.share.id, String(name ?? "")));
|
|
738
|
-
|
|
777
|
+
// Open the device's room now, not on the next 1 s reload: the client uses it right away.
|
|
778
|
+
await reloadShares().catch(error => log(`shares.json reload rejected; previous shares retained: ${error}`));
|
|
739
779
|
log(`share ${cred.share.label}: invite redeemed by device ${device.id} (${device.name})${device.approved ? "" : ", waiting for approval"}`);
|
|
740
780
|
return Response.json({ token: device.token, device: { id: device.id, name: device.name, approved: device.approved }, share: cred.share.label, profiles: cred.share.profiles });
|
|
741
781
|
} catch (error) { return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 403 }); }
|
|
@@ -744,17 +784,13 @@ export async function serve() {
|
|
|
744
784
|
if (reqUrl.pathname === "/profiles") {
|
|
745
785
|
const denied = authCheck(req, key);
|
|
746
786
|
if (denied) return denied;
|
|
747
|
-
|
|
748
|
-
const cache = await readChromeProfileCache().catch(() => null) ?? {};
|
|
749
|
-
const keys = listener.profiles === "*" ? Object.keys(registry) : listener.profiles;
|
|
750
|
-
return Response.json({ share: cred?.share.label, profiles: keys.map(k => ({ key: k, name: cache[registry[k]?.profileDir ?? ""]?.name ?? "", registered: !!registry[k] })) });
|
|
787
|
+
return Response.json({ share: cred?.share.label, profiles: await profileInfo(listener.profiles) });
|
|
751
788
|
}
|
|
752
789
|
|
|
753
790
|
if (reqUrl.pathname === "/sessions") {
|
|
754
791
|
const denied = authCheck(req, key);
|
|
755
792
|
if (denied) return denied;
|
|
756
|
-
|
|
757
|
-
try { live = new Set(readdirSync(tmpSocketRoot()).filter(f => f.endsWith(".sock")).map(f => f.slice(0, -5))); } catch {}
|
|
793
|
+
const live = liveSessions();
|
|
758
794
|
const all = listener.profiles === "*" && !cred;
|
|
759
795
|
const rows = [...sessionOwners.entries()]
|
|
760
796
|
.filter(([, o]) => all || o.owner === owner)
|
|
@@ -812,7 +848,10 @@ export async function serve() {
|
|
|
812
848
|
if (scoped || !reqUrl.searchParams.get("deep"))
|
|
813
849
|
return Response.json({
|
|
814
850
|
ok: true, bind: listenHost, listener: listener.name, profiles: listener.profiles, multiListener: true, consecutiveTimeouts, degraded, chromeAccessProblem,
|
|
815
|
-
...(cred ? {
|
|
851
|
+
...(cred ? {
|
|
852
|
+
share: cred.share.label, kind: cred.kind, ...(cred.kind === "device" ? { device: { id: cred.device.id, name: cred.device.name, approved: cred.device.approved } } : { invite: inviteState(cred.share) }), expiresAt: cred.share.expiresAt,
|
|
853
|
+
profileInfo: await profileInfo(cred.share.profiles), sessions: shareSessions(cred.share, sessionOwners, liveSessions()),
|
|
854
|
+
} : {}),
|
|
816
855
|
});
|
|
817
856
|
// Deep probe: exercise the relay read-only via `tab-list` (opens nothing) against the
|
|
818
857
|
// most-recently-used session — never a fresh one, which could spawn a browser window.
|
|
@@ -1003,8 +1042,8 @@ export async function serve() {
|
|
|
1003
1042
|
// read-storage: a localStorage/sessionStorage value, page-level; secretOut only.
|
|
1004
1043
|
if (filteredArgs[0] === "read-storage") {
|
|
1005
1044
|
try {
|
|
1006
|
-
const { area, key, path, allowDomains } = parseReadStorageWire(filteredArgs);
|
|
1007
|
-
const { host, value } = await readStorageOnSession({ socketRoot: tmpSocketRoot(), session: namespacedSession, cwd: runWorkDir, area, key, path, allowDomains });
|
|
1045
|
+
const { area, key, path, allowDomains, gotoUrl } = parseReadStorageWire(filteredArgs);
|
|
1046
|
+
const { host, value } = await readStorageOnSession({ socketRoot: tmpSocketRoot(), session: namespacedSession, cwd: runWorkDir, area, key, path, allowDomains, gotoUrl });
|
|
1008
1047
|
if (!value) throw new Error(`read-storage: ${area} ${key} on ${host} is empty`);
|
|
1009
1048
|
secretMasker.add(value, Infinity);
|
|
1010
1049
|
log(`exit: 0 | read-storage ${area} ${key}${path.length ? " " + path.join(".") : ""} on ${host} (${value.length} chars, value not logged)`);
|
|
@@ -1032,6 +1071,87 @@ export async function serve() {
|
|
|
1032
1071
|
}
|
|
1033
1072
|
}
|
|
1034
1073
|
|
|
1074
|
+
// read-headers: response status + headers (no bodies) for an --allow-domain host over a
|
|
1075
|
+
// window, credential header values and URL query values redacted. Written as a JSON evidence file in
|
|
1076
|
+
// the output dir and returned in `files`; header values never reach stdout or a log line.
|
|
1077
|
+
if (filteredArgs[0] === "read-headers") {
|
|
1078
|
+
try {
|
|
1079
|
+
const { allowDomains, seconds, gotoUrl } = parseReadHeadersWire(filteredArgs);
|
|
1080
|
+
const { host, captured } = await readHeadersOnSession({ socketRoot: tmpSocketRoot(), session: namespacedSession, cwd: runWorkDir, allowDomains, seconds, gotoUrl });
|
|
1081
|
+
const name = `read-headers-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
|
|
1082
|
+
await Bun.write(join(runWorkDir, name), readHeadersEvidence(captured, t => secretMasker.mask(t)));
|
|
1083
|
+
log(`exit: 0 | read-headers ${allowDomains.join(",")} (${captured.length} responses, ${seconds}s window, header values redacted)`);
|
|
1084
|
+
return Response.json({ status: 0, stdout: `[rech] read-headers: captured ${captured.length} responses on ${host} to ${name}\n`, stderr: "", files: [(outputPrefix + name).replaceAll("\\", "/")] });
|
|
1085
|
+
} catch (error) {
|
|
1086
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1087
|
+
log(`exit: 1 | read-headers: ${message.split("\n")[0]}`);
|
|
1088
|
+
return Response.json({ status: 1, stdout: "", stderr: `[rech] ${message}\n`, files: [] });
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// record: serve runs the capture loop (short run-code screenshot chunks, back to back) and
|
|
1093
|
+
// streams every frame into ffmpeg as it arrives. `record start` / `record stop` bracket an
|
|
1094
|
+
// open-ended recording; plain `record --seconds N` does both in one call. See record.js.
|
|
1095
|
+
if (filteredArgs[0] === "record") {
|
|
1096
|
+
const fail = (message: string) => {
|
|
1097
|
+
log(`exit: 1 | ${message.split("\n")[0]}`);
|
|
1098
|
+
return Response.json({ status: 1, stdout: "", stderr: `[rech] ${message}\n`, files: [] });
|
|
1099
|
+
};
|
|
1100
|
+
try {
|
|
1101
|
+
const a = parseRecordArgs(filteredArgs);
|
|
1102
|
+
const existing = recordings.get(namespacedSession);
|
|
1103
|
+
if (a.mode === "stop") {
|
|
1104
|
+
if (!existing) return fail("record: no recording in progress; run `rech record start` first");
|
|
1105
|
+
recordings.delete(namespacedSession);
|
|
1106
|
+
clearTimeout(existing.expire);
|
|
1107
|
+
const s = existing.done ?? await existing.rec.stop();
|
|
1108
|
+
return Response.json({ status: 0, stderr: "", ...deliverRecording(existing, s, a.filename) });
|
|
1109
|
+
}
|
|
1110
|
+
if (existing) return fail(existing.done ? "record: a finished recording is waiting; run `rech record stop` to collect it first" : `record: already recording (since ${new Date(existing.rec.startAt).toISOString()}); run \`rech record stop\``);
|
|
1111
|
+
const ffmpegBin = resolveFfmpeg();
|
|
1112
|
+
const format = resolveFormat(a.format, ffmpegBin !== null);
|
|
1113
|
+
const out = join(runWorkDir, `.recording-${Date.now().toString(36)}-${randomBytes(3).toString("hex")}.${format}.part`);
|
|
1114
|
+
const code = buildCaptureChunkCode(a);
|
|
1115
|
+
const capture = async (): Promise<CaptureChunk> => {
|
|
1116
|
+
const { isError, text } = await runCodeOnSession({ socketRoot: tmpSocketRoot(), session: namespacedSession, cwd: runWorkDir, code, json: true, timeoutMs: RECORD_CHUNK_MS + 30_000 });
|
|
1117
|
+
if (isError) throw new Error(text.trim().split("\n")[0] || "record: could not screenshot the tab");
|
|
1118
|
+
// The run-code return (a JSON string) comes back JSON-wrapped by the daemon; unwrap up to twice.
|
|
1119
|
+
let parsed: unknown; try { parsed = JSON.parse(text).result; } catch { parsed = text.match(/### Result\n([^\n]*)/)?.[1]; }
|
|
1120
|
+
for (let i = 0; i < 2 && typeof parsed === "string"; i++) { try { parsed = JSON.parse(parsed); } catch { parsed = undefined; } }
|
|
1121
|
+
const chunk = parsed as CaptureChunk | undefined;
|
|
1122
|
+
if (!Array.isArray(chunk?.frames) || !Array.isArray(chunk?.times)) throw new Error("record: the browser returned no frames");
|
|
1123
|
+
return chunk;
|
|
1124
|
+
};
|
|
1125
|
+
const entry: RecordingEntry = {
|
|
1126
|
+
dir: runWorkDir, outputPrefix, listener: listener.name, keep: listener.keepDownloads === true,
|
|
1127
|
+
rec: new Recording({
|
|
1128
|
+
fps: a.fps, format, out, maxMs: (a.mode === "timed" ? a.seconds : a.maxSeconds) * 1000,
|
|
1129
|
+
capture, sink: recordSink(format, ffmpegBin, a.fps, out),
|
|
1130
|
+
onAutoStop: s => {
|
|
1131
|
+
entry.done = s;
|
|
1132
|
+
// Nobody came for it: drop it like an uncollected download.
|
|
1133
|
+
entry.expire = setTimeout(() => { if (recordings.get(namespacedSession) === entry) recordings.delete(namespacedSession); try { unlinkSync(s.out); } catch {} }, DOWNLOAD_PICKUP_MS);
|
|
1134
|
+
log(`record: ${namespacedSession} stopped by itself after ${s.seconds.toFixed(1)} s${s.error ? ` (${s.error.split("\n")[0]})` : ""}`);
|
|
1135
|
+
},
|
|
1136
|
+
}),
|
|
1137
|
+
};
|
|
1138
|
+
recordings.set(namespacedSession, entry);
|
|
1139
|
+
if (a.mode === "start") {
|
|
1140
|
+
const note = format !== a.format ? ` (no ffmpeg on the host, so ${format})` : "";
|
|
1141
|
+
log(`exit: 0 | record start (${a.fps} fps, ${format}, max ${a.maxSeconds} s)`);
|
|
1142
|
+
return Response.json({ status: 0, stdout: `record: recording at ${a.fps} fps to ${format}${note}; run \`rech record stop\` to save (stops by itself after ${a.maxSeconds} s)\n`, stderr: "", files: [] });
|
|
1143
|
+
}
|
|
1144
|
+
// Answer early if the capture ends on its own (the tab went away).
|
|
1145
|
+
await Promise.race([Bun.sleep(a.seconds * 1000), entry.rec.ended]);
|
|
1146
|
+
recordings.delete(namespacedSession);
|
|
1147
|
+
clearTimeout(entry.expire);
|
|
1148
|
+
const s = await entry.rec.stop();
|
|
1149
|
+
return Response.json({ status: 0, stderr: "", ...deliverRecording(entry, s, a.filename) });
|
|
1150
|
+
} catch (error) {
|
|
1151
|
+
return fail(error instanceof Error ? error.message : String(error));
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1035
1155
|
// For open commands, default to about:blank to avoid leaving connect.html visible
|
|
1036
1156
|
const isOpenCmd = filteredArgs[0] === "open";
|
|
1037
1157
|
const isOpenNoUrl = isOpenCmd && filteredArgs.length === 1;
|
|
@@ -1377,6 +1497,10 @@ export async function serve() {
|
|
|
1377
1497
|
return Response.json({ status: 1, stdout: "", stderr: err.message }, { status: 500 });
|
|
1378
1498
|
},
|
|
1379
1499
|
async fetch(req, server) {
|
|
1500
|
+
// Bun's default idle limit is 10 s. Bun 1.4 does not apply it to a POST with a body, but don't
|
|
1501
|
+
// rely on that: a /run can take longer (`record --seconds 60`, a slow page). Allow it longer
|
|
1502
|
+
// than the client waits (70 s + a recording's length).
|
|
1503
|
+
if (req.method === "POST") server.timeout(req, RUN_IDLE_SECONDS);
|
|
1380
1504
|
const listener = policies.get(listenerAddress(initial));
|
|
1381
1505
|
if (!listener) return new Response("Listener removed", { status: 403 });
|
|
1382
1506
|
return handle(req, listener, () => `${req.headers.get("x-forwarded-for") || server.requestIP(req)?.address || "unknown"}`);
|
package/serve.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { receiveUpload, readRequestJson } from "./scoped-upload.ts";
|
|
2
2
|
import { readListeners, listenerAddress, authorizeProfileRequest, canReadProfileFile, profileOutputPrefix, normalizePrefix, resolveAllowedProfile, shareRtcListeners, rtcListeners, type Listener } from "./listeners.ts";
|
|
3
|
-
import { WEBRTC, authorizeCredential, credentialForBearer, readShares, redeemInvite, shareProblem, updateShares, webrtcCredentials, type Credential, type ShareConfig } from "./shares.ts";
|
|
3
|
+
import { WEBRTC, authorizeCredential, credentialForBearer, inviteState, readShares, redeemInvite, shareProblem, shareSessions, updateShares, webrtcCredentials, type Credential, type ShareConfig } from "./shares.ts";
|
|
4
4
|
import { bearerOf, safeEqual } from "./secret.ts";
|
|
5
5
|
import { WebrtcHost } from "./webrtc.ts";
|
|
6
6
|
import { file } from "bun";
|
|
7
7
|
import { createHash, randomBytes, X509Certificate } from "crypto";
|
|
8
|
-
import { mkdirSync, lstatSync, statSync, chmodSync, unlinkSync, accessSync, readdirSync, realpathSync, existsSync, readFileSync, constants as fsConstants } from "fs";
|
|
8
|
+
import { mkdirSync, lstatSync, statSync, chmodSync, unlinkSync, accessSync, readdirSync, realpathSync, existsSync, readFileSync, renameSync, constants as fsConstants } from "fs";
|
|
9
9
|
import { join, resolve, relative, isAbsolute, sep } from "path";
|
|
10
10
|
import { tmpdir } from "os";
|
|
11
11
|
import {
|
|
@@ -24,7 +24,8 @@ import {
|
|
|
24
24
|
checkTailscaleServe,
|
|
25
25
|
chromeDataAccessHint,
|
|
26
26
|
} from "./rechrome.ts";
|
|
27
|
-
import { SecretMasker, parseReadCookieWire, readCookieOnSession, parseReadStorageWire, readStorageOnSession, parseReadSecretWire, readSecretOnSession, parseFillSecretWire, fillSecretOnSession, passwordValuesOnSession, hasSnapshot, maskPasswordLines, withholdSnapshots } from "./fill-secret.ts";
|
|
27
|
+
import { SecretMasker, runCodeOnSession, parseReadCookieWire, readCookieOnSession, parseReadStorageWire, readStorageOnSession, parseReadSecretWire, readSecretOnSession, parseReadHeadersWire, readHeadersOnSession, readHeadersEvidence, parseFillSecretWire, fillSecretOnSession, passwordValuesOnSession, hasSnapshot, maskPasswordLines, withholdSnapshots } from "./fill-secret.ts";
|
|
28
|
+
import { parseRecordArgs, buildCaptureChunkCode, resolveFfmpeg, resolveFormat, recordSink, Recording, freeName, RECORD_CHUNK_MS, RECORD_MAX_SECONDS, type CaptureChunk, type RecordingSummary } from "./record.ts";
|
|
28
29
|
|
|
29
30
|
const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
|
|
30
31
|
const CERT_RENEW_THRESHOLD_DAYS = 7;
|
|
@@ -105,6 +106,34 @@ const deniedFiles = new Set<string>();
|
|
|
105
106
|
// keeps them on the host instead (opt-in).
|
|
106
107
|
const pendingDownloads = new Map<string, ReturnType<typeof setTimeout>>();
|
|
107
108
|
const DOWNLOAD_PICKUP_MS = 10 * 60_000;
|
|
109
|
+
// Per-request idle limit for POSTs (Bun's default is 10 s): past the client's 70 s plus the longest timed recording.
|
|
110
|
+
export const RUN_IDLE_SECONDS = 70 + RECORD_MAX_SECONDS + 20;
|
|
111
|
+
// rech record start/stop: the recording in progress (or finished by --max-seconds and not yet
|
|
112
|
+
// collected) per namespaced session. serve owns the capture loop; see record.ts.
|
|
113
|
+
type RecordingEntry = { rec: Recording; dir: string; outputPrefix: string; listener: string; keep: boolean; done?: RecordingSummary; expire?: ReturnType<typeof setTimeout> };
|
|
114
|
+
const recordings = new Map<string, RecordingEntry>();
|
|
115
|
+
|
|
116
|
+
/** Rename a finished recording to its final name and hand it to the client like a download. */
|
|
117
|
+
function deliverRecording(entry: RecordingEntry, s: RecordingSummary, filename: string | undefined): { stdout: string; files: string[] } {
|
|
118
|
+
// Nothing captured is the real story (no tab, session gone); ffmpeg's "no stream" only follows from it.
|
|
119
|
+
if (!s.frames || !existsSync(s.out)) {
|
|
120
|
+
try { unlinkSync(s.out); } catch {}
|
|
121
|
+
const why = s.error?.split("\n")[0];
|
|
122
|
+
throw new Error(`record: nothing was captured${why ? ` (${why})` : ""}${why?.includes("rech open") ? "" : "; is a tab open? run \`rech open <url>\` first"}`);
|
|
123
|
+
}
|
|
124
|
+
if (s.encodeError) { try { unlinkSync(s.out); } catch {} throw new Error(s.encodeError); }
|
|
125
|
+
// Never over an existing file: `--filename clip.mp4` twice keeps both (clip.mp4, clip-2.mp4).
|
|
126
|
+
const out = filename ? join(entry.dir, freeName(filename, n => existsSync(join(entry.dir, n)))) : s.out.replace(/\/\.(recording-[^/]+)\.part$/, "/$1");
|
|
127
|
+
if (out !== s.out) renameSync(s.out, out);
|
|
128
|
+
log(`record: ${JSON.stringify({ listener: entry.listener, name: relative(entry.dir, out), frames: s.frames, captured: s.captured, fps: s.fps, format: s.format, size: statSync(out).size, kept: entry.keep, ...(s.error ? { ended: s.error.split("\n")[0] } : {}) })}`);
|
|
129
|
+
if (!entry.keep) {
|
|
130
|
+
const real = realpathSync(out);
|
|
131
|
+
clearTimeout(pendingDownloads.get(real));
|
|
132
|
+
pendingDownloads.set(real, setTimeout(() => { pendingDownloads.delete(real); try { unlinkSync(real); } catch {} }, DOWNLOAD_PICKUP_MS));
|
|
133
|
+
}
|
|
134
|
+
const note = s.error ? ` — capture ended early: ${s.error.split("\n")[0]}` : "";
|
|
135
|
+
return { stdout: `record: saved ${relative(entry.dir, out)} (${s.format}, ${s.seconds.toFixed(1)} s at ${s.fps} fps, ${s.captured} screenshots)${note}\n`, files: [(entry.outputPrefix + relative(entry.dir, out)).replaceAll("\\", "/")] };
|
|
136
|
+
}
|
|
108
137
|
// playwright-cli's line for a finished download (tools/backend/response.ts).
|
|
109
138
|
const DOWNLOADED_LINE = /^- Downloaded file (.+?) to "(.+)"$/gm;
|
|
110
139
|
// Where the CLI saves downloads: its output dir under the session's cwd (context.ts outputDir).
|
|
@@ -701,6 +730,16 @@ export async function serve() {
|
|
|
701
730
|
}).catch(error => log(`could not record device use: ${error}`));
|
|
702
731
|
};
|
|
703
732
|
|
|
733
|
+
const profileInfo = async (profiles: string[] | "*") => {
|
|
734
|
+
const registry = await readTokenRegistry().catch(() => ({} as Awaited<ReturnType<typeof readTokenRegistry>>));
|
|
735
|
+
const cache = await readChromeProfileCache().catch(() => null) ?? {};
|
|
736
|
+
const keys = profiles === "*" ? Object.keys(registry) : profiles;
|
|
737
|
+
return keys.map(k => ({ key: k, name: cache[registry[k]?.profileDir ?? ""]?.name ?? "", registered: !!registry[k] }));
|
|
738
|
+
};
|
|
739
|
+
const liveSessions = () => {
|
|
740
|
+
try { return new Set(readdirSync(tmpSocketRoot()).filter(f => f.endsWith(".sock")).map(f => f.slice(0, -5))); } catch { return new Set<string>(); }
|
|
741
|
+
};
|
|
742
|
+
|
|
704
743
|
const handle = async (req: Request, initialListener: Listener, remoteAddr: () => string): Promise<Response> => {
|
|
705
744
|
let listener = initialListener;
|
|
706
745
|
const reqUrl = new URL(req.url);
|
|
@@ -735,7 +774,8 @@ export async function serve() {
|
|
|
735
774
|
const { name } = await req.json().catch(() => ({})) as { name?: string };
|
|
736
775
|
try {
|
|
737
776
|
const device = await updateShares(config => redeemInvite(config, cred.share.id, String(name ?? "")));
|
|
738
|
-
|
|
777
|
+
// Open the device's room now, not on the next 1 s reload: the client uses it right away.
|
|
778
|
+
await reloadShares().catch(error => log(`shares.json reload rejected; previous shares retained: ${error}`));
|
|
739
779
|
log(`share ${cred.share.label}: invite redeemed by device ${device.id} (${device.name})${device.approved ? "" : ", waiting for approval"}`);
|
|
740
780
|
return Response.json({ token: device.token, device: { id: device.id, name: device.name, approved: device.approved }, share: cred.share.label, profiles: cred.share.profiles });
|
|
741
781
|
} catch (error) { return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 403 }); }
|
|
@@ -744,17 +784,13 @@ export async function serve() {
|
|
|
744
784
|
if (reqUrl.pathname === "/profiles") {
|
|
745
785
|
const denied = authCheck(req, key);
|
|
746
786
|
if (denied) return denied;
|
|
747
|
-
|
|
748
|
-
const cache = await readChromeProfileCache().catch(() => null) ?? {};
|
|
749
|
-
const keys = listener.profiles === "*" ? Object.keys(registry) : listener.profiles;
|
|
750
|
-
return Response.json({ share: cred?.share.label, profiles: keys.map(k => ({ key: k, name: cache[registry[k]?.profileDir ?? ""]?.name ?? "", registered: !!registry[k] })) });
|
|
787
|
+
return Response.json({ share: cred?.share.label, profiles: await profileInfo(listener.profiles) });
|
|
751
788
|
}
|
|
752
789
|
|
|
753
790
|
if (reqUrl.pathname === "/sessions") {
|
|
754
791
|
const denied = authCheck(req, key);
|
|
755
792
|
if (denied) return denied;
|
|
756
|
-
|
|
757
|
-
try { live = new Set(readdirSync(tmpSocketRoot()).filter(f => f.endsWith(".sock")).map(f => f.slice(0, -5))); } catch {}
|
|
793
|
+
const live = liveSessions();
|
|
758
794
|
const all = listener.profiles === "*" && !cred;
|
|
759
795
|
const rows = [...sessionOwners.entries()]
|
|
760
796
|
.filter(([, o]) => all || o.owner === owner)
|
|
@@ -812,7 +848,10 @@ export async function serve() {
|
|
|
812
848
|
if (scoped || !reqUrl.searchParams.get("deep"))
|
|
813
849
|
return Response.json({
|
|
814
850
|
ok: true, bind: listenHost, listener: listener.name, profiles: listener.profiles, multiListener: true, consecutiveTimeouts, degraded, chromeAccessProblem,
|
|
815
|
-
...(cred ? {
|
|
851
|
+
...(cred ? {
|
|
852
|
+
share: cred.share.label, kind: cred.kind, ...(cred.kind === "device" ? { device: { id: cred.device.id, name: cred.device.name, approved: cred.device.approved } } : { invite: inviteState(cred.share) }), expiresAt: cred.share.expiresAt,
|
|
853
|
+
profileInfo: await profileInfo(cred.share.profiles), sessions: shareSessions(cred.share, sessionOwners, liveSessions()),
|
|
854
|
+
} : {}),
|
|
816
855
|
});
|
|
817
856
|
// Deep probe: exercise the relay read-only via `tab-list` (opens nothing) against the
|
|
818
857
|
// most-recently-used session — never a fresh one, which could spawn a browser window.
|
|
@@ -1003,8 +1042,8 @@ export async function serve() {
|
|
|
1003
1042
|
// read-storage: a localStorage/sessionStorage value, page-level; secretOut only.
|
|
1004
1043
|
if (filteredArgs[0] === "read-storage") {
|
|
1005
1044
|
try {
|
|
1006
|
-
const { area, key, path, allowDomains } = parseReadStorageWire(filteredArgs);
|
|
1007
|
-
const { host, value } = await readStorageOnSession({ socketRoot: tmpSocketRoot(), session: namespacedSession, cwd: runWorkDir, area, key, path, allowDomains });
|
|
1045
|
+
const { area, key, path, allowDomains, gotoUrl } = parseReadStorageWire(filteredArgs);
|
|
1046
|
+
const { host, value } = await readStorageOnSession({ socketRoot: tmpSocketRoot(), session: namespacedSession, cwd: runWorkDir, area, key, path, allowDomains, gotoUrl });
|
|
1008
1047
|
if (!value) throw new Error(`read-storage: ${area} ${key} on ${host} is empty`);
|
|
1009
1048
|
secretMasker.add(value, Infinity);
|
|
1010
1049
|
log(`exit: 0 | read-storage ${area} ${key}${path.length ? " " + path.join(".") : ""} on ${host} (${value.length} chars, value not logged)`);
|
|
@@ -1032,6 +1071,87 @@ export async function serve() {
|
|
|
1032
1071
|
}
|
|
1033
1072
|
}
|
|
1034
1073
|
|
|
1074
|
+
// read-headers: response status + headers (no bodies) for an --allow-domain host over a
|
|
1075
|
+
// window, credential header values and URL query values redacted. Written as a JSON evidence file in
|
|
1076
|
+
// the output dir and returned in `files`; header values never reach stdout or a log line.
|
|
1077
|
+
if (filteredArgs[0] === "read-headers") {
|
|
1078
|
+
try {
|
|
1079
|
+
const { allowDomains, seconds, gotoUrl } = parseReadHeadersWire(filteredArgs);
|
|
1080
|
+
const { host, captured } = await readHeadersOnSession({ socketRoot: tmpSocketRoot(), session: namespacedSession, cwd: runWorkDir, allowDomains, seconds, gotoUrl });
|
|
1081
|
+
const name = `read-headers-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
|
|
1082
|
+
await Bun.write(join(runWorkDir, name), readHeadersEvidence(captured, t => secretMasker.mask(t)));
|
|
1083
|
+
log(`exit: 0 | read-headers ${allowDomains.join(",")} (${captured.length} responses, ${seconds}s window, header values redacted)`);
|
|
1084
|
+
return Response.json({ status: 0, stdout: `[rech] read-headers: captured ${captured.length} responses on ${host} to ${name}\n`, stderr: "", files: [(outputPrefix + name).replaceAll("\\", "/")] });
|
|
1085
|
+
} catch (error) {
|
|
1086
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1087
|
+
log(`exit: 1 | read-headers: ${message.split("\n")[0]}`);
|
|
1088
|
+
return Response.json({ status: 1, stdout: "", stderr: `[rech] ${message}\n`, files: [] });
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// record: serve runs the capture loop (short run-code screenshot chunks, back to back) and
|
|
1093
|
+
// streams every frame into ffmpeg as it arrives. `record start` / `record stop` bracket an
|
|
1094
|
+
// open-ended recording; plain `record --seconds N` does both in one call. See record.ts.
|
|
1095
|
+
if (filteredArgs[0] === "record") {
|
|
1096
|
+
const fail = (message: string) => {
|
|
1097
|
+
log(`exit: 1 | ${message.split("\n")[0]}`);
|
|
1098
|
+
return Response.json({ status: 1, stdout: "", stderr: `[rech] ${message}\n`, files: [] });
|
|
1099
|
+
};
|
|
1100
|
+
try {
|
|
1101
|
+
const a = parseRecordArgs(filteredArgs);
|
|
1102
|
+
const existing = recordings.get(namespacedSession);
|
|
1103
|
+
if (a.mode === "stop") {
|
|
1104
|
+
if (!existing) return fail("record: no recording in progress; run `rech record start` first");
|
|
1105
|
+
recordings.delete(namespacedSession);
|
|
1106
|
+
clearTimeout(existing.expire);
|
|
1107
|
+
const s = existing.done ?? await existing.rec.stop();
|
|
1108
|
+
return Response.json({ status: 0, stderr: "", ...deliverRecording(existing, s, a.filename) });
|
|
1109
|
+
}
|
|
1110
|
+
if (existing) return fail(existing.done ? "record: a finished recording is waiting; run `rech record stop` to collect it first" : `record: already recording (since ${new Date(existing.rec.startAt).toISOString()}); run \`rech record stop\``);
|
|
1111
|
+
const ffmpegBin = resolveFfmpeg();
|
|
1112
|
+
const format = resolveFormat(a.format, ffmpegBin !== null);
|
|
1113
|
+
const out = join(runWorkDir, `.recording-${Date.now().toString(36)}-${randomBytes(3).toString("hex")}.${format}.part`);
|
|
1114
|
+
const code = buildCaptureChunkCode(a);
|
|
1115
|
+
const capture = async (): Promise<CaptureChunk> => {
|
|
1116
|
+
const { isError, text } = await runCodeOnSession({ socketRoot: tmpSocketRoot(), session: namespacedSession, cwd: runWorkDir, code, json: true, timeoutMs: RECORD_CHUNK_MS + 30_000 });
|
|
1117
|
+
if (isError) throw new Error(text.trim().split("\n")[0] || "record: could not screenshot the tab");
|
|
1118
|
+
// The run-code return (a JSON string) comes back JSON-wrapped by the daemon; unwrap up to twice.
|
|
1119
|
+
let parsed: unknown; try { parsed = JSON.parse(text).result; } catch { parsed = text.match(/### Result\n([^\n]*)/)?.[1]; }
|
|
1120
|
+
for (let i = 0; i < 2 && typeof parsed === "string"; i++) { try { parsed = JSON.parse(parsed); } catch { parsed = undefined; } }
|
|
1121
|
+
const chunk = parsed as CaptureChunk | undefined;
|
|
1122
|
+
if (!Array.isArray(chunk?.frames) || !Array.isArray(chunk?.times)) throw new Error("record: the browser returned no frames");
|
|
1123
|
+
return chunk;
|
|
1124
|
+
};
|
|
1125
|
+
const entry: RecordingEntry = {
|
|
1126
|
+
dir: runWorkDir, outputPrefix, listener: listener.name, keep: listener.keepDownloads === true,
|
|
1127
|
+
rec: new Recording({
|
|
1128
|
+
fps: a.fps, format, out, maxMs: (a.mode === "timed" ? a.seconds : a.maxSeconds) * 1000,
|
|
1129
|
+
capture, sink: recordSink(format, ffmpegBin, a.fps, out),
|
|
1130
|
+
onAutoStop: s => {
|
|
1131
|
+
entry.done = s;
|
|
1132
|
+
// Nobody came for it: drop it like an uncollected download.
|
|
1133
|
+
entry.expire = setTimeout(() => { if (recordings.get(namespacedSession) === entry) recordings.delete(namespacedSession); try { unlinkSync(s.out); } catch {} }, DOWNLOAD_PICKUP_MS);
|
|
1134
|
+
log(`record: ${namespacedSession} stopped by itself after ${s.seconds.toFixed(1)} s${s.error ? ` (${s.error.split("\n")[0]})` : ""}`);
|
|
1135
|
+
},
|
|
1136
|
+
}),
|
|
1137
|
+
};
|
|
1138
|
+
recordings.set(namespacedSession, entry);
|
|
1139
|
+
if (a.mode === "start") {
|
|
1140
|
+
const note = format !== a.format ? ` (no ffmpeg on the host, so ${format})` : "";
|
|
1141
|
+
log(`exit: 0 | record start (${a.fps} fps, ${format}, max ${a.maxSeconds} s)`);
|
|
1142
|
+
return Response.json({ status: 0, stdout: `record: recording at ${a.fps} fps to ${format}${note}; run \`rech record stop\` to save (stops by itself after ${a.maxSeconds} s)\n`, stderr: "", files: [] });
|
|
1143
|
+
}
|
|
1144
|
+
// Answer early if the capture ends on its own (the tab went away).
|
|
1145
|
+
await Promise.race([Bun.sleep(a.seconds * 1000), entry.rec.ended]);
|
|
1146
|
+
recordings.delete(namespacedSession);
|
|
1147
|
+
clearTimeout(entry.expire);
|
|
1148
|
+
const s = await entry.rec.stop();
|
|
1149
|
+
return Response.json({ status: 0, stderr: "", ...deliverRecording(entry, s, a.filename) });
|
|
1150
|
+
} catch (error) {
|
|
1151
|
+
return fail(error instanceof Error ? error.message : String(error));
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1035
1155
|
// For open commands, default to about:blank to avoid leaving connect.html visible
|
|
1036
1156
|
const isOpenCmd = filteredArgs[0] === "open";
|
|
1037
1157
|
const isOpenNoUrl = isOpenCmd && filteredArgs.length === 1;
|
|
@@ -1377,6 +1497,10 @@ export async function serve() {
|
|
|
1377
1497
|
return Response.json({ status: 1, stdout: "", stderr: err.message }, { status: 500 });
|
|
1378
1498
|
},
|
|
1379
1499
|
async fetch(req, server) {
|
|
1500
|
+
// Bun's default idle limit is 10 s. Bun 1.4 does not apply it to a POST with a body, but don't
|
|
1501
|
+
// rely on that: a /run can take longer (`record --seconds 60`, a slow page). Allow it longer
|
|
1502
|
+
// than the client waits (70 s + a recording's length).
|
|
1503
|
+
if (req.method === "POST") server.timeout(req, RUN_IDLE_SECONDS);
|
|
1380
1504
|
const listener = policies.get(listenerAddress(initial));
|
|
1381
1505
|
if (!listener) return new Response("Listener removed", { status: 403 });
|
|
1382
1506
|
return handle(req, listener, () => `${req.headers.get("x-forwarded-for") || server.requestIP(req)?.address || "unknown"}`);
|
package/shares.js
CHANGED
|
@@ -7,6 +7,8 @@ import { mkdir, readFile, rename, rm, stat, writeFile } from "fs/promises";
|
|
|
7
7
|
|
|
8
8
|
// secret.ts
|
|
9
9
|
import { hkdfSync, randomBytes, timingSafeEqual } from "crypto";
|
|
10
|
+
|
|
11
|
+
// token.ts
|
|
10
12
|
var TOKEN_PREFIX = "rech1_";
|
|
11
13
|
var ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
12
14
|
var B58 = "[1-9A-HJ-NP-Za-km-z]";
|
|
@@ -65,9 +67,6 @@ function formatToken(secret) {
|
|
|
65
67
|
throw new Error(`a share secret is ${SECRET_BYTES} bytes`);
|
|
66
68
|
return `${TOKEN_PREFIX}${base58(secret, BODY_LEN)}${base58(u32(crc32(secret)), SUM_LEN)}`;
|
|
67
69
|
}
|
|
68
|
-
function newToken() {
|
|
69
|
-
return formatToken(new Uint8Array(randomBytes(SECRET_BYTES)));
|
|
70
|
-
}
|
|
71
70
|
function parseToken(token) {
|
|
72
71
|
const m = EXACT.exec(token.trim());
|
|
73
72
|
if (!m)
|
|
@@ -78,15 +77,20 @@ function parseToken(token) {
|
|
|
78
77
|
const expected = u32(crc32(secret));
|
|
79
78
|
return sum.every((b, i) => b === expected[i]) ? { ok: true, secret } : { ok: false, reason: "checksum" };
|
|
80
79
|
}
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
|
|
81
|
+
// secret.ts
|
|
82
|
+
function newToken() {
|
|
83
|
+
return formatToken(new Uint8Array(randomBytes(SECRET_BYTES)));
|
|
84
|
+
}
|
|
85
|
+
function secretOf(token2) {
|
|
86
|
+
const parsed = parseToken(token2);
|
|
83
87
|
if (!parsed.ok)
|
|
84
88
|
throw new Error(parsed.reason === "checksum" ? "This rech1_ link is damaged (checksum mismatch): it was copied incompletely or mistyped." : "Not a rech1_ link.");
|
|
85
89
|
return parsed.secret;
|
|
86
90
|
}
|
|
87
|
-
var derive = (
|
|
88
|
-
var bearerOf = (
|
|
89
|
-
var credentialIdOf = (
|
|
91
|
+
var derive = (token2, label, bytes) => new Uint8Array(hkdfSync("sha256", secretOf(token2), new Uint8Array(0), `rech/${label}`, bytes));
|
|
92
|
+
var bearerOf = (token2) => `rb_${base58(derive(token2, "bearer", 24), 33)}`;
|
|
93
|
+
var credentialIdOf = (token2) => base58(derive(token2, "id", 6), 9);
|
|
90
94
|
function safeEqual(a, b) {
|
|
91
95
|
const x = Buffer.from(a), y = Buffer.from(b);
|
|
92
96
|
return x.length === y.length && timingSafeEqual(x, y);
|
|
@@ -180,6 +184,8 @@ function findShare(config, name) {
|
|
|
180
184
|
throw new Error(`No share named "${name}". See: rech share ls`);
|
|
181
185
|
return share;
|
|
182
186
|
}
|
|
187
|
+
var REUSABLE_MAX_DEVICES = 100;
|
|
188
|
+
var REUSABLE_DEFAULT_TTL = "7d";
|
|
183
189
|
function createShare(config, opts) {
|
|
184
190
|
const now = opts.now ?? Date.now();
|
|
185
191
|
if (!LABEL.test(opts.label))
|
|
@@ -201,10 +207,14 @@ function createShare(config, opts) {
|
|
|
201
207
|
signal: opts.via.includes(WEBRTC) ? normalizeSignal(opts.signal ?? DEFAULT_SIGNAL) : undefined,
|
|
202
208
|
createdAt: now,
|
|
203
209
|
expiresAt: opts.expiresAt,
|
|
204
|
-
maxDevices: opts.maxDevices ?? 1,
|
|
210
|
+
maxDevices: opts.maxDevices ?? (opts.reusable ? REUSABLE_MAX_DEVICES : 1),
|
|
205
211
|
approve: !!opts.approve,
|
|
206
212
|
devices: []
|
|
207
213
|
};
|
|
214
|
+
if (opts.reusable) {
|
|
215
|
+
share.reusable = true;
|
|
216
|
+
share.redeemed = 0;
|
|
217
|
+
}
|
|
208
218
|
config.shares.push(share);
|
|
209
219
|
return share;
|
|
210
220
|
}
|
|
@@ -256,11 +266,33 @@ function shareProblem(share, now = Date.now()) {
|
|
|
256
266
|
}
|
|
257
267
|
function inviteProblem(share, now = Date.now()) {
|
|
258
268
|
if (share.inviteExpiresAt && now >= share.inviteExpiresAt)
|
|
259
|
-
return `This invite expired unused. Ask the host for a fresh one: rech share rotate ${share.label}`;
|
|
269
|
+
return `This invite expired${share.reusable ? "" : " unused"}. Ask the host for a fresh one: rech share rotate ${share.label}`;
|
|
270
|
+
if (share.reusable) {
|
|
271
|
+
if (share.devices.length >= share.maxDevices)
|
|
272
|
+
return `This reusable invite reached its limit of ${share.maxDevices} devices. Ask the host to remove unused devices (rech share devices ${share.label} --rm <id>) or to rotate it.`;
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
260
275
|
if (share.devices.length >= share.maxDevices)
|
|
261
276
|
return `This invite was already used${share.maxDevices > 1 ? ` by ${share.maxDevices} devices` : ""}. Ask the host for a fresh one (rech share rotate ${share.label}); if you didn't use it, tell them, since someone else did.`;
|
|
262
277
|
return null;
|
|
263
278
|
}
|
|
279
|
+
function inviteState(share, now = Date.now()) {
|
|
280
|
+
const used = share.devices.length;
|
|
281
|
+
const state = used >= share.maxDevices ? "used" : share.inviteExpiresAt && now >= share.inviteExpiresAt ? "expired" : "open";
|
|
282
|
+
return { state, maxDevices: share.maxDevices, used, ...share.inviteExpiresAt ? { expiresAt: share.inviteExpiresAt } : {} };
|
|
283
|
+
}
|
|
284
|
+
function shareSessions(share, owners, live) {
|
|
285
|
+
const prefix = `share:${share.id}:`;
|
|
286
|
+
const out = [];
|
|
287
|
+
for (const [session, o] of owners) {
|
|
288
|
+
if (!o.owner.startsWith(prefix) || !live.has(session))
|
|
289
|
+
continue;
|
|
290
|
+
const device = share.devices.find((d) => d.id === o.owner.slice(prefix.length));
|
|
291
|
+
if (device)
|
|
292
|
+
out.push({ name: o.name, device: device.name });
|
|
293
|
+
}
|
|
294
|
+
return out;
|
|
295
|
+
}
|
|
264
296
|
function authorizeCredential(cred, via, route, now = Date.now()) {
|
|
265
297
|
const problem = shareProblem(cred.share, now);
|
|
266
298
|
if (problem)
|
|
@@ -297,6 +329,8 @@ function redeemInvite(config, shareId, name, now = Date.now()) {
|
|
|
297
329
|
approved: !share.approve
|
|
298
330
|
};
|
|
299
331
|
share.devices.push(device);
|
|
332
|
+
if (share.reusable)
|
|
333
|
+
share.redeemed = (share.redeemed ?? 0) + 1;
|
|
300
334
|
return device;
|
|
301
335
|
}
|
|
302
336
|
function findDevice(share, id) {
|
|
@@ -321,8 +355,7 @@ function webrtcCredentials(config, now = Date.now(), rtcNames = [DEFAULT_RTC]) {
|
|
|
321
355
|
for (const share of config.shares) {
|
|
322
356
|
if (!share.via.some((v) => names.has(v)) || shareProblem(share, now))
|
|
323
357
|
continue;
|
|
324
|
-
|
|
325
|
-
out.push({ share, kind: "invite", token: share.invite });
|
|
358
|
+
out.push({ share, kind: "invite", token: share.invite });
|
|
326
359
|
for (const device of share.devices)
|
|
327
360
|
out.push({ share, kind: "device", token: device.token, device });
|
|
328
361
|
}
|
|
@@ -331,6 +364,8 @@ function webrtcCredentials(config, now = Date.now(), rtcNames = [DEFAULT_RTC]) {
|
|
|
331
364
|
export {
|
|
332
365
|
DEFAULT_RTC,
|
|
333
366
|
DEFAULT_SIGNAL,
|
|
367
|
+
REUSABLE_DEFAULT_TTL,
|
|
368
|
+
REUSABLE_MAX_DEVICES,
|
|
334
369
|
SHARES_FILE,
|
|
335
370
|
WEBRTC,
|
|
336
371
|
authorizeCredential,
|
|
@@ -340,11 +375,13 @@ export {
|
|
|
340
375
|
findShare,
|
|
341
376
|
inviteLink,
|
|
342
377
|
inviteProblem,
|
|
378
|
+
inviteState,
|
|
343
379
|
normalizeSignal,
|
|
344
380
|
parseDuration,
|
|
345
381
|
readShares,
|
|
346
382
|
redeemInvite,
|
|
347
383
|
shareProblem,
|
|
384
|
+
shareSessions,
|
|
348
385
|
updateShares,
|
|
349
386
|
validateShares,
|
|
350
387
|
webrtcCredentials,
|