rechrome 1.45.0 → 1.47.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 CHANGED
@@ -56,6 +56,9 @@ rech tab-list
56
56
  rech eval "() => document.title"
57
57
  rech --profile work@example.com open https://example.com # another registered profile
58
58
  rech --isolate open https://accounts.example.com # throwaway session, e.g. for a login flow
59
+ rech --isolate=login open https://accounts.example.com # named isolated session: reused by later
60
+ rech --isolate=login fill-secret e12 --from-env PASSWORD # --isolate=login commands (same tabs)
61
+ rech session close login # until you close it (rech session ls lists it)
59
62
  ```
60
63
 
61
64
  Any [playwright-cli](https://github.com/snomiao/playwright-cli) command works after `rech`. When a
@@ -151,10 +154,12 @@ comment for some .env loaders. rech quotes it when it saves one.
151
154
 
152
155
  ### 4. Share with another machine
153
156
 
154
- On the host (the machine with Chrome), make an invite:
157
+ On the host (the machine with Chrome), make an invite. `rech share` has no default transport:
158
+ in a terminal, `rech share` asks which profile and which listeners to share over; in a script,
159
+ name them:
155
160
 
156
161
  ```bash
157
- rech share new you@example.com --name alice
162
+ rech share you@example.com --webrtc --name alice
158
163
  # Share "alice": you@example.com
159
164
  # Invite for one device (unused, it expires …; secret: send it privately):
160
165
  # https://rechrome.pages.dev/#rech1_7fQxC9tZp2mVb8wLrN4hdK3kT9aQ
@@ -168,8 +173,7 @@ rech connect 'https://rechrome.pages.dev/#rech1_…'
168
173
  rech open https://example.com
169
174
  ```
170
175
 
171
- By default the two machines meet over **WebRTC**: neither needs an open port, a VPN or a
172
- domain. A small signal server introduces them, and then traffic flows peer to peer, end-to-end
176
+ Over **WebRTC** the two machines need no open port, VPN or domain. A small signal server introduces them, and then traffic flows peer to peer, end-to-end
173
177
  encrypted with keys the signal server never sees. The first `rech` command after connecting starts
174
178
  a small local bridge that keeps the connection open between commands.
175
179
 
@@ -180,10 +184,12 @@ expire after 24 hours, and WebRTC shares after 30 days unless renewed.
180
184
 
181
185
  | On the host | |
182
186
  |---|---|
183
- | `rech share new <profile..> [--name n]` | Invite link (`--via webrtc` by default) |
184
- | `--via lan` · `--via tailscale` · `--via <IP or listener>` | Direct HTTP instead of (or as well as) WebRTC |
187
+ | `rech share [<profile..>] [--name n]` | Invite link; in a terminal, with no transport flag, pick profile and listeners from a list |
188
+ | `--webrtc` · `--webrtc=<signal-url>` | WebRTC via the built-in `webrtc-default` listener (rechrome.pages.dev: best effort, for trying it out; no speed or uptime promise) · via your own signal server (its listener is made if needed) |
189
+ | `--lan` · `--tailscale` · `--via <listener,…>` | Direct HTTP on this network · your tailnet · existing listeners (`rech listener ls`); combine several for one link each |
185
190
  | `--devices 3` · `--approve` | Let 3 devices redeem it · make each new device wait for `rech share approve` |
186
- | `--expires 7d` · `--invite-ttl 1h` · `--signal <url>` | Lifetimes · your [own signal server](docs/signal-selfhost.md) |
191
+ | `--expires 7d` · `--invite-ttl 1h` | Lifetimes |
192
+ | `rech listener add <name> --webrtc=<signal-url> [--turn <url>]… [--turn-env VAR]…` | A WebRTC listener on your [own signal server](docs/signal-selfhost.md), with optional STUN/TURN |
187
193
  | `rech share ls` · `rech share devices <share> [--rm <device>]` | Shares, devices, expiry · drop one device |
188
194
  | `rech share allow\|deny <share> <profile..>` | Change the profiles; the link and devices stay |
189
195
  | `rech share rotate <share>` | A fresh invite; the old one stops working |
@@ -201,9 +207,12 @@ navigation, tabs, snapshots, screenshots and basic interactions, but not `eval`/
201
207
  filesystem commands (see [Remote access](#remote-access)).
202
208
 
203
209
  **WebRTC needs** `node-datachannel`, installed on first use into `~/.rechrome/webrtc`. When a
204
- direct path can't be found (some corporate networks, symmetric NAT), give the host TURN: set
205
- `CF_TURN_KEY_ID` and `CF_TURN_API_TOKEN` (a [Cloudflare TURN](https://developers.cloudflare.com/realtime/turn/)
206
- key; its free tier is 1,000 GB/month) in the daemon's environment, or share `--via tailscale`.
210
+ direct path can't be found (some corporate networks, symmetric NAT), give the host TURN: add it to
211
+ a WebRTC listener as one URL, `rech listener add home --webrtc=https://signal.example.com/ --turn-env TEAM_TURN`
212
+ with `TEAM_TURN=turns://user:pass@turn.example.com:5349` (user and password percent-encoded;
213
+ `rech listener ls` shows the username, never the password). Or set `CF_TURN_KEY_ID` and
214
+ `CF_TURN_API_TOKEN` (a [Cloudflare TURN](https://developers.cloudflare.com/realtime/turn/) key; its
215
+ free tier is 1,000 GB/month) in the daemon's environment, or share `--tailscale`.
207
216
 
208
217
  **Keep links out of git.** `rech status`, `rech connect` and `rech share --save` warn when the file
209
218
  holding `RECHROME_URL` is committed, staged, not git-ignored, or readable by other users. Links
@@ -539,7 +548,11 @@ Each client gets its own browser session, keyed by the **git worktree root path*
539
548
  roll up to their parent), or the current directory outside git. So two worktrees of one repo
