rechrome 1.44.0 → 1.46.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 +33 -10
- package/listeners.js +126 -3
- package/listeners.ts +81 -2
- package/package.json +1 -1
- package/rechrome.js +299 -91
- package/rechrome.ts +299 -91
- package/serve.js +84 -8
- package/serve.ts +84 -8
- package/shares.js +5 -2
- package/shares.ts +8 -2
- package/webrtc.js +16 -8
- package/webrtc.ts +15 -10
package/README.md
CHANGED
|
@@ -151,10 +151,12 @@ comment for some .env loaders. rech quotes it when it saves one.
|
|
|
151
151
|
|
|
152
152
|
### 4. Share with another machine
|
|
153
153
|
|
|
154
|
-
On the host (the machine with Chrome), make an invite:
|
|
154
|
+
On the host (the machine with Chrome), make an invite. `rech share` has no default transport:
|
|
155
|
+
in a terminal, `rech share` asks which profile and which listeners to share over; in a script,
|
|
156
|
+
name them:
|
|
155
157
|
|
|
156
158
|
```bash
|
|
157
|
-
rech share
|
|
159
|
+
rech share you@example.com --webrtc --name alice
|
|
158
160
|
# Share "alice": you@example.com
|
|
159
161
|
# Invite for one device (unused, it expires …; secret: send it privately):
|
|
160
162
|
# https://rechrome.pages.dev/#rech1_7fQxC9tZp2mVb8wLrN4hdK3kT9aQ
|
|
@@ -168,8 +170,7 @@ rech connect 'https://rechrome.pages.dev/#rech1_…'
|
|
|
168
170
|
rech open https://example.com
|
|
169
171
|
```
|
|
170
172
|
|
|
171
|
-
|
|
172
|
-
domain. A small signal server introduces them, and then traffic flows peer to peer, end-to-end
|
|
173
|
+
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
174
|
encrypted with keys the signal server never sees. The first `rech` command after connecting starts
|
|
174
175
|
a small local bridge that keeps the connection open between commands.
|
|
175
176
|
|
|
@@ -180,10 +181,12 @@ expire after 24 hours, and WebRTC shares after 30 days unless renewed.
|
|
|
180
181
|
|
|
181
182
|
| On the host | |
|
|
182
183
|
|---|---|
|
|
183
|
-
| `rech share
|
|
184
|
-
| `--
|
|
184
|
+
| `rech share [<profile..>] [--name n]` | Invite link; in a terminal, with no transport flag, pick profile and listeners from a list |
|
|
185
|
+
| `--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) |
|
|
186
|
+
| `--lan` · `--tailscale` · `--via <listener,…>` | Direct HTTP on this network · your tailnet · existing listeners (`rech listener ls`); combine several for one link each |
|
|
185
187
|
| `--devices 3` · `--approve` | Let 3 devices redeem it · make each new device wait for `rech share approve` |
|
|
186
|
-
| `--expires 7d` · `--invite-ttl 1h`
|
|
188
|
+
| `--expires 7d` · `--invite-ttl 1h` | Lifetimes |
|
|
189
|
+
| `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
190
|
| `rech share ls` · `rech share devices <share> [--rm <device>]` | Shares, devices, expiry · drop one device |
|
|
188
191
|
| `rech share allow\|deny <share> <profile..>` | Change the profiles; the link and devices stay |
|
|
189
192
|
| `rech share rotate <share>` | A fresh invite; the old one stops working |
|
|
@@ -201,9 +204,12 @@ navigation, tabs, snapshots, screenshots and basic interactions, but not `eval`/
|
|
|
201
204
|
filesystem commands (see [Remote access](#remote-access)).
|
|
202
205
|
|
|
203
206
|
**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:
|
|
205
|
-
|
|
206
|
-
|
|
207
|
+
direct path can't be found (some corporate networks, symmetric NAT), give the host TURN: add it to
|
|
208
|
+
a WebRTC listener as one URL, `rech listener add home --webrtc=https://signal.example.com/ --turn-env TEAM_TURN`
|
|
209
|
+
with `TEAM_TURN=turns://user:pass@turn.example.com:5349` (user and password percent-encoded;
|
|
210
|
+
`rech listener ls` shows the username, never the password). Or set `CF_TURN_KEY_ID` and
|
|
211
|
+
`CF_TURN_API_TOKEN` (a [Cloudflare TURN](https://developers.cloudflare.com/realtime/turn/) key; its
|
|
212
|
+
free tier is 1,000 GB/month) in the daemon's environment, or share `--tailscale`.
|
|
207
213
|
|
|
208
214
|
**Keep links out of git.** `rech status`, `rech connect` and `rech share --save` warn when the file
|
|
209
215
|
holding `RECHROME_URL` is committed, staged, not git-ignored, or readable by other users. Links
|
|
@@ -398,6 +404,23 @@ cp .env.example .env.local
|
|
|
398
404
|
>
|
|
399
405
|
> Shell-set `PLAYWRIGHT_MCP_*` variables take priority over `.env.local`, so you can always override per-command without editing files.
|
|
400
406
|
|
|
407
|
+
### Downloading files
|
|
408
|
+
|
|
409
|
+
A file the page downloads (a link, an export button) goes to **your** machine. Any type
|
|
410
|
+
works. It lands in `<project>/.rechrome/output/` with the command's other files. The host
|
|
411
|
+
saves it in a private folder, hands it to the client once, and then deletes it. A download
|
|
412
|
+
that is never fetched is deleted after 10 minutes. Each download is logged on the host
|
|
413
|
+
with its name, size and sha256.
|
|
414
|
+
|
|
415
|
+
Keeping a copy on the host is opt-in, per listener:
|
|
416
|
+
|
|
417
|
+
```bash
|
|
418
|
+
rech listener set <listener> --keep-downloads # --no-keep-downloads: back to client-only
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
A download that finishes after its command has returned is reported, and handed over,
|
|
422
|
+
by the next command on that tab.
|
|
423
|
+
|
|
401
424
|
### Uploading files
|
|
402
425
|
|
|
403
426
|
`rech upload <file>` puts a local image into the page: a file picker, an
|
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
|
-
|
|
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 =
|
|
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")}/`;
|
|
@@ -80,6 +192,8 @@ function validateListeners(value) {
|
|
|
80
192
|
throw new Error("readSecrets must be true or false");
|
|
81
193
|
if (l.readCookies !== undefined && typeof l.readCookies !== "boolean")
|
|
82
194
|
throw new Error("readCookies must be true or false");
|
|
195
|
+
if (l.keepDownloads !== undefined && typeof l.keepDownloads !== "boolean")
|
|
196
|
+
throw new Error("keepDownloads must be true or false");
|
|
83
197
|
const address = listenerAddress(l);
|
|
84
198
|
if (names.has(l.name) || addresses.has(address) || keys.has(l.key))
|
|
85
199
|
throw new Error("Listener names, addresses, and credentials must be unique");
|
|
@@ -87,6 +201,7 @@ function validateListeners(value) {
|
|
|
87
201
|
addresses.add(address);
|
|
88
202
|
keys.add(l.key);
|
|
89
203
|
}
|
|
204
|
+
validateRtcListeners(config.rtcListeners, names);
|
|
90
205
|
if (config.retiredPorts !== undefined && (!Array.isArray(config.retiredPorts) || config.retiredPorts.some((p) => !Number.isInteger(p) || p < 1 || p > 65535)))
|
|
91
206
|
throw new Error("listeners.json retiredPorts must be a list of ports");
|
|
92
207
|
return config;
|
|
@@ -116,7 +231,7 @@ async function writeListeners(config) {
|
|
|
116
231
|
config.retiredPorts = retired;
|
|
117
232
|
else
|
|
118
233
|
delete config.retiredPorts;
|
|
119
|
-
await mkdir(
|
|
234
|
+
await mkdir(join3(homedir2(), ".rechrome"), { recursive: true });
|
|
120
235
|
const temp = `${LISTENERS_FILE}.${randomBytes(6).toString("hex")}.tmp`;
|
|
121
236
|
await writeFile(temp, JSON.stringify(config, null, 2) + `
|
|
122
237
|
`, { mode: 384 });
|
|
@@ -241,24 +356,32 @@ function planProfileRemoval(key, registry, listeners, managedRoot) {
|
|
|
241
356
|
return { keys, listeners: edits, ...managed ? { dataDir: entry.userDataDir } : {} };
|
|
242
357
|
}
|
|
243
358
|
export {
|
|
359
|
+
DEFAULT_RTC,
|
|
360
|
+
DEFAULT_RTC_NOTE,
|
|
361
|
+
LEGACY_RTC,
|
|
244
362
|
LISTENERS_FILE,
|
|
245
363
|
allowProfiles,
|
|
246
364
|
authorizeProfileRequest,
|
|
247
365
|
canReadProfileFile,
|
|
248
366
|
canonicalProfileKeys,
|
|
367
|
+
defaultRtcListener,
|
|
249
368
|
denyProfiles,
|
|
369
|
+
iceLabel,
|
|
250
370
|
isLoopback,
|
|
251
371
|
listenerAddress,
|
|
252
372
|
normalizePrefix,
|
|
253
373
|
normalizePublicUrl,
|
|
374
|
+
parseIceUrl,
|
|
254
375
|
planProfileRemoval,
|
|
255
376
|
profileOutputPrefix,
|
|
256
377
|
readListeners,
|
|
257
378
|
resolveAllowedProfile,
|
|
258
379
|
retirePorts,
|
|
259
380
|
rotateListenerKey,
|
|
381
|
+
rtcListeners,
|
|
260
382
|
serviceUrl,
|
|
261
383
|
setPublicUrl,
|
|
384
|
+
shareRtcListeners,
|
|
262
385
|
validateListeners,
|
|
263
386
|
writeListeners
|
|
264
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
|
-
export type Listener = { name: string; host: string; port: number; key: string; profiles: string[] | "*"; prefix?: string; publicUrl?: string; uploads?: UploadPolicy | false; readSecrets?: boolean; readCookies?: boolean };
|
|
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
|
-
|
|
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);
|
|
@@ -63,10 +140,12 @@ export function validateListeners(value: unknown): ListenerConfig {
|
|
|
63
140
|
if (l.uploads !== undefined && l.uploads !== false) validateUploadPolicy(l.uploads);
|
|
64
141
|
if (l.readSecrets !== undefined && typeof l.readSecrets !== "boolean") throw new Error("readSecrets must be true or false");
|
|
65
142
|
if (l.readCookies !== undefined && typeof l.readCookies !== "boolean") throw new Error("readCookies must be true or false");
|
|
143
|
+
if (l.keepDownloads !== undefined && typeof l.keepDownloads !== "boolean") throw new Error("keepDownloads must be true or false");
|
|
66
144
|
const address = listenerAddress(l);
|
|
67
145
|
if (names.has(l.name) || addresses.has(address) || keys.has(l.key)) throw new Error("Listener names, addresses, and credentials must be unique");
|
|
68
146
|
names.add(l.name); addresses.add(address); keys.add(l.key);
|
|
69
147
|
}
|
|
148
|
+
validateRtcListeners(config.rtcListeners, names);
|
|
70
149
|
if (config.retiredPorts !== undefined && (!Array.isArray(config.retiredPorts) || config.retiredPorts.some(p => !Number.isInteger(p) || p < 1 || p > 65535)))
|
|
71
150
|
throw new Error("listeners.json retiredPorts must be a list of ports");
|
|
72
151
|
return config;
|