rechrome 1.25.0 → 1.27.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 +140 -103
- package/listeners.js +57 -0
- package/listeners.ts +54 -1
- package/package.json +1 -1
- package/rechrome.js +271 -40
- package/rechrome.ts +271 -40
- package/serve.js +9 -2
- package/serve.ts +9 -2
package/README.md
CHANGED
|
@@ -8,64 +8,136 @@ Built on top of [playwright-multi-tab](https://github.com/snomiao/playwright-mul
|
|
|
8
8
|
|
|
9
9
|
## Features
|
|
10
10
|
|
|
11
|
-
- **
|
|
12
|
-
- **
|
|
13
|
-
- **
|
|
14
|
-
- **
|
|
11
|
+
- **Your real Chrome, from anywhere** — drive a logged-in Chrome profile from scripts, agents, or other machines
|
|
12
|
+
- **Session isolation** — each git worktree gets its own browser session (tab group) automatically
|
|
13
|
+
- **Per-project files** — screenshots and downloads land in `<project>/.rechrome/output/`
|
|
14
|
+
- **Share through any proxy** — Tailscale Serve, Caddy, nginx, cloudflared; one command on the client to connect
|
|
15
|
+
- **Security** — per-listener keys and profile allowlists, scoped remote commands, path traversal protection
|
|
15
16
|
|
|
16
|
-
##
|
|
17
|
+
## Install
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
```bash
|
|
20
|
+
bun i -g rechrome # or run once with: bunx rechrome <command>
|
|
21
|
+
```
|
|
19
22
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
separate install, no `playwright` browser-binary download. `bun i -g rechrome` is enough for
|
|
23
|
-
`rechrome setup` to work out of the box.
|
|
23
|
+
This gives you `rechrome` and its short alias `rech` — the same program. Requires [Bun](https://bun.sh/) ≥ 1.0;
|
|
24
|
+
the patched multi-tab Playwright CLI is bundled, with no browser download.
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
> point at a local checkout of the [playwright-cli fork](https://github.com/snomiao/playwright-cli)).
|
|
26
|
+
## Tutorial
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
### 1. Set up Chrome on this machine
|
|
29
29
|
|
|
30
30
|
```bash
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
rech setup
|
|
32
|
+
```
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
Setup installs the background daemon, asks which Chrome profile to use, and opens an install
|
|
35
|
+
guide **in that profile** for the one manual step Chrome requires: *Load unpacked* the extension
|
|
36
|
+
at `chrome://extensions`. It then reads the extension's token itself and verifies the connection.
|
|
37
|
+
Pick the profile up front with `rech setup --profile you@example.com`.
|
|
38
|
+
|
|
39
|
+
Check it:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
rech status # daemon, the URL in use, registered profiles
|
|
43
|
+
rech profile # every Chrome profile and whether it is connected
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The `CONNECTION` column checks each profile's default session for the current worktree without
|
|
47
|
+
opening a browser: **Connected**, **Registered / idle**, **Registered / unknown** (the check could
|
|
48
|
+
not complete), or **Not set up**. `ACCESS` lists the listeners that serve the profile.
|
|
49
|
+
|
|
50
|
+
### 2. Drive the browser
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
rech open https://example.com
|
|
54
|
+
rech screenshot # saved to <project>/.rechrome/output/
|
|
55
|
+
rech tab-list
|
|
56
|
+
rech eval "() => document.title"
|
|
57
|
+
rech --profile work@example.com open https://example.com # another registered profile
|
|
58
|
+
rech --isolate open https://accounts.example.com # throwaway session, e.g. for a login flow
|
|
39
59
|
```
|
|
40
60
|
|
|
41
|
-
|
|
61
|
+
Any [playwright-cli](https://github.com/snomiao/playwright-cli) command works after `rech`. When a
|
|
62
|
+
name clashes with one of rech's own (`status`, `--version`…), use `rech pw <args>` to forward it
|
|
63
|
+
verbatim: `rech --version` prints rechrome's version, `rech pw --version` playwright-cli's.
|
|
64
|
+
Commands from the same git worktree share one browser session, so tabs you open persist
|
|
65
|
+
between calls; another worktree gets its own. `-s=<name>` opens a named sub-session.
|
|
42
66
|
|
|
43
|
-
|
|
44
|
-
Both also accept `ls` or `list`, for example `rechrome profiles ls`.
|
|
45
|
-
The connection column checks each profile's default session for the current worktree without
|
|
46
|
-
opening a browser: **Connected**, **Registered / idle**, **Registered / unknown** (the check
|
|
47
|
-
could not complete), or **Not set up**. Sessions opened with a custom `-s` or from another
|
|
48
|
-
worktree are outside this check. Managed test profiles are labeled separately from real Chrome.
|
|
67
|
+
### 3. Where things are kept
|
|
49
68
|
|
|
50
|
-
|
|
69
|
+
| Where | What |
|
|
70
|
+
| --- | --- |
|
|
71
|
+
| `<project>/.rechrome/` | this project's `.env.local` (its `RECHROME_URL`) and `output/` (screenshots, downloads). Git-ignores itself. |
|
|
72
|
+
| `~/.rechrome/` | machine-wide: registered profiles, listeners and keys, the extension, daemon logs |
|
|
51
73
|
|
|
52
|
-
|
|
53
|
-
|
|
74
|
+
`<project>` is the git worktree root (submodules count as their parent project), or the current
|
|
75
|
+
directory outside git. rechrome reads `RECHROME_URL` from the nearest `.rechrome/.env.local` or
|
|
76
|
+
`.env.local` walking up from the current directory; an explicit environment variable wins.
|
|
54
77
|
|
|
55
|
-
###
|
|
78
|
+
### 4. Use it from another machine
|
|
79
|
+
|
|
80
|
+
The daemon only listens on this machine until you expose a profile. The recommended way is a
|
|
81
|
+
**scoped listener behind a reverse proxy** — shown with Tailscale Serve, but any proxy that
|
|
82
|
+
forwards to `127.0.0.1:<port>` works. The commands are identical in bash, PowerShell and
|
|
83
|
+
`cmd.exe`.
|
|
84
|
+
|
|
85
|
+
On the host (the machine with Chrome):
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
rech listener add share --listen local --prefix=rechrome --port 13776 --profile you@example.com
|
|
89
|
+
tailscale serve --bg --set-path=/rechrome 13776
|
|
90
|
+
rech listener set share --public-url https://host.example.ts.net/rechrome/
|
|
91
|
+
rech url you@example.com --listener share # prints the URL to share — it contains a secret key
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`rech listener add` prints these follow-up lines with your port filled in. For scripts,
|
|
95
|
+
`rech listener port share` prints the port (`$(rech listener port share)` in bash or PowerShell).
|
|
96
|
+
|
|
97
|
+
On the other machine, inside the project that should use it:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
rech connect 'https://host.example.ts.net/rechrome/?profile=you%40example.com#key=…'
|
|
101
|
+
rech open https://example.com
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`rech connect` checks that the URL answers and allows the profile, then saves it to the project's
|
|
105
|
+
`.rechrome/.env.local`. Remote listeners allow navigation, tabs, snapshots, screenshots and basic
|
|
106
|
+
interactions, but not `eval`/`run-code` or filesystem commands (see [Remote access](#remote-access)).
|
|
107
|
+
|
|
108
|
+
On a trusted LAN without a proxy, `rech setup --listen lan --profile you@example.com` binds the
|
|
109
|
+
profile to your LAN address directly (plain HTTP); share the result of `rech url`.
|
|
110
|
+
|
|
111
|
+
### 5. Manage access
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
rech url ls # every listener × profile, local and public URLs (keys hidden)
|
|
115
|
+
rech url you@example.com --listener share # print one URL again (add --save to use it in this project)
|
|
116
|
+
rech listener allow share teammate@example.com
|
|
117
|
+
rech listener deny share teammate@example.com
|
|
118
|
+
rech listener rotate-key share # revoke: every URL for this listener stops working
|
|
119
|
+
rech listener remove share
|
|
120
|
+
```
|
|
56
121
|
|
|
57
|
-
|
|
122
|
+
Changes apply immediately; the daemon reloads its listeners without restarting Chrome.
|
|
123
|
+
|
|
124
|
+
## Setup reference
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
rech setup # choose a network, then a Chrome profile
|
|
128
|
+
rech setup --profile you@example.com # non-interactive profile selection
|
|
129
|
+
rech setup --listen lan --profile you@example.com # expose the profile on a LAN address
|
|
130
|
+
```
|
|
58
131
|
|
|
59
132
|
If no supported daemon manager is available, setup asks before installing `oxmgr` globally
|
|
60
133
|
(default: No). It uses `bun i -g oxmgr` when launched with bunx and `npm i -g oxmgr`
|
|
61
|
-
when launched with npx. Pass `--yes` to approve this
|
|
134
|
+
when launched with npx. Pass `--yes` to approve this without prompting,
|
|
62
135
|
for example `bunx rechrome setup --profile Default --yes`.
|
|
63
136
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
rech setup
|
|
67
|
-
|
|
68
|
-
```
|
|
137
|
+
Agents setting up Chrome on macOS can use the [rechrome setup skill](skills/rechrome-setup/SKILL.md),
|
|
138
|
+
which covers native extension installation with Computer Use and CLI connection verification.
|
|
139
|
+
`rech setup` detects Codex and Claude Code environment hints and prints agent-specific guidance;
|
|
140
|
+
set `RECH_SETUP_AGENT=codex`, `claude`, or `none` to override. Detection changes guidance only.
|
|
69
141
|
|
|
70
142
|
What it does per Chrome profile:
|
|
71
143
|
|
|
@@ -110,41 +182,9 @@ prompting for stdin, then uses the default configuration save location.
|
|
|
110
182
|
> Chrome (branded Google Chrome 149+ rejects `--load-extension`), so it has no logins/cookies; use it
|
|
111
183
|
> for clean QA fixtures, and `rech setup` for your real, logged-in Chrome.
|
|
112
184
|
|
|
113
|
-
### 1. Start the server
|
|
114
|
-
|
|
115
|
-
On the machine with a browser:
|
|
116
|
-
|
|
117
|
-
```bash
|
|
118
|
-
rechrome serve
|
|
119
|
-
```
|
|
120
|
-
|
|
121
|
-
This auto-generates a connection URL in `.env.local` (with an auth key).
|
|
122
|
-
|
|
123
|
-
### 2. Run commands from a client
|
|
124
|
-
|
|
125
|
-
Copy the `RECHROME_URL` from the server's `.env.local` to the client's project `.env.local`:
|
|
126
|
-
|
|
127
|
-
```bash
|
|
128
|
-
# .env.local in your project directory
|
|
129
|
-
RECHROME_URL=http://YOUR_KEY@server-host:13775
|
|
130
|
-
|
|
131
|
-
# Open a URL
|
|
132
|
-
rech open https://example.com
|
|
133
|
-
|
|
134
|
-
# Take a screenshot
|
|
135
|
-
rech screenshot
|
|
136
|
-
|
|
137
|
-
# List open tabs
|
|
138
|
-
rech tab-list
|
|
139
|
-
|
|
140
|
-
# Any playwright-cli command works
|
|
141
|
-
rech --help
|
|
142
|
-
```
|
|
143
|
-
|
|
144
|
-
rechrome walks up from the current working directory to find `.env.local`, so each project can have its own connection URL, Chrome profile, and extension token. Explicit environment variables take priority: `RECHROME_URL='http://KEY@host:13775' rech status` overrides the saved URL for that command.
|
|
145
|
-
|
|
146
185
|
## Configuration
|
|
147
186
|
|
|
187
|
+
|
|
148
188
|
Profiles are stored in `~/.rechrome/profiles.yaml`. Existing `profiles.json` registries are also supported and automatically migrated on first read; the original JSON is retained as a private backup. YAML takes precedence when both exist. Registry writes are atomic and use owner-only permissions because entries contain Playwright bridge tokens. Invalid YAML fails explicitly instead of falling back to potentially stale JSON credentials.
|
|
149
189
|
|
|
150
190
|
Connection parameters also accept URL fragments:
|
|
@@ -153,13 +193,13 @@ Connection parameters also accept URL fragments:
|
|
|
153
193
|
RECHROME_URL='https://your-host.ts.net/rechrome/?profile=qa#key=DAEMON_KEY' rech status
|
|
154
194
|
```
|
|
155
195
|
|
|
156
|
-
`rech setup` prints and saves this URI format. Retrieve it later with `rech profile qa --print-uri
|
|
196
|
+
`rech setup` prints and saves this URI format. Retrieve it later with `rech url qa` (alias: `rech profile qa --print-uri`), or omit `qa` to use the configured profile. `profiles` remains an alias. The command prints only the URI to stdout, using the configured `RECHROME_URL` endpoint; `--listener local` selects a local listener instead. For example: `rech profile qa --print-uri --listener local`. The output contains a secret daemon key.
|
|
157
197
|
|
|
158
198
|
Direct connections use the root path, such as `http://127.0.0.1:13775/?profile=qa#key=DAEMON_KEY`. A prefix is optional and only added when explicitly configured with `--prefix`, for example for a proxy mounted at `/rechrome/`. Tailscale can also serve at the root without a prefix.
|
|
159
199
|
|
|
160
200
|
Setup generates this format: the profile is in the query and the daemon listener's bearer `key` is in the fragment. The daemon looks up the registered profile's separate Playwright bridge token and browser paths locally. Advanced clients can still supply the bridge credential as `token` (for example `#key=DAEMON_KEY&token=BRIDGE_TOKEN`). Fragment parameters override matching query parameters; fragment `key` overrides legacy `KEY@host`. Both `#?key=…` and `#key=…` work. The CLI reads these locally and sends the daemon key as an Authorization header; fragments are omitted from HTTP request URLs. Opening the URL in a browser does not configure a client or display a dashboard. Fragments can still be stored in browser history and copied links, so treat the complete connection URL as a secret.
|
|
161
201
|
|
|
162
|
-
|
|
202
|
+
To configure by hand instead, copy `.env.example` to `.env.local` and edit:
|
|
163
203
|
|
|
164
204
|
```bash
|
|
165
205
|
cp .env.example .env.local
|
|
@@ -167,7 +207,7 @@ cp .env.example .env.local
|
|
|
167
207
|
|
|
168
208
|
| Variable | Description | Default |
|
|
169
209
|
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ---------------- |
|
|
170
|
-
| `RECHROME_URL` | Connection URL
|
|
210
|
+
| `RECHROME_URL` | Connection URL, saved by `rech setup` / `rech connect` / `rech url --save`. Also accepts `?extension_id=`, `?token=`, `?profile=` query params | — |
|
|
171
211
|
| `PLAYWRIGHT_CLI` | Override the playwright-cli command/path (defaults to the bundled `@playwright/cli`; set this only for a custom or forked CLI) | bundled `@playwright/cli` |
|
|
172
212
|
| `RECH_HOST` | Legacy bind address, used only before listeners.json is configured | `127.0.0.1` |
|
|
173
213
|
| `PLAYWRIGHT_MCP_EXTENSION_ID` | Chrome extension ID (client overrides server) | — |
|
|
@@ -178,8 +218,8 @@ cp .env.example .env.local
|
|
|
178
218
|
> **Multi-profile tip:** Each project's `.env.local` can specify a different Chrome profile via the `?profile=` query param in `RECHROME_URL`. The server resolves display names and email addresses to the actual Chrome profile directory automatically (reads `~/Library/Application Support/Google/Chrome/Local State`).
|
|
179
219
|
>
|
|
180
220
|
> ```
|
|
181
|
-
> #
|
|
182
|
-
> RECHROME_URL="http://
|
|
221
|
+
> # <project>/.rechrome/.env.local for a work project
|
|
222
|
+
> RECHROME_URL="http://127.0.0.1:13775/?profile=you%40company.com#key=KEY"
|
|
183
223
|
> ```
|
|
184
224
|
>
|
|
185
225
|
> Shell-set `PLAYWRIGHT_MCP_*` variables take priority over `.env.local`, so you can always override per-command without editing files.
|
|
@@ -199,8 +239,9 @@ rech listener remove qa
|
|
|
199
239
|
```
|
|
200
240
|
|
|
201
241
|
Repeat `--profile` to allow several registered profiles. A profile may appear on
|
|
202
|
-
multiple listeners; `setup --listen` selects its exposure afresh.
|
|
203
|
-
|
|
242
|
+
multiple listeners; `setup --listen` selects its exposure afresh. Change an existing
|
|
243
|
+
listener with `rech listener allow|deny <name> <profile...>`, and replace its key with
|
|
244
|
+
`rech listener rotate-key <name>` (URLs carrying the old key stop working). Changes do not terminate
|
|
204
245
|
browser sessions; removed listeners reject further requests. An occupied or unavailable
|
|
205
246
|
address causes reload to retain the old configuration, so verify reachability after editing.
|
|
206
247
|
|
|
@@ -215,25 +256,16 @@ proxy or tunnel to clients that should have restricted access.
|
|
|
215
256
|
LAN traffic is plain HTTP unless TLS is configured. Tailscale provides its private transport;
|
|
216
257
|
binding an address does not configure Tailscale ACLs, port forwarding, or public tunnels.
|
|
217
258
|
|
|
218
|
-
|
|
259
|
+
#### Reverse proxies
|
|
219
260
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
the
|
|
228
|
-
`rech listener add qa-proxy --listen local --profile <profile> --port 13776 --prefix=rechrome`.
|
|
229
|
-
Setup prints the matching Serve command but does not run it or change existing Serve routes.
|
|
230
|
-
Include the path in the proxy target: Serve strips the mount path and the target restores it.
|
|
231
|
-
|
|
232
|
-
For remote clients, change the saved connection URL's origin to
|
|
233
|
-
`https://<machine>.<tailnet>.ts.net`, retaining `/rechrome/`, the listener's bearer
|
|
234
|
-
userinfo, and the profile query. Health checks, commands, and file downloads honor
|
|
235
|
-
that base path. The setup guide remains a temporary local page owned by the setup
|
|
236
|
-
process; it is not published through Serve. Never proxy the unrestricted management listener.
|
|
261
|
+
See [Use it from another machine](#4-use-it-from-another-machine) for the workflow. Details:
|
|
262
|
+
a prefixed listener accepts requests with or without its prefix, so the proxy may strip the
|
|
263
|
+
mount path (bare port target) or keep it (`http://127.0.0.1:13776/rechrome`).
|
|
264
|
+
`--prefix=rechrome` and `--prefix=/rechrome/` both normalize to `/rechrome/`. `rech setup
|
|
265
|
+
--listen local --prefix=rechrome` also creates such a listener and, when a matching Tailscale
|
|
266
|
+
Serve route exists, prints the remote URL. `rech status` shows the URL in use and which
|
|
267
|
+
listener answered. Never proxy the unrestricted management listener. The setup guide is a
|
|
268
|
+
temporary local page owned by the setup process; it is not published through a proxy.
|
|
237
269
|
|
|
238
270
|
Existing installations retain the legacy listener until `rech setup` initializes the new
|
|
239
271
|
configuration. The first migration restarts only the daemon to load the new source. Existing
|
|
@@ -242,20 +274,25 @@ clients need the new listener credentials. Browser processes are left running.
|
|
|
242
274
|
|
|
243
275
|
## Session namespacing
|
|
244
276
|
|
|
245
|
-
Each client gets
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
Clients can also pass `-s=name` to create named sub-sessions within their namespace.
|
|
277
|
+
Each client gets its own browser session, keyed by the **git worktree root path** (submodules
|
|
278
|
+
roll up to their parent), or the current directory outside git. So two worktrees of one repo
|
|
279
|
+
get separate sessions, and `git checkout` keeps you in the same one. `RECH_IDENTITY=branch`
|
|
280
|
+
restores the older `<remote>/tree/<branch>` keying and `RECH_IDENTITY=cwd` keys on the exact
|
|
281
|
+
directory. Pass `-s=<name>` for a named sub-session, or `--isolate` for a throwaway one.
|
|
251
282
|
|
|
252
283
|
## Development
|
|
253
284
|
|
|
254
285
|
```bash
|
|
286
|
+
git clone https://github.com/snomiao/rechrome.git
|
|
287
|
+
cd rechrome
|
|
255
288
|
bun install
|
|
256
|
-
bun
|
|
289
|
+
bun link # makes this checkout the global rechrome / rech
|
|
290
|
+
bun test ./*.test.ts ./*.spec.ts ./scripts/*.test.ts # rechrome's own tests (plain `bun test` also finds the vendored forks' suites)
|
|
257
291
|
```
|
|
258
292
|
|
|
293
|
+
To use a different playwright-cli, set `PLAYWRIGHT_CLI=<cmd>` in `.env.local` (for example a
|
|
294
|
+
local checkout of the [playwright-cli fork](https://github.com/snomiao/playwright-cli)).
|
|
295
|
+
|
|
259
296
|
## Why we fork playwright
|
|
260
297
|
|
|
261
298
|
rechrome depends on [playwright-multi-tab](https://github.com/snomiao/playwright-multi-tab), which is a fork of [microsoft/playwright](https://github.com/microsoft/playwright). We maintain it because the upstream does not yet support several features required for rechrome's use case:
|
package/listeners.js
CHANGED
|
@@ -25,6 +25,18 @@ function serviceUrl(raw, route = "") {
|
|
|
25
25
|
result.pathname = normalizePrefix(source.pathname) + route.replace(/^\/+/, "");
|
|
26
26
|
return result.toString();
|
|
27
27
|
}
|
|
28
|
+
function normalizePublicUrl(value) {
|
|
29
|
+
let url;
|
|
30
|
+
try {
|
|
31
|
+
url = new URL(String(value));
|
|
32
|
+
} catch {
|
|
33
|
+
throw new Error(`Public URL must be an absolute http(s) URL, e.g. https://host.example.ts.net/rechrome/`);
|
|
34
|
+
}
|
|
35
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash)
|
|
36
|
+
throw new Error("Public URL must be plain http(s), without credentials, query, or fragment");
|
|
37
|
+
url.pathname = normalizePrefix(url.pathname);
|
|
38
|
+
return url.toString();
|
|
39
|
+
}
|
|
28
40
|
function validateListeners(value) {
|
|
29
41
|
const config = value;
|
|
30
42
|
if (config?.version !== 1 || !Array.isArray(config.listeners) || !config.listeners.length)
|
|
@@ -34,6 +46,8 @@ function validateListeners(value) {
|
|
|
34
46
|
if (!l || !/^[a-zA-Z0-9_-]+$/.test(l.name) || typeof l.host !== "string" || !isIP(l.host) || ["0.0.0.0", "::"].includes(l.host) || !Number.isInteger(l.port) || l.port < 1 || l.port > 65535 || typeof l.key !== "string" || l.key.length < 16)
|
|
35
47
|
throw new Error("Each listener needs a name, concrete IP, valid port, and bearer key of at least 16 characters");
|
|
36
48
|
l.prefix = normalizePrefix(l.prefix);
|
|
49
|
+
if (l.publicUrl !== undefined)
|
|
50
|
+
l.publicUrl = normalizePublicUrl(l.publicUrl);
|
|
37
51
|
if (l.profiles === "*") {
|
|
38
52
|
if (!isLoopback(l.host))
|
|
39
53
|
throw new Error("Unrestricted management listeners must bind to loopback");
|
|
@@ -89,16 +103,59 @@ function authorizeProfileRequest(listener, body) {
|
|
|
89
103
|
function canReadProfileFile(listener, path) {
|
|
90
104
|
return listener.profiles === "*" || listener.profiles.some((p) => path.startsWith(profileOutputPrefix(p)));
|
|
91
105
|
}
|
|
106
|
+
function scopedListener(config, name) {
|
|
107
|
+
const listener = config.listeners.find((l) => l.name === name);
|
|
108
|
+
if (!listener)
|
|
109
|
+
throw new Error(`Unknown listener "${name}". See rech listener ls.`);
|
|
110
|
+
if (listener.profiles === "*")
|
|
111
|
+
throw new Error(`"${name}" is the local management listener; it already serves every profile and its key is the daemon's own.`);
|
|
112
|
+
return listener;
|
|
113
|
+
}
|
|
114
|
+
function allowProfiles(config, name, profiles) {
|
|
115
|
+
const listener = scopedListener(config, name);
|
|
116
|
+
const added = [...new Set(profiles)].filter((p) => !listener.profiles.includes(p));
|
|
117
|
+
listener.profiles.push(...added);
|
|
118
|
+
return added;
|
|
119
|
+
}
|
|
120
|
+
function denyProfiles(config, name, profiles) {
|
|
121
|
+
const listener = scopedListener(config, name);
|
|
122
|
+
const removed = listener.profiles.filter((p) => profiles.includes(p));
|
|
123
|
+
const remaining = listener.profiles.filter((p) => !profiles.includes(p));
|
|
124
|
+
if (!remaining.length)
|
|
125
|
+
throw new Error(`That would leave "${name}" with no profiles; remove it instead: rech listener remove ${name}`);
|
|
126
|
+
listener.profiles = remaining;
|
|
127
|
+
return removed;
|
|
128
|
+
}
|
|
129
|
+
function rotateListenerKey(config, name) {
|
|
130
|
+
const listener = scopedListener(config, name);
|
|
131
|
+
listener.key = randomBytes(24).toString("base64url");
|
|
132
|
+
return listener.key;
|
|
133
|
+
}
|
|
134
|
+
function setPublicUrl(config, name, publicUrl) {
|
|
135
|
+
const listener = config.listeners.find((l) => l.name === name);
|
|
136
|
+
if (!listener)
|
|
137
|
+
throw new Error(`Unknown listener "${name}". See rech listener ls.`);
|
|
138
|
+
if (publicUrl === null)
|
|
139
|
+
delete listener.publicUrl;
|
|
140
|
+
else
|
|
141
|
+
listener.publicUrl = normalizePublicUrl(publicUrl);
|
|
142
|
+
return listener;
|
|
143
|
+
}
|
|
92
144
|
export {
|
|
93
145
|
LISTENERS_FILE,
|
|
146
|
+
allowProfiles,
|
|
94
147
|
authorizeProfileRequest,
|
|
95
148
|
canReadProfileFile,
|
|
149
|
+
denyProfiles,
|
|
96
150
|
isLoopback,
|
|
97
151
|
listenerAddress,
|
|
98
152
|
normalizePrefix,
|
|
153
|
+
normalizePublicUrl,
|
|
99
154
|
profileOutputPrefix,
|
|
100
155
|
readListeners,
|
|
156
|
+
rotateListenerKey,
|
|
101
157
|
serviceUrl,
|
|
158
|
+
setPublicUrl,
|
|
102
159
|
validateListeners,
|
|
103
160
|
writeListeners
|
|
104
161
|
};
|
package/listeners.ts
CHANGED
|
@@ -4,7 +4,8 @@ import { homedir } from "os";
|
|
|
4
4
|
import { join } from "path";
|
|
5
5
|
import { mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
// publicUrl: where a reverse proxy (Tailscale Serve, Caddy, nginx, ...) exposes this listener.
|
|
8
|
+
export type Listener = { name: string; host: string; port: number; key: string; profiles: string[] | "*"; prefix?: string; publicUrl?: string };
|
|
8
9
|
export type ListenerConfig = { version: 1; listeners: Listener[] };
|
|
9
10
|
export const LISTENERS_FILE = join(homedir(), ".rechrome", "listeners.json");
|
|
10
11
|
export const isLoopback = (host: string) => host === "::1" || /^127\./.test(host);
|
|
@@ -28,6 +29,16 @@ export function serviceUrl(raw: string, route = ""): string {
|
|
|
28
29
|
return result.toString();
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
/** A public base URL: http(s), no credentials, query or fragment (the key never lives here). */
|
|
33
|
+
export function normalizePublicUrl(value: unknown): string {
|
|
34
|
+
let url: URL;
|
|
35
|
+
try { url = new URL(String(value)); } catch { throw new Error(`Public URL must be an absolute http(s) URL, e.g. https://host.example.ts.net/rechrome/`); }
|
|
36
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash)
|
|
37
|
+
throw new Error("Public URL must be plain http(s), without credentials, query, or fragment");
|
|
38
|
+
url.pathname = normalizePrefix(url.pathname);
|
|
39
|
+
return url.toString();
|
|
40
|
+
}
|
|
41
|
+
|
|
31
42
|
export function validateListeners(value: unknown): ListenerConfig {
|
|
32
43
|
const config = value as ListenerConfig;
|
|
33
44
|
if (config?.version !== 1 || !Array.isArray(config.listeners) || !config.listeners.length) throw new Error("listeners.json requires version 1 and at least one listener");
|
|
@@ -37,6 +48,7 @@ export function validateListeners(value: unknown): ListenerConfig {
|
|
|
37
48
|
|| !Number.isInteger(l.port) || l.port < 1 || l.port > 65535 || typeof l.key !== "string" || l.key.length < 16)
|
|
38
49
|
throw new Error("Each listener needs a name, concrete IP, valid port, and bearer key of at least 16 characters");
|
|
39
50
|
l.prefix = normalizePrefix(l.prefix);
|
|
51
|
+
if (l.publicUrl !== undefined) l.publicUrl = normalizePublicUrl(l.publicUrl);
|
|
40
52
|
if (l.profiles === "*") {
|
|
41
53
|
if (!isLoopback(l.host)) throw new Error("Unrestricted management listeners must bind to loopback");
|
|
42
54
|
} else if (!Array.isArray(l.profiles) || !l.profiles.length || l.profiles.some(p => typeof p !== "string" || !p.trim() || p.includes("\0") || p === "*")) {
|
|
@@ -82,3 +94,44 @@ export function authorizeProfileRequest(listener: Listener, body: any): string {
|
|
|
82
94
|
export function canReadProfileFile(listener: Listener, path: string): boolean {
|
|
83
95
|
return listener.profiles === "*" || listener.profiles.some(p => path.startsWith(profileOutputPrefix(p)));
|
|
84
96
|
}
|
|
97
|
+
|
|
98
|
+
function scopedListener(config: ListenerConfig, name: string): Listener & { profiles: string[] } {
|
|
99
|
+
const listener = config.listeners.find(l => l.name === name);
|
|
100
|
+
if (!listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
|
|
101
|
+
if (listener.profiles === "*") throw new Error(`"${name}" is the local management listener; it already serves every profile and its key is the daemon's own.`);
|
|
102
|
+
return listener as Listener & { profiles: string[] };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Add profiles to a scoped listener's allowlist. Returns the profiles newly added. */
|
|
106
|
+
export function allowProfiles(config: ListenerConfig, name: string, profiles: string[]): string[] {
|
|
107
|
+
const listener = scopedListener(config, name);
|
|
108
|
+
const added = [...new Set(profiles)].filter(p => !listener.profiles.includes(p));
|
|
109
|
+
listener.profiles.push(...added);
|
|
110
|
+
return added;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Remove profiles from a scoped listener's allowlist. Returns the profiles removed. */
|
|
114
|
+
export function denyProfiles(config: ListenerConfig, name: string, profiles: string[]): string[] {
|
|
115
|
+
const listener = scopedListener(config, name);
|
|
116
|
+
const removed = listener.profiles.filter(p => profiles.includes(p));
|
|
117
|
+
const remaining = listener.profiles.filter(p => !profiles.includes(p));
|
|
118
|
+
if (!remaining.length) throw new Error(`That would leave "${name}" with no profiles; remove it instead: rech listener remove ${name}`);
|
|
119
|
+
listener.profiles = remaining;
|
|
120
|
+
return removed;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Give a scoped listener a new bearer key; URLs carrying the old key stop working. */
|
|
124
|
+
export function rotateListenerKey(config: ListenerConfig, name: string): string {
|
|
125
|
+
const listener = scopedListener(config, name);
|
|
126
|
+
listener.key = randomBytes(24).toString("base64url");
|
|
127
|
+
return listener.key;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Record (or with null, forget) where a proxy exposes a listener. */
|
|
131
|
+
export function setPublicUrl(config: ListenerConfig, name: string, publicUrl: string | null): Listener {
|
|
132
|
+
const listener = config.listeners.find(l => l.name === name);
|
|
133
|
+
if (!listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
|
|
134
|
+
if (publicUrl === null) delete listener.publicUrl;
|
|
135
|
+
else listener.publicUrl = normalizePublicUrl(publicUrl);
|
|
136
|
+
return listener;
|
|
137
|
+
}
|