540
549
  get separate sessions, and `git checkout` keeps you in the same one. `RECH_IDENTITY=branch`
541
550
  restores the older `<remote>/tree/<branch>` keying and `RECH_IDENTITY=cwd` keys on the exact
542
- directory. Pass `-s=<name>` for a named sub-session, or `--isolate` for a throwaway one.
551
+ directory. Pass `-s=<name>` for a named sub-session, `--isolate` for a throwaway one (a new session per
552
+ command), or `--isolate=<name>` for a named isolated session (`-s=iso_<name>`) that later
553
+ `--isolate=<name>` commands from the same worktree reuse until `rech session close <name>`.
554
+ An isolated session is its own tab group in the same Chrome profile: it shares the profile's
555
+ cookies, so profile-scoped listeners still refuse `eval`, `run-code` and other arbitrary code in it.
543
556
  A share device's sessions are additionally keyed by its share and device, so they never meet
544
557
  another device's or a listener link's; `rech session ls` lists the ones this link drove.
545
558
 
package/listeners.js CHANGED
@@ -21,11 +21,123 @@ Types: ${UPLOAD_TYPES.join(", ")}, up to ${MAX_UPLOAD_BYTES / 1024 / 1024} MiB.
21
21
  // listeners.ts
22
22
  import { isIP } from "net";
23
23
  import { createHash, randomBytes } from "crypto";
24
+ import { homedir as homedir2 } from "os";
25
+ import { join as join3 } from "path";
26
+ import { mkdir, readFile, rename, writeFile } from "fs/promises";
27
+
28
+ // shares.ts
24
29
  import { homedir } from "os";
25
30
  import { join as join2 } from "path";
