rechrome 1.38.2 → 1.39.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 +45 -42
- package/listeners.js +17 -8
- package/listeners.ts +5 -3
- package/package.json +3 -2
- package/rechrome.js +47 -11
- package/rechrome.ts +47 -11
- package/scoped-upload.js +164 -35
- package/scoped-upload.ts +172 -33
- package/serve.js +3 -2
- package/serve.ts +3 -2
- package/vendor/playwright-cli/node_modules/playwright-core/lib/coreBundle.js +229 -277
- package/vendor/playwright-cli/node_modules/playwright-core/lib/tools/cli-client/session.js +2 -3
- package/vendor/playwright-cli/playwright-cli.js +1 -33
package/README.md
CHANGED
|
@@ -359,56 +359,59 @@ cp .env.example .env.local
|
|
|
359
359
|
>
|
|
360
360
|
> Shell-set `PLAYWRIGHT_MCP_*` variables take priority over `.env.local`, so you can always override per-command without editing files.
|
|
361
361
|
|
|
362
|
-
###
|
|
362
|
+
### Uploading files
|
|
363
363
|
|
|
364
|
-
|
|
365
|
-
|
|
364
|
+
`rech upload <file>` puts a local image into the page: a file picker, an
|
|
365
|
+
`<input type=file>` (hidden ones too), a drag-and-drop zone, or an editor that
|
|
366
|
+
takes pasted images. The file is read on **your** machine and only its bytes are
|
|
367
|
+
sent, so it works the same against a remote host.
|
|
366
368
|
|
|
367
|
-
```
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
369
|
+
```bash
|
|
370
|
+
rech upload ./shot.png # the chooser a click opened, else the page's only file input
|
|
371
|
+
rech upload ./shot.png --into e42 # a file input, or the button that opens the picker (clicks it)
|
|
372
|
+
rech upload ./shot.png --into 'input#avatar'
|
|
373
|
+
rech upload ./shot.png --drop e17 # a drag-and-drop zone
|
|
374
|
+
rech upload ./shot.png --paste e9 # an editor that takes pasted images (default: the focused element)
|
|
375
|
+
rech upload --name shot.png < ./shot.png # bytes from stdin instead of a path
|
|
372
376
|
```
|
|
373
377
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
listener reload mechanism.
|
|
378
|
+
Targets are snapshot refs (`rech snapshot`) or selectors, the same as `rech click`.
|
|
379
|
+
The usual flow for a site's "add image" button is `rech snapshot`, then
|
|
380
|
+
`rech upload ./shot.png --into <the button's ref>`: rech clicks it and fills the file
|
|
381
|
+
chooser that opens. Run the steps in one session (`-s=<name>`) when they span
|
|
382
|
+
several commands.
|
|
380
383
|
|
|
381
|
-
|
|
382
|
-
|
|
384
|
+
**Types and size:** PNG, JPEG, GIF, WebP and SVG, up to **10 MiB**. Both the client
|
|
385
|
+
and the host check the size, and check the bytes against the type (PNG/JPEG/GIF/WebP
|
|
386
|
+
signatures; UTF-8 SVG without DTD/entities). These checks identify the format; they
|
|
387
|
+
don't sanitize SVG or scan for malware. The host never renders the file.
|
|
388
|
+
|
|
389
|
+
**Turning uploads on:** they are always on through the local management listener.
|
|
390
|
+
On a shared (scoped) listener they are **off** until the host turns them on:
|
|
383
391
|
|
|
384
392
|
```bash
|
|
385
|
-
rech
|
|
386
|
-
|
|
393
|
+
rech listener set <listener> --uploads # --no-uploads turns them off again
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
That writes an `uploads` object to the listener's entry in `~/.rechrome/listeners.json`.
|
|
397
|
+
The host can tune it by hand, lowering the byte limit or narrowing the types, but not
|
|
398
|
+
going above the 10 MiB cap or adding other types:
|
|
399
|
+
|
|
400
|
+
```json
|
|
401
|
+
"uploads": { "maxBytes": 2097152, "contentTypes": ["image/png", "image/jpeg"] }
|
|
387
402
|
```
|
|
388
403
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
file
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
that generated path to the existing file-chooser command and removes the directory
|
|
401
|
-
on success, error, or timeout. Neither filenames nor payload fields can select a
|
|
402
|
-
host file. A crash or forced process kill can interrupt cleanup; the files remain
|
|
403
|
-
private OS temporary files. Permission modes apply on POSIX hosts.
|
|
404
|
-
|
|
405
|
-
Each upload produces one `upload:` JSON audit record with listener, profile,
|
|
406
|
-
original name, byte size, SHA-256, page origin observed immediately before upload,
|
|
407
|
-
and result. It contains no file contents, credentials, page path, query, or fragment.
|
|
408
|
-
Requests rejected before inspection have null values for unavailable fields.
|
|
409
|
-
An HTTP(S) origin must be determined before an upload is attempted. As with other
|
|
410
|
-
browser commands, avoid concurrent tab navigation in the same session. Scoped
|
|
411
|
-
`eval`, `run-code`, host paths, and CLI option overrides remain unavailable.
|
|
404
|
+
**On the host:** the bytes are written under their original file name inside a fresh
|
|
405
|
+
private temporary directory (0700, file 0600), outside the downloadable output tree,
|
|
406
|
+
and the directory is removed afterwards (on success, error or timeout). Neither the
|
|
407
|
+
name, the target nor any other payload field can select a host file or inject code:
|
|
408
|
+
the drop/paste/input steps run as a server-built script that embeds them as JSON
|
|
409
|
+
data. Each upload writes one `upload:` JSON audit record: listener, profile,
|
|
410
|
+
original name, size, SHA-256, delivery mode, the page origin just before the
|
|
411
|
+
upload, and the result. It never contains file contents, credentials, or the page's
|
|
412
|
+
path, query or fragment. An HTTP(S) origin must be known before an upload is
|
|
413
|
+
attempted. Scoped `eval`, `run-code`, host paths and CLI option overrides stay
|
|
414
|
+
unavailable; enabling uploads doesn't change that.
|
|
412
415
|
|
|
413
416
|
### Remote access
|
|
414
417
|
|
|
@@ -475,7 +478,7 @@ git clone https://github.com/snomiao/rechrome.git
|
|
|
475
478
|
cd rechrome
|
|
476
479
|
bun install # `prepare` also builds vendor/ from vendor-src/, so the checkout has a working CLI
|
|
477
480
|
bun link # makes this checkout the global rechrome / rech
|
|
478
|
-
bun
|
|
481
|
+
bun run test # rechrome's own tests (plain `bun test` also finds the vendored forks' suites)
|
|
479
482
|
```
|
|
480
483
|
|
|
481
484
|
To use a different playwright-cli, set `PLAYWRIGHT_CLI=<cmd>` in `.env.local` (for example a
|
package/listeners.js
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// scoped-upload.ts
|
|
3
|
-
var MAX_UPLOAD_BYTES =
|
|
4
|
-
var MAX_REQUEST_BYTES =
|
|
5
|
-
var UPLOAD_TYPES = ["image/png", "image/svg+xml"];
|
|
3
|
+
var MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
|
4
|
+
var MAX_REQUEST_BYTES = 14 * 1024 * 1024;
|
|
5
|
+
var UPLOAD_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml"];
|
|
6
6
|
function validateUploadPolicy(value) {
|
|
7
7
|
const p = value;
|
|
8
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(
|
|
10
|
-
}
|
|
9
|
+
throw new Error(`uploads must be an object with optional maxBytes (1..${MAX_UPLOAD_BYTES}) and contentTypes (${UPLOAD_TYPES.join(", ")})`);
|
|
10
|
+
}
|
|
11
|
+
var UPLOAD_USAGE = `Usage: rech upload <file> [how] (the file is read on this machine; only its bytes are sent)
|
|
12
|
+
(no flag) the file chooser a click opened, else the page's only <input type=file>
|
|
13
|
+
--into <ref|selector> a file input (hidden ones too), or a button that opens a file chooser
|
|
14
|
+
--drop <ref|selector> a drag-and-drop zone
|
|
15
|
+
--paste [ref|selector] an editor that takes pasted images (default: the focused element)
|
|
16
|
+
--name <file> read the bytes from stdin instead: rech upload --name shot.png < shot.png
|
|
17
|
+
Types: ${UPLOAD_TYPES.join(", ")}, up to ${MAX_UPLOAD_BYTES / 1024 / 1024} MiB. Get refs from rech snapshot.`;
|
|
11
18
|
|
|
12
19
|
// listeners.ts
|
|
13
20
|
import { isIP } from "net";
|
|
@@ -109,7 +116,7 @@ async function writeListeners(config) {
|
|
|
109
116
|
`, { mode: 384 });
|
|
110
117
|
await rename(temp, LISTENERS_FILE);
|
|
111
118
|
}
|
|
112
|
-
var SCOPED_COMMANDS = new Set(["open", "goto", "tab-new", "tab-list", "tab-select", "tab-close", "snapshot", "screenshot", "click", "dblclick", "fill", "type", "press", "hover", "select", "check", "uncheck", "go-back", "go-forward", "reload", "resize", "close", "fill-secret"]);
|
|
119
|
+
var SCOPED_COMMANDS = new Set(["open", "goto", "tab-new", "tab-list", "tab-select", "tab-close", "snapshot", "screenshot", "click", "dblclick", "fill", "type", "press", "hover", "select", "check", "uncheck", "go-back", "go-forward", "reload", "resize", "close", "fill-secret", "console", "dialog-accept", "dialog-dismiss"]);
|
|
113
120
|
function authorizeProfileRequest(listener, body) {
|
|
114
121
|
if (!body || Array.isArray(body) || !body.identity || typeof body.identity.key !== "string" || !body.identity.key || !Array.isArray(body.args) || body.args.some((a) => typeof a !== "string"))
|
|
115
122
|
throw new Error("Scoped requests require identity.key, identity.profile and string args");
|
|
@@ -119,9 +126,11 @@ function authorizeProfileRequest(listener, body) {
|
|
|
119
126
|
if (body.env?.PLAYWRIGHT_MCP_PROFILE_DIRECTORY && body.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY !== profile)
|
|
120
127
|
throw new Error("Profile override does not match request identity");
|
|
121
128
|
const args = body.args.filter((a) => /^-s=[a-zA-Z0-9_-]{1,64}$/.test(a) === false);
|
|
122
|
-
if (
|
|
129
|
+
if (args[0] === "upload" && listener.uploads === undefined)
|
|
130
|
+
throw new Error(`Uploads are off on listener "${listener.name}". The host can turn them on: rech listener set ${listener.name} --uploads`);
|
|
131
|
+
if (args[0] !== "upload" && !SCOPED_COMMANDS.has(args[0]))
|
|
123
132
|
throw new Error("Command is unavailable on profile-scoped listeners; use the local management listener for arbitrary code or host access");
|
|
124
|
-
if (body.args.filter((a) => a.startsWith("-s=")).length > 1 || args.slice(1).some((a) => a.startsWith("-") && !(args[0] === "screenshot" && a === "--full-page") && !(args[0] === "fill-secret" && (a === "--submit" || a === "--totp" || a.startsWith("--allow-domain=")))))
|
|
133
|
+
if (body.args.filter((a) => a.startsWith("-s=")).length > 1 || args.slice(1).some((a) => 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=")))))
|
|
125
134
|
throw new Error("CLI option overrides are unavailable on profile-scoped listeners");
|
|
126
135
|
if (["open", "goto", "tab-new"].includes(args[0]) && args[1] && !/^https?:\/\//i.test(args[1]) && args[1] !== "about:blank")
|
|
127
136
|
throw new Error("Scoped navigation accepts only HTTP(S) or about:blank");
|
package/listeners.ts
CHANGED
|
@@ -96,15 +96,17 @@ export async function writeListeners(config: ListenerConfig): Promise<void> {
|
|
|
96
96
|
|
|
97
97
|
// Restricted listeners deliberately exclude arbitrary code, host filesystem access,
|
|
98
98
|
// attachment/config overrides, and commands that manage all browser sessions.
|
|
99
|
-
const SCOPED_COMMANDS = new Set(["open", "goto", "tab-new", "tab-list", "tab-select", "tab-close", "snapshot", "screenshot", "click", "dblclick", "fill", "type", "press", "hover", "select", "check", "uncheck", "go-back", "go-forward", "reload", "resize", "close", "fill-secret"]);
|
|
99
|
+
const SCOPED_COMMANDS = new Set(["open", "goto", "tab-new", "tab-list", "tab-select", "tab-close", "snapshot", "screenshot", "click", "dblclick", "fill", "type", "press", "hover", "select", "check", "uncheck", "go-back", "go-forward", "reload", "resize", "close", "fill-secret", "console", "dialog-accept", "dialog-dismiss"]);
|
|
100
100
|
export function authorizeProfileRequest(listener: Listener, body: any): string {
|
|
101
101
|
if (!body || Array.isArray(body) || !body.identity || typeof body.identity.key !== "string" || !body.identity.key || !Array.isArray(body.args) || body.args.some((a: unknown) => typeof a !== "string")) throw new Error("Scoped requests require identity.key, identity.profile and string args");
|
|
102
102
|
const profile = body.identity.profile;
|
|
103
103
|
if (typeof profile !== "string" || listener.profiles === "*" || !listener.profiles.includes(profile)) throw new Error("Profile is not allowed on this listener");
|
|
104
104
|
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
105
|
const args = body.args.filter((a: string) => /^-s=[a-zA-Z0-9_-]{1,64}$/.test(a) === false);
|
|
106
|
-
if (
|
|
107
|
-
|
|
106
|
+
if (args[0] === "upload" && listener.uploads === undefined)
|
|
107
|
+
throw new Error(`Uploads are off on listener "${listener.name}". The host can turn them on: rech listener set ${listener.name} --uploads`);
|
|
108
|
+
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
|
+
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");
|
|
108
110
|
if (["open", "goto", "tab-new"].includes(args[0]) && args[1] && !/^https?:\/\//i.test(args[1]) && args[1] !== "about:blank") throw new Error("Scoped navigation accepts only HTTP(S) or about:blank");
|
|
109
111
|
// Upload is handled by the byte-only receiver; the caller cannot supply host paths.
|
|
110
112
|
if (args[0] === "upload" && (args.length !== 1 || !body.upload)) throw new Error("Upload requires stdin bytes; host paths are unavailable");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rechrome",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.39.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/snomiao/rechrome.git"
|
|
@@ -45,7 +45,8 @@
|
|
|
45
45
|
"type": "module",
|
|
46
46
|
"scripts": {
|
|
47
47
|
"serve": "bun run rechrome.ts serve",
|
|
48
|
-
"test": "bun test",
|
|
48
|
+
"test": "bun test ./*.test.ts ./*.spec.ts ./scripts/*.test.ts",
|
|
49
|
+
"build-playwright": "bash scripts/build-playwright.sh",
|
|
49
50
|
"vendor-cli": "bash scripts/vendor-cli.sh",
|
|
50
51
|
"prepare": "bash scripts/vendor-cli.sh --prepare",
|
|
51
52
|
"check-pack": "bash scripts/check-pack.sh",
|
package/rechrome.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import {
|
|
2
|
+
import { prepareUpload, UPLOAD_USAGE, MAX_UPLOAD_BYTES, type UploadPayload } from "./scoped-upload.js";
|
|
3
3
|
import { readListeners, writeListeners as writeListenersFile, listenerAddress, isLoopback, normalizePrefix, serviceUrl, allowProfiles, denyProfiles, rotateListenerKey, setPublicUrl, canonicalProfileKeys, planProfileRemoval, type Listener, type ListenerConfig } from "./listeners.js";
|
|
4
4
|
|
|
5
5
|
import { file } from "bun";
|
|
@@ -2177,8 +2177,14 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
|
|
|
2177
2177
|
process.exit(2);
|
|
2178
2178
|
}
|
|
2179
2179
|
} else if (args.find(a => !/^-s=[a-zA-Z0-9_-]{1,64}$/.test(a)) === "upload") {
|
|
2180
|
-
if (
|
|
2181
|
-
|
|
2180
|
+
if (args.some(a => a === "--help" || a === "-h")) { console.log(UPLOAD_USAGE); process.exit(0); }
|
|
2181
|
+
try {
|
|
2182
|
+
// A path argument is read here; without one the bytes come from stdin (never a terminal).
|
|
2183
|
+
({ args, upload } = await prepareUpload(args, process.stdin.isTTY ? null : process.stdin));
|
|
2184
|
+
} catch (err) {
|
|
2185
|
+
console.error(`[rech] ${err instanceof Error ? err.message : String(err)}\n${UPLOAD_USAGE}`);
|
|
2186
|
+
process.exit(2);
|
|
2187
|
+
}
|
|
2182
2188
|
}
|
|
2183
2189
|
const served = await callServe(url, args, overrideEnv, identity, false, upload, secret);
|
|
2184
2190
|
// The daemon already masks the value; this is a second line of defence for an older daemon.
|
|
@@ -3333,11 +3339,19 @@ async function rotateKey(name: string): Promise<void> {
|
|
|
3333
3339
|
console.log(`New key for ${name}; URLs carrying the old key stop working now. Print new ones with: rech share <profile> --listener ${name}`);
|
|
3334
3340
|
}
|
|
3335
3341
|
|
|
3336
|
-
async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean }): Promise<void> {
|
|
3342
|
+
async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean }): Promise<void> {
|
|
3337
3343
|
const config = await requireListeners();
|
|
3338
|
-
|
|
3344
|
+
let listener = config.listeners.find(l => l.name === name);
|
|
3345
|
+
if (!listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
|
|
3346
|
+
if (opts.publicUrl !== undefined || opts.clearPublicUrl) listener = setPublicUrl(config, name, opts.clearPublicUrl ? null : opts.publicUrl ?? null);
|
|
3347
|
+
if (opts.uploads !== undefined) {
|
|
3348
|
+
if (listener.profiles === "*") throw new Error(`"${name}" is the local management listener: uploads are always on there.`);
|
|
3349
|
+
// {} = the defaults (every supported image type, the full size cap); hand-tuned limits stay.
|
|
3350
|
+
if (opts.uploads) listener.uploads ??= {}; else delete listener.uploads;
|
|
3351
|
+
}
|
|
3339
3352
|
await writeListeners(config);
|
|
3340
|
-
console.log(listener.publicUrl ? `${name} is reachable at ${listener.publicUrl}` : `${name}: public URL cleared`);
|
|
3353
|
+
if (opts.publicUrl !== undefined || opts.clearPublicUrl) console.log(listener.publicUrl ? `${name} is reachable at ${listener.publicUrl}` : `${name}: public URL cleared`);
|
|
3354
|
+
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`);
|
|
3341
3355
|
}
|
|
3342
3356
|
|
|
3343
3357
|
const hideKey = (url: string) => url.replace(/([#&?]key=)[^&]*/, "$1…");
|
|
@@ -4166,9 +4180,30 @@ const selfCommand = () => [process.execPath, Bun.main];
|
|
|
4166
4180
|
*/
|
|
4167
4181
|
const bridgedUrls = new Set<string>();
|
|
4168
4182
|
|
|
4183
|
+
/**
|
|
4184
|
+
* RECHROME_URL may address one of this machine's own scoped listeners directly (e.g. its tailnet
|
|
4185
|
+
* IP), which refuses management-only commands. Same user, same daemon: go through the loopback
|
|
4186
|
+
* management listener instead. The session is unchanged, since it is keyed by identity, not by
|
|
4187
|
+
* listener. HTTPS is left alone: the daemon's certificate doesn't name 127.0.0.1.
|
|
4188
|
+
*/
|
|
4189
|
+
export function preferManagementListener(url: string, config: { listeners: Listener[] } | null): string {
|
|
4190
|
+
const { key, host, port, protocol } = parseUrl(url);
|
|
4191
|
+
const own = config?.listeners.find(l => l.key === key);
|
|
4192
|
+
const management = config?.listeners.find(l => l.profiles === "*" && isLoopback(l.host));
|
|
4193
|
+
if (!key || !own || own.profiles === "*" || !management || protocol !== "http") return url;
|
|
4194
|
+
if (own.host !== host.replace(/^\[|\]$/g, "") || own.port !== port) return url;
|
|
4195
|
+
const source = new URL(url);
|
|
4196
|
+
const next = new URL(`http://${listenerAddress(management)}${normalizePrefix(management.prefix)}`);
|
|
4197
|
+
next.search = source.search;
|
|
4198
|
+
const fragment = new URLSearchParams(source.hash.slice(1).replace(/^\?/, ""));
|
|
4199
|
+
fragment.set("key", management.key);
|
|
4200
|
+
next.hash = fragment.toString();
|
|
4201
|
+
return next.toString();
|
|
4202
|
+
}
|
|
4203
|
+
|
|
4169
4204
|
export async function transportUrl(url: string): Promise<string> {
|
|
4170
4205
|
const parts = shareLinkParts(url);
|
|
4171
|
-
if (!parts?.webrtc) return url;
|
|
4206
|
+
if (!parts?.webrtc) return preferManagementListener(url, await readListeners().catch(() => null));
|
|
4172
4207
|
const info = await spawnBridge(parts.token, parts.origin, selfCommand());
|
|
4173
4208
|
const bridged = new URL(`http://127.0.0.1:${info.port}/`);
|
|
4174
4209
|
new URL(url).searchParams.forEach((v, k) => bridged.searchParams.set(k, v));
|
|
@@ -4304,7 +4339,7 @@ export type RechHandlers = {
|
|
|
4304
4339
|
allowListener(name: string, profiles: string[]): Promise<void>;
|
|
4305
4340
|
denyListener(name: string, profiles: string[]): Promise<void>;
|
|
4306
4341
|
rotateKey(name: string): Promise<void>;
|
|
4307
|
-
setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean }): Promise<void>;
|
|
4342
|
+
setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean }): Promise<void>;
|
|
4308
4343
|
setup(opts: SetupOptions): Promise<void>;
|
|
4309
4344
|
tray(action?: string): Promise<void>;
|
|
4310
4345
|
provisionProfile(name: string, opts: { headed: boolean; experimental: boolean }): Promise<void>;
|
|
@@ -4446,12 +4481,13 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
|
4446
4481
|
.command("deny <name> <profiles..>", "Remove profiles from a listener", y => y
|
|
4447
4482
|
.positional("name", { type: "string", demandOption: true }).positional("profiles", { type: "string", array: true, demandOption: true }),
|
|
4448
4483
|
a => handlers.denyListener(a.name, a.profiles))
|
|
4449
|
-
.command("set <name>", "Record where a reverse proxy exposes a listener", y => y
|
|
4484
|
+
.command("set <name>", "Record where a reverse proxy exposes a listener, or turn uploads on/off", y => y
|
|
4450
4485
|
.positional("name", { type: "string", demandOption: true })
|
|
4451
4486
|
.option("public-url", { type: "string", requiresArg: true, describe: "e.g. https://host.example.js.net/rechrome/" })
|
|
4452
4487
|
.option("clear-public-url", { type: "boolean", conflicts: "public-url" })
|
|
4453
|
-
.
|
|
4454
|
-
a =>
|
|
4488
|
+
.option("uploads", { type: "boolean", describe: "Let this listener's links upload images (rech upload); --no-uploads turns it off" })
|
|
4489
|
+
.check(a => a.publicUrl !== undefined || a.clearPublicUrl || a.uploads !== undefined ? true : "Pass --public-url <url>, --clear-public-url, or --uploads/--no-uploads"),
|
|
4490
|
+
a => handlers.setListener(a.name, { publicUrl: a.publicUrl, clearPublicUrl: a.clearPublicUrl, ...(a.uploads !== undefined ? { uploads: a.uploads } : {}) }))
|
|
4455
4491
|
.command("port [name]", "Print a listener's port (for a proxy command)", y => y.positional("name", { type: "string" }),
|
|
4456
4492
|
a => handlers.listenerPort(a.name))
|
|
4457
4493
|
.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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import {
|
|
2
|
+
import { prepareUpload, UPLOAD_USAGE, MAX_UPLOAD_BYTES, type UploadPayload } from "./scoped-upload.ts";
|
|
3
3
|
import { readListeners, writeListeners as writeListenersFile, listenerAddress, isLoopback, normalizePrefix, serviceUrl, allowProfiles, denyProfiles, rotateListenerKey, setPublicUrl, canonicalProfileKeys, planProfileRemoval, type Listener, type ListenerConfig } from "./listeners.ts";
|
|
4
4
|
|
|
5
5
|
import { file } from "bun";
|
|
@@ -2177,8 +2177,14 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
|
|
|
2177
2177
|
process.exit(2);
|
|
2178
2178
|
}
|
|
2179
2179
|
} else if (args.find(a => !/^-s=[a-zA-Z0-9_-]{1,64}$/.test(a)) === "upload") {
|
|
2180
|
-
if (
|
|
2181
|
-
|
|
2180
|
+
if (args.some(a => a === "--help" || a === "-h")) { console.log(UPLOAD_USAGE); process.exit(0); }
|
|
2181
|
+
try {
|
|
2182
|
+
// A path argument is read here; without one the bytes come from stdin (never a terminal).
|
|
2183
|
+
({ args, upload } = await prepareUpload(args, process.stdin.isTTY ? null : process.stdin));
|
|
2184
|
+
} catch (err) {
|
|
2185
|
+
console.error(`[rech] ${err instanceof Error ? err.message : String(err)}\n${UPLOAD_USAGE}`);
|
|
2186
|
+
process.exit(2);
|
|
2187
|
+
}
|
|
2182
2188
|
}
|
|
2183
2189
|
const served = await callServe(url, args, overrideEnv, identity, false, upload, secret);
|
|
2184
2190
|
// The daemon already masks the value; this is a second line of defence for an older daemon.
|
|
@@ -3333,11 +3339,19 @@ async function rotateKey(name: string): Promise<void> {
|
|
|
3333
3339
|
console.log(`New key for ${name}; URLs carrying the old key stop working now. Print new ones with: rech share <profile> --listener ${name}`);
|
|
3334
3340
|
}
|
|
3335
3341
|
|
|
3336
|
-
async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean }): Promise<void> {
|
|
3342
|
+
async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean }): Promise<void> {
|
|
3337
3343
|
const config = await requireListeners();
|
|
3338
|
-
|
|
3344
|
+
let listener = config.listeners.find(l => l.name === name);
|
|
3345
|
+
if (!listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
|
|
3346
|
+
if (opts.publicUrl !== undefined || opts.clearPublicUrl) listener = setPublicUrl(config, name, opts.clearPublicUrl ? null : opts.publicUrl ?? null);
|
|
3347
|
+
if (opts.uploads !== undefined) {
|
|
3348
|
+
if (listener.profiles === "*") throw new Error(`"${name}" is the local management listener: uploads are always on there.`);
|
|
3349
|
+
// {} = the defaults (every supported image type, the full size cap); hand-tuned limits stay.
|
|
3350
|
+
if (opts.uploads) listener.uploads ??= {}; else delete listener.uploads;
|
|
3351
|
+
}
|
|
3339
3352
|
await writeListeners(config);
|
|
3340
|
-
console.log(listener.publicUrl ? `${name} is reachable at ${listener.publicUrl}` : `${name}: public URL cleared`);
|
|
3353
|
+
if (opts.publicUrl !== undefined || opts.clearPublicUrl) console.log(listener.publicUrl ? `${name} is reachable at ${listener.publicUrl}` : `${name}: public URL cleared`);
|
|
3354
|
+
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`);
|
|
3341
3355
|
}
|
|
3342
3356
|
|
|
3343
3357
|
const hideKey = (url: string) => url.replace(/([#&?]key=)[^&]*/, "$1…");
|
|
@@ -4166,9 +4180,30 @@ const selfCommand = () => [process.execPath, Bun.main];
|
|
|
4166
4180
|
*/
|
|
4167
4181
|
const bridgedUrls = new Set<string>();
|
|
4168
4182
|
|
|
4183
|
+
/**
|
|
4184
|
+
* RECHROME_URL may address one of this machine's own scoped listeners directly (e.g. its tailnet
|
|
4185
|
+
* IP), which refuses management-only commands. Same user, same daemon: go through the loopback
|
|
4186
|
+
* management listener instead. The session is unchanged, since it is keyed by identity, not by
|
|
4187
|
+
* listener. HTTPS is left alone: the daemon's certificate doesn't name 127.0.0.1.
|
|
4188
|
+
*/
|
|
4189
|
+
export function preferManagementListener(url: string, config: { listeners: Listener[] } | null): string {
|
|
4190
|
+
const { key, host, port, protocol } = parseUrl(url);
|
|
4191
|
+
const own = config?.listeners.find(l => l.key === key);
|
|
4192
|
+
const management = config?.listeners.find(l => l.profiles === "*" && isLoopback(l.host));
|
|
4193
|
+
if (!key || !own || own.profiles === "*" || !management || protocol !== "http") return url;
|
|
4194
|
+
if (own.host !== host.replace(/^\[|\]$/g, "") || own.port !== port) return url;
|
|
4195
|
+
const source = new URL(url);
|
|
4196
|
+
const next = new URL(`http://${listenerAddress(management)}${normalizePrefix(management.prefix)}`);
|
|
4197
|
+
next.search = source.search;
|
|
4198
|
+
const fragment = new URLSearchParams(source.hash.slice(1).replace(/^\?/, ""));
|
|
4199
|
+
fragment.set("key", management.key);
|
|
4200
|
+
next.hash = fragment.toString();
|
|
4201
|
+
return next.toString();
|
|
4202
|
+
}
|
|
4203
|
+
|
|
4169
4204
|
export async function transportUrl(url: string): Promise<string> {
|
|
4170
4205
|
const parts = shareLinkParts(url);
|
|
4171
|
-
if (!parts?.webrtc) return url;
|
|
4206
|
+
if (!parts?.webrtc) return preferManagementListener(url, await readListeners().catch(() => null));
|
|
4172
4207
|
const info = await spawnBridge(parts.token, parts.origin, selfCommand());
|
|
4173
4208
|
const bridged = new URL(`http://127.0.0.1:${info.port}/`);
|
|
4174
4209
|
new URL(url).searchParams.forEach((v, k) => bridged.searchParams.set(k, v));
|
|
@@ -4304,7 +4339,7 @@ export type RechHandlers = {
|
|
|
4304
4339
|
allowListener(name: string, profiles: string[]): Promise<void>;
|
|
4305
4340
|
denyListener(name: string, profiles: string[]): Promise<void>;
|
|
4306
4341
|
rotateKey(name: string): Promise<void>;
|
|
4307
|
-
setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean }): Promise<void>;
|
|
4342
|
+
setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean }): Promise<void>;
|
|
4308
4343
|
setup(opts: SetupOptions): Promise<void>;
|
|
4309
4344
|
tray(action?: string): Promise<void>;
|
|
4310
4345
|
provisionProfile(name: string, opts: { headed: boolean; experimental: boolean }): Promise<void>;
|
|
@@ -4446,12 +4481,13 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
|
4446
4481
|
.command("deny <name> <profiles..>", "Remove profiles from a listener", y => y
|
|
4447
4482
|
.positional("name", { type: "string", demandOption: true }).positional("profiles", { type: "string", array: true, demandOption: true }),
|
|
4448
4483
|
a => handlers.denyListener(a.name, a.profiles))
|
|
4449
|
-
.command("set <name>", "Record where a reverse proxy exposes a listener", y => y
|
|
4484
|
+
.command("set <name>", "Record where a reverse proxy exposes a listener, or turn uploads on/off", y => y
|
|
4450
4485
|
.positional("name", { type: "string", demandOption: true })
|
|
4451
4486
|
.option("public-url", { type: "string", requiresArg: true, describe: "e.g. https://host.example.ts.net/rechrome/" })
|
|
4452
4487
|
.option("clear-public-url", { type: "boolean", conflicts: "public-url" })
|
|
4453
|
-
.
|
|
4454
|
-
a =>
|
|
4488
|
+
.option("uploads", { type: "boolean", describe: "Let this listener's links upload images (rech upload); --no-uploads turns it off" })
|
|
4489
|
+
.check(a => a.publicUrl !== undefined || a.clearPublicUrl || a.uploads !== undefined ? true : "Pass --public-url <url>, --clear-public-url, or --uploads/--no-uploads"),
|
|
4490
|
+
a => handlers.setListener(a.name, { publicUrl: a.publicUrl, clearPublicUrl: a.clearPublicUrl, ...(a.uploads !== undefined ? { uploads: a.uploads } : {}) }))
|
|
4455
4491
|
.command("port [name]", "Print a listener's port (for a proxy command)", y => y.positional("name", { type: "string" }),
|
|
4456
4492
|
a => handlers.listenerPort(a.name))
|
|
4457
4493
|
.command("rotate-key <name>", "Give a listener a new key (old URLs stop working)", y => y.positional("name", { type: "string", demandOption: true }),
|