rechrome 1.38.0 → 1.38.2
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 +16 -0
- package/listeners.ts +17 -1
- package/package.json +1 -1
- package/rechrome.js +128 -18
- package/rechrome.ts +128 -18
- 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,8 +89,20 @@ 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) + `
|
|
@@ -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 });
|
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";
|
|
@@ -1493,13 +1493,69 @@ export function chooseShareListener(profile: string, listeners: Listener[]): str
|
|
|
1493
1493
|
].filter(Boolean).join("\n"), "none", scoped);
|
|
1494
1494
|
}
|
|
1495
1495
|
|
|
1496
|
-
/**
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1496
|
+
/**
|
|
1497
|
+
* Why a saved link to one of this machine's listeners stopped working, with the command that
|
|
1498
|
+
* replaces it; null when it still matches its listener or points at another machine.
|
|
1499
|
+
* A removed listener's port goes to the next one created (`freeListenerPort`), so a stale
|
|
1500
|
+
* link can reach a different listener: its path is not served there and its key is unknown.
|
|
1501
|
+
*/
|
|
1502
|
+
export function staleLinkHint(url: string, listeners: Listener[], source?: string): string | null {
|
|
1503
|
+
let parsed: ReturnType<typeof parseUrl>;
|
|
1504
|
+
try { parsed = parseUrl(url); } catch { return null; }
|
|
1505
|
+
const loopback = (host: string) => isLoopback(host) || host === "localhost";
|
|
1506
|
+
const urlHost = parsed.host.replace(/^\[|\]$/g, "");
|
|
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);
|
|
1516
|
+
const owner = listeners.find(l => l.key === parsed.key);
|
|
1517
|
+
if (!direct && !proxied && !owner) return null; // another machine's daemon: only its owner knows
|
|
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;
|
|
1523
|
+
let fix = profile ? `rech share ${JSON.stringify(profile)} --save` : "rech share --save";
|
|
1524
|
+
if (profile) {
|
|
1525
|
+
try {
|
|
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`;
|
|
1528
|
+
} catch { /* no single listener shares it: the default creates a fresh link */ }
|
|
1529
|
+
}
|
|
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(", ")}).`];
|
|
1535
|
+
if (source && source !== "environment") lines.push(`The stale RECHROME_URL is saved in ${source}.`);
|
|
1536
|
+
lines.push(`Replace it with a current link: ${fix}`);
|
|
1537
|
+
return lines.join("\n ");
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
/** staleLinkHint against this machine's listeners.json; null when it can't be read. */
|
|
1541
|
+
async function localStaleLinkHint(url: string): Promise<string | null> {
|
|
1542
|
+
const config = await readListeners().catch(() => null);
|
|
1543
|
+
return config ? staleLinkHint(url, config.listeners, url === process.env[ENV_KEY] ? rechromeUrlSource : undefined) : null;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
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
|
|
1503
1559
|
throw new Error("No free port for the share-all listener; pass one with rech listener add");
|
|
1504
1560
|
}
|
|
1505
1561
|
|
|
@@ -1521,9 +1577,10 @@ function canonicalKeyFor(registry: Record<string, TokenEntry>, key: string): str
|
|
|
1521
1577
|
}
|
|
1522
1578
|
|
|
1523
1579
|
/**
|
|
1524
|
-
* `rech share a b c`: one link for
|
|
1525
|
-
*
|
|
1526
|
-
* 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.
|
|
1527
1584
|
*/
|
|
1528
1585
|
async function shareProfiles(selectors: string[], opts: { listener?: string; local?: boolean; save?: boolean }): Promise<void> {
|
|
1529
1586
|
const registry = await readTokenRegistry(), cache = await readChromeProfileCache();
|
|
@@ -1536,11 +1593,11 @@ async function shareProfiles(selectors: string[], opts: { listener?: string; loc
|
|
|
1536
1593
|
const config = await requireListeners();
|
|
1537
1594
|
const same = config.listeners.find(l => l.profiles !== "*" && l.profiles.length === keys.length && keys.every(k => (l.profiles as string[]).includes(k)));
|
|
1538
1595
|
const id = createHash("sha256").update([...keys].sort().join("\0")).digest("hex").slice(0, 6);
|
|
1539
|
-
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(" ")}` });
|
|
1540
1597
|
}
|
|
1541
1598
|
|
|
1542
1599
|
/** Put exactly `snapshot` on one scoped listener (created if missing) and print its link. */
|
|
1543
|
-
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> {
|
|
1544
1601
|
const config = await requireListeners();
|
|
1545
1602
|
const name = opts.listener ?? opts.defaultName;
|
|
1546
1603
|
let listener = config.listeners.find(l => l.name === name);
|
|
@@ -1549,11 +1606,14 @@ async function shareSet(snapshot: string[], opts: { listener?: string; local?: b
|
|
|
1549
1606
|
let changes = "";
|
|
1550
1607
|
if (!listener) {
|
|
1551
1608
|
if (opts.listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
|
|
1552
|
-
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 };
|
|
1553
1610
|
config.listeners.push(listener);
|
|
1554
1611
|
changes = `created listener "${name}" on ${listenerAddress(listener)}${listener.prefix}`;
|
|
1555
1612
|
} else {
|
|
1556
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))];
|
|
1557
1617
|
const added = snapshot.filter(p => !before.includes(p)), removed = before.filter(p => !snapshot.includes(p));
|
|
1558
1618
|
listener.profiles = snapshot;
|
|
1559
1619
|
changes = added.length || removed.length ? [added.length && `added ${added.join(", ")}`, removed.length && `removed ${removed.join(", ")}`].filter(Boolean).join("; ") : "no changes";
|
|
@@ -1918,8 +1978,9 @@ async function callServe(
|
|
|
1918
1978
|
process.exit(1);
|
|
1919
1979
|
});
|
|
1920
1980
|
if (res.status === 401) {
|
|
1921
|
-
|
|
1922
|
-
|
|
1981
|
+
const stale = await localStaleLinkHint(url);
|
|
1982
|
+
if (throwOnFailure) throw new Error(stale ? `The daemon rejected this link's key: ${stale}` : "The daemon rejected its connection key. Run setup again to refresh it.");
|
|
1983
|
+
console.error(`[rech] rech-client -> rech-server[ok]\n -x: bearer key rejected (used: ${key.slice(0, 4)}...) -> playwright[unknown]${stale ? `\n ${stale}` : ""}`);
|
|
1923
1984
|
process.exit(1);
|
|
1924
1985
|
}
|
|
1925
1986
|
const text = await res.text();
|
|
@@ -1930,6 +1991,8 @@ async function callServe(
|
|
|
1930
1991
|
let detail = `HTTP ${res.status} from ${serviceUrl(url, "run")} is not a rechrome daemon response: ${text.slice(0, 200).trim()}`;
|
|
1931
1992
|
if (res.status === 502 || res.status === 503 || res.status === 504)
|
|
1932
1993
|
detail += `\n The proxy is up but nothing answers behind it: this link's listener was likely removed or moved. Ask its owner for a fresh link (\`rech share\`), then \`rechrome connect <link>\`.`;
|
|
1994
|
+
const stale = await localStaleLinkHint(url);
|
|
1995
|
+
if (stale) detail += `\n ${stale}`;
|
|
1933
1996
|
if (throwOnFailure) throw new Error(detail);
|
|
1934
1997
|
console.error(`[rech] rech-client -> ${serviceUrl(url, "run")}\n -x: ${detail}`);
|
|
1935
1998
|
process.exit(1);
|
|
@@ -3346,9 +3409,53 @@ async function writeEnvUrl(envPath: string, url: string): Promise<string> {
|
|
|
3346
3409
|
const lines = (await file(envPath).text().catch(() => "")).split("\n").filter(l => l.trim() && !l.startsWith(`${ENV_KEY}=`));
|
|
3347
3410
|
await writeFile(envPath, [...lines, envAssignment(ENV_KEY, url), ""].join("\n"), { mode: 0o600 });
|
|
3348
3411
|
await chmod(envPath, 0o600);
|
|
3412
|
+
await recordSavedLink(envPath).catch(() => {}); // only bookkeeping for later warnings
|
|
3349
3413
|
return envPath;
|
|
3350
3414
|
}
|
|
3351
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
|
+
|
|
3352
3459
|
/**
|
|
3353
3460
|
* What would shadow the machine-wide RECHROME_URL from `cwd`: the shell's own RECHROME_URL, or the
|
|
3354
3461
|
* nearest project env file that sets one (searched the way loadNearestEnv walks). Null when none does.
|
|
@@ -3841,7 +3948,7 @@ async function shareTransportListener(selector: string, profiles: string[], labe
|
|
|
3841
3948
|
const address = chooseListenAddress(await detectListenChoices(), selector);
|
|
3842
3949
|
const existing = config.listeners.find(l => l.host === address && l.profiles !== "*" && normalizePrefix(l.prefix) === "/");
|
|
3843
3950
|
if (existing) return existing.name;
|
|
3844
|
-
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 };
|
|
3845
3952
|
config.listeners.push(listener);
|
|
3846
3953
|
await writeListeners(config);
|
|
3847
3954
|
if (!(await waitForListener(listener))) console.error(`[rech] warning: the daemon has not opened ${listenerAddress(listener)} yet; is it running? (rech status)`);
|
|
@@ -4135,7 +4242,8 @@ async function status(): Promise<void> {
|
|
|
4135
4242
|
// Show the URL this client connects to; through a proxy, the daemon's bind is on another port.
|
|
4136
4243
|
const details = [pingBody?.listener && `listener ${pingBody.listener}`, pingBody?.bind && `bind ${pingBody.bind}`].filter(Boolean).join(", ");
|
|
4137
4244
|
// Any HTTP answer is not the daemon: a proxy's 404/502 means the link's listener is gone or moved.
|
|
4138
|
-
|
|
4245
|
+
// A 200 that isn't the /ping JSON is another listener's catch-all: its port was reused.
|
|
4246
|
+
const answered = (pingResponse?.ok && !!pingBody) || pingResponse?.status === 401 || (pingResponse?.status === 403 && !!pingBody?.error);
|
|
4139
4247
|
console.log(`serve: ${!ping ? `not reachable at ${serviceUrl(url)}`
|
|
4140
4248
|
: answered ? `running ${serviceUrl(url)}${details ? ` (${details})` : ""}`
|
|
4141
4249
|
: `✗ ${serviceUrl(url)} answers HTTP ${pingResponse?.status ?? ping.status}, not a rechrome daemon — this link's listener was likely removed or moved; ask its host for a fresh link (\`rech share <profile>\`), then \`rech connect '<url>'\``}`);
|
|
@@ -4147,6 +4255,8 @@ async function status(): Promise<void> {
|
|
|
4147
4255
|
if (pingResponse?.status === 403 && pingBody?.error) console.log(`auth: ✗ ${pingBody.error}`);
|
|
4148
4256
|
if (pingResponse?.status === 401)
|
|
4149
4257
|
console.log(`auth: ✗ key rejected — ask the host for a fresh URL (\`rech share <profile>\`), then \`rech connect '<url>'\``);
|
|
4258
|
+
const stale = ping && !pingBody ? await localStaleLinkHint(url) : null;
|
|
4259
|
+
if (stale) console.log(`stale: ✗ ${stale}`);
|
|
4150
4260
|
// daemonManager().id — there is no PM_BIN constant. Referencing one threw a
|
|
4151
4261
|
// ReferenceError that took down the whole of `rech status`, so the one command
|
|
4152
4262
|
// that reports "the relay is wedged" died exactly when the relay was wedged,
|
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";
|
|
@@ -1493,13 +1493,69 @@ export function chooseShareListener(profile: string, listeners: Listener[]): str
|
|
|
1493
1493
|
].filter(Boolean).join("\n"), "none", scoped);
|
|
1494
1494
|
}
|
|
1495
1495
|
|
|
1496
|
-
/**
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1496
|
+
/**
|
|
1497
|
+
* Why a saved link to one of this machine's listeners stopped working, with the command that
|
|
1498
|
+
* replaces it; null when it still matches its listener or points at another machine.
|
|
1499
|
+
* A removed listener's port goes to the next one created (`freeListenerPort`), so a stale
|
|
1500
|
+
* link can reach a different listener: its path is not served there and its key is unknown.
|
|
1501
|
+
*/
|
|
1502
|
+
export function staleLinkHint(url: string, listeners: Listener[], source?: string): string | null {
|
|
1503
|
+
let parsed: ReturnType<typeof parseUrl>;
|
|
1504
|
+
try { parsed = parseUrl(url); } catch { return null; }
|
|
1505
|
+
const loopback = (host: string) => isLoopback(host) || host === "localhost";
|
|
1506
|
+
const urlHost = parsed.host.replace(/^\[|\]$/g, "");
|
|
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);
|
|
1516
|
+
const owner = listeners.find(l => l.key === parsed.key);
|
|
1517
|
+
if (!direct && !proxied && !owner) return null; // another machine's daemon: only its owner knows
|
|
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;
|
|
1523
|
+
let fix = profile ? `rech share ${JSON.stringify(profile)} --save` : "rech share --save";
|
|
1524
|
+
if (profile) {
|
|
1525
|
+
try {
|
|
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`;
|
|
1528
|
+
} catch { /* no single listener shares it: the default creates a fresh link */ }
|
|
1529
|
+
}
|
|
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(", ")}).`];
|
|
1535
|
+
if (source && source !== "environment") lines.push(`The stale RECHROME_URL is saved in ${source}.`);
|
|
1536
|
+
lines.push(`Replace it with a current link: ${fix}`);
|
|
1537
|
+
return lines.join("\n ");
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
/** staleLinkHint against this machine's listeners.json; null when it can't be read. */
|
|
1541
|
+
async function localStaleLinkHint(url: string): Promise<string | null> {
|
|
1542
|
+
const config = await readListeners().catch(() => null);
|
|
1543
|
+
return config ? staleLinkHint(url, config.listeners, url === process.env[ENV_KEY] ? rechromeUrlSource : undefined) : null;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
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
|
|
1503
1559
|
throw new Error("No free port for the share-all listener; pass one with rech listener add");
|
|
1504
1560
|
}
|
|
1505
1561
|
|
|
@@ -1521,9 +1577,10 @@ function canonicalKeyFor(registry: Record<string, TokenEntry>, key: string): str
|
|
|
1521
1577
|
}
|
|
1522
1578
|
|
|
1523
1579
|
/**
|
|
1524
|
-
* `rech share a b c`: one link for
|
|
1525
|
-
*
|
|
1526
|
-
* 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.
|
|
1527
1584
|
*/
|
|
1528
1585
|
async function shareProfiles(selectors: string[], opts: { listener?: string; local?: boolean; save?: boolean }): Promise<void> {
|
|
1529
1586
|
const registry = await readTokenRegistry(), cache = await readChromeProfileCache();
|
|
@@ -1536,11 +1593,11 @@ async function shareProfiles(selectors: string[], opts: { listener?: string; loc
|
|
|
1536
1593
|
const config = await requireListeners();
|
|
1537
1594
|
const same = config.listeners.find(l => l.profiles !== "*" && l.profiles.length === keys.length && keys.every(k => (l.profiles as string[]).includes(k)));
|
|
1538
1595
|
const id = createHash("sha256").update([...keys].sort().join("\0")).digest("hex").slice(0, 6);
|
|
1539
|
-
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(" ")}` });
|
|
1540
1597
|
}
|
|
1541
1598
|
|
|
1542
1599
|
/** Put exactly `snapshot` on one scoped listener (created if missing) and print its link. */
|
|
1543
|
-
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> {
|
|
1544
1601
|
const config = await requireListeners();
|
|
1545
1602
|
const name = opts.listener ?? opts.defaultName;
|
|
1546
1603
|
let listener = config.listeners.find(l => l.name === name);
|
|
@@ -1549,11 +1606,14 @@ async function shareSet(snapshot: string[], opts: { listener?: string; local?: b
|
|
|
1549
1606
|
let changes = "";
|
|
1550
1607
|
if (!listener) {
|
|
1551
1608
|
if (opts.listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
|
|
1552
|
-
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 };
|
|
1553
1610
|
config.listeners.push(listener);
|
|
1554
1611
|
changes = `created listener "${name}" on ${listenerAddress(listener)}${listener.prefix}`;
|
|
1555
1612
|
} else {
|
|
1556
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))];
|
|
1557
1617
|
const added = snapshot.filter(p => !before.includes(p)), removed = before.filter(p => !snapshot.includes(p));
|
|
1558
1618
|
listener.profiles = snapshot;
|
|
1559
1619
|
changes = added.length || removed.length ? [added.length && `added ${added.join(", ")}`, removed.length && `removed ${removed.join(", ")}`].filter(Boolean).join("; ") : "no changes";
|
|
@@ -1918,8 +1978,9 @@ async function callServe(
|
|
|
1918
1978
|
process.exit(1);
|
|
1919
1979
|
});
|
|
1920
1980
|
if (res.status === 401) {
|
|
1921
|
-
|
|
1922
|
-
|
|
1981
|
+
const stale = await localStaleLinkHint(url);
|
|
1982
|
+
if (throwOnFailure) throw new Error(stale ? `The daemon rejected this link's key: ${stale}` : "The daemon rejected its connection key. Run setup again to refresh it.");
|
|
1983
|
+
console.error(`[rech] rech-client -> rech-server[ok]\n -x: bearer key rejected (used: ${key.slice(0, 4)}...) -> playwright[unknown]${stale ? `\n ${stale}` : ""}`);
|
|
1923
1984
|
process.exit(1);
|
|
1924
1985
|
}
|
|
1925
1986
|
const text = await res.text();
|
|
@@ -1930,6 +1991,8 @@ async function callServe(
|
|
|
1930
1991
|
let detail = `HTTP ${res.status} from ${serviceUrl(url, "run")} is not a rechrome daemon response: ${text.slice(0, 200).trim()}`;
|
|
1931
1992
|
if (res.status === 502 || res.status === 503 || res.status === 504)
|
|
1932
1993
|
detail += `\n The proxy is up but nothing answers behind it: this link's listener was likely removed or moved. Ask its owner for a fresh link (\`rech share\`), then \`rechrome connect <link>\`.`;
|
|
1994
|
+
const stale = await localStaleLinkHint(url);
|
|
1995
|
+
if (stale) detail += `\n ${stale}`;
|
|
1933
1996
|
if (throwOnFailure) throw new Error(detail);
|
|
1934
1997
|
console.error(`[rech] rech-client -> ${serviceUrl(url, "run")}\n -x: ${detail}`);
|
|
1935
1998
|
process.exit(1);
|
|
@@ -3346,9 +3409,53 @@ async function writeEnvUrl(envPath: string, url: string): Promise<string> {
|
|
|
3346
3409
|
const lines = (await file(envPath).text().catch(() => "")).split("\n").filter(l => l.trim() && !l.startsWith(`${ENV_KEY}=`));
|
|
3347
3410
|
await writeFile(envPath, [...lines, envAssignment(ENV_KEY, url), ""].join("\n"), { mode: 0o600 });
|
|
3348
3411
|
await chmod(envPath, 0o600);
|
|
3412
|
+
await recordSavedLink(envPath).catch(() => {}); // only bookkeeping for later warnings
|
|
3349
3413
|
return envPath;
|
|
3350
3414
|
}
|
|
3351
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
|
+
|
|
3352
3459
|
/**
|
|
3353
3460
|
* What would shadow the machine-wide RECHROME_URL from `cwd`: the shell's own RECHROME_URL, or the
|
|
3354
3461
|
* nearest project env file that sets one (searched the way loadNearestEnv walks). Null when none does.
|
|
@@ -3841,7 +3948,7 @@ async function shareTransportListener(selector: string, profiles: string[], labe
|
|
|
3841
3948
|
const address = chooseListenAddress(await detectListenChoices(), selector);
|
|
3842
3949
|
const existing = config.listeners.find(l => l.host === address && l.profiles !== "*" && normalizePrefix(l.prefix) === "/");
|
|
3843
3950
|
if (existing) return existing.name;
|
|
3844
|
-
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 };
|
|
3845
3952
|
config.listeners.push(listener);
|
|
3846
3953
|
await writeListeners(config);
|
|
3847
3954
|
if (!(await waitForListener(listener))) console.error(`[rech] warning: the daemon has not opened ${listenerAddress(listener)} yet; is it running? (rech status)`);
|
|
@@ -4135,7 +4242,8 @@ async function status(): Promise<void> {
|
|
|
4135
4242
|
// Show the URL this client connects to; through a proxy, the daemon's bind is on another port.
|
|
4136
4243
|
const details = [pingBody?.listener && `listener ${pingBody.listener}`, pingBody?.bind && `bind ${pingBody.bind}`].filter(Boolean).join(", ");
|
|
4137
4244
|
// Any HTTP answer is not the daemon: a proxy's 404/502 means the link's listener is gone or moved.
|
|
4138
|
-
|
|
4245
|
+
// A 200 that isn't the /ping JSON is another listener's catch-all: its port was reused.
|
|
4246
|
+
const answered = (pingResponse?.ok && !!pingBody) || pingResponse?.status === 401 || (pingResponse?.status === 403 && !!pingBody?.error);
|
|
4139
4247
|
console.log(`serve: ${!ping ? `not reachable at ${serviceUrl(url)}`
|
|
4140
4248
|
: answered ? `running ${serviceUrl(url)}${details ? ` (${details})` : ""}`
|
|
4141
4249
|
: `✗ ${serviceUrl(url)} answers HTTP ${pingResponse?.status ?? ping.status}, not a rechrome daemon — this link's listener was likely removed or moved; ask its host for a fresh link (\`rech share <profile>\`), then \`rech connect '<url>'\``}`);
|
|
@@ -4147,6 +4255,8 @@ async function status(): Promise<void> {
|
|
|
4147
4255
|
if (pingResponse?.status === 403 && pingBody?.error) console.log(`auth: ✗ ${pingBody.error}`);
|
|
4148
4256
|
if (pingResponse?.status === 401)
|
|
4149
4257
|
console.log(`auth: ✗ key rejected — ask the host for a fresh URL (\`rech share <profile>\`), then \`rech connect '<url>'\``);
|
|
4258
|
+
const stale = ping && !pingBody ? await localStaleLinkHint(url) : null;
|
|
4259
|
+
if (stale) console.log(`stale: ✗ ${stale}`);
|
|
4150
4260
|
// daemonManager().id — there is no PM_BIN constant. Referencing one threw a
|
|
4151
4261
|
// ReferenceError that took down the whole of `rech status`, so the one command
|
|
4152
4262
|
// that reports "the relay is wedged" died exactly when the relay was wedged,
|
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
|