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 +24 -11
- package/listeners.js +124 -3
- package/listeners.ts +79 -1
- package/package.json +1 -1
- package/rechrome.js +348 -100
- package/rechrome.ts +348 -100
- package/serve.js +21 -8
- package/serve.ts +21 -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
|
@@ -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
|
|
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
|
-
|
|
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
|
|
184
|
-
| `--
|
|
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`
|
|
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:
|
|
205
|
-
|
|
206
|
-
|
|
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,
|
|
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
|
-
|
|
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")}/`;
|
|
@@ -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(
|
|
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
|
-
|
|
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;
|