herdr-remote-relay 0.2.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/LICENSE +21 -0
- package/README.md +102 -0
- package/bin/herdr-remote-relay.js +113 -0
- package/config.example.json +14 -0
- package/deploy/cloudflared/config.yml.example +9 -0
- package/deploy/docker/compose.yaml +23 -0
- package/deploy/nginx/herdr-remote-relay.conf.example +49 -0
- package/deploy/systemd/herdr-remote-relay.service +41 -0
- package/deploy/systemd/relay.env.example +20 -0
- package/package.json +50 -0
- package/src/auth-store.js +188 -0
- package/src/metrics.js +136 -0
- package/src/relay-config.js +278 -0
- package/src/relay-server.js +776 -0
- package/src/scroll-input.js +139 -0
- package/src/state.js +47 -0
- package/src/stream-frame.js +106 -0
- package/web/dist/assets/index-D2ksY-SN.js +39 -0
- package/web/dist/assets/index-Zi2TgjUt.css +32 -0
- package/web/dist/assets/vendor-icons-VfRMalKD.js +294 -0
- package/web/dist/assets/vendor-react-B6szxdcU.js +9 -0
- package/web/dist/assets/vendor-xterm-Bldps5fz.js +66 -0
- package/web/dist/index.html +38 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dibin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# herdr-remote-relay
|
|
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.
|
|
5
|
+
|
|
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.
|
|
9
|
+
|
|
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.
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
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
|
|
19
|
+
```
|
|
20
|
+
|
|
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.
|
|
25
|
+
|
|
26
|
+
## Docker
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
docker run -d --name herdr-relay --restart unless-stopped \
|
|
30
|
+
-p 127.0.0.1:8787:8787 \
|
|
31
|
+
-v herdr-relay:/data \
|
|
32
|
+
-e RELAY_BIND=0.0.0.0 \
|
|
33
|
+
-e RELAY_PUBLIC_URL=https://herdr.example.com \
|
|
34
|
+
-e RELAY_PASSWORD=your-password \
|
|
35
|
+
-e RELAY_ADMIN_TOKEN=your-long-random-admin-token \
|
|
36
|
+
-e RELAY_TRUST_PROXY=1 \
|
|
37
|
+
-e RELAY_AUTH_STATE_FILE=/data/relay-auth.json \
|
|
38
|
+
node:22-alpine npx -y herdr-remote-relay
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Options
|
|
42
|
+
|
|
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
|
+
|
|
80
|
+
```bash
|
|
81
|
+
curl https://herdr.example.com/healthz # hosts should be 1
|
|
82
|
+
```
|
|
83
|
+
|
|
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
|
+
## License
|
|
101
|
+
|
|
102
|
+
MIT
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// Standalone entry point for the Herdr Remote relay.
|
|
5
|
+
//
|
|
6
|
+
// This binary has no knowledge of the herdr-remote plugin: it can be installed
|
|
7
|
+
// and run on its own host (`npm i -g herdr-remote-relay`) with nothing but
|
|
8
|
+
// environment variables for configuration.
|
|
9
|
+
|
|
10
|
+
const fs = require('node:fs');
|
|
11
|
+
const path = require('node:path');
|
|
12
|
+
const { loadRelayConfig, PACKAGE_ROOT } = require('../src/relay-config');
|
|
13
|
+
const { RelayServer, VERSION, PROTOCOL_VERSION } = require('../src/relay-server');
|
|
14
|
+
|
|
15
|
+
const USAGE = `herdr-remote-relay ${VERSION} — standalone relay for Herdr Remote
|
|
16
|
+
|
|
17
|
+
Usage: herdr-remote-relay [options]
|
|
18
|
+
|
|
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)
|
|
25
|
+
--bind <address> Listen address (default 127.0.0.1, RELAY_BIND)
|
|
26
|
+
--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
|
|
34
|
+
|
|
35
|
+
See docs/self-hosted-relay.md.
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
function webUiBuilt() {
|
|
39
|
+
return fs.existsSync(path.join(PACKAGE_ROOT, 'web', 'dist', 'index.html'));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function main(argv = process.argv.slice(2)) {
|
|
43
|
+
let loaded;
|
|
44
|
+
try {
|
|
45
|
+
loaded = loadRelayConfig({ argv });
|
|
46
|
+
} catch (error) {
|
|
47
|
+
process.stderr.write(`herdr-remote-relay: ${error.message}\n`);
|
|
48
|
+
process.exitCode = 1;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const { config, help, version, errors, warnings, configFile } = loaded;
|
|
52
|
+
|
|
53
|
+
if (help) {
|
|
54
|
+
process.stdout.write(USAGE);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (version) {
|
|
58
|
+
process.stdout.write(`${VERSION}\n`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (errors.length > 0) {
|
|
62
|
+
for (const message of errors) process.stderr.write(`herdr-remote-relay: ${message}\n`);
|
|
63
|
+
process.stderr.write(USAGE);
|
|
64
|
+
process.exitCode = 2;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const server = new RelayServer(config);
|
|
69
|
+
let address;
|
|
70
|
+
try {
|
|
71
|
+
address = await server.listen(config.relay.port, config.relay.host);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
const hint = error.code === 'EADDRINUSE'
|
|
74
|
+
? ` (port ${config.relay.port} is already in use)`
|
|
75
|
+
: error.code === 'EACCES'
|
|
76
|
+
? ` (no permission to bind port ${config.relay.port})`
|
|
77
|
+
: '';
|
|
78
|
+
process.stderr.write(`herdr-remote-relay: failed to listen${hint}: ${error.message}\n`);
|
|
79
|
+
process.exitCode = 1;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
process.stdout.write(`herdr-remote-relay ${VERSION} (protocol ${PROTOCOL_VERSION})\n`);
|
|
84
|
+
process.stdout.write(` listening http://${config.relay.host}:${address.port}\n`);
|
|
85
|
+
process.stdout.write(` public url ${config.relay.publicUrl}\n`);
|
|
86
|
+
process.stdout.write(` password ${config.auth.password ? 'set' : 'not set (public relay)'}\n`);
|
|
87
|
+
process.stdout.write(` admin ${config.auth.adminToken ? 'set (/admin)' : 'not configured'}\n`);
|
|
88
|
+
process.stdout.write(` state file ${server.stateFile}\n`);
|
|
89
|
+
if (configFile) process.stdout.write(` config file ${configFile}\n`);
|
|
90
|
+
if (!webUiBuilt()) {
|
|
91
|
+
process.stdout.write(' web ui NOT BUILT (run "npm run build" in this package)\n');
|
|
92
|
+
}
|
|
93
|
+
for (const warning of warnings) process.stdout.write(` warning: ${warning}\n`);
|
|
94
|
+
|
|
95
|
+
let closing = false;
|
|
96
|
+
const stop = (signal) => {
|
|
97
|
+
if (closing) return;
|
|
98
|
+
closing = true;
|
|
99
|
+
process.stdout.write(`herdr-remote-relay: ${signal} received, shutting down\n`);
|
|
100
|
+
server.close().finally(() => process.exit(0));
|
|
101
|
+
};
|
|
102
|
+
process.on('SIGINT', () => stop('SIGINT'));
|
|
103
|
+
process.on('SIGTERM', () => stop('SIGTERM'));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (require.main === module) {
|
|
107
|
+
main().catch((error) => {
|
|
108
|
+
process.stderr.write(`herdr-remote-relay: ${error.stack || error.message}\n`);
|
|
109
|
+
process.exitCode = 1;
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = { main, USAGE };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"relay": {
|
|
3
|
+
"mode": "remote",
|
|
4
|
+
"host": "127.0.0.1",
|
|
5
|
+
"port": 8787,
|
|
6
|
+
"publicUrl": "https://herdr.example.com",
|
|
7
|
+
"trustProxy": true
|
|
8
|
+
},
|
|
9
|
+
"auth": {
|
|
10
|
+
"password": "change-me",
|
|
11
|
+
"adminToken": "replace-with-a-long-random-operator-token",
|
|
12
|
+
"stateFile": "/var/lib/herdr-remote-relay/relay-auth.json"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Copy this file to ~/.cloudflared/config.yml after creating a named tunnel.
|
|
2
|
+
# The relay itself remains bound to 127.0.0.1.
|
|
3
|
+
tunnel: YOUR_TUNNEL_UUID
|
|
4
|
+
credentials-file: /home/YOUR_USER/.cloudflared/YOUR_TUNNEL_UUID.json
|
|
5
|
+
|
|
6
|
+
ingress:
|
|
7
|
+
- hostname: herdr.example.com
|
|
8
|
+
service: http://127.0.0.1:8787
|
|
9
|
+
- service: http_status:404
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Put your own reverse proxy (nginx, Caddy, Traefik, Cloudflare Tunnel…) in
|
|
2
|
+
# front of port 8787 and terminate TLS there.
|
|
3
|
+
|
|
4
|
+
services:
|
|
5
|
+
relay:
|
|
6
|
+
image: node:22-alpine
|
|
7
|
+
command: npx -y herdr-remote-relay
|
|
8
|
+
restart: unless-stopped
|
|
9
|
+
ports:
|
|
10
|
+
- "127.0.0.1:8787:8787"
|
|
11
|
+
environment:
|
|
12
|
+
RELAY_BIND: 0.0.0.0
|
|
13
|
+
RELAY_PORT: "8787"
|
|
14
|
+
RELAY_PUBLIC_URL: https://herdr.example.com
|
|
15
|
+
# Leave empty for a public relay.
|
|
16
|
+
RELAY_PASSWORD: change-me
|
|
17
|
+
RELAY_TRUST_PROXY: "1"
|
|
18
|
+
RELAY_AUTH_STATE_FILE: /data/relay-auth.json
|
|
19
|
+
volumes:
|
|
20
|
+
- relay-state:/data
|
|
21
|
+
|
|
22
|
+
volumes:
|
|
23
|
+
relay-state:
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# nginx site for a self-hosted relay. Obtain the certificate first, e.g. with
|
|
2
|
+
# sudo certbot --nginx -d herdr.example.com
|
|
3
|
+
|
|
4
|
+
map $http_upgrade $connection_upgrade {
|
|
5
|
+
default upgrade;
|
|
6
|
+
'' close;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
server {
|
|
10
|
+
listen 80;
|
|
11
|
+
listen [::]:80;
|
|
12
|
+
server_name herdr.example.com;
|
|
13
|
+
return 301 https://$host$request_uri;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
server {
|
|
17
|
+
listen 443 ssl;
|
|
18
|
+
listen [::]:443 ssl;
|
|
19
|
+
http2 on;
|
|
20
|
+
server_name herdr.example.com;
|
|
21
|
+
|
|
22
|
+
ssl_certificate /etc/letsencrypt/live/herdr.example.com/fullchain.pem;
|
|
23
|
+
ssl_certificate_key /etc/letsencrypt/live/herdr.example.com/privkey.pem;
|
|
24
|
+
|
|
25
|
+
location / {
|
|
26
|
+
proxy_pass http://127.0.0.1:8787;
|
|
27
|
+
proxy_http_version 1.1;
|
|
28
|
+
|
|
29
|
+
# Required for /ws/host and /ws/client. Without these the browser falls
|
|
30
|
+
# back to nothing: the terminal never opens.
|
|
31
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
32
|
+
proxy_set_header Connection $connection_upgrade;
|
|
33
|
+
|
|
34
|
+
proxy_set_header Host $host;
|
|
35
|
+
# The relay checks the Origin header against the request Host, and rate
|
|
36
|
+
# limits pairing per client address.
|
|
37
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
38
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
39
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
40
|
+
|
|
41
|
+
# A terminal can sit idle for a long time between keystrokes; the
|
|
42
|
+
# default 60s would drop the session.
|
|
43
|
+
proxy_read_timeout 3600s;
|
|
44
|
+
proxy_send_timeout 3600s;
|
|
45
|
+
|
|
46
|
+
# Terminal output must arrive as it is produced.
|
|
47
|
+
proxy_buffering off;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# System-wide unit for a self-hosted relay.
|
|
2
|
+
#
|
|
3
|
+
# sudo cp herdr-remote-relay.service /etc/systemd/system/
|
|
4
|
+
# sudo useradd --system --home /var/lib/herdr-remote-relay --create-home herdr-relay
|
|
5
|
+
# sudo install -m 600 -o herdr-relay -g herdr-relay relay.env /etc/herdr-remote-relay.env
|
|
6
|
+
# sudo systemctl enable --now herdr-remote-relay
|
|
7
|
+
|
|
8
|
+
[Unit]
|
|
9
|
+
Description=Herdr Remote relay
|
|
10
|
+
Documentation=https://github.com/herdr/herdr-remote
|
|
11
|
+
After=network-online.target
|
|
12
|
+
Wants=network-online.target
|
|
13
|
+
|
|
14
|
+
[Service]
|
|
15
|
+
Type=simple
|
|
16
|
+
User=herdr-relay
|
|
17
|
+
Group=herdr-relay
|
|
18
|
+
# Tokens live here, not on the command line, where `ps` would expose them.
|
|
19
|
+
EnvironmentFile=/etc/herdr-remote-relay.env
|
|
20
|
+
ExecStart=/usr/bin/npx herdr-remote-relay
|
|
21
|
+
Restart=always
|
|
22
|
+
RestartSec=3
|
|
23
|
+
StateDirectory=herdr-remote-relay
|
|
24
|
+
WorkingDirectory=/var/lib/herdr-remote-relay
|
|
25
|
+
|
|
26
|
+
# The relay reads no user files and runs no subprocesses; it only needs its own
|
|
27
|
+
# state directory and a socket.
|
|
28
|
+
NoNewPrivileges=true
|
|
29
|
+
PrivateTmp=true
|
|
30
|
+
ProtectSystem=strict
|
|
31
|
+
ProtectHome=true
|
|
32
|
+
ProtectKernelTunables=true
|
|
33
|
+
ProtectKernelModules=true
|
|
34
|
+
ProtectControlGroups=true
|
|
35
|
+
RestrictAddressFamilies=AF_INET AF_INET6
|
|
36
|
+
RestrictNamespaces=true
|
|
37
|
+
LockPersonality=true
|
|
38
|
+
MemoryDenyWriteExecute=false
|
|
39
|
+
|
|
40
|
+
[Install]
|
|
41
|
+
WantedBy=multi-user.target
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Copy to /etc/herdr-remote-relay.env with mode 0600.
|
|
2
|
+
|
|
3
|
+
RELAY_PUBLIC_URL=https://herdr.example.com
|
|
4
|
+
# Standalone/self-hosted relay; the workstation-managed relay sets this to local.
|
|
5
|
+
RELAY_DEPLOYMENT_MODE=remote
|
|
6
|
+
RELAY_BIND=127.0.0.1
|
|
7
|
+
RELAY_PORT=8787
|
|
8
|
+
|
|
9
|
+
# Password a workstation must present. Leave empty for a public relay.
|
|
10
|
+
RELAY_PASSWORD=change-me
|
|
11
|
+
|
|
12
|
+
# Credential for the relay operator dashboard at /admin. Keep this separate
|
|
13
|
+
# from RELAY_PASSWORD; it is sent only in X-Relay-Admin-Token.
|
|
14
|
+
RELAY_ADMIN_TOKEN=replace-with-a-long-random-operator-token
|
|
15
|
+
|
|
16
|
+
# Set when a reverse proxy sits in front, so pairing rate limits key on the
|
|
17
|
+
# real client address instead of the proxy's.
|
|
18
|
+
RELAY_TRUST_PROXY=1
|
|
19
|
+
|
|
20
|
+
RELAY_AUTH_STATE_FILE=/var/lib/herdr-remote-relay/relay-auth.json
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
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",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/dibin666/herdr-remote.git",
|
|
9
|
+
"directory": "packages/relay"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/dibin666/herdr-remote#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/dibin666/herdr-remote/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"herdr",
|
|
17
|
+
"relay",
|
|
18
|
+
"terminal",
|
|
19
|
+
"websocket",
|
|
20
|
+
"remote"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=22.0.0"
|
|
24
|
+
},
|
|
25
|
+
"bin": {
|
|
26
|
+
"herdr-remote-relay": "./bin/herdr-remote-relay.js"
|
|
27
|
+
},
|
|
28
|
+
"main": "./src/relay-server.js",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": "./src/relay-server.js",
|
|
31
|
+
"./protocol": "./src/stream-frame.js",
|
|
32
|
+
"./config": "./src/relay-config.js",
|
|
33
|
+
"./package.json": "./package.json"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"bin",
|
|
37
|
+
"src",
|
|
38
|
+
"web/dist",
|
|
39
|
+
"deploy",
|
|
40
|
+
"config.example.json"
|
|
41
|
+
],
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "npm --prefix web run build",
|
|
44
|
+
"start": "node bin/herdr-remote-relay.js",
|
|
45
|
+
"test": "node --test tests/*.test.js"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"ws": "^8.18.0"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const { randomToken, readJson, writeJsonAtomic, ensureDir } = require('./state');
|
|
5
|
+
|
|
6
|
+
function hash(value) {
|
|
7
|
+
return crypto.createHash('sha256').update(String(value), 'utf8').digest('hex');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function equalHash(left, right) {
|
|
11
|
+
if (typeof left !== 'string' || typeof right !== 'string') return false;
|
|
12
|
+
const a = Buffer.from(left, 'hex');
|
|
13
|
+
const b = Buffer.from(right, 'hex');
|
|
14
|
+
return a.length === b.length && a.length > 0 && crypto.timingSafeEqual(a, b);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function nowIso(now = Date.now()) {
|
|
18
|
+
return new Date(now).toISOString();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
class AuthStore {
|
|
22
|
+
constructor({ stateFile, pairingTtlMs = 10 * 60 * 1000, deviceTtlMs = 30 * 24 * 60 * 60 * 1000, maxDevices = 32, password = null } = {}) {
|
|
23
|
+
if (!stateFile) throw new TypeError('stateFile is required');
|
|
24
|
+
this.stateFile = stateFile;
|
|
25
|
+
this.pairingTtlMs = pairingTtlMs;
|
|
26
|
+
this.deviceTtlMs = deviceTtlMs;
|
|
27
|
+
this.maxDevices = maxDevices;
|
|
28
|
+
// Optional shared password. When unset the relay is public: anyone may
|
|
29
|
+
// enrol a workstation. That is safe because a workstation is only ever
|
|
30
|
+
// reachable through its own host token, which the relay never hands out.
|
|
31
|
+
this.password = password || null;
|
|
32
|
+
this.pairings = new Map();
|
|
33
|
+
this.lastDeviceSaveAt = new Map();
|
|
34
|
+
this.state = readJson(stateFile, { version: 1, hosts: {}, devices: {} });
|
|
35
|
+
this.state.version = 1;
|
|
36
|
+
this.state.hosts = this.state.hosts && typeof this.state.hosts === 'object' ? this.state.hosts : {};
|
|
37
|
+
this.state.devices = this.state.devices && typeof this.state.devices === 'object' ? this.state.devices : {};
|
|
38
|
+
ensureDir(require('node:path').dirname(stateFile));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
save() {
|
|
42
|
+
writeJsonAtomic(this.stateFile, this.state);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Does this request carry the relay password, if one is required at all? */
|
|
46
|
+
checkPassword(supplied) {
|
|
47
|
+
if (!this.password) return true;
|
|
48
|
+
return typeof supplied === 'string' && equalHash(hash(supplied), hash(this.password));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Enrol a workstation, or re-authenticate one that is already enrolled.
|
|
53
|
+
*
|
|
54
|
+
* The password (when the relay has one) decides who may *join*. The host
|
|
55
|
+
* token decides who may act *as a given workstation*: it is generated on the
|
|
56
|
+
* workstation and only its hash is ever stored here, so even on a public
|
|
57
|
+
* relay nobody else can impersonate an enrolled host or pair a device to it.
|
|
58
|
+
*/
|
|
59
|
+
registerHost(hostId, token, password = null, now = Date.now()) {
|
|
60
|
+
if (typeof hostId !== 'string' || hostId.length < 1 || hostId.length > 128 || typeof token !== 'string' || token.length < 16) {
|
|
61
|
+
return { ok: false, code: 'invalid_host_credentials', message: 'hostId and token are required' };
|
|
62
|
+
}
|
|
63
|
+
if (!this.checkPassword(password)) {
|
|
64
|
+
return { ok: false, code: 'relay_password_required', message: 'the relay password is missing or wrong' };
|
|
65
|
+
}
|
|
66
|
+
const existing = this.state.hosts[hostId];
|
|
67
|
+
if (existing) {
|
|
68
|
+
if (!equalHash(existing.tokenHash, hash(token))) {
|
|
69
|
+
return { ok: false, code: 'host_auth_failed', message: 'host token is invalid' };
|
|
70
|
+
}
|
|
71
|
+
existing.lastSeenAt = nowIso(now);
|
|
72
|
+
this.save();
|
|
73
|
+
return { ok: true, hostId, firstSeen: false };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
this.state.hosts[hostId] = {
|
|
77
|
+
tokenHash: hash(token),
|
|
78
|
+
createdAt: nowIso(now),
|
|
79
|
+
lastSeenAt: nowIso(now),
|
|
80
|
+
};
|
|
81
|
+
this.save();
|
|
82
|
+
return { ok: true, hostId, firstSeen: true };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Verify a host token without enrolling anything. */
|
|
86
|
+
authenticateHost(hostId, token) {
|
|
87
|
+
if (typeof hostId !== 'string' || typeof token !== 'string' || token.length < 16) return false;
|
|
88
|
+
const existing = this.state.hosts[hostId];
|
|
89
|
+
return Boolean(existing) && equalHash(existing.tokenHash, hash(token));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
hostCount() {
|
|
93
|
+
return Object.keys(this.state.hosts).length;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
startPairing(hostId, publicUrl, now = Date.now()) {
|
|
97
|
+
if (!this.state.hosts[hostId]) {
|
|
98
|
+
const error = new Error('host is not connected or enrolled');
|
|
99
|
+
error.code = 'host_not_found';
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
this.cleanup(now);
|
|
103
|
+
let code;
|
|
104
|
+
do {
|
|
105
|
+
code = randomToken(4).toUpperCase().replace(/[-_]/g, '').slice(0, 6);
|
|
106
|
+
} while ([...this.pairings.values()].some((pairing) => pairing.codeHash === hash(code)));
|
|
107
|
+
const expiresAt = now + this.pairingTtlMs;
|
|
108
|
+
this.pairings.set(code, {
|
|
109
|
+
codeHash: hash(code),
|
|
110
|
+
hostId,
|
|
111
|
+
expiresAt,
|
|
112
|
+
publicUrl,
|
|
113
|
+
});
|
|
114
|
+
return { code, hostId, publicUrl, expiresAt, expiresAtIso: nowIso(expiresAt) };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
completePairing(code, now = Date.now()) {
|
|
118
|
+
if (typeof code !== 'string' || code.length < 4 || code.length > 32) return null;
|
|
119
|
+
this.cleanup(now);
|
|
120
|
+
const normalized = code.trim().toUpperCase();
|
|
121
|
+
const pairing = this.pairings.get(normalized);
|
|
122
|
+
if (!pairing || pairing.expiresAt <= now || pairing.codeHash !== hash(normalized)) return null;
|
|
123
|
+
this.pairings.delete(normalized);
|
|
124
|
+
|
|
125
|
+
const devices = Object.values(this.state.devices);
|
|
126
|
+
if (devices.length >= this.maxDevices) {
|
|
127
|
+
devices.sort((a, b) => (a.lastSeenAt || '').localeCompare(b.lastSeenAt || ''));
|
|
128
|
+
delete this.state.devices[devices[0].deviceId];
|
|
129
|
+
}
|
|
130
|
+
const deviceId = `device-${randomToken(9)}`;
|
|
131
|
+
const token = randomToken(32);
|
|
132
|
+
const expiresAt = now + this.deviceTtlMs;
|
|
133
|
+
this.state.devices[deviceId] = {
|
|
134
|
+
deviceId,
|
|
135
|
+
hostId: pairing.hostId,
|
|
136
|
+
tokenHash: hash(token),
|
|
137
|
+
createdAt: nowIso(now),
|
|
138
|
+
lastSeenAt: nowIso(now),
|
|
139
|
+
expiresAt,
|
|
140
|
+
};
|
|
141
|
+
this.save();
|
|
142
|
+
return { deviceId, hostId: pairing.hostId, token, expiresAt, expiresAtIso: nowIso(expiresAt), publicUrl: pairing.publicUrl };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
authenticateDevice(token, now = Date.now()) {
|
|
146
|
+
if (typeof token !== 'string' || token.length < 16) return null;
|
|
147
|
+
const tokenHash = hash(token);
|
|
148
|
+
for (const device of Object.values(this.state.devices)) {
|
|
149
|
+
if (device.expiresAt <= now) continue;
|
|
150
|
+
if (!equalHash(device.tokenHash, tokenHash)) continue;
|
|
151
|
+
device.lastSeenAt = nowIso(now);
|
|
152
|
+
device.expiresAt = now + this.deviceTtlMs;
|
|
153
|
+
const lastSaveAt = this.lastDeviceSaveAt.get(device.deviceId) || 0;
|
|
154
|
+
if (now - lastSaveAt >= 60 * 1000) {
|
|
155
|
+
this.lastDeviceSaveAt.set(device.deviceId, now);
|
|
156
|
+
this.save();
|
|
157
|
+
}
|
|
158
|
+
return { ...device };
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
cleanup(now = Date.now()) {
|
|
164
|
+
let removedDevices = 0;
|
|
165
|
+
for (const [deviceId, device] of Object.entries(this.state.devices)) {
|
|
166
|
+
if (!device || device.expiresAt <= now) {
|
|
167
|
+
delete this.state.devices[deviceId];
|
|
168
|
+
this.lastDeviceSaveAt.delete(deviceId);
|
|
169
|
+
removedDevices += 1;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
let removedPairings = 0;
|
|
173
|
+
for (const [code, pairing] of this.pairings.entries()) {
|
|
174
|
+
if (pairing.expiresAt <= now) {
|
|
175
|
+
this.pairings.delete(code);
|
|
176
|
+
removedPairings += 1;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (removedDevices) this.save();
|
|
180
|
+
return { removedDevices, removedPairings };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
deviceCount() {
|
|
184
|
+
return Object.keys(this.state.devices).length;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = { AuthStore, hash, equalHash, nowIso };
|