herdr-remote 0.2.32 → 0.2.34

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.
Files changed (135) hide show
  1. package/README.md +3 -1
  2. package/README.zh-CN.md +2 -1
  3. package/dist/connector/agent-watch.js +16 -3
  4. package/dist/connector/herdr-prefix-keys.js +28 -0
  5. package/dist/connector/host-connector.js +6 -20
  6. package/dist/connector/host-hello.js +32 -0
  7. package/dist/herdr-api.js +7 -1
  8. package/dist/herdr-command.js +7 -2
  9. package/dist/i18n/en.js +5 -18
  10. package/dist/i18n/zh.js +5 -18
  11. package/dist/pty-session.js +27 -2
  12. package/dist/socket-discovery.js +16 -6
  13. package/dist/terminal-font/formats.js +26 -6
  14. package/dist/terminal-font/terminals.js +19 -3
  15. package/dist/tui/App.js +3 -25
  16. package/dist/tui/api.js +3 -3
  17. package/dist/tui/screens/About.js +18 -27
  18. package/dist/update-command.js +16 -19
  19. package/dist/updater.js +91 -48
  20. package/herdr-plugin.toml +2 -2
  21. package/node_modules/herdr-remote-relay/LICENSE +21 -0
  22. package/node_modules/herdr-remote-relay/README.md +76 -0
  23. package/node_modules/herdr-remote-relay/README.zh-CN.md +76 -0
  24. package/node_modules/herdr-remote-relay/bin/herdr-remote-relay.js +116 -0
  25. package/node_modules/herdr-remote-relay/config.example.json +18 -0
  26. package/node_modules/herdr-remote-relay/deploy/cloudflared/config.yml.example +9 -0
  27. package/node_modules/herdr-remote-relay/deploy/docker/compose.yaml +23 -0
  28. package/node_modules/herdr-remote-relay/deploy/nginx/herdr-remote-relay.conf.example +49 -0
  29. package/node_modules/herdr-remote-relay/deploy/systemd/herdr-remote-relay.service +41 -0
  30. package/node_modules/herdr-remote-relay/deploy/systemd/relay.env.example +25 -0
  31. package/node_modules/herdr-remote-relay/dist/auth-store.d.ts +114 -0
  32. package/node_modules/herdr-remote-relay/dist/auth-store.js +289 -0
  33. package/node_modules/herdr-remote-relay/dist/metrics.d.ts +76 -0
  34. package/node_modules/herdr-remote-relay/dist/metrics.js +249 -0
  35. package/node_modules/herdr-remote-relay/dist/protocol/frames.d.ts +27 -0
  36. package/node_modules/herdr-remote-relay/dist/protocol/frames.js +110 -0
  37. package/node_modules/herdr-remote-relay/dist/protocol/herdr-keys.d.ts +8 -0
  38. package/node_modules/herdr-remote-relay/dist/protocol/herdr-keys.js +34 -0
  39. package/node_modules/herdr-remote-relay/dist/protocol/http.d.ts +165 -0
  40. package/node_modules/herdr-remote-relay/dist/protocol/http.js +3 -0
  41. package/node_modules/herdr-remote-relay/dist/protocol/index.d.ts +7 -0
  42. package/node_modules/herdr-remote-relay/dist/protocol/index.js +9 -0
  43. package/node_modules/herdr-remote-relay/dist/protocol/messages.d.ts +362 -0
  44. package/node_modules/herdr-remote-relay/dist/protocol/messages.js +20 -0
  45. package/node_modules/herdr-remote-relay/dist/protocol/paste.d.ts +14 -0
  46. package/node_modules/herdr-remote-relay/dist/protocol/paste.js +50 -0
  47. package/node_modules/herdr-remote-relay/dist/protocol/release.d.ts +8 -0
  48. package/node_modules/herdr-remote-relay/dist/protocol/release.js +17 -0
  49. package/node_modules/herdr-remote-relay/dist/protocol/terminal.d.ts +79 -0
  50. package/node_modules/herdr-remote-relay/dist/protocol/terminal.js +150 -0
  51. package/node_modules/herdr-remote-relay/dist/relay-config.d.ts +74 -0
  52. package/node_modules/herdr-remote-relay/dist/relay-config.js +316 -0
  53. package/node_modules/herdr-remote-relay/dist/relay-server.d.ts +64 -0
  54. package/node_modules/herdr-remote-relay/dist/relay-server.js +194 -0
  55. package/node_modules/herdr-remote-relay/dist/scroll-input.d.ts +17 -0
  56. package/node_modules/herdr-remote-relay/dist/scroll-input.js +124 -0
  57. package/node_modules/herdr-remote-relay/dist/server/client-channel.d.ts +3 -0
  58. package/node_modules/herdr-remote-relay/dist/server/client-channel.js +396 -0
  59. package/node_modules/herdr-remote-relay/dist/server/font-proxy.d.ts +19 -0
  60. package/node_modules/herdr-remote-relay/dist/server/font-proxy.js +155 -0
  61. package/node_modules/herdr-remote-relay/dist/server/host-broadcasts.d.ts +27 -0
  62. package/node_modules/herdr-remote-relay/dist/server/host-broadcasts.js +83 -0
  63. package/node_modules/herdr-remote-relay/dist/server/host-channel.d.ts +3 -0
  64. package/node_modules/herdr-remote-relay/dist/server/host-channel.js +271 -0
  65. package/node_modules/herdr-remote-relay/dist/server/http-api.d.ts +3 -0
  66. package/node_modules/herdr-remote-relay/dist/server/http-api.js +214 -0
  67. package/node_modules/herdr-remote-relay/dist/server/maintenance.d.ts +5 -0
  68. package/node_modules/herdr-remote-relay/dist/server/maintenance.js +64 -0
  69. package/node_modules/herdr-remote-relay/dist/server/requests.d.ts +34 -0
  70. package/node_modules/herdr-remote-relay/dist/server/requests.js +142 -0
  71. package/node_modules/herdr-remote-relay/dist/server/sessions.d.ts +36 -0
  72. package/node_modules/herdr-remote-relay/dist/server/sessions.js +230 -0
  73. package/node_modules/herdr-remote-relay/dist/server/sockets.d.ts +12 -0
  74. package/node_modules/herdr-remote-relay/dist/server/sockets.js +64 -0
  75. package/node_modules/herdr-remote-relay/dist/server/status.d.ts +11 -0
  76. package/node_modules/herdr-remote-relay/dist/server/status.js +99 -0
  77. package/node_modules/herdr-remote-relay/dist/server/transport.d.ts +16 -0
  78. package/node_modules/herdr-remote-relay/dist/server/transport.js +110 -0
  79. package/node_modules/herdr-remote-relay/dist/server/types.d.ts +131 -0
  80. package/node_modules/herdr-remote-relay/dist/server/types.js +3 -0
  81. package/node_modules/herdr-remote-relay/dist/server/version.d.ts +2 -0
  82. package/node_modules/herdr-remote-relay/dist/server/version.js +5 -0
  83. package/node_modules/herdr-remote-relay/dist/state.d.ts +6 -0
  84. package/node_modules/herdr-remote-relay/dist/state.js +47 -0
  85. package/node_modules/herdr-remote-relay/package.json +76 -0
  86. package/node_modules/herdr-remote-relay/web/dist/assets/index-BeveQ9Di.js +61 -0
  87. package/node_modules/herdr-remote-relay/web/dist/assets/index-XYYRsgSb.css +32 -0
  88. package/node_modules/herdr-remote-relay/web/dist/assets/vendor-react-CYSfZuHu.js +17 -0
  89. package/node_modules/herdr-remote-relay/web/dist/assets/vendor-xterm-BhCxil_4.js +9 -0
  90. package/node_modules/herdr-remote-relay/web/dist/fonts/SymbolsNerdFontMono-Regular.woff2 +0 -0
  91. package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/OFL.txt +93 -0
  92. package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-400-italic.woff2 +0 -0
  93. package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-400-normal.woff2 +0 -0
  94. package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-700-italic.woff2 +0 -0
  95. package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-700-normal.woff2 +0 -0
  96. package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-ext-400-italic.woff2 +0 -0
  97. package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-ext-400-normal.woff2 +0 -0
  98. package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-ext-700-italic.woff2 +0 -0
  99. package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-ext-700-normal.woff2 +0 -0
  100. package/node_modules/herdr-remote-relay/web/dist/fonts/fira-code/OFL.txt +93 -0
  101. package/node_modules/herdr-remote-relay/web/dist/fonts/fira-code/fira-code-latin-400-normal.woff2 +0 -0
  102. package/node_modules/herdr-remote-relay/web/dist/fonts/fira-code/fira-code-latin-700-normal.woff2 +0 -0
  103. package/node_modules/herdr-remote-relay/web/dist/fonts/fira-code/fira-code-latin-ext-400-normal.woff2 +0 -0
  104. package/node_modules/herdr-remote-relay/web/dist/fonts/fira-code/fira-code-latin-ext-700-normal.woff2 +0 -0
  105. package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/OFL.txt +93 -0
  106. package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-italic.woff2 +0 -0
  107. package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  108. package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-700-italic.woff2 +0 -0
  109. package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-700-normal.woff2 +0 -0
  110. package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-italic.woff2 +0 -0
  111. package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
  112. package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-700-italic.woff2 +0 -0
  113. package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-700-normal.woff2 +0 -0
  114. package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/OFL.txt +93 -0
  115. package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-400-italic.woff2 +0 -0
  116. package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-400-normal.woff2 +0 -0
  117. package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-700-italic.woff2 +0 -0
  118. package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-700-normal.woff2 +0 -0
  119. package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-ext-400-italic.woff2 +0 -0
  120. package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
  121. package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-ext-700-italic.woff2 +0 -0
  122. package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-ext-700-normal.woff2 +0 -0
  123. package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/OFL.txt +93 -0
  124. package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-400-italic.woff2 +0 -0
  125. package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-400-normal.woff2 +0 -0
  126. package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-700-italic.woff2 +0 -0
  127. package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-700-normal.woff2 +0 -0
  128. package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-ext-400-italic.woff2 +0 -0
  129. package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-ext-400-normal.woff2 +0 -0
  130. package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-ext-700-italic.woff2 +0 -0
  131. package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-ext-700-normal.woff2 +0 -0
  132. package/node_modules/herdr-remote-relay/web/dist/index.html +36 -0
  133. package/package.json +6 -3
  134. package/dist/relay-updater.js +0 -81
  135. package/dist/tui/screens/useRelayUpdate.js +0 -122
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Standalone entry point for the Herdr Remote relay.
4
+ //
5
+ // This binary has no knowledge of the herdr-remote plugin: it can be installed
6
+ // and run on its own host (`npm i -g herdr-remote-relay`) with nothing but
7
+ // environment variables for configuration.
8
+
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { loadRelayConfig, PACKAGE_ROOT } from '../dist/relay-config.js';
12
+ import { RelayServer, VERSION, PROTOCOL_VERSION } from '../dist/relay-server.js';
13
+
14
+ const USAGE = `herdr-remote-relay ${VERSION} — standalone relay for Herdr Remote
15
+
16
+ Usage: herdr-remote-relay [options]
17
+
18
+ Options:
19
+ --password <password> Workstation password (RELAY_PASSWORD, empty for public)
20
+ --admin-token <token> Admin dashboard token (RELAY_ADMIN_TOKEN)
21
+ --deployment-mode <mode> Deployment mode: local or remote (default remote)
22
+ --public-url <url> Public URL for browsers (RELAY_PUBLIC_URL)
23
+ --bind <address> Listen address (default 127.0.0.1, RELAY_BIND)
24
+ --port <number> Listen port (default 8787, RELAY_PORT)
25
+ --trust-proxy Trust X-Forwarded-For headers (RELAY_TRUST_PROXY)
26
+ --state-file <file> Auth state file path (RELAY_AUTH_STATE_FILE)
27
+ --allowed-origins <list> Allowed browser origins, comma-separated
28
+ --max-clients <number> Max clients per workstation (default 16)
29
+ --max-hosts <number> Max workstations on this relay (default 1024)
30
+ --max-pending-handshakes <n> Max unauthenticated WebSockets (default 1024)
31
+ --max-buffered-bytes <n> Max queued bytes per browser (default 4194304)
32
+ --host-reconnect-grace-ms <n> Grace period for host handoff (default 30000)
33
+ --config <file> JSON config file (HERDR_RELAY_CONFIG)
34
+ -h, --help Show help
35
+ -v, --version Show version
36
+
37
+ Guide: docs/self-hosted-relay.md
38
+ `;
39
+
40
+ function webUiBuilt() {
41
+ return fs.existsSync(path.join(PACKAGE_ROOT, 'web', 'dist', 'index.html'));
42
+ }
43
+
44
+ async function main(argv = process.argv.slice(2)) {
45
+ let loaded;
46
+ try {
47
+ loaded = loadRelayConfig({ argv });
48
+ } catch (error) {
49
+ process.stderr.write(`herdr-remote-relay: ${error.message}\n`);
50
+ process.exitCode = 1;
51
+ return;
52
+ }
53
+ const { config, help, version, errors, warnings, configFile } = loaded;
54
+
55
+ if (help) {
56
+ process.stdout.write(USAGE);
57
+ return;
58
+ }
59
+ if (version) {
60
+ process.stdout.write(`${VERSION}\n`);
61
+ return;
62
+ }
63
+ if (errors.length > 0) {
64
+ for (const message of errors) process.stderr.write(`herdr-remote-relay: ${message}\n`);
65
+ process.stderr.write(USAGE);
66
+ process.exitCode = 2;
67
+ return;
68
+ }
69
+
70
+ const server = new RelayServer(config);
71
+ let address;
72
+ try {
73
+ address = await server.listen(config.relay.port, config.relay.host);
74
+ } catch (error) {
75
+ const hint =
76
+ error.code === 'EADDRINUSE'
77
+ ? ` (port ${config.relay.port} is already in use)`
78
+ : error.code === 'EACCES'
79
+ ? ` (no permission to bind port ${config.relay.port})`
80
+ : '';
81
+ process.stderr.write(`herdr-remote-relay: failed to listen${hint}: ${error.message}\n`);
82
+ process.exitCode = 1;
83
+ return;
84
+ }
85
+
86
+ process.stdout.write(`herdr-remote-relay ${VERSION} (protocol ${PROTOCOL_VERSION})\n`);
87
+ process.stdout.write(` listening http://${config.relay.host}:${address.port}\n`);
88
+ process.stdout.write(` public url ${config.relay.publicUrl}\n`);
89
+ process.stdout.write(
90
+ ` password ${config.auth.password ? 'set' : 'not set (public relay)'}\n`,
91
+ );
92
+ process.stdout.write(
93
+ ` admin ${config.auth.adminToken ? 'set (/admin)' : 'not configured'}\n`,
94
+ );
95
+ process.stdout.write(` state file ${server.stateFile}\n`);
96
+ if (configFile) process.stdout.write(` config file ${configFile}\n`);
97
+ if (!webUiBuilt()) {
98
+ process.stdout.write(' web ui NOT BUILT (run "npm run build" in this package)\n');
99
+ }
100
+ for (const warning of warnings) process.stdout.write(` warning: ${warning}\n`);
101
+
102
+ let closing = false;
103
+ const stop = (signal) => {
104
+ if (closing) return;
105
+ closing = true;
106
+ process.stdout.write(`herdr-remote-relay: ${signal} received, shutting down\n`);
107
+ server.close().finally(() => process.exit(0));
108
+ };
109
+ process.on('SIGINT', () => stop('SIGINT'));
110
+ process.on('SIGTERM', () => stop('SIGTERM'));
111
+ }
112
+
113
+ main().catch((error) => {
114
+ process.stderr.write(`herdr-remote-relay: ${error.stack || error.message}\n`);
115
+ process.exitCode = 1;
116
+ });
@@ -0,0 +1,18 @@
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
+ "maxHosts": 1024,
9
+ "maxPendingHandshakes": 1024,
10
+ "maxBufferedBytesPerClient": 4194304,
11
+ "hostReconnectGraceMs": 30000
12
+ },
13
+ "auth": {
14
+ "password": "change-me",
15
+ "adminToken": "replace-with-a-long-random-operator-token",
16
+ "stateFile": "/var/lib/herdr-remote-relay/relay-auth.json"
17
+ }
18
+ }
@@ -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,25 @@
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
+ RELAY_MAX_HOSTS=1024
9
+ RELAY_MAX_PENDING_HANDSHAKES=1024
10
+ RELAY_MAX_CLIENTS_PER_HOST=16
11
+ RELAY_MAX_BUFFERED_BYTES_PER_CLIENT=4194304
12
+ RELAY_HOST_RECONNECT_GRACE_MS=30000
13
+
14
+ # Password a workstation must present. Leave empty for a public relay.
15
+ RELAY_PASSWORD=change-me
16
+
17
+ # Credential for the relay operator dashboard at /admin. Keep this separate
18
+ # from RELAY_PASSWORD; it is sent only in X-Relay-Admin-Token.
19
+ RELAY_ADMIN_TOKEN=replace-with-a-long-random-operator-token
20
+
21
+ # Set when a reverse proxy sits in front, so pairing rate limits key on the
22
+ # real client address instead of the proxy's.
23
+ RELAY_TRUST_PROXY=1
24
+
25
+ RELAY_AUTH_STATE_FILE=/var/lib/herdr-remote-relay/relay-auth.json
@@ -0,0 +1,114 @@
1
+ import type { PairedDeviceInfo } from './protocol/http.js';
2
+ interface HostRecord {
3
+ tokenHash: string;
4
+ createdAt: string;
5
+ lastSeenAt: string;
6
+ }
7
+ export interface DeviceRecord {
8
+ deviceId: string;
9
+ hostId: string;
10
+ tokenHash: string;
11
+ createdAt: string;
12
+ lastSeenAt: string;
13
+ expiresAt: number;
14
+ userAgent?: string;
15
+ lastIp?: string;
16
+ }
17
+ interface AuthState {
18
+ version: 1;
19
+ hosts: Record<string, HostRecord>;
20
+ devices: Record<string, DeviceRecord>;
21
+ }
22
+ interface AuthStoreOptions {
23
+ stateFile?: string;
24
+ pairingTtlMs?: number;
25
+ deviceTtlMs?: number;
26
+ maxDevices?: number;
27
+ password?: string | null;
28
+ }
29
+ type RegisterHostResult = {
30
+ ok: true;
31
+ hostId: string;
32
+ firstSeen: boolean;
33
+ } | {
34
+ ok: false;
35
+ code: 'invalid_host_credentials' | 'relay_password_required' | 'host_auth_failed';
36
+ message: string;
37
+ };
38
+ interface PairingStart {
39
+ code: string;
40
+ hostId: string;
41
+ publicUrl: string;
42
+ expiresAt: number;
43
+ expiresAtIso: string;
44
+ }
45
+ interface PairingResult {
46
+ deviceId: string;
47
+ hostId: string;
48
+ token: string;
49
+ expiresAt: number;
50
+ expiresAtIso: string;
51
+ publicUrl: string;
52
+ }
53
+ interface RevokedDevice {
54
+ deviceId: string;
55
+ hostId: string;
56
+ userAgent: string | null;
57
+ lastIp: string | null;
58
+ }
59
+ declare class AuthStore {
60
+ readonly stateFile: string;
61
+ readonly pairingTtlMs: number;
62
+ readonly deviceTtlMs: number;
63
+ readonly maxDevices: number;
64
+ readonly password: string | null;
65
+ private readonly pairings;
66
+ private readonly lastDeviceSaveAt;
67
+ state: AuthState;
68
+ constructor({ stateFile, pairingTtlMs, deviceTtlMs, maxDevices, password, }?: AuthStoreOptions);
69
+ save(): void;
70
+ /** Does this request carry the relay password, if one is required at all? */
71
+ checkPassword(supplied: unknown): boolean;
72
+ /**
73
+ * Enrol a workstation, or re-authenticate one that is already enrolled.
74
+ *
75
+ * The password (when the relay has one) decides who may *join*. The host
76
+ * token decides who may act *as a given workstation*: it is generated on the
77
+ * workstation and only its hash is ever stored here, so even on a public
78
+ * relay nobody else can impersonate an enrolled host or pair a device to it.
79
+ */
80
+ registerHost(hostId: unknown, token: unknown, password?: unknown, now?: number): RegisterHostResult;
81
+ /** Verify a host token without enrolling anything. */
82
+ authenticateHost(hostId: unknown, token: unknown): boolean;
83
+ hostCount(): number;
84
+ startPairing(hostId: unknown, publicUrl: string, now?: number): PairingStart;
85
+ completePairing(code: unknown, now?: number): PairingResult | null;
86
+ /**
87
+ * Record how a device presented itself, so an operator revoking a device can
88
+ * tell which phone or laptop they are about to cut off. Only the coarse
89
+ * user agent string and the address are kept — never terminal content.
90
+ */
91
+ noteDeviceSeen(deviceId: string, { userAgent, ip }?: {
92
+ userAgent?: string | null;
93
+ ip?: string | null;
94
+ }, now?: number): DeviceRecord | null;
95
+ /**
96
+ * Devices an operator may act on, newest first. Token hashes are never
97
+ * included: the dashboard has no use for them and they must not leave the
98
+ * process.
99
+ */
100
+ listDevices(now?: number): PairedDeviceInfo[];
101
+ /**
102
+ * Permanently invalidate a paired device. The stored hash is dropped, so the
103
+ * token it was derived from can never authenticate again. Returns the removed
104
+ * record so the caller can also close whatever sockets it still holds.
105
+ */
106
+ revokeDevice(deviceId: unknown): RevokedDevice | null;
107
+ authenticateDevice(token: unknown, now?: number): DeviceRecord | null;
108
+ cleanup(now?: number): {
109
+ removedDevices: number;
110
+ removedPairings: number;
111
+ };
112
+ deviceCount(): number;
113
+ }
114
+ export { AuthStore };
@@ -0,0 +1,289 @@
1
+ import crypto from 'node:crypto';
2
+ import path from 'node:path';
3
+ import { randomToken, readJson, writeJsonAtomic, ensureDir } from './state.js';
4
+ function hash(value) {
5
+ return crypto.createHash('sha256').update(String(value), 'utf8').digest('hex');
6
+ }
7
+ function equalHash(left, right) {
8
+ if (typeof left !== 'string' || typeof right !== 'string')
9
+ return false;
10
+ const a = Buffer.from(left, 'hex');
11
+ const b = Buffer.from(right, 'hex');
12
+ return a.length === b.length && a.length > 0 && crypto.timingSafeEqual(a, b);
13
+ }
14
+ function nowIso(now = Date.now()) {
15
+ return new Date(now).toISOString();
16
+ }
17
+ function validHostId(value) {
18
+ return typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value);
19
+ }
20
+ /**
21
+ * What a person reads off one screen and types on another: six characters,
22
+ * without 0/O or 1/I/L to confuse. Cutting base64 and dropping its `-` and `_`
23
+ * made one code in seven shorter than six, and some too short to pair at all.
24
+ */
25
+ const PAIR_CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
26
+ const PAIR_CODE_LENGTH = 6;
27
+ function newPairCode() {
28
+ let code = '';
29
+ for (let i = 0; i < PAIR_CODE_LENGTH; i += 1)
30
+ code += PAIR_CODE_ALPHABET[crypto.randomInt(PAIR_CODE_ALPHABET.length)];
31
+ return code;
32
+ }
33
+ class AuthStore {
34
+ stateFile;
35
+ pairingTtlMs;
36
+ deviceTtlMs;
37
+ maxDevices;
38
+ password;
39
+ pairings = new Map();
40
+ lastDeviceSaveAt = new Map();
41
+ state;
42
+ constructor({ stateFile, pairingTtlMs = 10 * 60 * 1000, deviceTtlMs = 30 * 24 * 60 * 60 * 1000, maxDevices = 32, password = null, } = {}) {
43
+ if (!stateFile)
44
+ throw new TypeError('stateFile is required');
45
+ this.stateFile = stateFile;
46
+ this.pairingTtlMs = pairingTtlMs;
47
+ this.deviceTtlMs = deviceTtlMs;
48
+ this.maxDevices = maxDevices;
49
+ // Optional shared password. When unset the relay is public: anyone may
50
+ // enrol a workstation. That is safe because a workstation is only ever
51
+ // reachable through its own host token, which the relay never hands out.
52
+ this.password = password || null;
53
+ this.state = readJson(stateFile, { version: 1, hosts: {}, devices: {} });
54
+ this.state.version = 1;
55
+ this.state.hosts =
56
+ this.state.hosts && typeof this.state.hosts === 'object' ? this.state.hosts : {};
57
+ this.state.devices =
58
+ this.state.devices && typeof this.state.devices === 'object' ? this.state.devices : {};
59
+ ensureDir(path.dirname(stateFile));
60
+ }
61
+ save() {
62
+ writeJsonAtomic(this.stateFile, this.state);
63
+ }
64
+ /** Does this request carry the relay password, if one is required at all? */
65
+ checkPassword(supplied) {
66
+ if (!this.password)
67
+ return true;
68
+ return typeof supplied === 'string' && equalHash(hash(supplied), hash(this.password));
69
+ }
70
+ /**
71
+ * Enrol a workstation, or re-authenticate one that is already enrolled.
72
+ *
73
+ * The password (when the relay has one) decides who may *join*. The host
74
+ * token decides who may act *as a given workstation*: it is generated on the
75
+ * workstation and only its hash is ever stored here, so even on a public
76
+ * relay nobody else can impersonate an enrolled host or pair a device to it.
77
+ */
78
+ registerHost(hostId, token, password = null, now = Date.now()) {
79
+ if (!validHostId(hostId) ||
80
+ typeof token !== 'string' ||
81
+ token.length < 16 ||
82
+ token.length > 4096) {
83
+ return {
84
+ ok: false,
85
+ code: 'invalid_host_credentials',
86
+ message: 'hostId and token are required',
87
+ };
88
+ }
89
+ if (!this.checkPassword(password)) {
90
+ return {
91
+ ok: false,
92
+ code: 'relay_password_required',
93
+ message: 'the relay password is missing or wrong',
94
+ };
95
+ }
96
+ const existing = this.state.hosts[hostId];
97
+ if (existing) {
98
+ if (!equalHash(existing.tokenHash, hash(token))) {
99
+ return { ok: false, code: 'host_auth_failed', message: 'host token is invalid' };
100
+ }
101
+ existing.lastSeenAt = nowIso(now);
102
+ this.save();
103
+ return { ok: true, hostId, firstSeen: false };
104
+ }
105
+ this.state.hosts[hostId] = {
106
+ tokenHash: hash(token),
107
+ createdAt: nowIso(now),
108
+ lastSeenAt: nowIso(now),
109
+ };
110
+ this.save();
111
+ return { ok: true, hostId, firstSeen: true };
112
+ }
113
+ /** Verify a host token without enrolling anything. */
114
+ authenticateHost(hostId, token) {
115
+ if (!validHostId(hostId) ||
116
+ typeof token !== 'string' ||
117
+ token.length < 16 ||
118
+ token.length > 4096)
119
+ return false;
120
+ const existing = this.state.hosts[hostId];
121
+ return Boolean(existing) && equalHash(existing.tokenHash, hash(token));
122
+ }
123
+ hostCount() {
124
+ return Object.keys(this.state.hosts).length;
125
+ }
126
+ startPairing(hostId, publicUrl, now = Date.now()) {
127
+ if (!validHostId(hostId) || !this.state.hosts[hostId]) {
128
+ throw Object.assign(new Error('host is not connected or enrolled'), {
129
+ code: 'host_not_found',
130
+ });
131
+ }
132
+ this.cleanup(now);
133
+ let code;
134
+ do {
135
+ code = newPairCode();
136
+ } while ([...this.pairings.values()].some((pairing) => equalHash(pairing.codeHash, hash(code))));
137
+ const expiresAt = now + this.pairingTtlMs;
138
+ this.pairings.set(code, {
139
+ codeHash: hash(code),
140
+ hostId,
141
+ expiresAt,
142
+ publicUrl,
143
+ });
144
+ return { code, hostId, publicUrl, expiresAt, expiresAtIso: nowIso(expiresAt) };
145
+ }
146
+ completePairing(code, now = Date.now()) {
147
+ if (typeof code !== 'string' || code.length < 4 || code.length > 32)
148
+ return null;
149
+ this.cleanup(now);
150
+ const normalized = code.trim().toUpperCase();
151
+ const pairing = this.pairings.get(normalized);
152
+ if (!pairing || pairing.expiresAt <= now || !equalHash(pairing.codeHash, hash(normalized)))
153
+ return null;
154
+ this.pairings.delete(normalized);
155
+ const devices = Object.values(this.state.devices);
156
+ if (devices.length >= this.maxDevices) {
157
+ devices.sort((a, b) => (a.lastSeenAt || '').localeCompare(b.lastSeenAt || ''));
158
+ delete this.state.devices[devices[0].deviceId];
159
+ }
160
+ const deviceId = `device-${randomToken(9)}`;
161
+ const token = randomToken(32);
162
+ const expiresAt = now + this.deviceTtlMs;
163
+ this.state.devices[deviceId] = {
164
+ deviceId,
165
+ hostId: pairing.hostId,
166
+ tokenHash: hash(token),
167
+ createdAt: nowIso(now),
168
+ lastSeenAt: nowIso(now),
169
+ expiresAt,
170
+ };
171
+ this.save();
172
+ return {
173
+ deviceId,
174
+ hostId: pairing.hostId,
175
+ token,
176
+ expiresAt,
177
+ expiresAtIso: nowIso(expiresAt),
178
+ publicUrl: pairing.publicUrl,
179
+ };
180
+ }
181
+ /**
182
+ * Record how a device presented itself, so an operator revoking a device can
183
+ * tell which phone or laptop they are about to cut off. Only the coarse
184
+ * user agent string and the address are kept — never terminal content.
185
+ */
186
+ noteDeviceSeen(deviceId, { userAgent, ip } = {}, now = Date.now()) {
187
+ const device = this.state.devices[deviceId];
188
+ if (!device)
189
+ return null;
190
+ let changed = false;
191
+ if (userAgent && device.userAgent !== userAgent) {
192
+ device.userAgent = String(userAgent).slice(0, 256);
193
+ changed = true;
194
+ }
195
+ if (ip && device.lastIp !== ip) {
196
+ device.lastIp = String(ip).slice(0, 64);
197
+ changed = true;
198
+ }
199
+ device.lastSeenAt = nowIso(now);
200
+ if (changed)
201
+ this.save();
202
+ return { ...device };
203
+ }
204
+ /**
205
+ * Devices an operator may act on, newest first. Token hashes are never
206
+ * included: the dashboard has no use for them and they must not leave the
207
+ * process.
208
+ */
209
+ listDevices(now = Date.now()) {
210
+ return Object.values(this.state.devices)
211
+ .filter((device) => device && device.expiresAt > now)
212
+ .map((device) => ({
213
+ deviceId: device.deviceId,
214
+ hostId: device.hostId,
215
+ createdAt: device.createdAt,
216
+ lastSeenAt: device.lastSeenAt,
217
+ expiresAt: device.expiresAt,
218
+ expiresAtIso: nowIso(device.expiresAt),
219
+ userAgent: device.userAgent || null,
220
+ lastIp: device.lastIp || null,
221
+ }))
222
+ .sort((a, b) => String(b.lastSeenAt || '').localeCompare(String(a.lastSeenAt || '')));
223
+ }
224
+ /**
225
+ * Permanently invalidate a paired device. The stored hash is dropped, so the
226
+ * token it was derived from can never authenticate again. Returns the removed
227
+ * record so the caller can also close whatever sockets it still holds.
228
+ */
229
+ revokeDevice(deviceId) {
230
+ if (typeof deviceId !== 'string' || !deviceId)
231
+ return null;
232
+ const device = this.state.devices[deviceId];
233
+ if (!device)
234
+ return null;
235
+ delete this.state.devices[deviceId];
236
+ this.lastDeviceSaveAt.delete(deviceId);
237
+ this.save();
238
+ return {
239
+ deviceId: device.deviceId,
240
+ hostId: device.hostId,
241
+ userAgent: device.userAgent || null,
242
+ lastIp: device.lastIp || null,
243
+ };
244
+ }
245
+ authenticateDevice(token, now = Date.now()) {
246
+ if (typeof token !== 'string' || token.length < 16)
247
+ return null;
248
+ const tokenHash = hash(token);
249
+ for (const device of Object.values(this.state.devices)) {
250
+ if (device.expiresAt <= now)
251
+ continue;
252
+ if (!equalHash(device.tokenHash, tokenHash))
253
+ continue;
254
+ device.lastSeenAt = nowIso(now);
255
+ device.expiresAt = now + this.deviceTtlMs;
256
+ const lastSaveAt = this.lastDeviceSaveAt.get(device.deviceId) || 0;
257
+ if (now - lastSaveAt >= 60 * 1000) {
258
+ this.lastDeviceSaveAt.set(device.deviceId, now);
259
+ this.save();
260
+ }
261
+ return { ...device };
262
+ }
263
+ return null;
264
+ }
265
+ cleanup(now = Date.now()) {
266
+ let removedDevices = 0;
267
+ for (const [deviceId, device] of Object.entries(this.state.devices)) {
268
+ if (!device || device.expiresAt <= now) {
269
+ delete this.state.devices[deviceId];
270
+ this.lastDeviceSaveAt.delete(deviceId);
271
+ removedDevices += 1;
272
+ }
273
+ }
274
+ let removedPairings = 0;
275
+ for (const [code, pairing] of this.pairings.entries()) {
276
+ if (pairing.expiresAt <= now) {
277
+ this.pairings.delete(code);
278
+ removedPairings += 1;
279
+ }
280
+ }
281
+ if (removedDevices)
282
+ this.save();
283
+ return { removedDevices, removedPairings };
284
+ }
285
+ deviceCount() {
286
+ return Object.keys(this.state.devices).length;
287
+ }
288
+ }
289
+ export { AuthStore };