herdr-remote-relay 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
File without changes
package/README.md CHANGED
@@ -1,27 +1,21 @@
1
1
  # herdr-remote-relay
2
2
 
3
- Standalone relay server for [Herdr Remote](https://www.npmjs.com/package/herdr-remote):
4
- it serves the mobile web terminal and brokers browser ↔ workstation sessions.
3
+ *[English](README.md) · [简体中文](README.zh-CN.md)*
5
4
 
6
- Its only runtime dependency is `ws`, so the machine running it needs no
7
- compiler, no Herdr and no plugin. It never runs a shell and never touches your
8
- Herdr socket — only the host connector on your own workstation does that.
5
+ Standalone relay server and WebUI for [Herdr Remote](https://www.npmjs.com/package/herdr-remote).
9
6
 
10
- **You usually do not need to install this yourself.** `herdr-remote` starts a
11
- relay locally unless you point it at one you host. Install this package only
12
- when you want to reach your workstation from outside your own network.
7
+ Brokers WebSocket connections between browsers and workstation host connectors. Runtime dependency is `ws` only; requires no compilers or Herdr binaries.
8
+
9
+ > **Note**: `herdr-remote` runs a local relay automatically. Install this package only when deploying a dedicated relay server.
10
+
11
+ ## Installation
13
12
 
14
13
  ```bash
15
14
  npm install -g herdr-remote-relay
16
- herdr-remote-relay --public-url https://herdr.example.com \
17
- --password your-password --admin-token your-long-random-admin-token \
18
- --trust-proxy
15
+ herdr-remote-relay --public-url https://herdr.example.com --password <password>
19
16
  ```
20
17
 
21
- Requirements: Node.js 22+, a domain name, and TLS. Terminate TLS in a reverse
22
- proxy in front of the relay — nginx, Caddy, Traefik and Cloudflare Tunnel all
23
- work. Example configs for nginx, Cloudflare Tunnel, systemd and Docker Compose
24
- ship in `deploy/` inside this package.
18
+ Requirements: Node.js 22+, TLS reverse proxy (nginx, Caddy, Cloudflare Tunnel).
25
19
 
26
20
  ## Docker
27
21
 
@@ -32,7 +26,7 @@ docker run -d --name herdr-relay --restart unless-stopped \
32
26
  -e RELAY_BIND=0.0.0.0 \
33
27
  -e RELAY_PUBLIC_URL=https://herdr.example.com \
34
28
  -e RELAY_PASSWORD=your-password \
35
- -e RELAY_ADMIN_TOKEN=your-long-random-admin-token \
29
+ -e RELAY_ADMIN_TOKEN=your-admin-token \
36
30
  -e RELAY_TRUST_PROXY=1 \
37
31
  -e RELAY_AUTH_STATE_FILE=/data/relay-auth.json \
38
32
  node:22-alpine npx -y herdr-remote-relay
@@ -40,63 +34,33 @@ docker run -d --name herdr-relay --restart unless-stopped \
40
34
 
41
35
  ## Options
42
36
 
43
- Every setting has a flag, an environment variable and a JSON config file key
44
- (`--config`, or `HERDR_RELAY_CONFIG`). Precedence runs defaults → file →
45
- environment flags.
46
-
47
- | Variable | Default | |
48
- |---|---|---|
49
- | `RELAY_PUBLIC_URL` | `http://127.0.0.1:8787` | URL browsers open |
50
- | `RELAY_PASSWORD` | *(none)* | Password a workstation must present. **Empty = public relay** |
51
- | `RELAY_ADMIN_TOKEN` | *(none)* | Operator token for the relay dashboard at `/admin` |
52
- | `RELAY_BIND` | `127.0.0.1` | Listen address (`0.0.0.0` in Docker) |
53
- | `RELAY_PORT` | `8787` | Listen port |
54
- | `RELAY_TRUST_PROXY` | `0` | Set to `1` behind a reverse proxy |
55
- | `RELAY_AUTH_STATE_FILE` | `~/.local/state/herdr-remote-relay/relay-auth.json` | Device records |
56
- | `RELAY_ALLOWED_ORIGINS` | *(same-origin)* | Extra browser origins, comma separated |
57
- | `RELAY_MAX_CLIENTS_PER_HOST` | `16` | Browsers per workstation |
58
- | `RELAY_DEPLOYMENT_MODE` | `remote` | `local` is reserved for the workstation-managed relay |
59
-
60
- Whatever proxy you put in front must forward WebSocket upgrades and must not
61
- time out idle connections a terminal is idle between keystrokes.
62
-
63
- ## Public relays
64
-
65
- Leaving `RELAY_PASSWORD` empty makes the relay public: anyone may connect a
66
- workstation to it. That is safe to share, because each workstation is reachable
67
- only through its own host token — generated on that machine, never handed out
68
- by the relay. Nobody else can pair a device to your terminal.
69
-
70
- What a public relay does give away is bandwidth and the fact that your
71
- workstation is online. Set a password if that matters.
72
-
73
- ## Connect a workstation
74
-
75
- On the workstation, run `herdr-remote`, open the **Relay** tab and set the
76
- access mode to **Self-hosted relay**, the relay URL to
77
- `wss://herdr.example.com`, and the relay password to the same `RELAY_PASSWORD`.
78
- Then check:
79
-
37
+ | Option | Env Var | Default | Description |
38
+ |---|---|---|---|
39
+ | `--public-url` | `RELAY_PUBLIC_URL` | `http://127.0.0.1:8787` | Public URL for browsers |
40
+ | `--password` | `RELAY_PASSWORD` | *(empty)* | Workstation join password (empty = public) |
41
+ | `--admin-token` | `RELAY_ADMIN_TOKEN` | *(empty)* | Operator token for `/admin` |
42
+ | `--bind` | `RELAY_BIND` | `127.0.0.1` | Listen address |
43
+ | `--port` | `RELAY_PORT` | `8787` | Listen port |
44
+ | `--trust-proxy` | `RELAY_TRUST_PROXY` | `0` | Trust `X-Forwarded-For` header (`1` behind proxy) |
45
+ | `--state-file` | `RELAY_AUTH_STATE_FILE` | `~/.local/state/herdr-remote-relay/relay-auth.json` | Auth state file path |
46
+ | `--allowed-origins` | `RELAY_ALLOWED_ORIGINS` | *(same-origin)* | Allowed CORS origins, comma-separated |
47
+ | `--max-clients` | `RELAY_MAX_CLIENTS_PER_HOST` | `16` | Maximum browser clients per host |
48
+ | `--config` | `HERDR_RELAY_CONFIG` | *(none)* | JSON configuration file path |
49
+
50
+ Reverse proxies must pass WebSocket `Upgrade` headers and maintain long idle timeouts. Deployment examples for nginx, systemd, and Docker Compose are in `deploy/`.
51
+
52
+ ## Connecting a Workstation
53
+
54
+ In `herdr-remote` TUI under **Relay** tab:
55
+ - Set mode to **Self-hosted relay**
56
+ - Set Relay URL to `wss://herdr.example.com`
57
+ - Set Relay Password to matching `RELAY_PASSWORD`
58
+
59
+ Check connection status:
80
60
  ```bash
81
- curl https://herdr.example.com/healthz # hosts should be 1
61
+ curl https://herdr.example.com/healthz
82
62
  ```
83
63
 
84
- | Symptom | Cause |
85
- |---|---|
86
- | `hosts: 0` | Workstation not connected — check `herdr-remote status` |
87
- | `relay_password_required` | Password differs between the two sides |
88
- | Page loads, terminal never opens | Proxy is not forwarding `Upgrade` headers |
89
- | Drops after ~60s idle | Proxy read timeout too short |
90
-
91
- ## Security
92
-
93
- - Tokens are stored as SHA-256 hashes, never in clear text. Terminal content is
94
- never written to disk.
95
- - Pairing codes are single-use, expire in ten minutes, and are rate limited.
96
- - The `/admin` operator credential is separate from the workstation join
97
- password and from paired device tokens.
98
- - Losing the state file just means re-pairing your devices.
99
-
100
64
  ## License
101
65
 
102
66
  MIT
@@ -0,0 +1,66 @@
1
+ # herdr-remote-relay
2
+
3
+ *[English](README.md) · [简体中文](README.zh-CN.md)*
4
+
5
+ [Herdr Remote](https://www.npmjs.com/package/herdr-remote) 的独立 Relay 服务端与 WebUI。
6
+
7
+ 负责在浏览器与工作站主机连接器之间转发 WebSocket 连接。运行时仅依赖 `ws`,无需编译环境或 Herdr 二进制文件。
8
+
9
+ > **提示**:`herdr-remote` 默认在本地自动运行 relay。仅在需要部署独立中继服务器时安装本包。
10
+
11
+ ## 安装
12
+
13
+ ```bash
14
+ npm install -g herdr-remote-relay
15
+ herdr-remote-relay --public-url https://herdr.example.com --password <密码>
16
+ ```
17
+
18
+ 前置要求:Node.js 22+、TLS 反向代理(nginx、Caddy、Cloudflare Tunnel 等)。
19
+
20
+ ## Docker 运行
21
+
22
+ ```bash
23
+ docker run -d --name herdr-relay --restart unless-stopped \
24
+ -p 127.0.0.1:8787:8787 \
25
+ -v herdr-relay:/data \
26
+ -e RELAY_BIND=0.0.0.0 \
27
+ -e RELAY_PUBLIC_URL=https://herdr.example.com \
28
+ -e RELAY_PASSWORD=你的密码 \
29
+ -e RELAY_ADMIN_TOKEN=你的管理令牌 \
30
+ -e RELAY_TRUST_PROXY=1 \
31
+ -e RELAY_AUTH_STATE_FILE=/data/relay-auth.json \
32
+ node:22-alpine npx -y herdr-remote-relay
33
+ ```
34
+
35
+ ## 配置参数
36
+
37
+ | 参数 | 环境变量 | 默认值 | 说明 |
38
+ |---|---|---|---|
39
+ | `--public-url` | `RELAY_PUBLIC_URL` | `http://127.0.0.1:8787` | 浏览器访问的公开地址 |
40
+ | `--password` | `RELAY_PASSWORD` | *(空)* | 工作站接入密码(留空为公用 Relay) |
41
+ | `--admin-token` | `RELAY_ADMIN_TOKEN` | *(空)* | `/admin` 管理后台操作员令牌 |
42
+ | `--bind` | `RELAY_BIND` | `127.0.0.1` | 监听地址 |
43
+ | `--port` | `RELAY_PORT` | `8787` | 监听端口 |
44
+ | `--trust-proxy` | `RELAY_TRUST_PROXY` | `0` | 信任 `X-Forwarded-For` 头(反代后设为 `1`) |
45
+ | `--state-file` | `RELAY_AUTH_STATE_FILE` | `~/.local/state/herdr-remote-relay/relay-auth.json` | 认证状态文件路径 |
46
+ | `--allowed-origins` | `RELAY_ALLOWED_ORIGINS` | *(同源)* | 允许的跨域源,逗号分隔 |
47
+ | `--max-clients` | `RELAY_MAX_CLIENTS_PER_HOST` | `16` | 每台工作站最大客户端连接数 |
48
+ | `--config` | `HERDR_RELAY_CONFIG` | *(无)* | JSON 配置文件路径 |
49
+
50
+ 反向代理必须转发 WebSocket `Upgrade` 头,并设置较长空闲超时时间。`deploy/` 目录下提供 nginx、systemd 与 Docker Compose 示例。
51
+
52
+ ## 工作站连接
53
+
54
+ 在工作站的 `herdr-remote` TUI 中进入 **Relay** 页面:
55
+ - 访问方式选择 **自建 relay**
56
+ - Relay 地址填写 `wss://herdr.example.com`
57
+ - Relay 密码填写对应的 `RELAY_PASSWORD`
58
+
59
+ 验证连接状态:
60
+ ```bash
61
+ curl https://herdr.example.com/healthz
62
+ ```
63
+
64
+ ## 开源协议
65
+
66
+ MIT
@@ -17,22 +17,21 @@ const USAGE = `herdr-remote-relay ${VERSION} — standalone relay for Herdr Remo
17
17
  Usage: herdr-remote-relay [options]
18
18
 
19
19
  Options:
20
- --password <password> Password a workstation must present (RELAY_PASSWORD).
21
- Leave unset for a public relay.
22
- --admin-token <token> Operator token for the relay dashboard (RELAY_ADMIN_TOKEN).
23
- --deployment-mode <mode> WebUI deployment mode: local or remote (default remote).
24
- --public-url <url> Public URL browsers use (RELAY_PUBLIC_URL)
20
+ --password <password> Workstation password (RELAY_PASSWORD, empty for public)
21
+ --admin-token <token> Admin dashboard token (RELAY_ADMIN_TOKEN)
22
+ --deployment-mode <mode> Deployment mode: local or remote (default remote)
23
+ --public-url <url> Public URL for browsers (RELAY_PUBLIC_URL)
25
24
  --bind <address> Listen address (default 127.0.0.1, RELAY_BIND)
26
25
  --port <number> Listen port (default 8787, RELAY_PORT)
27
- --trust-proxy Read X-Forwarded-For for rate limiting (RELAY_TRUST_PROXY)
28
- --state-file <file> Where device/host records are stored (RELAY_AUTH_STATE_FILE)
29
- --allowed-origins <list> Extra comma-separated browser origins (RELAY_ALLOWED_ORIGINS)
30
- --max-clients <number> Max browsers per workstation (RELAY_MAX_CLIENTS_PER_HOST)
31
- --config <file> JSON config file (also HERDR_RELAY_CONFIG)
32
- -h, --help Show this help
33
- -v, --version Show the version
26
+ --trust-proxy Trust X-Forwarded-For headers (RELAY_TRUST_PROXY)
27
+ --state-file <file> Auth state file path (RELAY_AUTH_STATE_FILE)
28
+ --allowed-origins <list> Allowed browser origins, comma-separated
29
+ --max-clients <number> Max clients per workstation (default 16)
30
+ --config <file> JSON config file (HERDR_RELAY_CONFIG)
31
+ -h, --help Show help
32
+ -v, --version Show version
34
33
 
35
- See docs/self-hosted-relay.md.
34
+ Guide: docs/self-hosted-relay.md
36
35
  `;
37
36
 
38
37
  function webUiBuilt() {
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "herdr-remote-relay",
3
- "version": "0.2.0",
4
- "description": "Standalone relay server for Herdr Remote: serves the mobile web terminal and brokers browser <-> workstation sessions",
3
+ "version": "0.2.2",
4
+ "description": "Standalone relay server and web terminal for Herdr Remote",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -37,7 +37,8 @@
37
37
  "src",
38
38
  "web/dist",
39
39
  "deploy",
40
- "config.example.json"
40
+ "config.example.json",
41
+ "README.zh-CN.md"
41
42
  ],
42
43
  "scripts": {
43
44
  "build": "npm --prefix web run build",
package/src/auth-store.js CHANGED
@@ -142,6 +142,69 @@ class AuthStore {
142
142
  return { deviceId, hostId: pairing.hostId, token, expiresAt, expiresAtIso: nowIso(expiresAt), publicUrl: pairing.publicUrl };
143
143
  }
144
144
 
145
+ /**
146
+ * Record how a device presented itself, so an operator revoking a device can
147
+ * tell which phone or laptop they are about to cut off. Only the coarse
148
+ * user agent string and the address are kept — never terminal content.
149
+ */
150
+ noteDeviceSeen(deviceId, { userAgent, ip } = {}, now = Date.now()) {
151
+ const device = this.state.devices[deviceId];
152
+ if (!device) return null;
153
+ let changed = false;
154
+ if (userAgent && device.userAgent !== userAgent) {
155
+ device.userAgent = String(userAgent).slice(0, 256);
156
+ changed = true;
157
+ }
158
+ if (ip && device.lastIp !== ip) {
159
+ device.lastIp = String(ip).slice(0, 64);
160
+ changed = true;
161
+ }
162
+ device.lastSeenAt = nowIso(now);
163
+ if (changed) this.save();
164
+ return { ...device };
165
+ }
166
+
167
+ /**
168
+ * Devices an operator may act on, newest first. Token hashes are never
169
+ * included: the dashboard has no use for them and they must not leave the
170
+ * process.
171
+ */
172
+ listDevices(now = Date.now()) {
173
+ return Object.values(this.state.devices)
174
+ .filter((device) => device && device.expiresAt > now)
175
+ .map((device) => ({
176
+ deviceId: device.deviceId,
177
+ hostId: device.hostId,
178
+ createdAt: device.createdAt,
179
+ lastSeenAt: device.lastSeenAt,
180
+ expiresAt: device.expiresAt,
181
+ expiresAtIso: nowIso(device.expiresAt),
182
+ userAgent: device.userAgent || null,
183
+ lastIp: device.lastIp || null,
184
+ }))
185
+ .sort((a, b) => String(b.lastSeenAt || '').localeCompare(String(a.lastSeenAt || '')));
186
+ }
187
+
188
+ /**
189
+ * Permanently invalidate a paired device. The stored hash is dropped, so the
190
+ * token it was derived from can never authenticate again. Returns the removed
191
+ * record so the caller can also close whatever sockets it still holds.
192
+ */
193
+ revokeDevice(deviceId) {
194
+ if (typeof deviceId !== 'string' || !deviceId) return null;
195
+ const device = this.state.devices[deviceId];
196
+ if (!device) return null;
197
+ delete this.state.devices[deviceId];
198
+ this.lastDeviceSaveAt.delete(deviceId);
199
+ this.save();
200
+ return {
201
+ deviceId: device.deviceId,
202
+ hostId: device.hostId,
203
+ userAgent: device.userAgent || null,
204
+ lastIp: device.lastIp || null,
205
+ };
206
+ }
207
+
145
208
  authenticateDevice(token, now = Date.now()) {
146
209
  if (typeof token !== 'string' || token.length < 16) return null;
147
210
  const tokenHash = hash(token);
package/src/metrics.js CHANGED
@@ -92,15 +92,19 @@ class RelayMetrics {
92
92
  }
93
93
  const memory = process.memoryUsage();
94
94
  const load = os.loadavg();
95
- const controller = clients.find((client) => client.role === 'controller');
95
+ // Every attached window has full input, so there is no "active controller"
96
+ // to name: reporting the first of them as one had an operator reading a
97
+ // distinction that does not exist. The host is still worth naming, and any
98
+ // client knows which workstation it is on.
99
+ const anyClient = clients[0];
96
100
  return {
97
101
  version: this.version,
98
102
  protocolVersion: this.protocolVersion,
99
103
  uptimeSeconds: Math.floor((now - this.startedAt) / 1000),
100
104
  startTime: new Date(this.startedAt).toISOString(),
101
105
  serverTime: new Date(now).toISOString(),
102
- activeControllerId: controller?.id || null,
103
- activeHostId: controller?.hostId || hosts[0]?.id || null,
106
+ activeControllerId: null,
107
+ activeHostId: anyClient?.hostId || hosts[0]?.id || null,
104
108
  clients,
105
109
  hosts,
106
110
  ptys,
File without changes