rechrome 1.39.1 → 1.40.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 +20 -4
- package/fill-secret.js +5 -1
- package/fill-secret.ts +6 -1
- package/listeners.js +9 -7
- package/listeners.ts +5 -3
- package/package.json +1 -1
- package/rechrome.js +131 -28
- package/rechrome.ts +131 -28
- package/scoped-upload.js +82 -11
- package/scoped-upload.ts +75 -15
- package/serve.js +1 -1
- package/serve.ts +1 -1
- package/webrtc.js +11 -2
- package/webrtc.ts +15 -3
package/README.md
CHANGED
|
@@ -386,14 +386,30 @@ and the host check the size, and check the bytes against the type (PNG/JPEG/GIF/
|
|
|
386
386
|
signatures; UTF-8 SVG without DTD/entities). These checks identify the format; they
|
|
387
387
|
don't sanitize SVG or scan for malware. The host never renders the file.
|
|
388
388
|
|
|
389
|
-
**
|
|
390
|
-
|
|
389
|
+
**On by default:** uploads are on for every listener, shared ones too. The client
|
|
390
|
+
reads the file and sends only its bytes, and the host never reads a path for an
|
|
391
|
+
upload, so a link holder can only upload files they already have. Each upload is
|
|
392
|
+
logged in the daemon log with its name, size, sha256 and the page's origin. The
|
|
393
|
+
host can turn them off for a shared listener:
|
|
391
394
|
|
|
392
395
|
```bash
|
|
393
|
-
rech listener set <listener> --uploads
|
|
396
|
+
rech listener set <listener> --no-uploads # writes "uploads": false; --uploads turns them on again
|
|
394
397
|
```
|
|
395
398
|
|
|
396
|
-
|
|
399
|
+
**Files already on the host (opt-in):** off by default. The host can let a shared
|
|
400
|
+
listener's links upload files that are already on the host, but only from
|
|
401
|
+
directories it names:
|
|
402
|
+
|
|
403
|
+
```bash
|
|
404
|
+
rech listener set <listener> --host-upload-dir ~/shots # repeatable; --no-host-uploads turns it off
|
|
405
|
+
rech upload --host /Users/me/shots/a.png # on the client: sends the path, not bytes
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
The host resolves symlinks and refuses anything outside those directories, anything
|
|
409
|
+
that isn't a regular file, and anything over the size cap. The log line also records
|
|
410
|
+
the host path.
|
|
411
|
+
|
|
412
|
+
An `uploads` object in the listener's entry in `~/.rechrome/listeners.json` sets limits.
|
|
397
413
|
The host can tune it by hand, lowering the byte limit or narrowing the types, but not
|
|
398
414
|
going above the 10 MiB cap or adding other types:
|
|
399
415
|
|
package/fill-secret.js
CHANGED
|
@@ -7,6 +7,8 @@ import { homedir } from "os";
|
|
|
7
7
|
import { join } from "path";
|
|
8
8
|
var SECRET_MASK = "***";
|
|
9
9
|
var SECRET_MASK_TTL_MS = 15 * 60000;
|
|
10
|
+
var MIN_MASK_LENGTH = 6;
|
|
11
|
+
var maskable = (value) => !!value && value.length >= MIN_MASK_LENGTH;
|
|
10
12
|
var FILL_SECRET_USAGE = `Usage: rech fill-secret <ref> (--from-env VAR | --from-stdin | --totp-from-env VAR | --totp-from-stdin)
|
|
11
13
|
[--env-file <path>] [--allow-domain <glob>]... [--submit]
|
|
12
14
|
Fills <ref> (from \`rech snapshot\`) with a secret the daemon never logs or echoes.
|
|
@@ -211,7 +213,7 @@ class SecretMasker {
|
|
|
211
213
|
this.now = now;
|
|
212
214
|
}
|
|
213
215
|
add(value, ttlMs = this.ttlMs) {
|
|
214
|
-
if (value)
|
|
216
|
+
if (maskable(value))
|
|
215
217
|
this.values.set(value, this.now() + ttlMs);
|
|
216
218
|
}
|
|
217
219
|
get active() {
|
|
@@ -378,6 +380,7 @@ function maskPasswordLines(text) {
|
|
|
378
380
|
}
|
|
379
381
|
export {
|
|
380
382
|
FILL_SECRET_USAGE,
|
|
383
|
+
MIN_MASK_LENGTH,
|
|
381
384
|
SECRET_MASK,
|
|
382
385
|
SECRET_MASK_TTL_MS,
|
|
383
386
|
SecretMasker,
|
|
@@ -390,6 +393,7 @@ export {
|
|
|
390
393
|
hasSnapshot,
|
|
391
394
|
hostAllowed,
|
|
392
395
|
maskPasswordLines,
|
|
396
|
+
maskable,
|
|
393
397
|
parseDotenv,
|
|
394
398
|
parseFillSecretArgs,
|
|
395
399
|
parseFillSecretWire,
|
package/fill-secret.ts
CHANGED
|
@@ -19,6 +19,11 @@ import { join } from "path";
|
|
|
19
19
|
|
|
20
20
|
export const SECRET_MASK = "***";
|
|
21
21
|
export const SECRET_MASK_TTL_MS = 15 * 60_000;
|
|
22
|
+
/** Shorter values are never masked: masking "1" everywhere would turn every 1 in every output
|
|
23
|
+
* into ***, corrupting ids and refs for the daemon's lifetime. A TOTP code (6 digits) still is.
|
|
24
|
+
* The fill-secret command's own output is still scrubbed of its value, whatever the length. */
|
|
25
|
+
export const MIN_MASK_LENGTH = 6;
|
|
26
|
+
export const maskable = (value: string | undefined | null): value is string => !!value && value.length >= MIN_MASK_LENGTH;
|
|
22
27
|
|
|
23
28
|
// ---------- client side ----------
|
|
24
29
|
|
|
@@ -233,7 +238,7 @@ export function buildFillCode(ref: string, value: string, allowDomains: string[]
|
|
|
233
238
|
export class SecretMasker {
|
|
234
239
|
private values = new Map<string, number>();
|
|
235
240
|
constructor(private ttlMs = SECRET_MASK_TTL_MS, private now = () => Date.now()) {}
|
|
236
|
-
add(value: string, ttlMs = this.ttlMs) { if (value) this.values.set(value, this.now() + ttlMs); }
|
|
241
|
+
add(value: string, ttlMs = this.ttlMs) { if (maskable(value)) this.values.set(value, this.now() + ttlMs); }
|
|
237
242
|
get active(): boolean { this.prune(); return this.values.size > 0; }
|
|
238
243
|
mask(text: string): string {
|
|
239
244
|
if (!text) return text;
|
package/listeners.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// scoped-upload.ts
|
|
3
|
+
import { basename, join, isAbsolute, sep } from "path";
|
|
3
4
|
var MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
|
4
5
|
var MAX_REQUEST_BYTES = 14 * 1024 * 1024;
|
|
5
6
|
var UPLOAD_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml"];
|
|
6
7
|
function validateUploadPolicy(value) {
|
|
7
8
|
const p = value;
|
|
8
|
-
if (!p || typeof p !== "object" || Array.isArray(p) || Object.keys(p).some((k) => !["maxBytes", "contentTypes"].includes(k)) || p.maxBytes !== undefined && (!Number.isSafeInteger(p.maxBytes) || p.maxBytes < 1 || p.maxBytes > MAX_UPLOAD_BYTES) || p.contentTypes !== undefined && (!Array.isArray(p.contentTypes) || !p.contentTypes.length || p.contentTypes.some((t) => !UPLOAD_TYPES.includes(t))))
|
|
9
|
-
throw new Error(`uploads must be an object with optional maxBytes (1..${MAX_UPLOAD_BYTES})
|
|
9
|
+
if (!p || typeof p !== "object" || Array.isArray(p) || Object.keys(p).some((k) => !["maxBytes", "contentTypes", "hostDirs"].includes(k)) || p.maxBytes !== undefined && (!Number.isSafeInteger(p.maxBytes) || p.maxBytes < 1 || p.maxBytes > MAX_UPLOAD_BYTES) || p.contentTypes !== undefined && (!Array.isArray(p.contentTypes) || !p.contentTypes.length || p.contentTypes.some((t) => !UPLOAD_TYPES.includes(t))) || p.hostDirs !== undefined && (!Array.isArray(p.hostDirs) || !p.hostDirs.length || p.hostDirs.some((d) => typeof d !== "string" || !isAbsolute(d) || d.includes("\x00") || d === sep)))
|
|
10
|
+
throw new Error(`uploads must be an object with optional maxBytes (1..${MAX_UPLOAD_BYTES}), contentTypes (${UPLOAD_TYPES.join(", ")}) and hostDirs (absolute directories, not /)`);
|
|
10
11
|
}
|
|
11
12
|
var UPLOAD_USAGE = `Usage: rech upload <file> [how] (the file is read on this machine; only its bytes are sent)
|
|
12
13
|
(no flag) the file chooser a click opened, else the page's only <input type=file>
|
|
@@ -14,16 +15,17 @@ var UPLOAD_USAGE = `Usage: rech upload <file> [how] (the file is read on this
|
|
|
14
15
|
--drop <ref|selector> a drag-and-drop zone
|
|
15
16
|
--paste [ref|selector] an editor that takes pasted images (default: the focused element)
|
|
16
17
|
--name <file> read the bytes from stdin instead: rech upload --name shot.png < shot.png
|
|
18
|
+
--host <path> a file already on the host (only where its listener allows that directory)
|
|
17
19
|
Types: ${UPLOAD_TYPES.join(", ")}, up to ${MAX_UPLOAD_BYTES / 1024 / 1024} MiB. Get refs from rech snapshot.`;
|
|
18
20
|
|
|
19
21
|
// listeners.ts
|
|
20
22
|
import { isIP } from "net";
|
|
21
23
|
import { createHash, randomBytes } from "crypto";
|
|
22
24
|
import { homedir } from "os";
|
|
23
|
-
import { join } from "path";
|
|
25
|
+
import { join as join2 } from "path";
|
|
24
26
|
import { mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
25
27
|
var MAX_RETIRED_PORTS = 32;
|
|
26
|
-
var LISTENERS_FILE =
|
|
28
|
+
var LISTENERS_FILE = join2(homedir(), ".rechrome", "listeners.json");
|
|
27
29
|
var isLoopback = (host) => host === "::1" || /^127\./.test(host);
|
|
28
30
|
var listenerAddress = (l) => `${l.host.includes(":") ? `[${l.host}]` : l.host}:${l.port}`;
|
|
29
31
|
var profileOutputPrefix = (profile) => `profiles/${createHash("sha256").update(profile).digest("hex")}/`;
|
|
@@ -72,7 +74,7 @@ function validateListeners(value) {
|
|
|
72
74
|
} else if (!Array.isArray(l.profiles) || !l.profiles.length || l.profiles.some((p) => typeof p !== "string" || !p.trim() || p.includes("\x00") || p === "*")) {
|
|
73
75
|
throw new Error("Listener profiles must be a nonempty list of registered profile keys");
|
|
74
76
|
}
|
|
75
|
-
if (l.uploads !== undefined)
|
|
77
|
+
if (l.uploads !== undefined && l.uploads !== false)
|
|
76
78
|
validateUploadPolicy(l.uploads);
|
|
77
79
|
const address = listenerAddress(l);
|
|
78
80
|
if (names.has(l.name) || addresses.has(address) || keys.has(l.key))
|
|
@@ -110,7 +112,7 @@ async function writeListeners(config) {
|
|
|
110
112
|
config.retiredPorts = retired;
|
|
111
113
|
else
|
|
112
114
|
delete config.retiredPorts;
|
|
113
|
-
await mkdir(
|
|
115
|
+
await mkdir(join2(homedir(), ".rechrome"), { recursive: true });
|
|
114
116
|
const temp = `${LISTENERS_FILE}.${randomBytes(6).toString("hex")}.tmp`;
|
|
115
117
|
await writeFile(temp, JSON.stringify(config, null, 2) + `
|
|
116
118
|
`, { mode: 384 });
|
|
@@ -126,7 +128,7 @@ function authorizeProfileRequest(listener, body) {
|
|
|
126
128
|
if (body.env?.PLAYWRIGHT_MCP_PROFILE_DIRECTORY && body.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY !== profile)
|
|
127
129
|
throw new Error("Profile override does not match request identity");
|
|
128
130
|
const args = body.args.filter((a) => /^-s=[a-zA-Z0-9_-]{1,64}$/.test(a) === false);
|
|
129
|
-
if (args[0] === "upload" && listener.uploads ===
|
|
131
|
+
if (args[0] === "upload" && listener.uploads === false)
|
|
130
132
|
throw new Error(`Uploads are off on listener "${listener.name}". The host can turn them on: rech listener set ${listener.name} --uploads`);
|
|
131
133
|
if (args[0] !== "upload" && !SCOPED_COMMANDS.has(args[0]))
|
|
132
134
|
throw new Error("Command is unavailable on profile-scoped listeners; use the local management listener for arbitrary code or host access");
|
package/listeners.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { join } from "path";
|
|
|
6
6
|
import { mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
7
7
|
|
|
8
8
|
// publicUrl: where a reverse proxy (Tailscale Serve, Caddy, nginx, ...) exposes this listener.
|
|
9
|
-
export type Listener = { name: string; host: string; port: number; key: string; profiles: string[] | "*"; prefix?: string; publicUrl?: string; uploads?: UploadPolicy };
|
|
9
|
+
export type Listener = { name: string; host: string; port: number; key: string; profiles: string[] | "*"; prefix?: string; publicUrl?: string; uploads?: UploadPolicy | false };
|
|
10
10
|
// retiredPorts: ports of removed listeners, newest last. New listeners skip them so a link
|
|
11
11
|
// saved for a removed listener gets "connection refused", not some other listener's answer.
|
|
12
12
|
export type ListenerConfig = { version: 1; listeners: Listener[]; retiredPorts?: number[] };
|
|
@@ -58,7 +58,9 @@ export function validateListeners(value: unknown): ListenerConfig {
|
|
|
58
58
|
} else if (!Array.isArray(l.profiles) || !l.profiles.length || l.profiles.some(p => typeof p !== "string" || !p.trim() || p.includes("\0") || p === "*")) {
|
|
59
59
|
throw new Error("Listener profiles must be a nonempty list of registered profile keys");
|
|
60
60
|
}
|
|
61
|
-
|
|
61
|
+
// Uploads are on unless set to false: the client sends file bytes it already has, and the
|
|
62
|
+
// host never reads a path for them.
|
|
63
|
+
if (l.uploads !== undefined && l.uploads !== false) validateUploadPolicy(l.uploads);
|
|
62
64
|
const address = listenerAddress(l);
|
|
63
65
|
if (names.has(l.name) || addresses.has(address) || keys.has(l.key)) throw new Error("Listener names, addresses, and credentials must be unique");
|
|
64
66
|
names.add(l.name); addresses.add(address); keys.add(l.key);
|
|
@@ -103,7 +105,7 @@ export function authorizeProfileRequest(listener: Listener, body: any): string {
|
|
|
103
105
|
if (typeof profile !== "string" || listener.profiles === "*" || !listener.profiles.includes(profile)) throw new Error("Profile is not allowed on this listener");
|
|
104
106
|
if (body.env?.PLAYWRIGHT_MCP_PROFILE_DIRECTORY && body.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY !== profile) throw new Error("Profile override does not match request identity");
|
|
105
107
|
const args = body.args.filter((a: string) => /^-s=[a-zA-Z0-9_-]{1,64}$/.test(a) === false);
|
|
106
|
-
if (args[0] === "upload" && listener.uploads ===
|
|
108
|
+
if (args[0] === "upload" && listener.uploads === false)
|
|
107
109
|
throw new Error(`Uploads are off on listener "${listener.name}". The host can turn them on: rech listener set ${listener.name} --uploads`);
|
|
108
110
|
if (args[0] !== "upload" && !SCOPED_COMMANDS.has(args[0])) throw new Error("Command is unavailable on profile-scoped listeners; use the local management listener for arbitrary code or host access");
|
|
109
111
|
if (body.args.filter((a: string) => a.startsWith("-s=")).length > 1 || args.slice(1).some((a: string) => a.startsWith("-") && !(args[0] === "screenshot" && a === "--full-page") && !(args[0] === "console" && a === "--clear") && !(args[0] === "fill-secret" && (a === "--submit" || a === "--totp" || a.startsWith("--allow-domain="))))) throw new Error("CLI option overrides are unavailable on profile-scoped listeners");
|
package/package.json
CHANGED
package/rechrome.js
CHANGED
|
@@ -7,7 +7,7 @@ import yargs from "yargs";
|
|
|
7
7
|
import { readExtensionTokenFromProfile } from "./extension-token.js";
|
|
8
8
|
import { FILL_SECRET_USAGE, SECRET_MASK, parseFillSecretArgs, fillSecretWireArgs, readSecretSource, totpCode, totpWaitMs } from "./fill-secret.js";
|
|
9
9
|
import { createHash, randomBytes } from "crypto";
|
|
10
|
-
import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, unlinkSync, readFileSync, readdirSync, renameSync, rmdirSync, constants as fsConstants } from "fs";
|
|
10
|
+
import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, unlinkSync, readFileSync, readdirSync, renameSync, readlinkSync, rmdirSync, constants as fsConstants } from "fs";
|
|
11
11
|
import { hostname, homedir, networkInterfaces } from "os";
|
|
12
12
|
import { isIPv4 } from "net";
|
|
13
13
|
import { join, basename, dirname, resolve } from "path";
|
|
@@ -16,7 +16,7 @@ import { createRequire } from "node:module";
|
|
|
16
16
|
import { spawn as cpSpawn } from "child_process";
|
|
17
17
|
import { readFile, writeFile, rename, chmod, mkdir, stat } from "node:fs/promises";
|
|
18
18
|
import { isDeprecatedPm2Fallback, listsProcess, oxmgrInstallCommand, pickDaemonManager, PM2_DEPRECATION, type DaemonManager } from "./daemon-manager.js";
|
|
19
|
-
import { DEFAULT_SIGNAL, WEBRTC, createShare, findDevice, findShare, inviteLink, inviteProblem, parseDuration, readShares, updateShares, type Share } from "./shares.js";
|
|
19
|
+
import { DEFAULT_SIGNAL, WEBRTC, createShare, normalizeSignal, findDevice, findShare, inviteLink, inviteProblem, parseDuration, readShares, updateShares, type Share } from "./shares.js";
|
|
20
20
|
import { bearerOf, looksLikeToken, newToken, parseToken, redactSecrets } from "./secret.js";
|
|
21
21
|
import { WebrtcClient, loadRTC, runBridge, spawnBridge } from "./webrtc.js";
|
|
22
22
|
import { startSignalRelay } from "./signal-relay.js";
|
|
@@ -2240,9 +2240,8 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
|
|
|
2240
2240
|
// Saved files belong to the project: <project>/.rechrome/output. The folder also holds
|
|
2241
2241
|
// .env.local (secrets), so it is git-ignored as a whole when first created.
|
|
2242
2242
|
const dlDir = join(identity.dataDir, "output");
|
|
2243
|
+
await ensureProjectDir(identity.dataDir);
|
|
2243
2244
|
mkdirSync(dlDir, { recursive: true });
|
|
2244
|
-
const gitignorePath = join(identity.dataDir, ".gitignore");
|
|
2245
|
-
if (!existsSync(gitignorePath)) await Bun.write(gitignorePath, "*\n");
|
|
2246
2245
|
for (const name of files) {
|
|
2247
2246
|
const fileRes = await fetch(serviceUrl(url, `files/${name}`), {
|
|
2248
2247
|
headers: { Authorization: `Bearer ${parseUrl(url).key}` },
|
|
@@ -3339,7 +3338,7 @@ async function rotateKey(name: string): Promise<void> {
|
|
|
3339
3338
|
console.log(`New key for ${name}; URLs carrying the old key stop working now. Print new ones with: rech share <profile> --listener ${name}`);
|
|
3340
3339
|
}
|
|
3341
3340
|
|
|
3342
|
-
async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean }): Promise<void> {
|
|
3341
|
+
async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean }): Promise<void> {
|
|
3343
3342
|
const config = await requireListeners();
|
|
3344
3343
|
let listener = config.listeners.find(l => l.name === name);
|
|
3345
3344
|
if (!listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
|
|
@@ -3347,11 +3346,23 @@ async function setListener(name: string, opts: { publicUrl?: string; clearPublic
|
|
|
3347
3346
|
if (opts.uploads !== undefined) {
|
|
3348
3347
|
if (listener.profiles === "*") throw new Error(`"${name}" is the local management listener: uploads are always on there.`);
|
|
3349
3348
|
// {} = the defaults (every supported image type, the full size cap); hand-tuned limits stay.
|
|
3350
|
-
if (opts.uploads) listener.uploads
|
|
3349
|
+
if (opts.uploads) listener.uploads = listener.uploads || {}; else listener.uploads = false;
|
|
3350
|
+
}
|
|
3351
|
+
if (opts.hostUploadDirs?.length || opts.noHostUploads) {
|
|
3352
|
+
if (listener.profiles === "*") throw new Error(`"${name}" is the local management listener; host-path uploads are set on shared listeners.`);
|
|
3353
|
+
if (listener.uploads === false) throw new Error(`Uploads are off on "${name}"; turn them on first: rech listener set ${name} --uploads`);
|
|
3354
|
+
const policy = listener.uploads ?? {};
|
|
3355
|
+
if (opts.noHostUploads) delete policy.hostDirs;
|
|
3356
|
+
else policy.hostDirs = [...new Set([...(policy.hostDirs ?? []), ...opts.hostUploadDirs!.map(d => realpathSync(resolve(d)))])];
|
|
3357
|
+
listener.uploads = policy;
|
|
3351
3358
|
}
|
|
3352
3359
|
await writeListeners(config);
|
|
3353
3360
|
if (opts.publicUrl !== undefined || opts.clearPublicUrl) console.log(listener.publicUrl ? `${name} is reachable at ${listener.publicUrl}` : `${name}: public URL cleared`);
|
|
3354
|
-
if (opts.
|
|
3361
|
+
if (opts.hostUploadDirs?.length || opts.noHostUploads) {
|
|
3362
|
+
const dirs = (listener.uploads || {}).hostDirs;
|
|
3363
|
+
console.log(dirs?.length ? `${name}: host-path uploads allowed from ${dirs.join(", ")} (rech upload --host <path>; each is logged)` : `${name}: host-path uploads off`);
|
|
3364
|
+
}
|
|
3365
|
+
if (opts.uploads !== undefined) console.log(opts.uploads ? `${name}: uploads on (rech upload <file>; images up to ${((listener.uploads || {}).maxBytes ?? MAX_UPLOAD_BYTES) / 1024 / 1024} MiB)` : `${name}: uploads off`);
|
|
3355
3366
|
}
|
|
3356
3367
|
|
|
3357
3368
|
const hideKey = (url: string) => url.replace(/([#&?]key=)[^&]*/, "$1…");
|
|
@@ -3406,9 +3417,7 @@ async function warnSecretFile(path: string | undefined): Promise<void> {
|
|
|
3406
3417
|
/** Write RECHROME_URL to this project's .rechrome/.env.local (the folder git-ignores itself). */
|
|
3407
3418
|
async function saveProjectUrl(url: string): Promise<string> {
|
|
3408
3419
|
const { dataDir } = await getClientIdentity();
|
|
3409
|
-
|
|
3410
|
-
const gitignore = join(dataDir, ".gitignore");
|
|
3411
|
-
if (!existsSync(gitignore)) await Bun.write(gitignore, "*\n");
|
|
3420
|
+
await ensureProjectDir(dataDir);
|
|
3412
3421
|
return writeEnvUrl(join(dataDir, ".env.local"), url);
|
|
3413
3422
|
}
|
|
3414
3423
|
|
|
@@ -3419,10 +3428,45 @@ async function saveGlobalUrl(url: string): Promise<string> {
|
|
|
3419
3428
|
}
|
|
3420
3429
|
|
|
3421
3430
|
/** Replace RECHROME_URL in an env file, keeping its other lines; owner-only, since the URL holds a key. */
|
|
3431
|
+
/** Write a file whole or not at all: a temp file beside it, then rename (atomic on one filesystem). */
|
|
3432
|
+
export async function writeAtomic(path: string, data: string, mode?: number): Promise<void> {
|
|
3433
|
+
// A symlinked .env.local stays a link: write the file it points to.
|
|
3434
|
+
// Follow links by hand: realpath fails on a dangling one, whose target we still create.
|
|
3435
|
+
for (let hops = 0; hops < 8; hops++) {
|
|
3436
|
+
let target: string;
|
|
3437
|
+
try { target = readlinkSync(path); } catch { break; } // not a link, or not there yet
|
|
3438
|
+
path = resolve(dirname(path), target);
|
|
3439
|
+
}
|
|
3440
|
+
const tmp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
3441
|
+
try {
|
|
3442
|
+
await writeFile(tmp, data, mode === undefined ? {} : { mode });
|
|
3443
|
+
if (mode !== undefined) await chmod(tmp, mode);
|
|
3444
|
+
renameSync(tmp, path);
|
|
3445
|
+
} catch (error) { try { unlinkSync(tmp); } catch { /* never written */ } throw error; }
|
|
3446
|
+
}
|
|
3447
|
+
|
|
3448
|
+
export const PROJECT_DIR_README = `# .rechrome/
|
|
3449
|
+
|
|
3450
|
+
rech's state for this project (like .git/). It ignores itself: see .gitignore.
|
|
3451
|
+
|
|
3452
|
+
- .env.local RECHROME_URL for this project. Holds a key: keep it private.
|
|
3453
|
+
- output/ files rech commands saved (screenshots, snapshots, downloads).
|
|
3454
|
+
|
|
3455
|
+
Safe to delete; rech recreates it. More: https://github.com/snomiao/rechrome
|
|
3456
|
+
`;
|
|
3457
|
+
|
|
3458
|
+
/** Create <project>/.rechrome with its .gitignore ("*") and README, so a key in it is never committed. */
|
|
3459
|
+
export async function ensureProjectDir(dir: string): Promise<void> {
|
|
3460
|
+
mkdirSync(dir, { recursive: true });
|
|
3461
|
+
const gitignore = join(dir, ".gitignore");
|
|
3462
|
+
if (!existsSync(gitignore)) await writeAtomic(gitignore, "*\n");
|
|
3463
|
+
const readme = join(dir, "README.md");
|
|
3464
|
+
if (!existsSync(readme)) await writeAtomic(readme, PROJECT_DIR_README);
|
|
3465
|
+
}
|
|
3466
|
+
|
|
3422
3467
|
async function writeEnvUrl(envPath: string, url: string): Promise<string> {
|
|
3423
3468
|
const lines = (await file(envPath).text().catch(() => "")).split("\n").filter(l => l.trim() && !l.startsWith(`${ENV_KEY}=`));
|
|
3424
|
-
await
|
|
3425
|
-
await chmod(envPath, 0o600);
|
|
3469
|
+
await writeAtomic(envPath, [...lines, envAssignment(ENV_KEY, url), ""].join("\n"), 0o600);
|
|
3426
3470
|
await recordSavedLink(envPath).catch(() => {}); // only bookkeeping for later warnings
|
|
3427
3471
|
return envPath;
|
|
3428
3472
|
}
|
|
@@ -3644,18 +3688,10 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
|
|
|
3644
3688
|
const scheme = previous.protocol;
|
|
3645
3689
|
let url = `${scheme}://${management.key}@${listenerAddress(management)}${normalizePrefix(management.prefix)}`;
|
|
3646
3690
|
const { host, port, protocol, key: serveKey } = parseUrl(url);
|
|
3691
|
+
// Connect the profile locally first; who else may reach it is asked at the end (setupShareStep),
|
|
3692
|
+
// once the bridge works. --listen still picks the exposure up front, for scripts.
|
|
3647
3693
|
let desiredBind = management.host;
|
|
3648
3694
|
if (opts.listen) desiredBind = chooseListenAddress(listenChoices, opts.listen);
|
|
3649
|
-
else if (isTTY) {
|
|
3650
|
-
console.log("\n Where should this profile be accessible? (Other profiles keep their listeners.)");
|
|
3651
|
-
listenChoices.forEach((choice, index) => console.log(` ${index + 1}. ${choice.label} — ${choice.address}`));
|
|
3652
|
-
while (true) {
|
|
3653
|
-
const answer = (await ask(" Choice [1]: ", "1")).trim();
|
|
3654
|
-
const choice = /^\d+$/.test(answer) ? listenChoices[Number(answer) - 1] : undefined;
|
|
3655
|
-
if (choice) { desiredBind = choice.address; break; }
|
|
3656
|
-
console.log(" Enter one of the numbers shown above.");
|
|
3657
|
-
}
|
|
3658
|
-
}
|
|
3659
3695
|
const pingManagement = () => fetch(serviceUrl(url, "ping"), {
|
|
3660
3696
|
headers: { Authorization: `Bearer ${serveKey}` }, signal: AbortSignal.timeout(2000),
|
|
3661
3697
|
}).then(async r => r.ok && (await r.json()).multiListener === true).catch(() => false);
|
|
@@ -3894,7 +3930,7 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
|
|
|
3894
3930
|
)).trim();
|
|
3895
3931
|
if (saveChoice !== "4") {
|
|
3896
3932
|
const globalEnvPath = saveChoice === "3" ? homeEnvPath : saveChoice === "2" ? pwdRechPath : pwdEnvPath;
|
|
3897
|
-
if (saveChoice === "2")
|
|
3933
|
+
if (saveChoice === "2") await ensureProjectDir(join(process.cwd(), ".rechrome"));
|
|
3898
3934
|
const existedBefore = await file(globalEnvPath).exists();
|
|
3899
3935
|
const existing = await file(globalEnvPath).text().catch(() => "");
|
|
3900
3936
|
const keysToRemove = ["PLAYWRIGHT_MCP_USER_DATA_DIR", "PLAYWRIGHT_MCP_EXTENSION_ID", "PLAYWRIGHT_MCP_EXTENSION_TOKEN", "PLAYWRIGHT_MCP_PROFILE_DIRECTORY"];
|
|
@@ -3902,7 +3938,7 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
|
|
|
3902
3938
|
const rechIdx = lines.findIndex(l => l.startsWith("RECHROME_URL="));
|
|
3903
3939
|
if (rechIdx >= 0) lines[rechIdx] = newLine;
|
|
3904
3940
|
else lines.push(newLine);
|
|
3905
|
-
await
|
|
3941
|
+
await writeAtomic(globalEnvPath, lines.join("\n").trim() + "\n", 0o600);
|
|
3906
3942
|
console.log(`\n${existedBefore ? "Updated" : "Created"} ${globalEnvPath}`);
|
|
3907
3943
|
}
|
|
3908
3944
|
|
|
@@ -3938,6 +3974,7 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
|
|
|
3938
3974
|
guide?.close();
|
|
3939
3975
|
console.log(` Saved token for ${extraDisplay}`);
|
|
3940
3976
|
}
|
|
3977
|
+
if (bridgeVerified && isTTY && !opts.listen) await setupShareStep(ask, profileEmail);
|
|
3941
3978
|
rl?.close();
|
|
3942
3979
|
envWatcher?.close();
|
|
3943
3980
|
if (bridgeVerified)
|
|
@@ -3946,6 +3983,70 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
|
|
|
3946
3983
|
console.error(`\nSetup was saved, but the selected profile did not pass the bridge check. Reload the extension at chrome://extensions and run \`rech setup --profile ${profileEmail}\` again.`);
|
|
3947
3984
|
}
|
|
3948
3985
|
|
|
3986
|
+
const SIGNAL_SELFHOST_DOC = "https://github.com/snomiao/rechrome/blob/main/docs/signal-selfhost.md";
|
|
3987
|
+
|
|
3988
|
+
/** The menu of setupShareStep: existing listeners first, then the new kinds. */
|
|
3989
|
+
export function shareMenu(listeners: Listener[], choices: ListenChoice[]): { key: string; label: string; listener?: string; kind?: "lan" | "tailscale" | "webrtc" | "webrtc-own" }[] {
|
|
3990
|
+
const menu: ReturnType<typeof shareMenu> = [{ key: "1", label: "Don't add sharing (local access is always on; existing shares stay: rech share ls)" }];
|
|
3991
|
+
for (const l of listeners.filter(l => l.profiles !== "*"))
|
|
3992
|
+
menu.push({ key: String(menu.length + 1), label: `Existing listener "${l.name}" (${listenerAddress(l)}${l.publicUrl ? `, ${l.publicUrl}` : ""})`, listener: l.name });
|
|
3993
|
+
const has = (k: string) => choices.some(c => c.kind === k);
|
|
3994
|
+
if (has("lan")) menu.push({ key: String(menu.length + 1), label: "New: LAN (machines on this network)", kind: "lan" });
|
|
3995
|
+
if (has("tailscale")) menu.push({ key: String(menu.length + 1), label: "New: Tailscale only (your tailnet)", kind: "tailscale" });
|
|
3996
|
+
menu.push({ key: String(menu.length + 1), label: "New: WebRTC via rechrome.pages.dev (works across networks, nothing to run)", kind: "webrtc" });
|
|
3997
|
+
menu.push({ key: String(menu.length + 1), label: "New: WebRTC via your own signal server (and TURN)", kind: "webrtc-own" });
|
|
3998
|
+
return menu;
|
|
3999
|
+
}
|
|
4000
|
+
|
|
4001
|
+
/** setup's last step: once the profile works here, offer to share it with other machines. */
|
|
4002
|
+
async function setupShareStep(ask: (q: string, def?: string) => Promise<string>, profile: string): Promise<void> {
|
|
4003
|
+
const listeners = (await readListeners().catch(() => null))?.listeners ?? [];
|
|
4004
|
+
const choices = await detectListenChoices();
|
|
4005
|
+
const menu = shareMenu(listeners, choices);
|
|
4006
|
+
console.log(`\n○ Share ${profile} with other machines?`);
|
|
4007
|
+
for (const m of menu) console.log(` ${m.key}. ${m.label}`);
|
|
4008
|
+
let pick: (typeof menu)[number] | undefined;
|
|
4009
|
+
while (!pick) {
|
|
4010
|
+
const answer = (await ask(" Choice [1]: ", "1")).trim();
|
|
4011
|
+
pick = menu.find(m => m.key === answer);
|
|
4012
|
+
if (!pick) console.log(" Enter one of the numbers shown above.");
|
|
4013
|
+
}
|
|
4014
|
+
try {
|
|
4015
|
+
if (pick.key === "1") { console.log("✓ Not shared. Later: rech share new " + JSON.stringify(profile)); return; }
|
|
4016
|
+
if (pick.listener) {
|
|
4017
|
+
const config = await requireListeners();
|
|
4018
|
+
allowProfiles(config, pick.listener, [profile]);
|
|
4019
|
+
await writeListeners(config);
|
|
4020
|
+
await printProfileUri(profile, pick.listener);
|
|
4021
|
+
return;
|
|
4022
|
+
}
|
|
4023
|
+
if (pick.kind === "tailscale") {
|
|
4024
|
+
const dns = (JSON.parse(await runTailscale(["status", "--json"]) ?? "null")?.Self?.DNSName as string | undefined)?.replace(/\.$/, "");
|
|
4025
|
+
if (!dns?.endsWith(".js.net")) console.log(` ! Tailscale's hostname is ${dns ? `"${dns}", not under ts.net` : "unknown (is tailscale up?)"}; the link uses the tailnet IP.`);
|
|
4026
|
+
else console.log(` Tailscale hostname: ${dns}`);
|
|
4027
|
+
}
|
|
4028
|
+
let signal: string | undefined;
|
|
4029
|
+
if (pick.kind === "webrtc-own") {
|
|
4030
|
+
console.log(`\n Run a signal server, then paste its URL here:`);
|
|
4031
|
+
console.log(` 1. Read ${SIGNAL_SELFHOST_DOC}`);
|
|
4032
|
+
console.log(` (A: Cloudflare Workers is the easiest; C: Tailscale Funnel needs no domain).`);
|
|
4033
|
+
console.log(` 2. Deploy it and open its URL in a browser to check that it answers.`);
|
|
4034
|
+
console.log(` 3. For TURN (strict NATs), set CF_TURN_KEY_ID and CF_TURN_API_TOKEN in the daemon's env (see "TURN" in that doc).`);
|
|
4035
|
+
while (!signal) {
|
|
4036
|
+
const raw = (await ask(" Signal server URL (empty: cancel): ")).trim();
|
|
4037
|
+
if (!raw) { console.log(" Not shared. Later: rech share new <profile> --signal <url>"); return; }
|
|
4038
|
+
try { signal = normalizeSignal(raw); } catch (error) { console.log(` ${error instanceof Error ? error.message : error}`); continue; }
|
|
4039
|
+
const ok = await fetch(signal, { signal: AbortSignal.timeout(5000) }).then(() => true).catch(() => false);
|
|
4040
|
+
if (!ok) console.log(` ! ${signal} did not answer; the share is made anyway, check the server before sending the link.`);
|
|
4041
|
+
}
|
|
4042
|
+
}
|
|
4043
|
+
const via = pick.kind === "webrtc" || pick.kind === "webrtc-own" ? WEBRTC : pick.kind!;
|
|
4044
|
+
await shareNew([profile], { via: [via], ...(signal ? { signal } : {}) });
|
|
4045
|
+
} catch (error) {
|
|
4046
|
+
console.error(` Sharing failed: ${error instanceof Error ? error.message : error}. Setup itself is saved; retry with rech share new ${JSON.stringify(profile)}.`);
|
|
4047
|
+
}
|
|
4048
|
+
}
|
|
4049
|
+
|
|
3949
4050
|
// ---- shares: invite links, devices, transports (see shares.js) ----------------------------
|
|
3950
4051
|
|
|
3951
4052
|
const fmtDate = (ms?: number) => ms ? new Date(ms).toISOString().slice(0, 16).replace("T", " ") : "-";
|
|
@@ -4339,7 +4440,7 @@ export type RechHandlers = {
|
|
|
4339
4440
|
allowListener(name: string, profiles: string[]): Promise<void>;
|
|
4340
4441
|
denyListener(name: string, profiles: string[]): Promise<void>;
|
|
4341
4442
|
rotateKey(name: string): Promise<void>;
|
|
4342
|
-
setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean }): Promise<void>;
|
|
4443
|
+
setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean }): Promise<void>;
|
|
4343
4444
|
setup(opts: SetupOptions): Promise<void>;
|
|
4344
4445
|
tray(action?: string): Promise<void>;
|
|
4345
4446
|
provisionProfile(name: string, opts: { headed: boolean; experimental: boolean }): Promise<void>;
|
|
@@ -4485,9 +4586,11 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
|
4485
4586
|
.positional("name", { type: "string", demandOption: true })
|
|
4486
4587
|
.option("public-url", { type: "string", requiresArg: true, describe: "e.g. https://host.example.js.net/rechrome/" })
|
|
4487
4588
|
.option("clear-public-url", { type: "boolean", conflicts: "public-url" })
|
|
4488
|
-
.option("uploads", { type: "boolean", describe: "
|
|
4489
|
-
.
|
|
4490
|
-
|
|
4589
|
+
.option("uploads", { type: "boolean", describe: "Uploads of images the link holder sends (rech upload) are on by default; --no-uploads turns them off" })
|
|
4590
|
+
.option("host-upload-dir", { type: "string", array: true, requiresArg: true, describe: "Off by default. Also let links upload files already on this host, from inside this directory (rech upload --host <path>); repeatable" })
|
|
4591
|
+
.option("host-uploads", { type: "boolean", describe: "--no-host-uploads turns host-path uploads off again" })
|
|
4592
|
+
.check(a => a.publicUrl !== undefined || a.clearPublicUrl || a.uploads !== undefined || a.hostUploadDir?.length || a.hostUploads === false ? true : "Pass --public-url <url>, --clear-public-url, --uploads/--no-uploads, or --host-upload-dir/--no-host-uploads"),
|
|
4593
|
+
a => handlers.setListener(a.name, { publicUrl: a.publicUrl, clearPublicUrl: a.clearPublicUrl, ...(a.uploads !== undefined ? { uploads: a.uploads } : {}), ...(a.hostUploadDir?.length ? { hostUploadDirs: a.hostUploadDir as string[] } : {}), ...(a.hostUploads === false ? { noHostUploads: true } : {}) }))
|
|
4491
4594
|
.command("port [name]", "Print a listener's port (for a proxy command)", y => y.positional("name", { type: "string" }),
|
|
4492
4595
|
a => handlers.listenerPort(a.name))
|
|
4493
4596
|
.command("rotate-key <name>", "Give a listener a new key (old URLs stop working)", y => y.positional("name", { type: "string", demandOption: true }),
|
package/rechrome.ts
CHANGED
|
@@ -7,7 +7,7 @@ import yargs from "yargs";
|
|
|
7
7
|
import { readExtensionTokenFromProfile } from "./extension-token.ts";
|
|
8
8
|
import { FILL_SECRET_USAGE, SECRET_MASK, parseFillSecretArgs, fillSecretWireArgs, readSecretSource, totpCode, totpWaitMs } from "./fill-secret.ts";
|
|
9
9
|
import { createHash, randomBytes } from "crypto";
|
|
10
|
-
import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, unlinkSync, readFileSync, readdirSync, renameSync, rmdirSync, constants as fsConstants } from "fs";
|
|
10
|
+
import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, unlinkSync, readFileSync, readdirSync, renameSync, readlinkSync, rmdirSync, constants as fsConstants } from "fs";
|
|
11
11
|
import { hostname, homedir, networkInterfaces } from "os";
|
|
12
12
|
import { isIPv4 } from "net";
|
|
13
13
|
import { join, basename, dirname, resolve } from "path";
|
|
@@ -16,7 +16,7 @@ import { createRequire } from "node:module";
|
|
|
16
16
|
import { spawn as cpSpawn } from "child_process";
|
|
17
17
|
import { readFile, writeFile, rename, chmod, mkdir, stat } from "node:fs/promises";
|
|
18
18
|
import { isDeprecatedPm2Fallback, listsProcess, oxmgrInstallCommand, pickDaemonManager, PM2_DEPRECATION, type DaemonManager } from "./daemon-manager.ts";
|
|
19
|
-
import { DEFAULT_SIGNAL, WEBRTC, createShare, findDevice, findShare, inviteLink, inviteProblem, parseDuration, readShares, updateShares, type Share } from "./shares.ts";
|
|
19
|
+
import { DEFAULT_SIGNAL, WEBRTC, createShare, normalizeSignal, findDevice, findShare, inviteLink, inviteProblem, parseDuration, readShares, updateShares, type Share } from "./shares.ts";
|
|
20
20
|
import { bearerOf, looksLikeToken, newToken, parseToken, redactSecrets } from "./secret.ts";
|
|
21
21
|
import { WebrtcClient, loadRTC, runBridge, spawnBridge } from "./webrtc.ts";
|
|
22
22
|
import { startSignalRelay } from "./signal-relay.ts";
|
|
@@ -2240,9 +2240,8 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
|
|
|
2240
2240
|
// Saved files belong to the project: <project>/.rechrome/output. The folder also holds
|
|
2241
2241
|
// .env.local (secrets), so it is git-ignored as a whole when first created.
|
|
2242
2242
|
const dlDir = join(identity.dataDir, "output");
|
|
2243
|
+
await ensureProjectDir(identity.dataDir);
|
|
2243
2244
|
mkdirSync(dlDir, { recursive: true });
|
|
2244
|
-
const gitignorePath = join(identity.dataDir, ".gitignore");
|
|
2245
|
-
if (!existsSync(gitignorePath)) await Bun.write(gitignorePath, "*\n");
|
|
2246
2245
|
for (const name of files) {
|
|
2247
2246
|
const fileRes = await fetch(serviceUrl(url, `files/${name}`), {
|
|
2248
2247
|
headers: { Authorization: `Bearer ${parseUrl(url).key}` },
|
|
@@ -3339,7 +3338,7 @@ async function rotateKey(name: string): Promise<void> {
|
|
|
3339
3338
|
console.log(`New key for ${name}; URLs carrying the old key stop working now. Print new ones with: rech share <profile> --listener ${name}`);
|
|
3340
3339
|
}
|
|
3341
3340
|
|
|
3342
|
-
async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean }): Promise<void> {
|
|
3341
|
+
async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean }): Promise<void> {
|
|
3343
3342
|
const config = await requireListeners();
|
|
3344
3343
|
let listener = config.listeners.find(l => l.name === name);
|
|
3345
3344
|
if (!listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
|
|
@@ -3347,11 +3346,23 @@ async function setListener(name: string, opts: { publicUrl?: string; clearPublic
|
|
|
3347
3346
|
if (opts.uploads !== undefined) {
|
|
3348
3347
|
if (listener.profiles === "*") throw new Error(`"${name}" is the local management listener: uploads are always on there.`);
|
|
3349
3348
|
// {} = the defaults (every supported image type, the full size cap); hand-tuned limits stay.
|
|
3350
|
-
if (opts.uploads) listener.uploads
|
|
3349
|
+
if (opts.uploads) listener.uploads = listener.uploads || {}; else listener.uploads = false;
|
|
3350
|
+
}
|
|
3351
|
+
if (opts.hostUploadDirs?.length || opts.noHostUploads) {
|
|
3352
|
+
if (listener.profiles === "*") throw new Error(`"${name}" is the local management listener; host-path uploads are set on shared listeners.`);
|
|
3353
|
+
if (listener.uploads === false) throw new Error(`Uploads are off on "${name}"; turn them on first: rech listener set ${name} --uploads`);
|
|
3354
|
+
const policy = listener.uploads ?? {};
|
|
3355
|
+
if (opts.noHostUploads) delete policy.hostDirs;
|
|
3356
|
+
else policy.hostDirs = [...new Set([...(policy.hostDirs ?? []), ...opts.hostUploadDirs!.map(d => realpathSync(resolve(d)))])];
|
|
3357
|
+
listener.uploads = policy;
|
|
3351
3358
|
}
|
|
3352
3359
|
await writeListeners(config);
|
|
3353
3360
|
if (opts.publicUrl !== undefined || opts.clearPublicUrl) console.log(listener.publicUrl ? `${name} is reachable at ${listener.publicUrl}` : `${name}: public URL cleared`);
|
|
3354
|
-
if (opts.
|
|
3361
|
+
if (opts.hostUploadDirs?.length || opts.noHostUploads) {
|
|
3362
|
+
const dirs = (listener.uploads || {}).hostDirs;
|
|
3363
|
+
console.log(dirs?.length ? `${name}: host-path uploads allowed from ${dirs.join(", ")} (rech upload --host <path>; each is logged)` : `${name}: host-path uploads off`);
|
|
3364
|
+
}
|
|
3365
|
+
if (opts.uploads !== undefined) console.log(opts.uploads ? `${name}: uploads on (rech upload <file>; images up to ${((listener.uploads || {}).maxBytes ?? MAX_UPLOAD_BYTES) / 1024 / 1024} MiB)` : `${name}: uploads off`);
|
|
3355
3366
|
}
|
|
3356
3367
|
|
|
3357
3368
|
const hideKey = (url: string) => url.replace(/([#&?]key=)[^&]*/, "$1…");
|
|
@@ -3406,9 +3417,7 @@ async function warnSecretFile(path: string | undefined): Promise<void> {
|
|
|
3406
3417
|
/** Write RECHROME_URL to this project's .rechrome/.env.local (the folder git-ignores itself). */
|
|
3407
3418
|
async function saveProjectUrl(url: string): Promise<string> {
|
|
3408
3419
|
const { dataDir } = await getClientIdentity();
|
|
3409
|
-
|
|
3410
|
-
const gitignore = join(dataDir, ".gitignore");
|
|
3411
|
-
if (!existsSync(gitignore)) await Bun.write(gitignore, "*\n");
|
|
3420
|
+
await ensureProjectDir(dataDir);
|
|
3412
3421
|
return writeEnvUrl(join(dataDir, ".env.local"), url);
|
|
3413
3422
|
}
|
|
3414
3423
|
|
|
@@ -3419,10 +3428,45 @@ async function saveGlobalUrl(url: string): Promise<string> {
|
|
|
3419
3428
|
}
|
|
3420
3429
|
|
|
3421
3430
|
/** Replace RECHROME_URL in an env file, keeping its other lines; owner-only, since the URL holds a key. */
|
|
3431
|
+
/** Write a file whole or not at all: a temp file beside it, then rename (atomic on one filesystem). */
|
|
3432
|
+
export async function writeAtomic(path: string, data: string, mode?: number): Promise<void> {
|
|
3433
|
+
// A symlinked .env.local stays a link: write the file it points to.
|
|
3434
|
+
// Follow links by hand: realpath fails on a dangling one, whose target we still create.
|
|
3435
|
+
for (let hops = 0; hops < 8; hops++) {
|
|
3436
|
+
let target: string;
|
|
3437
|
+
try { target = readlinkSync(path); } catch { break; } // not a link, or not there yet
|
|
3438
|
+
path = resolve(dirname(path), target);
|
|
3439
|
+
}
|
|
3440
|
+
const tmp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
3441
|
+
try {
|
|
3442
|
+
await writeFile(tmp, data, mode === undefined ? {} : { mode });
|
|
3443
|
+
if (mode !== undefined) await chmod(tmp, mode);
|
|
3444
|
+
renameSync(tmp, path);
|
|
3445
|
+
} catch (error) { try { unlinkSync(tmp); } catch { /* never written */ } throw error; }
|
|
3446
|
+
}
|
|
3447
|
+
|
|
3448
|
+
export const PROJECT_DIR_README = `# .rechrome/
|
|
3449
|
+
|
|
3450
|
+
rech's state for this project (like .git/). It ignores itself: see .gitignore.
|
|
3451
|
+
|
|
3452
|
+
- .env.local RECHROME_URL for this project. Holds a key: keep it private.
|
|
3453
|
+
- output/ files rech commands saved (screenshots, snapshots, downloads).
|
|
3454
|
+
|
|
3455
|
+
Safe to delete; rech recreates it. More: https://github.com/snomiao/rechrome
|
|
3456
|
+
`;
|
|
3457
|
+
|
|
3458
|
+
/** Create <project>/.rechrome with its .gitignore ("*") and README, so a key in it is never committed. */
|
|
3459
|
+
export async function ensureProjectDir(dir: string): Promise<void> {
|
|
3460
|
+
mkdirSync(dir, { recursive: true });
|
|
3461
|
+
const gitignore = join(dir, ".gitignore");
|
|
3462
|
+
if (!existsSync(gitignore)) await writeAtomic(gitignore, "*\n");
|
|
3463
|
+
const readme = join(dir, "README.md");
|
|
3464
|
+
if (!existsSync(readme)) await writeAtomic(readme, PROJECT_DIR_README);
|
|
3465
|
+
}
|
|
3466
|
+
|
|
3422
3467
|
async function writeEnvUrl(envPath: string, url: string): Promise<string> {
|
|
3423
3468
|
const lines = (await file(envPath).text().catch(() => "")).split("\n").filter(l => l.trim() && !l.startsWith(`${ENV_KEY}=`));
|
|
3424
|
-
await
|
|
3425
|
-
await chmod(envPath, 0o600);
|
|
3469
|
+
await writeAtomic(envPath, [...lines, envAssignment(ENV_KEY, url), ""].join("\n"), 0o600);
|
|
3426
3470
|
await recordSavedLink(envPath).catch(() => {}); // only bookkeeping for later warnings
|
|
3427
3471
|
return envPath;
|
|
3428
3472
|
}
|
|
@@ -3644,18 +3688,10 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
|
|
|
3644
3688
|
const scheme = previous.protocol;
|
|
3645
3689
|
let url = `${scheme}://${management.key}@${listenerAddress(management)}${normalizePrefix(management.prefix)}`;
|
|
3646
3690
|
const { host, port, protocol, key: serveKey } = parseUrl(url);
|
|
3691
|
+
// Connect the profile locally first; who else may reach it is asked at the end (setupShareStep),
|
|
3692
|
+
// once the bridge works. --listen still picks the exposure up front, for scripts.
|
|
3647
3693
|
let desiredBind = management.host;
|
|
3648
3694
|
if (opts.listen) desiredBind = chooseListenAddress(listenChoices, opts.listen);
|
|
3649
|
-
else if (isTTY) {
|
|
3650
|
-
console.log("\n Where should this profile be accessible? (Other profiles keep their listeners.)");
|
|
3651
|
-
listenChoices.forEach((choice, index) => console.log(` ${index + 1}. ${choice.label} — ${choice.address}`));
|
|
3652
|
-
while (true) {
|
|
3653
|
-
const answer = (await ask(" Choice [1]: ", "1")).trim();
|
|
3654
|
-
const choice = /^\d+$/.test(answer) ? listenChoices[Number(answer) - 1] : undefined;
|
|
3655
|
-
if (choice) { desiredBind = choice.address; break; }
|
|
3656
|
-
console.log(" Enter one of the numbers shown above.");
|
|
3657
|
-
}
|
|
3658
|
-
}
|
|
3659
3695
|
const pingManagement = () => fetch(serviceUrl(url, "ping"), {
|
|
3660
3696
|
headers: { Authorization: `Bearer ${serveKey}` }, signal: AbortSignal.timeout(2000),
|
|
3661
3697
|
}).then(async r => r.ok && (await r.json()).multiListener === true).catch(() => false);
|
|
@@ -3894,7 +3930,7 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
|
|
|
3894
3930
|
)).trim();
|
|
3895
3931
|
if (saveChoice !== "4") {
|
|
3896
3932
|
const globalEnvPath = saveChoice === "3" ? homeEnvPath : saveChoice === "2" ? pwdRechPath : pwdEnvPath;
|
|
3897
|
-
if (saveChoice === "2")
|
|
3933
|
+
if (saveChoice === "2") await ensureProjectDir(join(process.cwd(), ".rechrome"));
|
|
3898
3934
|
const existedBefore = await file(globalEnvPath).exists();
|
|
3899
3935
|
const existing = await file(globalEnvPath).text().catch(() => "");
|
|
3900
3936
|
const keysToRemove = ["PLAYWRIGHT_MCP_USER_DATA_DIR", "PLAYWRIGHT_MCP_EXTENSION_ID", "PLAYWRIGHT_MCP_EXTENSION_TOKEN", "PLAYWRIGHT_MCP_PROFILE_DIRECTORY"];
|
|
@@ -3902,7 +3938,7 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
|
|
|
3902
3938
|
const rechIdx = lines.findIndex(l => l.startsWith("RECHROME_URL="));
|
|
3903
3939
|
if (rechIdx >= 0) lines[rechIdx] = newLine;
|
|
3904
3940
|
else lines.push(newLine);
|
|
3905
|
-
await
|
|
3941
|
+
await writeAtomic(globalEnvPath, lines.join("\n").trim() + "\n", 0o600);
|
|
3906
3942
|
console.log(`\n${existedBefore ? "Updated" : "Created"} ${globalEnvPath}`);
|
|
3907
3943
|
}
|
|
3908
3944
|
|
|
@@ -3938,6 +3974,7 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
|
|
|
3938
3974
|
guide?.close();
|
|
3939
3975
|
console.log(` Saved token for ${extraDisplay}`);
|
|
3940
3976
|
}
|
|
3977
|
+
if (bridgeVerified && isTTY && !opts.listen) await setupShareStep(ask, profileEmail);
|
|
3941
3978
|
rl?.close();
|
|
3942
3979
|
envWatcher?.close();
|
|
3943
3980
|
if (bridgeVerified)
|
|
@@ -3946,6 +3983,70 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
|
|
|
3946
3983
|
console.error(`\nSetup was saved, but the selected profile did not pass the bridge check. Reload the extension at chrome://extensions and run \`rech setup --profile ${profileEmail}\` again.`);
|
|
3947
3984
|
}
|
|
3948
3985
|
|
|
3986
|
+
const SIGNAL_SELFHOST_DOC = "https://github.com/snomiao/rechrome/blob/main/docs/signal-selfhost.md";
|
|
3987
|
+
|
|
3988
|
+
/** The menu of setupShareStep: existing listeners first, then the new kinds. */
|
|
3989
|
+
export function shareMenu(listeners: Listener[], choices: ListenChoice[]): { key: string; label: string; listener?: string; kind?: "lan" | "tailscale" | "webrtc" | "webrtc-own" }[] {
|
|
3990
|
+
const menu: ReturnType<typeof shareMenu> = [{ key: "1", label: "Don't add sharing (local access is always on; existing shares stay: rech share ls)" }];
|
|
3991
|
+
for (const l of listeners.filter(l => l.profiles !== "*"))
|
|
3992
|
+
menu.push({ key: String(menu.length + 1), label: `Existing listener "${l.name}" (${listenerAddress(l)}${l.publicUrl ? `, ${l.publicUrl}` : ""})`, listener: l.name });
|
|
3993
|
+
const has = (k: string) => choices.some(c => c.kind === k);
|
|
3994
|
+
if (has("lan")) menu.push({ key: String(menu.length + 1), label: "New: LAN (machines on this network)", kind: "lan" });
|
|
3995
|
+
if (has("tailscale")) menu.push({ key: String(menu.length + 1), label: "New: Tailscale only (your tailnet)", kind: "tailscale" });
|
|
3996
|
+
menu.push({ key: String(menu.length + 1), label: "New: WebRTC via rechrome.pages.dev (works across networks, nothing to run)", kind: "webrtc" });
|
|
3997
|
+
menu.push({ key: String(menu.length + 1), label: "New: WebRTC via your own signal server (and TURN)", kind: "webrtc-own" });
|
|
3998
|
+
return menu;
|
|
3999
|
+
}
|
|
4000
|
+
|
|
4001
|
+
/** setup's last step: once the profile works here, offer to share it with other machines. */
|
|
4002
|
+
async function setupShareStep(ask: (q: string, def?: string) => Promise<string>, profile: string): Promise<void> {
|
|
4003
|
+
const listeners = (await readListeners().catch(() => null))?.listeners ?? [];
|
|
4004
|
+
const choices = await detectListenChoices();
|
|
4005
|
+
const menu = shareMenu(listeners, choices);
|
|
4006
|
+
console.log(`\n○ Share ${profile} with other machines?`);
|
|
4007
|
+
for (const m of menu) console.log(` ${m.key}. ${m.label}`);
|
|
4008
|
+
let pick: (typeof menu)[number] | undefined;
|
|
4009
|
+
while (!pick) {
|
|
4010
|
+
const answer = (await ask(" Choice [1]: ", "1")).trim();
|
|
4011
|
+
pick = menu.find(m => m.key === answer);
|
|
4012
|
+
if (!pick) console.log(" Enter one of the numbers shown above.");
|
|
4013
|
+
}
|
|
4014
|
+
try {
|
|
4015
|
+
if (pick.key === "1") { console.log("✓ Not shared. Later: rech share new " + JSON.stringify(profile)); return; }
|
|
4016
|
+
if (pick.listener) {
|
|
4017
|
+
const config = await requireListeners();
|
|
4018
|
+
allowProfiles(config, pick.listener, [profile]);
|
|
4019
|
+
await writeListeners(config);
|
|
4020
|
+
await printProfileUri(profile, pick.listener);
|
|
4021
|
+
return;
|
|
4022
|
+
}
|
|
4023
|
+
if (pick.kind === "tailscale") {
|
|
4024
|
+
const dns = (JSON.parse(await runTailscale(["status", "--json"]) ?? "null")?.Self?.DNSName as string | undefined)?.replace(/\.$/, "");
|
|
4025
|
+
if (!dns?.endsWith(".ts.net")) console.log(` ! Tailscale's hostname is ${dns ? `"${dns}", not under ts.net` : "unknown (is tailscale up?)"}; the link uses the tailnet IP.`);
|
|
4026
|
+
else console.log(` Tailscale hostname: ${dns}`);
|
|
4027
|
+
}
|
|
4028
|
+
let signal: string | undefined;
|
|
4029
|
+
if (pick.kind === "webrtc-own") {
|
|
4030
|
+
console.log(`\n Run a signal server, then paste its URL here:`);
|
|
4031
|
+
console.log(` 1. Read ${SIGNAL_SELFHOST_DOC}`);
|
|
4032
|
+
console.log(` (A: Cloudflare Workers is the easiest; C: Tailscale Funnel needs no domain).`);
|
|
4033
|
+
console.log(` 2. Deploy it and open its URL in a browser to check that it answers.`);
|
|
4034
|
+
console.log(` 3. For TURN (strict NATs), set CF_TURN_KEY_ID and CF_TURN_API_TOKEN in the daemon's env (see "TURN" in that doc).`);
|
|
4035
|
+
while (!signal) {
|
|
4036
|
+
const raw = (await ask(" Signal server URL (empty: cancel): ")).trim();
|
|
4037
|
+
if (!raw) { console.log(" Not shared. Later: rech share new <profile> --signal <url>"); return; }
|
|
4038
|
+
try { signal = normalizeSignal(raw); } catch (error) { console.log(` ${error instanceof Error ? error.message : error}`); continue; }
|
|
4039
|
+
const ok = await fetch(signal, { signal: AbortSignal.timeout(5000) }).then(() => true).catch(() => false);
|
|
4040
|
+
if (!ok) console.log(` ! ${signal} did not answer; the share is made anyway, check the server before sending the link.`);
|
|
4041
|
+
}
|
|
4042
|
+
}
|
|
4043
|
+
const via = pick.kind === "webrtc" || pick.kind === "webrtc-own" ? WEBRTC : pick.kind!;
|
|
4044
|
+
await shareNew([profile], { via: [via], ...(signal ? { signal } : {}) });
|
|
4045
|
+
} catch (error) {
|
|
4046
|
+
console.error(` Sharing failed: ${error instanceof Error ? error.message : error}. Setup itself is saved; retry with rech share new ${JSON.stringify(profile)}.`);
|
|
4047
|
+
}
|
|
4048
|
+
}
|
|
4049
|
+
|
|
3949
4050
|
// ---- shares: invite links, devices, transports (see shares.ts) ----------------------------
|
|
3950
4051
|
|
|
3951
4052
|
const fmtDate = (ms?: number) => ms ? new Date(ms).toISOString().slice(0, 16).replace("T", " ") : "-";
|
|
@@ -4339,7 +4440,7 @@ export type RechHandlers = {
|
|
|
4339
4440
|
allowListener(name: string, profiles: string[]): Promise<void>;
|
|
4340
4441
|
denyListener(name: string, profiles: string[]): Promise<void>;
|
|
4341
4442
|
rotateKey(name: string): Promise<void>;
|
|
4342
|
-
setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean }): Promise<void>;
|
|
4443
|
+
setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean }): Promise<void>;
|
|
4343
4444
|
setup(opts: SetupOptions): Promise<void>;
|
|
4344
4445
|
tray(action?: string): Promise<void>;
|
|
4345
4446
|
provisionProfile(name: string, opts: { headed: boolean; experimental: boolean }): Promise<void>;
|
|
@@ -4485,9 +4586,11 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
|
4485
4586
|
.positional("name", { type: "string", demandOption: true })
|
|
4486
4587
|
.option("public-url", { type: "string", requiresArg: true, describe: "e.g. https://host.example.ts.net/rechrome/" })
|
|
4487
4588
|
.option("clear-public-url", { type: "boolean", conflicts: "public-url" })
|
|
4488
|
-
.option("uploads", { type: "boolean", describe: "
|
|
4489
|
-
.
|
|
4490
|
-
|
|
4589
|
+
.option("uploads", { type: "boolean", describe: "Uploads of images the link holder sends (rech upload) are on by default; --no-uploads turns them off" })
|
|
4590
|
+
.option("host-upload-dir", { type: "string", array: true, requiresArg: true, describe: "Off by default. Also let links upload files already on this host, from inside this directory (rech upload --host <path>); repeatable" })
|
|
4591
|
+
.option("host-uploads", { type: "boolean", describe: "--no-host-uploads turns host-path uploads off again" })
|
|
4592
|
+
.check(a => a.publicUrl !== undefined || a.clearPublicUrl || a.uploads !== undefined || a.hostUploadDir?.length || a.hostUploads === false ? true : "Pass --public-url <url>, --clear-public-url, --uploads/--no-uploads, or --host-upload-dir/--no-host-uploads"),
|
|
4593
|
+
a => handlers.setListener(a.name, { publicUrl: a.publicUrl, clearPublicUrl: a.clearPublicUrl, ...(a.uploads !== undefined ? { uploads: a.uploads } : {}), ...(a.hostUploadDir?.length ? { hostUploadDirs: a.hostUploadDir as string[] } : {}), ...(a.hostUploads === false ? { noHostUploads: true } : {}) }))
|
|
4491
4594
|
.command("port [name]", "Print a listener's port (for a proxy command)", y => y.positional("name", { type: "string" }),
|
|
4492
4595
|
a => handlers.listenerPort(a.name))
|
|
4493
4596
|
.command("rotate-key <name>", "Give a listener a new key (old URLs stop working)", y => y.positional("name", { type: "string", demandOption: true }),
|
package/scoped-upload.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// scoped-upload.ts
|
|
3
3
|
import { createHash, randomBytes } from "crypto";
|
|
4
|
-
import { mkdtemp, writeFile, rm } from "fs/promises";
|
|
5
|
-
import {
|
|
4
|
+
import { mkdtemp, writeFile, rm, realpath, stat, open } from "fs/promises";
|
|
5
|
+
import { constants as fsConstants } from "fs";
|
|
6
|
+
import { basename, join, isAbsolute, sep } from "path";
|
|
6
7
|
import { tmpdir } from "os";
|
|
7
8
|
var MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
|
8
9
|
var MAX_REQUEST_BYTES = 14 * 1024 * 1024;
|
|
@@ -11,8 +12,8 @@ var EXTENSION_TYPES = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg",
|
|
|
11
12
|
var UPLOAD_MODES = ["auto", "chooser", "into", "drop", "paste"];
|
|
12
13
|
function validateUploadPolicy(value) {
|
|
13
14
|
const p = value;
|
|
14
|
-
if (!p || typeof p !== "object" || Array.isArray(p) || Object.keys(p).some((k) => !["maxBytes", "contentTypes"].includes(k)) || p.maxBytes !== undefined && (!Number.isSafeInteger(p.maxBytes) || p.maxBytes < 1 || p.maxBytes > MAX_UPLOAD_BYTES) || p.contentTypes !== undefined && (!Array.isArray(p.contentTypes) || !p.contentTypes.length || p.contentTypes.some((t) => !UPLOAD_TYPES.includes(t))))
|
|
15
|
-
throw new Error(`uploads must be an object with optional maxBytes (1..${MAX_UPLOAD_BYTES})
|
|
15
|
+
if (!p || typeof p !== "object" || Array.isArray(p) || Object.keys(p).some((k) => !["maxBytes", "contentTypes", "hostDirs"].includes(k)) || p.maxBytes !== undefined && (!Number.isSafeInteger(p.maxBytes) || p.maxBytes < 1 || p.maxBytes > MAX_UPLOAD_BYTES) || p.contentTypes !== undefined && (!Array.isArray(p.contentTypes) || !p.contentTypes.length || p.contentTypes.some((t) => !UPLOAD_TYPES.includes(t))) || p.hostDirs !== undefined && (!Array.isArray(p.hostDirs) || !p.hostDirs.length || p.hostDirs.some((d) => typeof d !== "string" || !isAbsolute(d) || d.includes("\x00") || d === sep)))
|
|
16
|
+
throw new Error(`uploads must be an object with optional maxBytes (1..${MAX_UPLOAD_BYTES}), contentTypes (${UPLOAD_TYPES.join(", ")}) and hostDirs (absolute directories, not /)`);
|
|
16
17
|
}
|
|
17
18
|
function validName(name) {
|
|
18
19
|
return typeof name === "string" && name.length > 0 && name.length <= 128 && name !== "." && name !== ".." && !name.startsWith("-") && !/[\\/:\x00-\x1f\x7f]/.test(name);
|
|
@@ -49,6 +50,7 @@ var UPLOAD_USAGE = `Usage: rech upload <file> [how] (the file is read on this
|
|
|
49
50
|
--drop <ref|selector> a drag-and-drop zone
|
|
50
51
|
--paste [ref|selector] an editor that takes pasted images (default: the focused element)
|
|
51
52
|
--name <file> read the bytes from stdin instead: rech upload --name shot.png < shot.png
|
|
53
|
+
--host <path> a file already on the host (only where its listener allows that directory)
|
|
52
54
|
Types: ${UPLOAD_TYPES.join(", ")}, up to ${MAX_UPLOAD_BYTES / 1024 / 1024} MiB. Get refs from rech snapshot.`;
|
|
53
55
|
async function prepareUpload(args, stdin, readLocal = (path) => Bun.file(path).bytes()) {
|
|
54
56
|
const sessions = args.filter((a) => /^-s=[a-zA-Z0-9_-]{1,64}$/.test(a));
|
|
@@ -64,7 +66,7 @@ async function prepareUpload(args, stdin, readLocal = (path) => Bun.file(path).b
|
|
|
64
66
|
};
|
|
65
67
|
while (options.length) {
|
|
66
68
|
const option = options.shift();
|
|
67
|
-
const match = option.match(/^--(name|content-type|into|drop|paste|chooser)(?:=(.*))?$/);
|
|
69
|
+
const match = option.match(/^--(name|content-type|into|drop|paste|chooser|host)(?:=(.*))?$/);
|
|
68
70
|
if (!match) {
|
|
69
71
|
if (option.startsWith("-") || path !== undefined)
|
|
70
72
|
throw new Error(path !== undefined ? `One file per upload; unexpected ${option}` : `Unknown option ${option}`);
|
|
@@ -91,6 +93,24 @@ async function prepareUpload(args, stdin, readLocal = (path) => Bun.file(path).b
|
|
|
91
93
|
}
|
|
92
94
|
if (target !== undefined && !validTarget(target))
|
|
93
95
|
throw new Error("Upload target must be a snapshot ref (e12) or a selector");
|
|
96
|
+
if (fields.host !== undefined) {
|
|
97
|
+
if (path !== undefined || fields.name !== undefined)
|
|
98
|
+
throw new Error("--host takes the host file instead of a local file or --name");
|
|
99
|
+
if (!isAbsolute(fields.host))
|
|
100
|
+
throw new Error("--host needs an absolute path on the host");
|
|
101
|
+
const hostName = basename(fields.host);
|
|
102
|
+
if (!validName(hostName))
|
|
103
|
+
throw new Error("The host file name must be a plain name");
|
|
104
|
+
const hostType = fields["content-type"] ?? EXTENSION_TYPES[hostName.toLowerCase().split(".").pop() ?? ""] ?? "";
|
|
105
|
+
if (!UPLOAD_TYPES.includes(hostType))
|
|
106
|
+
throw new Error(`Upload content type must be one of ${UPLOAD_TYPES.join(", ")} (from the file extension, or --content-type)`);
|
|
107
|
+
const upload = { name: hostName, contentType: hostType, hostPath: fields.host };
|
|
108
|
+
if (mode !== "auto")
|
|
109
|
+
upload.mode = mode;
|
|
110
|
+
if (target !== undefined)
|
|
111
|
+
upload.target = target;
|
|
112
|
+
return { args: ["upload", ...sessions], upload };
|
|
113
|
+
}
|
|
94
114
|
if (path === undefined && !stdin)
|
|
95
115
|
throw new Error(`upload needs a file.`);
|
|
96
116
|
const name = fields.name ?? (path !== undefined ? basename(path) : undefined);
|
|
@@ -185,6 +205,45 @@ function currentPageOrigin(output) {
|
|
|
185
205
|
function cleanOutput(text, path, dir, name) {
|
|
186
206
|
return text.replace(/### Ran Playwright code\n```js\n[\s\S]*?\n```\n*/g, "").replaceAll(path, name).replaceAll(dir, "<upload>");
|
|
187
207
|
}
|
|
208
|
+
async function readHostFile(path, hostDirs, limit) {
|
|
209
|
+
if (!hostDirs?.length)
|
|
210
|
+
throw new Error("Host-path uploads are off on this listener (the host can allow a directory: rech listener set <name> --host-upload-dir <dir>)");
|
|
211
|
+
if (!isAbsolute(path) || path.includes("\x00"))
|
|
212
|
+
throw new Error("Host upload path must be absolute");
|
|
213
|
+
const roots = (await Promise.all(hostDirs.map((d) => realpath(d).catch(() => null)))).filter((r) => !!r);
|
|
214
|
+
const inside = (real) => roots.some((root) => real.startsWith(root.endsWith(sep) ? root : root + sep));
|
|
215
|
+
const resolveInside = async () => {
|
|
216
|
+
let real;
|
|
217
|
+
try {
|
|
218
|
+
real = await realpath(path);
|
|
219
|
+
} catch {
|
|
220
|
+
throw new Error("Host upload file not found");
|
|
221
|
+
}
|
|
222
|
+
if (!inside(real))
|
|
223
|
+
throw new Error("Host upload path is outside the listener's allowed directories");
|
|
224
|
+
return real;
|
|
225
|
+
};
|
|
226
|
+
const handle = await open(await resolveInside(), fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
|
|
227
|
+
try {
|
|
228
|
+
const info = await handle.stat();
|
|
229
|
+
if (!info.isFile())
|
|
230
|
+
throw new Error("Host upload path is not a regular file");
|
|
231
|
+
const now = await stat(await resolveInside());
|
|
232
|
+
if (now.dev !== info.dev || now.ino !== info.ino)
|
|
233
|
+
throw new Error("Host upload file changed while it was being opened");
|
|
234
|
+
if (info.size > limit)
|
|
235
|
+
throw new Error("Upload is empty or exceeds maxBytes");
|
|
236
|
+
const buf = Buffer.alloc(limit + 1);
|
|
237
|
+
let n = 0;
|
|
238
|
+
for (let r;n < buf.length && (r = await handle.read(buf, n, buf.length - n, n)).bytesRead > 0; )
|
|
239
|
+
n += r.bytesRead;
|
|
240
|
+
if (n > limit)
|
|
241
|
+
throw new Error("Upload is empty or exceeds maxBytes");
|
|
242
|
+
return buf.subarray(0, n);
|
|
243
|
+
} finally {
|
|
244
|
+
await handle.close();
|
|
245
|
+
}
|
|
246
|
+
}
|
|
188
247
|
async function receiveUpload(options) {
|
|
189
248
|
const record = { name: null, size: null, sha256: null, origin: null, result: "rejected" };
|
|
190
249
|
let dir;
|
|
@@ -199,16 +258,27 @@ async function receiveUpload(options) {
|
|
|
199
258
|
validateUploadPolicy(options.policy);
|
|
200
259
|
if (options.args.length !== 1 || options.args[0] !== "upload")
|
|
201
260
|
throw new Error("Upload takes file bytes, not host paths or CLI options");
|
|
202
|
-
|
|
261
|
+
const fromHost = p?.hostPath !== undefined;
|
|
262
|
+
if (!p || !validName(p.name) || typeof p.contentType !== "string" || (fromHost ? p.data !== undefined || typeof p.hostPath !== "string" : typeof p.data !== "string") || Object.keys(p).some((k) => !["name", "contentType", "data", "hostPath", "mode", "target"].includes(k)) || p.mode !== undefined && !UPLOAD_MODES.includes(p.mode) || p.target !== undefined && !validTarget(p.target) || (p.mode === "into" || p.mode === "drop") && p.target === undefined || (p.mode === "auto" || p.mode === "chooser") && p.target !== undefined)
|
|
203
263
|
throw new Error("Invalid upload payload");
|
|
204
264
|
const limit = options.policy.maxBytes ?? MAX_UPLOAD_BYTES;
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
265
|
+
let bytes;
|
|
266
|
+
if (fromHost) {
|
|
267
|
+
record.hostPath = p.hostPath.slice(0, 512);
|
|
268
|
+
bytes = await readHostFile(p.hostPath, options.policy.hostDirs, limit);
|
|
269
|
+
if (basename(p.hostPath) !== p.name)
|
|
270
|
+
throw new Error("Host upload name must be the file's own name");
|
|
271
|
+
} else {
|
|
272
|
+
if (p.data.length > 4 * Math.ceil(limit / 3) || /[^A-Za-z0-9+/=]/.test(p.data))
|
|
273
|
+
throw new Error("Upload exceeds maxBytes or has invalid base64");
|
|
274
|
+
bytes = Buffer.from(p.data, "base64");
|
|
275
|
+
if (bytes.toString("base64") !== p.data)
|
|
276
|
+
throw new Error("Upload is not canonical base64");
|
|
277
|
+
}
|
|
208
278
|
record.size = bytes.length;
|
|
209
279
|
record.sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
210
|
-
if (!bytes.length || bytes.length > limit
|
|
211
|
-
throw new Error("Upload is empty
|
|
280
|
+
if (!bytes.length || bytes.length > limit)
|
|
281
|
+
throw new Error("Upload is empty or exceeds maxBytes");
|
|
212
282
|
if (!(options.policy.contentTypes ?? UPLOAD_TYPES).includes(p.contentType) || !matchesType(bytes, p.contentType))
|
|
213
283
|
throw new Error("Upload content type is not allowed or does not match the bytes");
|
|
214
284
|
const tabs = await options.run(["tab-list"]);
|
|
@@ -287,6 +357,7 @@ export {
|
|
|
287
357
|
currentPageOrigin,
|
|
288
358
|
prepareStdinUpload,
|
|
289
359
|
prepareUpload,
|
|
360
|
+
readHostFile,
|
|
290
361
|
readRequestJson,
|
|
291
362
|
receiveUpload,
|
|
292
363
|
uploadScript,
|
package/scoped-upload.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
-
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
|
|
3
|
-
import {
|
|
2
|
+
import { mkdtemp, writeFile, rm, realpath, stat, open } from 'node:fs/promises';
|
|
3
|
+
import { constants as fsConstants } from 'node:fs';
|
|
4
|
+
import { basename, join, isAbsolute, sep } from 'node:path';
|
|
4
5
|
import { tmpdir } from 'node:os';
|
|
5
6
|
|
|
6
7
|
export const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
|
@@ -16,17 +17,20 @@ const EXTENSION_TYPES: Record<string, string> = { png: 'image/png', jpg: 'image/
|
|
|
16
17
|
*/
|
|
17
18
|
export const UPLOAD_MODES = ['auto', 'chooser', 'into', 'drop', 'paste'] as const;
|
|
18
19
|
export type UploadMode = typeof UPLOAD_MODES[number];
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
/** hostDirs: opt-in, off by default. Lets a link holder upload a file that is already on the
|
|
21
|
+
* host, inside one of these directories (rech upload --host <path>). */
|
|
22
|
+
export type UploadPolicy = { maxBytes?: number; contentTypes?: string[]; hostDirs?: string[] };
|
|
23
|
+
export type UploadPayload = { name: string; contentType: string; data?: string; hostPath?: string; mode?: UploadMode; target?: string };
|
|
21
24
|
export type UploadResult = { status: number; stdout: string; stderr: string };
|
|
22
|
-
export type UploadAudit = { name: string | null; size: number | null; sha256: string | null; origin: string | null; result: string; mode?: UploadMode };
|
|
25
|
+
export type UploadAudit = { name: string | null; size: number | null; sha256: string | null; origin: string | null; result: string; mode?: UploadMode; hostPath?: string };
|
|
23
26
|
|
|
24
27
|
export function validateUploadPolicy(value: unknown): asserts value is UploadPolicy {
|
|
25
28
|
const p = value as UploadPolicy;
|
|
26
|
-
if (!p || typeof p !== 'object' || Array.isArray(p) || Object.keys(p).some(k => !['maxBytes', 'contentTypes'].includes(k))
|
|
29
|
+
if (!p || typeof p !== 'object' || Array.isArray(p) || Object.keys(p).some(k => !['maxBytes', 'contentTypes', 'hostDirs'].includes(k))
|
|
27
30
|
|| (p.maxBytes !== undefined && (!Number.isSafeInteger(p.maxBytes) || p.maxBytes < 1 || p.maxBytes > MAX_UPLOAD_BYTES))
|
|
28
|
-
|| (p.contentTypes !== undefined && (!Array.isArray(p.contentTypes) || !p.contentTypes.length || p.contentTypes.some(t => !UPLOAD_TYPES.includes(t as any))))
|
|
29
|
-
|
|
31
|
+
|| (p.contentTypes !== undefined && (!Array.isArray(p.contentTypes) || !p.contentTypes.length || p.contentTypes.some(t => !UPLOAD_TYPES.includes(t as any))))
|
|
32
|
+
|| (p.hostDirs !== undefined && (!Array.isArray(p.hostDirs) || !p.hostDirs.length || p.hostDirs.some(d => typeof d !== 'string' || !isAbsolute(d) || d.includes('\0') || d === sep))))
|
|
33
|
+
throw new Error(`uploads must be an object with optional maxBytes (1..${MAX_UPLOAD_BYTES}), contentTypes (${UPLOAD_TYPES.join(', ')}) and hostDirs (absolute directories, not /)`);
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
function validName(name: unknown): name is string {
|
|
@@ -62,6 +66,7 @@ export const UPLOAD_USAGE = `Usage: rech upload <file> [how] (the file is read
|
|
|
62
66
|
--drop <ref|selector> a drag-and-drop zone
|
|
63
67
|
--paste [ref|selector] an editor that takes pasted images (default: the focused element)
|
|
64
68
|
--name <file> read the bytes from stdin instead: rech upload --name shot.png < shot.png
|
|
69
|
+
--host <path> a file already on the host (only where its listener allows that directory)
|
|
65
70
|
Types: ${UPLOAD_TYPES.join(', ')}, up to ${MAX_UPLOAD_BYTES / 1024 / 1024} MiB. Get refs from rech snapshot.`;
|
|
66
71
|
|
|
67
72
|
/**
|
|
@@ -78,7 +83,7 @@ export async function prepareUpload(args: string[], stdin: AsyncIterable<Uint8Ar
|
|
|
78
83
|
const setMode = (next: UploadMode) => { if (mode !== 'auto') throw new Error(`Pick one of --into, --drop, --paste.`); mode = next; };
|
|
79
84
|
while (options.length) {
|
|
80
85
|
const option = options.shift()!;
|
|
81
|
-
const match = option.match(/^--(name|content-type|into|drop|paste|chooser)(?:=(.*))?$/);
|
|
86
|
+
const match = option.match(/^--(name|content-type|into|drop|paste|chooser|host)(?:=(.*))?$/);
|
|
82
87
|
if (!match) {
|
|
83
88
|
if (option.startsWith('-') || path !== undefined) throw new Error(path !== undefined ? `One file per upload; unexpected ${option}` : `Unknown option ${option}`);
|
|
84
89
|
path = option;
|
|
@@ -94,6 +99,19 @@ export async function prepareUpload(args: string[], stdin: AsyncIterable<Uint8Ar
|
|
|
94
99
|
fields[flag] = value;
|
|
95
100
|
}
|
|
96
101
|
if (target !== undefined && !validTarget(target)) throw new Error('Upload target must be a snapshot ref (e12) or a selector');
|
|
102
|
+
if (fields.host !== undefined) {
|
|
103
|
+
// A file on the host: send only its path; the host reads it if its listener allows that directory.
|
|
104
|
+
if (path !== undefined || fields.name !== undefined) throw new Error('--host takes the host file instead of a local file or --name');
|
|
105
|
+
if (!isAbsolute(fields.host)) throw new Error('--host needs an absolute path on the host');
|
|
106
|
+
const hostName = basename(fields.host);
|
|
107
|
+
if (!validName(hostName)) throw new Error('The host file name must be a plain name');
|
|
108
|
+
const hostType = fields['content-type'] ?? EXTENSION_TYPES[hostName.toLowerCase().split('.').pop() ?? ''] ?? '';
|
|
109
|
+
if (!UPLOAD_TYPES.includes(hostType as any)) throw new Error(`Upload content type must be one of ${UPLOAD_TYPES.join(', ')} (from the file extension, or --content-type)`);
|
|
110
|
+
const upload: UploadPayload = { name: hostName, contentType: hostType, hostPath: fields.host };
|
|
111
|
+
if (mode !== 'auto') upload.mode = mode;
|
|
112
|
+
if (target !== undefined) upload.target = target;
|
|
113
|
+
return { args: ['upload', ...sessions], upload };
|
|
114
|
+
}
|
|
97
115
|
if (path === undefined && !stdin) throw new Error(`upload needs a file.`);
|
|
98
116
|
const name = fields.name ?? (path !== undefined ? basename(path) : undefined);
|
|
99
117
|
if (!validName(name)) throw new Error(path !== undefined ? 'The file name must be a plain name; rename the file or pass --name' : 'Reading stdin needs --name with a plain filename (no path)');
|
|
@@ -195,6 +213,39 @@ function cleanOutput(text: string, path: string, dir: string, name: string): str
|
|
|
195
213
|
/** No payload field is ever used as a host path. run() is a server-owned CLI runner,
|
|
196
214
|
* not caller-supplied code. Audit records the selected origin immediately before upload.
|
|
197
215
|
*/
|
|
216
|
+
/**
|
|
217
|
+
* A host file for `rech upload --host`: only when the listener lists hostDirs, and only a regular
|
|
218
|
+
* file whose real path (symlinks resolved) is inside one of them.
|
|
219
|
+
*/
|
|
220
|
+
export async function readHostFile(path: string, hostDirs: string[] | undefined, limit: number): Promise<Buffer> {
|
|
221
|
+
if (!hostDirs?.length) throw new Error('Host-path uploads are off on this listener (the host can allow a directory: rech listener set <name> --host-upload-dir <dir>)');
|
|
222
|
+
if (!isAbsolute(path) || path.includes('\0')) throw new Error('Host upload path must be absolute');
|
|
223
|
+
const roots = (await Promise.all(hostDirs.map(d => realpath(d).catch(() => null)))).filter((r): r is string => !!r);
|
|
224
|
+
const inside = (real: string) => roots.some(root => real.startsWith(root.endsWith(sep) ? root : root + sep));
|
|
225
|
+
const resolveInside = async () => {
|
|
226
|
+
let real: string;
|
|
227
|
+
try { real = await realpath(path); } catch { throw new Error('Host upload file not found'); }
|
|
228
|
+
if (!inside(real)) throw new Error('Host upload path is outside the listener\'s allowed directories');
|
|
229
|
+
return real;
|
|
230
|
+
};
|
|
231
|
+
// Open once, then prove the opened file is the one an allowed path names now (same device and
|
|
232
|
+
// inode), so swapping in a symlink between the check and the open can't reach outside. Read from
|
|
233
|
+
// that descriptor only, at most limit + 1 bytes, so a file growing after the check can't either.
|
|
234
|
+
const handle = await open(await resolveInside(), fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
|
|
235
|
+
try {
|
|
236
|
+
const info = await handle.stat();
|
|
237
|
+
if (!info.isFile()) throw new Error('Host upload path is not a regular file');
|
|
238
|
+
const now = await stat(await resolveInside());
|
|
239
|
+
if (now.dev !== info.dev || now.ino !== info.ino) throw new Error('Host upload file changed while it was being opened');
|
|
240
|
+
if (info.size > limit) throw new Error("Upload is empty or exceeds maxBytes");
|
|
241
|
+
const buf = Buffer.alloc(limit + 1);
|
|
242
|
+
let n = 0;
|
|
243
|
+
for (let r; n < buf.length && (r = await handle.read(buf, n, buf.length - n, n)).bytesRead > 0;) n += r.bytesRead;
|
|
244
|
+
if (n > limit) throw new Error('Upload is empty or exceeds maxBytes');
|
|
245
|
+
return buf.subarray(0, n);
|
|
246
|
+
} finally { await handle.close(); }
|
|
247
|
+
}
|
|
248
|
+
|
|
198
249
|
export async function receiveUpload(options: {
|
|
199
250
|
policy: UploadPolicy | undefined; payload: unknown; args: string[];
|
|
200
251
|
run: (args: string[]) => Promise<UploadResult>; audit: (event: UploadAudit) => void;
|
|
@@ -208,18 +259,27 @@ export async function receiveUpload(options: {
|
|
|
208
259
|
if (options.policy === undefined) throw new Error('Uploads are disabled on this listener');
|
|
209
260
|
validateUploadPolicy(options.policy);
|
|
210
261
|
if (options.args.length !== 1 || options.args[0] !== 'upload') throw new Error('Upload takes file bytes, not host paths or CLI options');
|
|
211
|
-
|
|
212
|
-
|
|
262
|
+
const fromHost = p?.hostPath !== undefined;
|
|
263
|
+
if (!p || !validName(p.name) || typeof p.contentType !== 'string' || (fromHost ? p.data !== undefined || typeof p.hostPath !== 'string' : typeof p.data !== 'string')
|
|
264
|
+
|| Object.keys(p).some(k => !['name', 'contentType', 'data', 'hostPath', 'mode', 'target'].includes(k))
|
|
213
265
|
|| (p.mode !== undefined && !UPLOAD_MODES.includes(p.mode)) || (p.target !== undefined && !validTarget(p.target))
|
|
214
266
|
|| ((p.mode === 'into' || p.mode === 'drop') && p.target === undefined) || ((p.mode === 'auto' || p.mode === 'chooser') && p.target !== undefined))
|
|
215
267
|
throw new Error('Invalid upload payload');
|
|
216
268
|
const limit = options.policy.maxBytes ?? MAX_UPLOAD_BYTES;
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
269
|
+
let bytes: Buffer;
|
|
270
|
+
if (fromHost) {
|
|
271
|
+
record.hostPath = p.hostPath!.slice(0, 512);
|
|
272
|
+
bytes = await readHostFile(p.hostPath!, options.policy.hostDirs, limit);
|
|
273
|
+
if (basename(p.hostPath!) !== p.name) throw new Error('Host upload name must be the file\'s own name');
|
|
274
|
+
} else {
|
|
275
|
+
if (p.data!.length > 4 * Math.ceil(limit / 3) || /[^A-Za-z0-9+/=]/.test(p.data!))
|
|
276
|
+
throw new Error('Upload exceeds maxBytes or has invalid base64');
|
|
277
|
+
bytes = Buffer.from(p.data!, 'base64');
|
|
278
|
+
if (bytes.toString('base64') !== p.data) throw new Error('Upload is not canonical base64');
|
|
279
|
+
}
|
|
220
280
|
record.size = bytes.length;
|
|
221
281
|
record.sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
222
|
-
if (!bytes.length || bytes.length > limit
|
|
282
|
+
if (!bytes.length || bytes.length > limit) throw new Error('Upload is empty or exceeds maxBytes');
|
|
223
283
|
if (!(options.policy.contentTypes ?? UPLOAD_TYPES).includes(p.contentType) || !matchesType(bytes, p.contentType))
|
|
224
284
|
throw new Error('Upload content type is not allowed or does not match the bytes');
|
|
225
285
|
const tabs = await options.run(['tab-list']);
|
package/serve.js
CHANGED
|
@@ -1039,7 +1039,7 @@ export async function serve() {
|
|
|
1039
1039
|
if (filteredArgs[0] === "upload") {
|
|
1040
1040
|
const result = await receiveUpload({
|
|
1041
1041
|
// The management key can already run any code, so uploads need no extra opt-in there.
|
|
1042
|
-
policy: listener.uploads
|
|
1042
|
+
policy: listener.uploads === false ? undefined : listener.uploads ?? {}, payload: body.upload, args: filteredArgs,
|
|
1043
1043
|
audit: event => log(`upload: ${JSON.stringify({ listener: listener.name, profile: scopedProfile ?? body.identity?.profile ?? null, ...event })}`),
|
|
1044
1044
|
run: async uploadArgs => {
|
|
1045
1045
|
const child = Bun.spawn([bin, ...binArgs, ...uploadArgs, `-s=${namespacedSession}`], {
|
package/serve.ts
CHANGED
|
@@ -1039,7 +1039,7 @@ export async function serve() {
|
|
|
1039
1039
|
if (filteredArgs[0] === "upload") {
|
|
1040
1040
|
const result = await receiveUpload({
|
|
1041
1041
|
// The management key can already run any code, so uploads need no extra opt-in there.
|
|
1042
|
-
policy: listener.uploads
|
|
1042
|
+
policy: listener.uploads === false ? undefined : listener.uploads ?? {}, payload: body.upload, args: filteredArgs,
|
|
1043
1043
|
audit: event => log(`upload: ${JSON.stringify({ listener: listener.name, profile: scopedProfile ?? body.identity?.profile ?? null, ...event })}`),
|
|
1044
1044
|
run: async uploadArgs => {
|
|
1045
1045
|
const child = Bun.spawn([bin, ...binArgs, ...uploadArgs, `-s=${namespacedSession}`], {
|
package/webrtc.js
CHANGED
|
@@ -466,13 +466,16 @@ class WebrtcHost {
|
|
|
466
466
|
this.log(`webrtc ${room.cred.label}: another host took this room over; not reconnecting`);
|
|
467
467
|
return;
|
|
468
468
|
}
|
|
469
|
-
room.
|
|
469
|
+
if (room.openedAt && Date.now() - room.openedAt >= STABLE_MS)
|
|
470
|
+
room.backoff = 1000;
|
|
471
|
+
room.openedAt = undefined;
|
|
472
|
+
room.retry = setTimeout(() => void this.connect(room), reconnectDelay(room.backoff));
|
|
470
473
|
room.backoff = Math.min(room.backoff * 2, 30000);
|
|
471
474
|
});
|
|
472
475
|
room.signal = sock;
|
|
473
476
|
try {
|
|
474
477
|
await sock.open();
|
|
475
|
-
room.
|
|
478
|
+
room.openedAt = Date.now();
|
|
476
479
|
} catch (error) {
|
|
477
480
|
this.log(`webrtc ${room.cred.label}: ${error instanceof Error ? error.message : error}`);
|
|
478
481
|
}
|
|
@@ -861,9 +864,14 @@ async function spawnBridge(token, signal, entry, dir = BRIDGE_DIR) {
|
|
|
861
864
|
throw new Error(`the local WebRTC bridge did not start${tail ? `:
|
|
862
865
|
${tail}` : ""}`);
|
|
863
866
|
}
|
|
867
|
+
var STABLE_MS = 60000;
|
|
868
|
+
function reconnectDelay(backoff, random = Math.random) {
|
|
869
|
+
return Math.round(backoff * (0.75 + random() * 0.5));
|
|
870
|
+
}
|
|
864
871
|
export {
|
|
865
872
|
BRIDGE_DIR,
|
|
866
873
|
SIGNAL_PROTO,
|
|
874
|
+
STABLE_MS,
|
|
867
875
|
WEBRTC_RUNTIME_DIR,
|
|
868
876
|
WebrtcClient,
|
|
869
877
|
WebrtcHost,
|
|
@@ -873,6 +881,7 @@ export {
|
|
|
873
881
|
liveBridge,
|
|
874
882
|
loadRTC,
|
|
875
883
|
readBridge,
|
|
884
|
+
reconnectDelay,
|
|
876
885
|
runBridge,
|
|
877
886
|
signalSocketUrl,
|
|
878
887
|
spawnBridge
|
package/webrtc.ts
CHANGED
|
@@ -210,7 +210,7 @@ export type HostHandler = (req: Request, token: string) => Promise<Response>;
|
|
|
210
210
|
|
|
211
211
|
/** Keeps one signal room per credential and serves each joining peer through `handler`. */
|
|
212
212
|
export class WebrtcHost {
|
|
213
|
-
private rooms = new Map<string, { cred: HostCredential; signal?: SignalSocket; peers: Map<string, { pc: any; channel?: SealedChannel }>; backoff: number; stopped: boolean; retry?: ReturnType<typeof setTimeout> }>();
|
|
213
|
+
private rooms = new Map<string, { cred: HostCredential; signal?: SignalSocket; peers: Map<string, { pc: any; channel?: SealedChannel }>; backoff: number; openedAt?: number; stopped: boolean; retry?: ReturnType<typeof setTimeout> }>();
|
|
214
214
|
constructor(private handler: HostHandler, private log: Log) {}
|
|
215
215
|
|
|
216
216
|
/** Open rooms for new credentials, close rooms for ones that are gone. */
|
|
@@ -245,11 +245,15 @@ export class WebrtcHost {
|
|
|
245
245
|
(code, reason) => {
|
|
246
246
|
if (room.stopped) return;
|
|
247
247
|
if (code === 1000 && /replaced/.test(reason)) { this.log(`webrtc ${room.cred.label}: another host took this room over; not reconnecting`); return; }
|
|
248
|
-
|
|
248
|
+
// Only a connection that stayed up resets the back-off: a server that accepts and then
|
|
249
|
+
// drops us must not be hit every second.
|
|
250
|
+
if (room.openedAt && Date.now() - room.openedAt >= STABLE_MS) room.backoff = 1000;
|
|
251
|
+
room.openedAt = undefined;
|
|
252
|
+
room.retry = setTimeout(() => void this.connect(room), reconnectDelay(room.backoff));
|
|
249
253
|
room.backoff = Math.min(room.backoff * 2, 30_000);
|
|
250
254
|
});
|
|
251
255
|
room.signal = sock;
|
|
252
|
-
try { await sock.open(); room.
|
|
256
|
+
try { await sock.open(); room.openedAt = Date.now(); }
|
|
253
257
|
catch (error) { this.log(`webrtc ${room.cred.label}: ${error instanceof Error ? error.message : error}`); }
|
|
254
258
|
}
|
|
255
259
|
|
|
@@ -533,3 +537,11 @@ export async function spawnBridge(token: string, signal: string, entry: string[]
|
|
|
533
537
|
const tail = existsSync(logPath) ? readFileSync(logPath, "utf8").trim().split("\n").slice(-3).join("\n") : "";
|
|
534
538
|
throw new Error(`the local WebRTC bridge did not start${tail ? `:\n${tail}` : ""}`);
|
|
535
539
|
}
|
|
540
|
+
|
|
541
|
+
/** How long a signal connection must stay up before the reconnect back-off starts over. */
|
|
542
|
+
export const STABLE_MS = 60_000;
|
|
543
|
+
/** The back-off with ±25% jitter, so hosts dropped together (a signal server restart) don't
|
|
544
|
+
* all reconnect in the same instant. */
|
|
545
|
+
export function reconnectDelay(backoff: number, random = Math.random): number {
|
|
546
|
+
return Math.round(backoff * (0.75 + random() * 0.5));
|
|
547
|
+
}
|