rechrome 1.38.1 → 1.38.3
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/listeners.js +18 -2
- package/listeners.ts +19 -3
- package/package.json +1 -1
- package/rechrome.js +112 -24
- package/rechrome.ts +112 -24
- package/serve.js +3 -1
- package/serve.ts +3 -1
package/listeners.js
CHANGED
|
@@ -15,6 +15,7 @@ import { createHash, randomBytes } from "crypto";
|
|
|
15
15
|
import { homedir } from "os";
|
|
16
16
|
import { join } from "path";
|
|
17
17
|
import { mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
18
|
+
var MAX_RETIRED_PORTS = 32;
|
|
18
19
|
var LISTENERS_FILE = join(homedir(), ".rechrome", "listeners.json");
|
|
19
20
|
var isLoopback = (host) => host === "::1" || /^127\./.test(host);
|
|
20
21
|
var listenerAddress = (l) => `${l.host.includes(":") ? `[${l.host}]` : l.host}:${l.port}`;
|
|
@@ -73,6 +74,8 @@ function validateListeners(value) {
|
|
|
73
74
|
addresses.add(address);
|
|
74
75
|
keys.add(l.key);
|
|
75
76
|
}
|
|
77
|
+
if (config.retiredPorts !== undefined && (!Array.isArray(config.retiredPorts) || config.retiredPorts.some((p) => !Number.isInteger(p) || p < 1 || p > 65535)))
|
|
78
|
+
throw new Error("listeners.json retiredPorts must be a list of ports");
|
|
76
79
|
return config;
|
|
77
80
|
}
|
|
78
81
|
async function readListeners() {
|
|
@@ -86,15 +89,27 @@ async function readListeners() {
|
|
|
86
89
|
}
|
|
87
90
|
return validateListeners(JSON.parse(raw));
|
|
88
91
|
}
|
|
92
|
+
function retirePorts(previous, next) {
|
|
93
|
+
const inUse = new Set(next.listeners.map((l) => l.port));
|
|
94
|
+
const freed = (previous?.listeners ?? []).map((l) => l.port).filter((p) => !inUse.has(p));
|
|
95
|
+
const merged = [...previous?.retiredPorts ?? [], ...next.retiredPorts ?? [], ...freed].filter((p) => !inUse.has(p));
|
|
96
|
+
return [...new Set(merged.reverse())].reverse().slice(-MAX_RETIRED_PORTS);
|
|
97
|
+
}
|
|
89
98
|
async function writeListeners(config) {
|
|
90
99
|
validateListeners(config);
|
|
100
|
+
const previous = await readListeners().catch(() => null);
|
|
101
|
+
const retired = retirePorts(previous, config);
|
|
102
|
+
if (retired.length)
|
|
103
|
+
config.retiredPorts = retired;
|
|
104
|
+
else
|
|
105
|
+
delete config.retiredPorts;
|
|
91
106
|
await mkdir(join(homedir(), ".rechrome"), { recursive: true });
|
|
92
107
|
const temp = `${LISTENERS_FILE}.${randomBytes(6).toString("hex")}.tmp`;
|
|
93
108
|
await writeFile(temp, JSON.stringify(config, null, 2) + `
|
|
94
109
|
`, { mode: 384 });
|
|
95
110
|
await rename(temp, LISTENERS_FILE);
|
|
96
111
|
}
|
|
97
|
-
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"]);
|
|
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", "console", "dialog-accept", "dialog-dismiss"]);
|
|
98
113
|
function authorizeProfileRequest(listener, body) {
|
|
99
114
|
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"))
|
|
100
115
|
throw new Error("Scoped requests require identity.key, identity.profile and string args");
|
|
@@ -106,7 +121,7 @@ function authorizeProfileRequest(listener, body) {
|
|
|
106
121
|
const args = body.args.filter((a) => /^-s=[a-zA-Z0-9_-]{1,64}$/.test(a) === false);
|
|
107
122
|
if (!(args[0] === "upload" && listener.uploads !== undefined) && !SCOPED_COMMANDS.has(args[0]))
|
|
108
123
|
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) => 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=")))))
|
|
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] === "console" && a === "--clear") && !(args[0] === "fill-secret" && (a === "--submit" || a === "--totp" || a.startsWith("--allow-domain=")))))
|
|
110
125
|
throw new Error("CLI option overrides are unavailable on profile-scoped listeners");
|
|
111
126
|
if (["open", "goto", "tab-new"].includes(args[0]) && args[1] && !/^https?:\/\//i.test(args[1]) && args[1] !== "about:blank")
|
|
112
127
|
throw new Error("Scoped navigation accepts only HTTP(S) or about:blank");
|
|
@@ -219,6 +234,7 @@ export {
|
|
|
219
234
|
profileOutputPrefix,
|
|
220
235
|
readListeners,
|
|
221
236
|
resolveAllowedProfile,
|
|
237
|
+
retirePorts,
|
|
222
238
|
rotateListenerKey,
|
|
223
239
|
serviceUrl,
|
|
224
240
|
setPublicUrl,
|
package/listeners.ts
CHANGED
|
@@ -7,7 +7,10 @@ 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
9
|
export type Listener = { name: string; host: string; port: number; key: string; profiles: string[] | "*"; prefix?: string; publicUrl?: string; uploads?: UploadPolicy };
|
|
10
|
-
|
|
10
|
+
// retiredPorts: ports of removed listeners, newest last. New listeners skip them so a link
|
|
11
|
+
// saved for a removed listener gets "connection refused", not some other listener's answer.
|
|
12
|
+
export type ListenerConfig = { version: 1; listeners: Listener[]; retiredPorts?: number[] };
|
|
13
|
+
const MAX_RETIRED_PORTS = 32;
|
|
11
14
|
export const LISTENERS_FILE = join(homedir(), ".rechrome", "listeners.json");
|
|
12
15
|
export const isLoopback = (host: string) => host === "::1" || /^127\./.test(host);
|
|
13
16
|
export const listenerAddress = (l: Listener) => `${l.host.includes(":") ? `[${l.host}]` : l.host}:${l.port}`;
|
|
@@ -60,6 +63,8 @@ export function validateListeners(value: unknown): ListenerConfig {
|
|
|
60
63
|
if (names.has(l.name) || addresses.has(address) || keys.has(l.key)) throw new Error("Listener names, addresses, and credentials must be unique");
|
|
61
64
|
names.add(l.name); addresses.add(address); keys.add(l.key);
|
|
62
65
|
}
|
|
66
|
+
if (config.retiredPorts !== undefined && (!Array.isArray(config.retiredPorts) || config.retiredPorts.some(p => !Number.isInteger(p) || p < 1 || p > 65535)))
|
|
67
|
+
throw new Error("listeners.json retiredPorts must be a list of ports");
|
|
63
68
|
return config;
|
|
64
69
|
}
|
|
65
70
|
|
|
@@ -70,8 +75,19 @@ export async function readListeners(): Promise<ListenerConfig | null> {
|
|
|
70
75
|
return validateListeners(JSON.parse(raw));
|
|
71
76
|
}
|
|
72
77
|
|
|
78
|
+
/** `previous`'s retired ports plus the ports its listeners used that `next` no longer does. */
|
|
79
|
+
export function retirePorts(previous: ListenerConfig | null, next: ListenerConfig): number[] {
|
|
80
|
+
const inUse = new Set(next.listeners.map(l => l.port));
|
|
81
|
+
const freed = (previous?.listeners ?? []).map(l => l.port).filter(p => !inUse.has(p));
|
|
82
|
+
const merged = [...(previous?.retiredPorts ?? []), ...(next.retiredPorts ?? []), ...freed].filter(p => !inUse.has(p));
|
|
83
|
+
return [...new Set(merged.reverse())].reverse().slice(-MAX_RETIRED_PORTS);
|
|
84
|
+
}
|
|
85
|
+
|
|
73
86
|
export async function writeListeners(config: ListenerConfig): Promise<void> {
|
|
74
87
|
validateListeners(config);
|
|
88
|
+
const previous = await readListeners().catch(() => null);
|
|
89
|
+
const retired = retirePorts(previous, config);
|
|
90
|
+
if (retired.length) config.retiredPorts = retired; else delete config.retiredPorts;
|
|
75
91
|
await mkdir(join(homedir(), ".rechrome"), { recursive: true });
|
|
76
92
|
const temp = `${LISTENERS_FILE}.${randomBytes(6).toString("hex")}.tmp`;
|
|
77
93
|
await writeFile(temp, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
|
|
@@ -80,7 +96,7 @@ export async function writeListeners(config: ListenerConfig): Promise<void> {
|
|
|
80
96
|
|
|
81
97
|
// Restricted listeners deliberately exclude arbitrary code, host filesystem access,
|
|
82
98
|
// attachment/config overrides, and commands that manage all browser sessions.
|
|
83
|
-
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"]);
|
|
84
100
|
export function authorizeProfileRequest(listener: Listener, body: any): string {
|
|
85
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");
|
|
86
102
|
const profile = body.identity.profile;
|
|
@@ -88,7 +104,7 @@ export function authorizeProfileRequest(listener: Listener, body: any): string {
|
|
|
88
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");
|
|
89
105
|
const args = body.args.filter((a: string) => /^-s=[a-zA-Z0-9_-]{1,64}$/.test(a) === false);
|
|
90
106
|
if (!(args[0] === "upload" && listener.uploads !== undefined) && !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");
|
|
91
|
-
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] === "fill-secret" && (a === "--submit" || a === "--totp" || a.startsWith("--allow-domain="))))) throw new Error("CLI option overrides are unavailable on profile-scoped listeners");
|
|
107
|
+
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");
|
|
92
108
|
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");
|
|
93
109
|
// Upload is handled by the byte-only receiver; the caller cannot supply host paths.
|
|
94
110
|
if (args[0] === "upload" && (args.length !== 1 || !body.upload)) throw new Error("Upload requires stdin bytes; host paths are unavailable");
|
package/package.json
CHANGED
package/rechrome.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { prepareStdinUpload, type UploadPayload } from "./scoped-upload.js";
|
|
3
|
-
import { readListeners, writeListeners, listenerAddress, isLoopback, normalizePrefix, serviceUrl, allowProfiles, denyProfiles, rotateListenerKey, setPublicUrl, canonicalProfileKeys, planProfileRemoval, type Listener } from "./listeners.js";
|
|
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";
|
|
6
6
|
import yargs from "yargs";
|
|
@@ -1504,21 +1504,34 @@ export function staleLinkHint(url: string, listeners: Listener[], source?: strin
|
|
|
1504
1504
|
try { parsed = parseUrl(url); } catch { return null; }
|
|
1505
1505
|
const loopback = (host: string) => isLoopback(host) || host === "localhost";
|
|
1506
1506
|
const urlHost = parsed.host.replace(/^\[|\]$/g, "");
|
|
1507
|
-
|
|
1507
|
+
// Direct: the link names a listener's own address. Proxied: it names a recorded public URL.
|
|
1508
|
+
const at = listeners.find(l => l.port === parsed.port && (l.host === urlHost || loopback(l.host) && loopback(urlHost)));
|
|
1509
|
+
const direct = !!at || loopback(urlHost);
|
|
1510
|
+
const viaProxy = (l: Listener) => {
|
|
1511
|
+
if (!l.publicUrl) return false;
|
|
1512
|
+
const pub = parseUrl(l.publicUrl);
|
|
1513
|
+
return pub.host === parsed.host && pub.port === parsed.port && pub.prefix === parsed.prefix;
|
|
1514
|
+
};
|
|
1515
|
+
const proxied = listeners.find(viaProxy);
|
|
1508
1516
|
const owner = listeners.find(l => l.key === parsed.key);
|
|
1509
|
-
if (!
|
|
1510
|
-
if (owner && owner === at && normalizePrefix(owner.prefix) === parsed.prefix) return null;
|
|
1517
|
+
if (!direct && !proxied && !owner) return null; // another machine's daemon: only its owner knows
|
|
1511
1518
|
const profile = parsed.profileDirectory;
|
|
1519
|
+
const allows = (l: Listener) => l.profiles === "*" || !profile || l.profiles.includes(profile);
|
|
1520
|
+
// Through a proxy rech knows nothing about, only the key and the allowlist can be checked.
|
|
1521
|
+
const reachesOwner = owner && (direct ? owner === at && normalizePrefix(owner.prefix) === parsed.prefix : !proxied || owner === proxied);
|
|
1522
|
+
if (owner && reachesOwner && allows(owner)) return null;
|
|
1512
1523
|
let fix = profile ? `rech share ${JSON.stringify(profile)} --save` : "rech share --save";
|
|
1513
1524
|
if (profile) {
|
|
1514
1525
|
try {
|
|
1515
|
-
const name = owner && owner.profiles !== "*" ? owner.name : chooseShareListener(profile, listeners);
|
|
1516
|
-
fix = `rech share ${JSON.stringify(profile)} --listener ${name}${
|
|
1526
|
+
const name = owner && owner.profiles !== "*" && allows(owner) ? owner.name : chooseShareListener(profile, listeners);
|
|
1527
|
+
fix = `rech share ${JSON.stringify(profile)} --listener ${name}${direct ? " --local" : ""} --save`;
|
|
1517
1528
|
} catch { /* no single listener shares it: the default creates a fresh link */ }
|
|
1518
1529
|
}
|
|
1519
|
-
const lines = [owner
|
|
1520
|
-
? `this link's listener
|
|
1521
|
-
:
|
|
1530
|
+
const lines = [!owner
|
|
1531
|
+
? `this link's key matches no listener on this machine: its listener was removed or its key rotated.${at ? ` ${listenerAddress(at)} belongs to listener "${at.name}" (${normalizePrefix(at.prefix)}), which this link is not for.` : ""}`
|
|
1532
|
+
: !reachesOwner
|
|
1533
|
+
? `this link's listener "${owner.name}" moved: it now serves ${owner.publicUrl && !direct ? owner.publicUrl : `${listenerAddress(owner)}${normalizePrefix(owner.prefix)}`}, not ${parsed.host}:${parsed.port}${parsed.prefix}.`
|
|
1534
|
+
: `this link's listener "${owner.name}" no longer allows "${profile}" (it serves: ${(owner.profiles as string[]).join(", ")}).`];
|
|
1522
1535
|
if (source && source !== "environment") lines.push(`The stale RECHROME_URL is saved in ${source}.`);
|
|
1523
1536
|
lines.push(`Replace it with a current link: ${fix}`);
|
|
1524
1537
|
return lines.join("\n ");
|
|
@@ -1530,13 +1543,19 @@ async function localStaleLinkHint(url: string): Promise<string | null> {
|
|
|
1530
1543
|
return config ? staleLinkHint(url, config.listeners, url === process.env[ENV_KEY] ? rechromeUrlSource : undefined) : null;
|
|
1531
1544
|
}
|
|
1532
1545
|
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1546
|
+
const loopbackPortIsFree = (port: number) => {
|
|
1547
|
+
try { Bun.serve({ hostname: "127.0.0.1", port, fetch: () => new Response() }).stop(true); return true; } catch { return false; }
|
|
1548
|
+
};
|
|
1549
|
+
|
|
1550
|
+
/**
|
|
1551
|
+
* A loopback port no listener uses and nothing else is bound to, from the management port + 2.
|
|
1552
|
+
* A removed listener's port is skipped: reusing it would route that listener's saved links to
|
|
1553
|
+
* this one, a confusing wrong answer instead of a plain "connection refused".
|
|
1554
|
+
*/
|
|
1555
|
+
export function freeListenerPort(config: Pick<ListenerConfig, "listeners" | "retiredPorts">, isFree = loopbackPortIsFree): number {
|
|
1556
|
+
const used = new Set(config.listeners.map(l => l.port)), retired = new Set(config.retiredPorts ?? []);
|
|
1557
|
+
for (let port = DEFAULT_PORT + 2; port < 65536; port++) if (!used.has(port) && !retired.has(port) && isFree(port)) return port;
|
|
1558
|
+
for (const port of config.retiredPorts ?? []) if (!used.has(port) && isFree(port)) return port; // oldest first, as a last resort
|
|
1540
1559
|
throw new Error("No free port for the share-all listener; pass one with rech listener add");
|
|
1541
1560
|
}
|
|
1542
1561
|
|
|
@@ -1558,9 +1577,10 @@ function canonicalKeyFor(registry: Record<string, TokenEntry>, key: string): str
|
|
|
1558
1577
|
}
|
|
1559
1578
|
|
|
1560
1579
|
/**
|
|
1561
|
-
* `rech share a b c`: one link for
|
|
1562
|
-
*
|
|
1563
|
-
* it reuses a listener with exactly this set, or
|
|
1580
|
+
* `rech share a b c`: one link for these profiles, on one listener. With --listener it adds
|
|
1581
|
+
* them to that listener (e.g. one that already has a proxy route) and never removes any:
|
|
1582
|
+
* that is rech listener deny. Otherwise it reuses a listener with exactly this set, or
|
|
1583
|
+
* creates one with its own key.
|
|
1564
1584
|
*/
|
|
1565
1585
|
async function shareProfiles(selectors: string[], opts: { listener?: string; local?: boolean; save?: boolean }): Promise<void> {
|
|
1566
1586
|
const registry = await readTokenRegistry(), cache = await readChromeProfileCache();
|
|
@@ -1573,11 +1593,11 @@ async function shareProfiles(selectors: string[], opts: { listener?: string; loc
|
|
|
1573
1593
|
const config = await requireListeners();
|
|
1574
1594
|
const same = config.listeners.find(l => l.profiles !== "*" && l.profiles.length === keys.length && keys.every(k => (l.profiles as string[]).includes(k)));
|
|
1575
1595
|
const id = createHash("sha256").update([...keys].sort().join("\0")).digest("hex").slice(0, 6);
|
|
1576
|
-
return shareSet(keys, { ...opts, listener: opts.listener ?? same?.name, defaultName: `share-${id}`, prefix: `/rechrome-${id}/`, again: `rech share ${selectors.map(s => JSON.stringify(s)).join(" ")}` });
|
|
1596
|
+
return shareSet(keys, { ...opts, merge: !!opts.listener, listener: opts.listener ?? same?.name, defaultName: `share-${id}`, prefix: `/rechrome-${id}/`, again: `rech share ${selectors.map(s => JSON.stringify(s)).join(" ")}` });
|
|
1577
1597
|
}
|
|
1578
1598
|
|
|
1579
1599
|
/** Put exactly `snapshot` on one scoped listener (created if missing) and print its link. */
|
|
1580
|
-
async function shareSet(snapshot: string[], opts: { listener?: string; local?: boolean; save?: boolean; defaultName: string; prefix: string; again: string; host?: string; tailscaleServe?: boolean }): Promise<void> {
|
|
1600
|
+
async function shareSet(snapshot: string[], opts: { listener?: string; local?: boolean; save?: boolean; defaultName: string; prefix: string; again: string; host?: string; tailscaleServe?: boolean; merge?: boolean }): Promise<void> {
|
|
1581
1601
|
const config = await requireListeners();
|
|
1582
1602
|
const name = opts.listener ?? opts.defaultName;
|
|
1583
1603
|
let listener = config.listeners.find(l => l.name === name);
|
|
@@ -1586,11 +1606,14 @@ async function shareSet(snapshot: string[], opts: { listener?: string; local?: b
|
|
|
1586
1606
|
let changes = "";
|
|
1587
1607
|
if (!listener) {
|
|
1588
1608
|
if (opts.listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
|
|
1589
|
-
listener = { name, host: opts.host ?? "127.0.0.1", port: freeListenerPort(config
|
|
1609
|
+
listener = { name, host: opts.host ?? "127.0.0.1", port: freeListenerPort(config), prefix: opts.prefix, key: randomBytes(24).toString("base64url"), profiles: snapshot };
|
|
1590
1610
|
config.listeners.push(listener);
|
|
1591
1611
|
changes = `created listener "${name}" on ${listenerAddress(listener)}${listener.prefix}`;
|
|
1592
1612
|
} else {
|
|
1593
1613
|
const before = listener.profiles as string[];
|
|
1614
|
+
// Merging (a listener named with --listener) only adds: profiles shared there for someone
|
|
1615
|
+
// else stay. Removing one is explicit: rech listener deny.
|
|
1616
|
+
if (opts.merge) snapshot = [...before, ...snapshot.filter(p => !before.includes(p))];
|
|
1594
1617
|
const added = snapshot.filter(p => !before.includes(p)), removed = before.filter(p => !snapshot.includes(p));
|
|
1595
1618
|
listener.profiles = snapshot;
|
|
1596
1619
|
changes = added.length || removed.length ? [added.length && `added ${added.join(", ")}`, removed.length && `removed ${removed.join(", ")}`].filter(Boolean).join("; ") : "no changes";
|
|
@@ -3386,9 +3409,53 @@ async function writeEnvUrl(envPath: string, url: string): Promise<string> {
|
|
|
3386
3409
|
const lines = (await file(envPath).text().catch(() => "")).split("\n").filter(l => l.trim() && !l.startsWith(`${ENV_KEY}=`));
|
|
3387
3410
|
await writeFile(envPath, [...lines, envAssignment(ENV_KEY, url), ""].join("\n"), { mode: 0o600 });
|
|
3388
3411
|
await chmod(envPath, 0o600);
|
|
3412
|
+
await recordSavedLink(envPath).catch(() => {}); // only bookkeeping for later warnings
|
|
3389
3413
|
return envPath;
|
|
3390
3414
|
}
|
|
3391
3415
|
|
|
3416
|
+
/** Env files rech saved a RECHROME_URL into: paths only, never the URLs (they hold keys). */
|
|
3417
|
+
export const SAVED_LINKS_FILE = join(RECH_DIR, "saved-links.json");
|
|
3418
|
+
|
|
3419
|
+
async function readSavedLinks(): Promise<string[]> {
|
|
3420
|
+
const value = await file(SAVED_LINKS_FILE).json().catch(() => []);
|
|
3421
|
+
return Array.isArray(value) ? value.filter((p): p is string => typeof p === "string") : [];
|
|
3422
|
+
}
|
|
3423
|
+
|
|
3424
|
+
async function recordSavedLink(envPath: string): Promise<void> {
|
|
3425
|
+
const path = resolve(envPath), known = await readSavedLinks();
|
|
3426
|
+
if (known.includes(path)) return;
|
|
3427
|
+
mkdirSync(dirname(SAVED_LINKS_FILE), { recursive: true });
|
|
3428
|
+
await writeFile(SAVED_LINKS_FILE, JSON.stringify([...known, path], null, 2) + "\n", { mode: 0o600 });
|
|
3429
|
+
}
|
|
3430
|
+
|
|
3431
|
+
/** The RECHROME_URL an env file sets, or null. */
|
|
3432
|
+
function savedUrlIn(envPath: string): string | null {
|
|
3433
|
+
try { return parseBunEnvFile(readFileSync(envPath, "utf8"))[ENV_KEY] || null; } catch { return null; }
|
|
3434
|
+
}
|
|
3435
|
+
|
|
3436
|
+
/**
|
|
3437
|
+
* One warning per saved link that worked with `before`'s listeners and is stale with `after`'s:
|
|
3438
|
+
* a listener change (rm, rotate-key, deny, setup moving a profile) that breaks a link rech
|
|
3439
|
+
* saved says so, with the fix, instead of the link failing later in some other project.
|
|
3440
|
+
* Links that were already stale stay quiet: this change didn't break them.
|
|
3441
|
+
*/
|
|
3442
|
+
export function savedLinksBrokenBy(before: Listener[], after: Listener[], saved: { path: string; url: string }[]): string[] {
|
|
3443
|
+
return saved.flatMap(({ path, url }) => {
|
|
3444
|
+
if (staleLinkHint(url, before)) return [];
|
|
3445
|
+
const hint = staleLinkHint(url, after, path);
|
|
3446
|
+
return hint ? [`[rech] warning: a saved link stops working: ${hint}`] : [];
|
|
3447
|
+
});
|
|
3448
|
+
}
|
|
3449
|
+
|
|
3450
|
+
/** writeListeners, then warn about saved links the write broke (see savedLinksBrokenBy). */
|
|
3451
|
+
async function writeListeners(config: ListenerConfig): Promise<void> {
|
|
3452
|
+
const before = await readListeners().catch(() => null);
|
|
3453
|
+
await writeListenersFile(config);
|
|
3454
|
+
if (!before) return;
|
|
3455
|
+
const saved = (await readSavedLinks()).flatMap(path => { const url = savedUrlIn(path); return url ? [{ path, url }] : []; });
|
|
3456
|
+
for (const line of savedLinksBrokenBy(before.listeners, config.listeners, saved)) console.error(line);
|
|
3457
|
+
}
|
|
3458
|
+
|
|
3392
3459
|
/**
|
|
3393
3460
|
* What would shadow the machine-wide RECHROME_URL from `cwd`: the shell's own RECHROME_URL, or the
|
|
3394
3461
|
* nearest project env file that sets one (searched the way loadNearestEnv walks). Null when none does.
|
|
@@ -3881,7 +3948,7 @@ async function shareTransportListener(selector: string, profiles: string[], labe
|
|
|
3881
3948
|
const address = chooseListenAddress(await detectListenChoices(), selector);
|
|
3882
3949
|
const existing = config.listeners.find(l => l.host === address && l.profiles !== "*" && normalizePrefix(l.prefix) === "/");
|
|
3883
3950
|
if (existing) return existing.name;
|
|
3884
|
-
const listener: Listener = { name: `share-${label}`.slice(0, 40), host: address, port: freeListenerPort(config
|
|
3951
|
+
const listener: Listener = { name: `share-${label}`.slice(0, 40), host: address, port: freeListenerPort(config), key: randomBytes(24).toString("base64url"), profiles };
|
|
3885
3952
|
config.listeners.push(listener);
|
|
3886
3953
|
await writeListeners(config);
|
|
3887
3954
|
if (!(await waitForListener(listener))) console.error(`[rech] warning: the daemon has not opened ${listenerAddress(listener)} yet; is it running? (rech status)`);
|
|
@@ -4099,9 +4166,30 @@ const selfCommand = () => [process.execPath, Bun.main];
|
|
|
4099
4166
|
*/
|
|
4100
4167
|
const bridgedUrls = new Set<string>();
|
|
4101
4168
|
|
|
4169
|
+
/**
|
|
4170
|
+
* RECHROME_URL may address one of this machine's own scoped listeners directly (e.g. its tailnet
|
|
4171
|
+
* IP), which refuses management-only commands. Same user, same daemon: go through the loopback
|
|
4172
|
+
* management listener instead. The session is unchanged, since it is keyed by identity, not by
|
|
4173
|
+
* listener. HTTPS is left alone: the daemon's certificate doesn't name 127.0.0.1.
|
|
4174
|
+
*/
|
|
4175
|
+
export function preferManagementListener(url: string, config: { listeners: Listener[] } | null): string {
|
|
4176
|
+
const { key, host, port, protocol } = parseUrl(url);
|
|
4177
|
+
const own = config?.listeners.find(l => l.key === key);
|
|
4178
|
+
const management = config?.listeners.find(l => l.profiles === "*" && isLoopback(l.host));
|
|
4179
|
+
if (!key || !own || own.profiles === "*" || !management || protocol !== "http") return url;
|
|
4180
|
+
if (own.host !== host.replace(/^\[|\]$/g, "") || own.port !== port) return url;
|
|
4181
|
+
const source = new URL(url);
|
|
4182
|
+
const next = new URL(`http://${listenerAddress(management)}${normalizePrefix(management.prefix)}`);
|
|
4183
|
+
next.search = source.search;
|
|
4184
|
+
const fragment = new URLSearchParams(source.hash.slice(1).replace(/^\?/, ""));
|
|
4185
|
+
fragment.set("key", management.key);
|
|
4186
|
+
next.hash = fragment.toString();
|
|
4187
|
+
return next.toString();
|
|
4188
|
+
}
|
|
4189
|
+
|
|
4102
4190
|
export async function transportUrl(url: string): Promise<string> {
|
|
4103
4191
|
const parts = shareLinkParts(url);
|
|
4104
|
-
if (!parts?.webrtc) return url;
|
|
4192
|
+
if (!parts?.webrtc) return preferManagementListener(url, await readListeners().catch(() => null));
|
|
4105
4193
|
const info = await spawnBridge(parts.token, parts.origin, selfCommand());
|
|
4106
4194
|
const bridged = new URL(`http://127.0.0.1:${info.port}/`);
|
|
4107
4195
|
new URL(url).searchParams.forEach((v, k) => bridged.searchParams.set(k, v));
|
package/rechrome.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { prepareStdinUpload, type UploadPayload } from "./scoped-upload.ts";
|
|
3
|
-
import { readListeners, writeListeners, listenerAddress, isLoopback, normalizePrefix, serviceUrl, allowProfiles, denyProfiles, rotateListenerKey, setPublicUrl, canonicalProfileKeys, planProfileRemoval, type Listener } from "./listeners.ts";
|
|
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";
|
|
6
6
|
import yargs from "yargs";
|
|
@@ -1504,21 +1504,34 @@ export function staleLinkHint(url: string, listeners: Listener[], source?: strin
|
|
|
1504
1504
|
try { parsed = parseUrl(url); } catch { return null; }
|
|
1505
1505
|
const loopback = (host: string) => isLoopback(host) || host === "localhost";
|
|
1506
1506
|
const urlHost = parsed.host.replace(/^\[|\]$/g, "");
|
|
1507
|
-
|
|
1507
|
+
// Direct: the link names a listener's own address. Proxied: it names a recorded public URL.
|
|
1508
|
+
const at = listeners.find(l => l.port === parsed.port && (l.host === urlHost || loopback(l.host) && loopback(urlHost)));
|
|
1509
|
+
const direct = !!at || loopback(urlHost);
|
|
1510
|
+
const viaProxy = (l: Listener) => {
|
|
1511
|
+
if (!l.publicUrl) return false;
|
|
1512
|
+
const pub = parseUrl(l.publicUrl);
|
|
1513
|
+
return pub.host === parsed.host && pub.port === parsed.port && pub.prefix === parsed.prefix;
|
|
1514
|
+
};
|
|
1515
|
+
const proxied = listeners.find(viaProxy);
|
|
1508
1516
|
const owner = listeners.find(l => l.key === parsed.key);
|
|
1509
|
-
if (!
|
|
1510
|
-
if (owner && owner === at && normalizePrefix(owner.prefix) === parsed.prefix) return null;
|
|
1517
|
+
if (!direct && !proxied && !owner) return null; // another machine's daemon: only its owner knows
|
|
1511
1518
|
const profile = parsed.profileDirectory;
|
|
1519
|
+
const allows = (l: Listener) => l.profiles === "*" || !profile || l.profiles.includes(profile);
|
|
1520
|
+
// Through a proxy rech knows nothing about, only the key and the allowlist can be checked.
|
|
1521
|
+
const reachesOwner = owner && (direct ? owner === at && normalizePrefix(owner.prefix) === parsed.prefix : !proxied || owner === proxied);
|
|
1522
|
+
if (owner && reachesOwner && allows(owner)) return null;
|
|
1512
1523
|
let fix = profile ? `rech share ${JSON.stringify(profile)} --save` : "rech share --save";
|
|
1513
1524
|
if (profile) {
|
|
1514
1525
|
try {
|
|
1515
|
-
const name = owner && owner.profiles !== "*" ? owner.name : chooseShareListener(profile, listeners);
|
|
1516
|
-
fix = `rech share ${JSON.stringify(profile)} --listener ${name}${
|
|
1526
|
+
const name = owner && owner.profiles !== "*" && allows(owner) ? owner.name : chooseShareListener(profile, listeners);
|
|
1527
|
+
fix = `rech share ${JSON.stringify(profile)} --listener ${name}${direct ? " --local" : ""} --save`;
|
|
1517
1528
|
} catch { /* no single listener shares it: the default creates a fresh link */ }
|
|
1518
1529
|
}
|
|
1519
|
-
const lines = [owner
|
|
1520
|
-
? `this link's listener
|
|
1521
|
-
:
|
|
1530
|
+
const lines = [!owner
|
|
1531
|
+
? `this link's key matches no listener on this machine: its listener was removed or its key rotated.${at ? ` ${listenerAddress(at)} belongs to listener "${at.name}" (${normalizePrefix(at.prefix)}), which this link is not for.` : ""}`
|
|
1532
|
+
: !reachesOwner
|
|
1533
|
+
? `this link's listener "${owner.name}" moved: it now serves ${owner.publicUrl && !direct ? owner.publicUrl : `${listenerAddress(owner)}${normalizePrefix(owner.prefix)}`}, not ${parsed.host}:${parsed.port}${parsed.prefix}.`
|
|
1534
|
+
: `this link's listener "${owner.name}" no longer allows "${profile}" (it serves: ${(owner.profiles as string[]).join(", ")}).`];
|
|
1522
1535
|
if (source && source !== "environment") lines.push(`The stale RECHROME_URL is saved in ${source}.`);
|
|
1523
1536
|
lines.push(`Replace it with a current link: ${fix}`);
|
|
1524
1537
|
return lines.join("\n ");
|
|
@@ -1530,13 +1543,19 @@ async function localStaleLinkHint(url: string): Promise<string | null> {
|
|
|
1530
1543
|
return config ? staleLinkHint(url, config.listeners, url === process.env[ENV_KEY] ? rechromeUrlSource : undefined) : null;
|
|
1531
1544
|
}
|
|
1532
1545
|
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1546
|
+
const loopbackPortIsFree = (port: number) => {
|
|
1547
|
+
try { Bun.serve({ hostname: "127.0.0.1", port, fetch: () => new Response() }).stop(true); return true; } catch { return false; }
|
|
1548
|
+
};
|
|
1549
|
+
|
|
1550
|
+
/**
|
|
1551
|
+
* A loopback port no listener uses and nothing else is bound to, from the management port + 2.
|
|
1552
|
+
* A removed listener's port is skipped: reusing it would route that listener's saved links to
|
|
1553
|
+
* this one, a confusing wrong answer instead of a plain "connection refused".
|
|
1554
|
+
*/
|
|
1555
|
+
export function freeListenerPort(config: Pick<ListenerConfig, "listeners" | "retiredPorts">, isFree = loopbackPortIsFree): number {
|
|
1556
|
+
const used = new Set(config.listeners.map(l => l.port)), retired = new Set(config.retiredPorts ?? []);
|
|
1557
|
+
for (let port = DEFAULT_PORT + 2; port < 65536; port++) if (!used.has(port) && !retired.has(port) && isFree(port)) return port;
|
|
1558
|
+
for (const port of config.retiredPorts ?? []) if (!used.has(port) && isFree(port)) return port; // oldest first, as a last resort
|
|
1540
1559
|
throw new Error("No free port for the share-all listener; pass one with rech listener add");
|
|
1541
1560
|
}
|
|
1542
1561
|
|
|
@@ -1558,9 +1577,10 @@ function canonicalKeyFor(registry: Record<string, TokenEntry>, key: string): str
|
|
|
1558
1577
|
}
|
|
1559
1578
|
|
|
1560
1579
|
/**
|
|
1561
|
-
* `rech share a b c`: one link for
|
|
1562
|
-
*
|
|
1563
|
-
* it reuses a listener with exactly this set, or
|
|
1580
|
+
* `rech share a b c`: one link for these profiles, on one listener. With --listener it adds
|
|
1581
|
+
* them to that listener (e.g. one that already has a proxy route) and never removes any:
|
|
1582
|
+
* that is rech listener deny. Otherwise it reuses a listener with exactly this set, or
|
|
1583
|
+
* creates one with its own key.
|
|
1564
1584
|
*/
|
|
1565
1585
|
async function shareProfiles(selectors: string[], opts: { listener?: string; local?: boolean; save?: boolean }): Promise<void> {
|
|
1566
1586
|
const registry = await readTokenRegistry(), cache = await readChromeProfileCache();
|
|
@@ -1573,11 +1593,11 @@ async function shareProfiles(selectors: string[], opts: { listener?: string; loc
|
|
|
1573
1593
|
const config = await requireListeners();
|
|
1574
1594
|
const same = config.listeners.find(l => l.profiles !== "*" && l.profiles.length === keys.length && keys.every(k => (l.profiles as string[]).includes(k)));
|
|
1575
1595
|
const id = createHash("sha256").update([...keys].sort().join("\0")).digest("hex").slice(0, 6);
|
|
1576
|
-
return shareSet(keys, { ...opts, listener: opts.listener ?? same?.name, defaultName: `share-${id}`, prefix: `/rechrome-${id}/`, again: `rech share ${selectors.map(s => JSON.stringify(s)).join(" ")}` });
|
|
1596
|
+
return shareSet(keys, { ...opts, merge: !!opts.listener, listener: opts.listener ?? same?.name, defaultName: `share-${id}`, prefix: `/rechrome-${id}/`, again: `rech share ${selectors.map(s => JSON.stringify(s)).join(" ")}` });
|
|
1577
1597
|
}
|
|
1578
1598
|
|
|
1579
1599
|
/** Put exactly `snapshot` on one scoped listener (created if missing) and print its link. */
|
|
1580
|
-
async function shareSet(snapshot: string[], opts: { listener?: string; local?: boolean; save?: boolean; defaultName: string; prefix: string; again: string; host?: string; tailscaleServe?: boolean }): Promise<void> {
|
|
1600
|
+
async function shareSet(snapshot: string[], opts: { listener?: string; local?: boolean; save?: boolean; defaultName: string; prefix: string; again: string; host?: string; tailscaleServe?: boolean; merge?: boolean }): Promise<void> {
|
|
1581
1601
|
const config = await requireListeners();
|
|
1582
1602
|
const name = opts.listener ?? opts.defaultName;
|
|
1583
1603
|
let listener = config.listeners.find(l => l.name === name);
|
|
@@ -1586,11 +1606,14 @@ async function shareSet(snapshot: string[], opts: { listener?: string; local?: b
|
|
|
1586
1606
|
let changes = "";
|
|
1587
1607
|
if (!listener) {
|
|
1588
1608
|
if (opts.listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
|
|
1589
|
-
listener = { name, host: opts.host ?? "127.0.0.1", port: freeListenerPort(config
|
|
1609
|
+
listener = { name, host: opts.host ?? "127.0.0.1", port: freeListenerPort(config), prefix: opts.prefix, key: randomBytes(24).toString("base64url"), profiles: snapshot };
|
|
1590
1610
|
config.listeners.push(listener);
|
|
1591
1611
|
changes = `created listener "${name}" on ${listenerAddress(listener)}${listener.prefix}`;
|
|
1592
1612
|
} else {
|
|
1593
1613
|
const before = listener.profiles as string[];
|
|
1614
|
+
// Merging (a listener named with --listener) only adds: profiles shared there for someone
|
|
1615
|
+
// else stay. Removing one is explicit: rech listener deny.
|
|
1616
|
+
if (opts.merge) snapshot = [...before, ...snapshot.filter(p => !before.includes(p))];
|
|
1594
1617
|
const added = snapshot.filter(p => !before.includes(p)), removed = before.filter(p => !snapshot.includes(p));
|
|
1595
1618
|
listener.profiles = snapshot;
|
|
1596
1619
|
changes = added.length || removed.length ? [added.length && `added ${added.join(", ")}`, removed.length && `removed ${removed.join(", ")}`].filter(Boolean).join("; ") : "no changes";
|
|
@@ -3386,9 +3409,53 @@ async function writeEnvUrl(envPath: string, url: string): Promise<string> {
|
|
|
3386
3409
|
const lines = (await file(envPath).text().catch(() => "")).split("\n").filter(l => l.trim() && !l.startsWith(`${ENV_KEY}=`));
|
|
3387
3410
|
await writeFile(envPath, [...lines, envAssignment(ENV_KEY, url), ""].join("\n"), { mode: 0o600 });
|
|
3388
3411
|
await chmod(envPath, 0o600);
|
|
3412
|
+
await recordSavedLink(envPath).catch(() => {}); // only bookkeeping for later warnings
|
|
3389
3413
|
return envPath;
|
|
3390
3414
|
}
|
|
3391
3415
|
|
|
3416
|
+
/** Env files rech saved a RECHROME_URL into: paths only, never the URLs (they hold keys). */
|
|
3417
|
+
export const SAVED_LINKS_FILE = join(RECH_DIR, "saved-links.json");
|
|
3418
|
+
|
|
3419
|
+
async function readSavedLinks(): Promise<string[]> {
|
|
3420
|
+
const value = await file(SAVED_LINKS_FILE).json().catch(() => []);
|
|
3421
|
+
return Array.isArray(value) ? value.filter((p): p is string => typeof p === "string") : [];
|
|
3422
|
+
}
|
|
3423
|
+
|
|
3424
|
+
async function recordSavedLink(envPath: string): Promise<void> {
|
|
3425
|
+
const path = resolve(envPath), known = await readSavedLinks();
|
|
3426
|
+
if (known.includes(path)) return;
|
|
3427
|
+
mkdirSync(dirname(SAVED_LINKS_FILE), { recursive: true });
|
|
3428
|
+
await writeFile(SAVED_LINKS_FILE, JSON.stringify([...known, path], null, 2) + "\n", { mode: 0o600 });
|
|
3429
|
+
}
|
|
3430
|
+
|
|
3431
|
+
/** The RECHROME_URL an env file sets, or null. */
|
|
3432
|
+
function savedUrlIn(envPath: string): string | null {
|
|
3433
|
+
try { return parseBunEnvFile(readFileSync(envPath, "utf8"))[ENV_KEY] || null; } catch { return null; }
|
|
3434
|
+
}
|
|
3435
|
+
|
|
3436
|
+
/**
|
|
3437
|
+
* One warning per saved link that worked with `before`'s listeners and is stale with `after`'s:
|
|
3438
|
+
* a listener change (rm, rotate-key, deny, setup moving a profile) that breaks a link rech
|
|
3439
|
+
* saved says so, with the fix, instead of the link failing later in some other project.
|
|
3440
|
+
* Links that were already stale stay quiet: this change didn't break them.
|
|
3441
|
+
*/
|
|
3442
|
+
export function savedLinksBrokenBy(before: Listener[], after: Listener[], saved: { path: string; url: string }[]): string[] {
|
|
3443
|
+
return saved.flatMap(({ path, url }) => {
|
|
3444
|
+
if (staleLinkHint(url, before)) return [];
|
|
3445
|
+
const hint = staleLinkHint(url, after, path);
|
|
3446
|
+
return hint ? [`[rech] warning: a saved link stops working: ${hint}`] : [];
|
|
3447
|
+
});
|
|
3448
|
+
}
|
|
3449
|
+
|
|
3450
|
+
/** writeListeners, then warn about saved links the write broke (see savedLinksBrokenBy). */
|
|
3451
|
+
async function writeListeners(config: ListenerConfig): Promise<void> {
|
|
3452
|
+
const before = await readListeners().catch(() => null);
|
|
3453
|
+
await writeListenersFile(config);
|
|
3454
|
+
if (!before) return;
|
|
3455
|
+
const saved = (await readSavedLinks()).flatMap(path => { const url = savedUrlIn(path); return url ? [{ path, url }] : []; });
|
|
3456
|
+
for (const line of savedLinksBrokenBy(before.listeners, config.listeners, saved)) console.error(line);
|
|
3457
|
+
}
|
|
3458
|
+
|
|
3392
3459
|
/**
|
|
3393
3460
|
* What would shadow the machine-wide RECHROME_URL from `cwd`: the shell's own RECHROME_URL, or the
|
|
3394
3461
|
* nearest project env file that sets one (searched the way loadNearestEnv walks). Null when none does.
|
|
@@ -3881,7 +3948,7 @@ async function shareTransportListener(selector: string, profiles: string[], labe
|
|
|
3881
3948
|
const address = chooseListenAddress(await detectListenChoices(), selector);
|
|
3882
3949
|
const existing = config.listeners.find(l => l.host === address && l.profiles !== "*" && normalizePrefix(l.prefix) === "/");
|
|
3883
3950
|
if (existing) return existing.name;
|
|
3884
|
-
const listener: Listener = { name: `share-${label}`.slice(0, 40), host: address, port: freeListenerPort(config
|
|
3951
|
+
const listener: Listener = { name: `share-${label}`.slice(0, 40), host: address, port: freeListenerPort(config), key: randomBytes(24).toString("base64url"), profiles };
|
|
3885
3952
|
config.listeners.push(listener);
|
|
3886
3953
|
await writeListeners(config);
|
|
3887
3954
|
if (!(await waitForListener(listener))) console.error(`[rech] warning: the daemon has not opened ${listenerAddress(listener)} yet; is it running? (rech status)`);
|
|
@@ -4099,9 +4166,30 @@ const selfCommand = () => [process.execPath, Bun.main];
|
|
|
4099
4166
|
*/
|
|
4100
4167
|
const bridgedUrls = new Set<string>();
|
|
4101
4168
|
|
|
4169
|
+
/**
|
|
4170
|
+
* RECHROME_URL may address one of this machine's own scoped listeners directly (e.g. its tailnet
|
|
4171
|
+
* IP), which refuses management-only commands. Same user, same daemon: go through the loopback
|
|
4172
|
+
* management listener instead. The session is unchanged, since it is keyed by identity, not by
|
|
4173
|
+
* listener. HTTPS is left alone: the daemon's certificate doesn't name 127.0.0.1.
|
|
4174
|
+
*/
|
|
4175
|
+
export function preferManagementListener(url: string, config: { listeners: Listener[] } | null): string {
|
|
4176
|
+
const { key, host, port, protocol } = parseUrl(url);
|
|
4177
|
+
const own = config?.listeners.find(l => l.key === key);
|
|
4178
|
+
const management = config?.listeners.find(l => l.profiles === "*" && isLoopback(l.host));
|
|
4179
|
+
if (!key || !own || own.profiles === "*" || !management || protocol !== "http") return url;
|
|
4180
|
+
if (own.host !== host.replace(/^\[|\]$/g, "") || own.port !== port) return url;
|
|
4181
|
+
const source = new URL(url);
|
|
4182
|
+
const next = new URL(`http://${listenerAddress(management)}${normalizePrefix(management.prefix)}`);
|
|
4183
|
+
next.search = source.search;
|
|
4184
|
+
const fragment = new URLSearchParams(source.hash.slice(1).replace(/^\?/, ""));
|
|
4185
|
+
fragment.set("key", management.key);
|
|
4186
|
+
next.hash = fragment.toString();
|
|
4187
|
+
return next.toString();
|
|
4188
|
+
}
|
|
4189
|
+
|
|
4102
4190
|
export async function transportUrl(url: string): Promise<string> {
|
|
4103
4191
|
const parts = shareLinkParts(url);
|
|
4104
|
-
if (!parts?.webrtc) return url;
|
|
4192
|
+
if (!parts?.webrtc) return preferManagementListener(url, await readListeners().catch(() => null));
|
|
4105
4193
|
const info = await spawnBridge(parts.token, parts.origin, selfCommand());
|
|
4106
4194
|
const bridged = new URL(`http://127.0.0.1:${info.port}/`);
|
|
4107
4195
|
new URL(url).searchParams.forEach((v, k) => bridged.searchParams.set(k, v));
|
package/serve.js
CHANGED
|
@@ -800,7 +800,9 @@ export async function serve() {
|
|
|
800
800
|
// #fragment, which browsers never send, so the page is static and the key never reaches here.
|
|
801
801
|
if (reqUrl.pathname === "/" && req.method === "GET" && (req.headers.get("accept") ?? "").includes("text/html"))
|
|
802
802
|
return new Response(landingPage(), { headers: LANDING_HEADERS });
|
|
803
|
-
|
|
803
|
+
// Unknown paths are a 404, not the banner: a stale link (its listener removed, the port
|
|
804
|
+
// reused by another with a different prefix) must fail loudly, not look like a live daemon.
|
|
805
|
+
if (reqUrl.pathname !== "/run") return reqUrl.pathname === "/" ? new Response("rech server\n") : new Response("rech server: nothing at this path\n", { status: 404 });
|
|
804
806
|
const denied = authCheck(req, key);
|
|
805
807
|
if (denied) return denied;
|
|
806
808
|
markActivity(); // a real command: this serve is not idle
|
package/serve.ts
CHANGED
|
@@ -800,7 +800,9 @@ export async function serve() {
|
|
|
800
800
|
// #fragment, which browsers never send, so the page is static and the key never reaches here.
|
|
801
801
|
if (reqUrl.pathname === "/" && req.method === "GET" && (req.headers.get("accept") ?? "").includes("text/html"))
|
|
802
802
|
return new Response(landingPage(), { headers: LANDING_HEADERS });
|
|
803
|
-
|
|
803
|
+
// Unknown paths are a 404, not the banner: a stale link (its listener removed, the port
|
|
804
|
+
// reused by another with a different prefix) must fail loudly, not look like a live daemon.
|
|
805
|
+
if (reqUrl.pathname !== "/run") return reqUrl.pathname === "/" ? new Response("rech server\n") : new Response("rech server: nothing at this path\n", { status: 404 });
|
|
804
806
|
const denied = authCheck(req, key);
|
|
805
807
|
if (denied) return denied;
|
|
806
808
|
markActivity(); // a real command: this serve is not idle
|