26
- import { mkdir, readFile, rename, writeFile } from "fs/promises";
31
+
32
+ // secret.ts
33
+ var B58 = "[1-9A-HJ-NP-Za-km-z]";
34
+ var BODY_LEN = 22;
35
+ var SUM_LEN = 6;
36
+ var TOKEN_PATTERN = new RegExp(`\\brech1_${B58}{${BODY_LEN + SUM_LEN}}\\b`, "g");
37
+ var EXACT = new RegExp(`^rech1_(${B58}{${BODY_LEN}})(${B58}{${SUM_LEN}})$`);
38
+ var CRC_TABLE = (() => {
39
+ const t = new Uint32Array(256);
40
+ for (let i = 0;i < 256; i++) {
41
+ let c = i;
42
+ for (let k = 0;k < 8; k++)
43
+ c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
44
+ t[i] = c >>> 0;
45
+ }
46
+ return t;
47
+ })();
48
+
49
+ // shares.ts
50
+ var DEFAULT_SIGNAL = "https://rechrome.pages.dev/";
51
+ var DEFAULT_RTC = "webrtc-default";
52
+ var SHARES_FILE = join2(homedir(), ".rechrome", "shares.json");
53
+ function normalizeSignal(value) {
54
+ let url;
55
+ try {
56
+ url = new URL(value);
57
+ } catch {
58
+ throw new Error(`Signal server must be an http(s) URL, e.g. https://signal.example.com; got "${value}"`);
59
+ }
60
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash)
61
+ throw new Error("Signal server must be a plain http(s) URL, without credentials, query or fragment");
62
+ if (!url.pathname.endsWith("/"))
63
+ url.pathname += "/";
64
+ return url.toString();
65
+ }
66
+ var bearerCache = new Map;
67
+
68
+ // listeners.ts
69
+ var LEGACY_RTC = "webrtc";
70
+ var DEFAULT_RTC_NOTE = "best effort, for trying it out: no speed or uptime promise. Host your own: rech listener add <name> --webrtc=<url>";
71
+ var defaultRtcListener = () => ({ name: DEFAULT_RTC, kind: "webrtc", signal: DEFAULT_SIGNAL, builtin: true });
72
+ function rtcListeners(config) {
73
+ return [...config?.rtcListeners ?? [], defaultRtcListener()];
74
+ }
75
+ function shareRtcListeners(share, config) {
76
+ const all = rtcListeners(config);
77
+ const out = [];
78
+ for (const via of share.via) {
79
+ if (via === LEGACY_RTC)
80
+ out.push({ name: LEGACY_RTC, kind: "webrtc", signal: normalizeSignal(share.signal ?? DEFAULT_SIGNAL), builtin: true });
81
+ else {
82
+ const l = all.find((x) => x.name === via);
83
+ if (l)
84
+ out.push(l);
85
+ }
86
+ }
87
+ return out;
88
+ }
89
+ function parseIceUrl(value) {
90
+ const raw = value.trim();
91
+ const m = /^(stuns?|turns?):(?:\/\/)?(?:([^:@/\s]*)(?::([^@/\s]*))?@)?([^/?#@\s]+)(\?[^#]*)?$/i.exec(raw);
92
+ if (!m)
93
+ throw new Error("A STUN/TURN server is a URL like turns://user:pass@turn.example.com:5349 or stun:stun.example.com:3478");
94
+ const [, scheme, user, pass, hostPort, query = ""] = m;
95
+ const kind = scheme.toLowerCase();
96
+ if (query && !/^\?transport=(udp|tcp)$/i.test(query))
97
+ throw new Error("A STUN/TURN URL may only carry ?transport=udp or ?transport=tcp");
98
+ const urls = `${kind}:${hostPort}${query}`;
99
+ if (user === undefined)
100
+ return { urls };
101
+ if (kind.startsWith("stun"))
102
+ throw new Error("A STUN server takes no username or password");
103
+ let username, credential;
104
+ try {
105
+ username = decodeURIComponent(user);
106
+ credential = decodeURIComponent(pass ?? "");
107
+ } catch {
108
+ throw new Error("The TURN username or password has a bad %-escape");
109
+ }
110
+ if (!username || !credential)
111
+ throw new Error("A TURN URL with credentials needs both: turns://user:pass@host:port");
112
+ return { urls, username, credential };
113
+ }
114
+ function iceLabel(s) {
115
+ const url = [s.urls].flat()[0] ?? "";
116
+ if (!s.username)
117
+ return url;
118
+ const [scheme, ...rest] = url.split(":");
119
+ return `${scheme}://${encodeURIComponent(s.username)}:\u2026@${rest.join(":")}`;
120
+ }
121
+ function validateRtcListeners(value, socketNames) {
122
+ if (value === undefined)
123
+ return;
124
+ if (!Array.isArray(value))
125
+ throw new Error("listeners.json rtcListeners must be a list");
126
+ const names = new Set;
127
+ for (const l of value) {
128
+ if (!l || l.kind !== "webrtc" || typeof l.name !== "string" || !/^[a-zA-Z0-9_-]{1,40}$/.test(l.name) || typeof l.signal !== "string")
129
+ throw new Error("Each WebRTC listener needs a name and a signal server URL");
130
+ if (l.name === DEFAULT_RTC || l.name === LEGACY_RTC || names.has(l.name) || socketNames.has(l.name))
131
+ throw new Error(`Listener names must be unique ("${DEFAULT_RTC}" and "${LEGACY_RTC}" are built in)`);
132
+ l.signal = normalizeSignal(l.signal);
133
+ if (l.ice !== undefined && (!Array.isArray(l.ice) || l.ice.some((s) => !s || typeof s.urls !== "string" && !Array.isArray(s.urls) || s.username !== undefined && typeof s.username !== "string" || s.credential !== undefined && typeof s.credential !== "string")))
134
+ throw new Error(`WebRTC listener "${l.name}": ice must be a list of {urls, username?, credential?}`);
135
+ delete l.builtin;
136
+ names.add(l.name);
137
+ }
138
+ }
27
139
  var MAX_RETIRED_PORTS = 32;
28
- var LISTENERS_FILE = join2(homedir(), ".rechrome", "listeners.json");
140
+ var LISTENERS_FILE = join3(homedir2(), ".rechrome", "listeners.json");
29
141
  var isLoopback = (host) => host === "::1" || /^127\./.test(host);
30
142
  var listenerAddress = (l) => `${l.host.includes(":") ? `[${l.host}]` : l.host}:${l.port}`;
31
143
  var profileOutputPrefix = (profile) => `profiles/${createHash("sha256").update(profile).digest("hex")}/`;
@@ -89,6 +201,7 @@ function validateListeners(value) {
89
201
  addresses.add(address);
90
202
  keys.add(l.key);
91
203
  }
204
+ validateRtcListeners(config.rtcListeners, names);
92
205
  if (config.retiredPorts !== undefined && (!Array.isArray(config.retiredPorts) || config.retiredPorts.some((p) => !Number.isInteger(p) || p < 1 || p > 65535)))
93
206
  throw new Error("listeners.json retiredPorts must be a list of ports");
94
207
  return config;
@@ -118,7 +231,7 @@ async function writeListeners(config) {
118
231
  config.retiredPorts = retired;
119
232
  else
120
233
  delete config.retiredPorts;
121
- await mkdir(join2(homedir(), ".rechrome"), { recursive: true });
234
+ await mkdir(join3(homedir2(), ".rechrome"), { recursive: true });
122
235
  const temp = `${LISTENERS_FILE}.${randomBytes(6).toString("hex")}.tmp`;
123
236
  await writeFile(temp, JSON.stringify(config, null, 2) + `
124
237
  `, { mode: 384 });
@@ -243,24 +356,32 @@ function planProfileRemoval(key, registry, listeners, managedRoot) {
243
356
  return { keys, listeners: edits, ...managed ? { dataDir: entry.userDataDir } : {} };
244
357
  }
245
358
  export {
359
+ DEFAULT_RTC,
360
+ DEFAULT_RTC_NOTE,
361
+ LEGACY_RTC,
246
362
  LISTENERS_FILE,
247
363
  allowProfiles,
248
364
  authorizeProfileRequest,
249
365
  canReadProfileFile,
250
366
  canonicalProfileKeys,
367
+ defaultRtcListener,
251
368
  denyProfiles,
369
+ iceLabel,
252
370
  isLoopback,
253
371
  listenerAddress,
254
372
  normalizePrefix,
255
373
  normalizePublicUrl,
374
+ parseIceUrl,
256
375
  planProfileRemoval,
257
376
  profileOutputPrefix,
258
377
  readListeners,
259
378
  resolveAllowedProfile,
260
379
  retirePorts,
261
380
  rotateListenerKey,
381
+ rtcListeners,
262
382
  serviceUrl,
263
383
  setPublicUrl,
384
+ shareRtcListeners,
264
385
  validateListeners,
265
386
  writeListeners
266
387
  };
package/listeners.ts CHANGED
@@ -4,12 +4,89 @@ import { createHash, randomBytes } from "crypto";
4
4
  import { homedir } from "os";
5
5
  import { join } from "path";
6
6
  import { mkdir, readFile, rename, writeFile } from "fs/promises";
7
+ import { DEFAULT_RTC, DEFAULT_SIGNAL, normalizeSignal } from "./shares.ts";
7
8
 
8
9
  // publicUrl: where a reverse proxy (Tailscale Serve, Caddy, nginx, ...) exposes this listener.
9
10
  export type Listener = { name: string; host: string; port: number; key: string; profiles: string[] | "*"; prefix?: string; publicUrl?: string; uploads?: UploadPolicy | false; readSecrets?: boolean; readCookies?: boolean; keepDownloads?: boolean };
10
11
  // retiredPorts: ports of removed listeners, newest last. New listeners skip them so a link
11
12
  // 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
+ // rtcListeners: WebRTC transports (a signal server, plus optional STUN/TURN). They live under
14
+ // their own key so a daemon from before they existed reads the file unchanged and ignores them.
15
+ export type ListenerConfig = { version: 1; listeners: Listener[]; retiredPorts?: number[]; rtcListeners?: RtcListener[] };
16
+ export type IceServer = { urls: string | string[]; username?: string; credential?: string };
17
+ export type RtcListener = { name: string; kind: "webrtc"; signal: string; ice?: IceServer[]; builtin?: true };
18
+
19
+ // The built-in WebRTC listener: the public signal server, for trying sharing out. Never written
20
+ // to listeners.json; a share names it in `via`. The legacy `via: ["webrtc"]` (+ share.signal)
21
+ // maps to a virtual listener named "webrtc".
22
+ export { DEFAULT_RTC };
23
+ export const LEGACY_RTC = "webrtc";
24
+ export const DEFAULT_RTC_NOTE = "best effort, for trying it out: no speed or uptime promise. Host your own: rech listener add <name> --webrtc=<url>";
25
+ export const defaultRtcListener = (): RtcListener => ({ name: DEFAULT_RTC, kind: "webrtc", signal: DEFAULT_SIGNAL, builtin: true });
26
+
27
+ /** Every WebRTC listener a share can name: the saved ones plus the built-in default. */
28
+ export function rtcListeners(config: ListenerConfig | null): RtcListener[] {
29
+ return [...(config?.rtcListeners ?? []), defaultRtcListener()];
30
+ }
31
+
32
+ /** The WebRTC listeners a share is offered on, in `via` order. */
33
+ export function shareRtcListeners(share: { via: string[]; signal?: string }, config: ListenerConfig | null): RtcListener[] {
34
+ const all = rtcListeners(config);
35
+ const out: RtcListener[] = [];
36
+ for (const via of share.via) {
37
+ if (via === LEGACY_RTC) out.push({ name: LEGACY_RTC, kind: "webrtc", signal: normalizeSignal(share.signal ?? DEFAULT_SIGNAL), builtin: true });
38
+ else { const l = all.find(x => x.name === via); if (l) out.push(l); }
39
+ }
40
+ return out;
41
+ }
42
+
43
+ /**
44
+ * One STUN/TURN server from a URL: `turn[s]://user:pass@host:port?transport=tcp` (user and
45
+ * password percent-encoded) becomes {urls: "turn[s]:host:port?transport=tcp", username, credential}.
46
+ * `stun:host:port` and `turn:host:port` without credentials are accepted as they are.
47
+ */
48
+ export function parseIceUrl(value: string): IceServer {
49
+ const raw = value.trim();
50
+ const m = /^(stuns?|turns?):(?:\/\/)?(?:([^:@/\s]*)(?::([^@/\s]*))?@)?([^/?#@\s]+)(\?[^#]*)?$/i.exec(raw);
51
+ if (!m) throw new Error("A STUN/TURN server is a URL like turns://user:pass@turn.example.com:5349 or stun:stun.example.com:3478");
52
+ const [, scheme, user, pass, hostPort, query = ""] = m;
53
+ const kind = scheme!.toLowerCase();
54
+ if (query && !/^\?transport=(udp|tcp)$/i.test(query)) throw new Error("A STUN/TURN URL may only carry ?transport=udp or ?transport=tcp");
55
+ const urls = `${kind}:${hostPort}${query}`;
56
+ if (user === undefined) return { urls };
57
+ if (kind.startsWith("stun")) throw new Error("A STUN server takes no username or password");
58
+ let username: string, credential: string;
59
+ try { username = decodeURIComponent(user); credential = decodeURIComponent(pass ?? ""); }
60
+ catch { throw new Error("The TURN username or password has a bad %-escape"); }
61
+ if (!username || !credential) throw new Error("A TURN URL with credentials needs both: turns://user:pass@host:port");
62
+ return { urls, username, credential };
63
+ }
64
+
65
+ /** How a STUN/TURN server is shown: the username, never the password. */
66
+ export function iceLabel(s: IceServer): string {
67
+ const url = [s.urls].flat()[0] ?? "";
68
+ if (!s.username) return url;
69
+ const [scheme, ...rest] = url.split(":");
70
+ return `${scheme}://${encodeURIComponent(s.username)}:…@${rest.join(":")}`;
71
+ }
72
+
73
+ function validateRtcListeners(value: unknown, socketNames: Set<string>): void {
74
+ if (value === undefined) return;
75
+ if (!Array.isArray(value)) throw new Error("listeners.json rtcListeners must be a list");
76
+ const names = new Set<string>();
77
+ for (const l of value as RtcListener[]) {
78
+ if (!l || l.kind !== "webrtc" || typeof l.name !== "string" || !/^[a-zA-Z0-9_-]{1,40}$/.test(l.name) || typeof l.signal !== "string")
79
+ throw new Error("Each WebRTC listener needs a name and a signal server URL");
80
+ if (l.name === DEFAULT_RTC || l.name === LEGACY_RTC || names.has(l.name) || socketNames.has(l.name))
81
+ throw new Error(`Listener names must be unique ("${DEFAULT_RTC}" and "${LEGACY_RTC}" are built in)`);
82
+ l.signal = normalizeSignal(l.signal);
83
+ if (l.ice !== undefined && (!Array.isArray(l.ice) || l.ice.some(s => !s || (typeof s.urls !== "string" && !Array.isArray(s.urls))
84
+ || (s.username !== undefined && typeof s.username !== "string") || (s.credential !== undefined && typeof s.credential !== "string"))))
85
+ throw new Error(`WebRTC listener "${l.name}": ice must be a list of {urls, username?, credential?}`);
86
+ delete l.builtin;
87
+ names.add(l.name);
88
+ }
89
+ }
13
90
  const MAX_RETIRED_PORTS = 32;
14
91
  export const LISTENERS_FILE = join(homedir(), ".rechrome", "listeners.json");
15
92
  export const isLoopback = (host: string) => host === "::1" || /^127\./.test(host);
@@ -68,6 +145,7 @@ export function validateListeners(value: unknown): ListenerConfig {
68
145
  if (names.has(l.name) || addresses.has(address) || keys.has(l.key)) throw new Error("Listener names, addresses, and credentials must be unique");
69
146
  names.add(l.name); addresses.add(address); keys.add(l.key);
70
147
  }
148
+ validateRtcListeners(config.rtcListeners, names);
71
149
  if (config.retiredPorts !== undefined && (!Array.isArray(config.retiredPorts) || config.retiredPorts.some(p => !Number.isInteger(p) || p < 1 || p > 65535)))
72
150
  throw new Error("listeners.json retiredPorts must be a list of ports");
73
151
  return config;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.45.0",
3
+ "version": "1.47.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"