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.
- package/README.md +3 -1
- package/README.zh-CN.md +2 -1
- package/dist/connector/agent-watch.js +16 -3
- package/dist/connector/herdr-prefix-keys.js +28 -0
- package/dist/connector/host-connector.js +6 -20
- package/dist/connector/host-hello.js +32 -0
- package/dist/herdr-api.js +7 -1
- package/dist/herdr-command.js +7 -2
- package/dist/i18n/en.js +5 -18
- package/dist/i18n/zh.js +5 -18
- package/dist/pty-session.js +27 -2
- package/dist/socket-discovery.js +16 -6
- package/dist/terminal-font/formats.js +26 -6
- package/dist/terminal-font/terminals.js +19 -3
- package/dist/tui/App.js +3 -25
- package/dist/tui/api.js +3 -3
- package/dist/tui/screens/About.js +18 -27
- package/dist/update-command.js +16 -19
- package/dist/updater.js +91 -48
- package/herdr-plugin.toml +2 -2
- package/node_modules/herdr-remote-relay/LICENSE +21 -0
- package/node_modules/herdr-remote-relay/README.md +76 -0
- package/node_modules/herdr-remote-relay/README.zh-CN.md +76 -0
- package/node_modules/herdr-remote-relay/bin/herdr-remote-relay.js +116 -0
- package/node_modules/herdr-remote-relay/config.example.json +18 -0
- package/node_modules/herdr-remote-relay/deploy/cloudflared/config.yml.example +9 -0
- package/node_modules/herdr-remote-relay/deploy/docker/compose.yaml +23 -0
- package/node_modules/herdr-remote-relay/deploy/nginx/herdr-remote-relay.conf.example +49 -0
- package/node_modules/herdr-remote-relay/deploy/systemd/herdr-remote-relay.service +41 -0
- package/node_modules/herdr-remote-relay/deploy/systemd/relay.env.example +25 -0
- package/node_modules/herdr-remote-relay/dist/auth-store.d.ts +114 -0
- package/node_modules/herdr-remote-relay/dist/auth-store.js +289 -0
- package/node_modules/herdr-remote-relay/dist/metrics.d.ts +76 -0
- package/node_modules/herdr-remote-relay/dist/metrics.js +249 -0
- package/node_modules/herdr-remote-relay/dist/protocol/frames.d.ts +27 -0
- package/node_modules/herdr-remote-relay/dist/protocol/frames.js +110 -0
- package/node_modules/herdr-remote-relay/dist/protocol/herdr-keys.d.ts +8 -0
- package/node_modules/herdr-remote-relay/dist/protocol/herdr-keys.js +34 -0
- package/node_modules/herdr-remote-relay/dist/protocol/http.d.ts +165 -0
- package/node_modules/herdr-remote-relay/dist/protocol/http.js +3 -0
- package/node_modules/herdr-remote-relay/dist/protocol/index.d.ts +7 -0
- package/node_modules/herdr-remote-relay/dist/protocol/index.js +9 -0
- package/node_modules/herdr-remote-relay/dist/protocol/messages.d.ts +362 -0
- package/node_modules/herdr-remote-relay/dist/protocol/messages.js +20 -0
- package/node_modules/herdr-remote-relay/dist/protocol/paste.d.ts +14 -0
- package/node_modules/herdr-remote-relay/dist/protocol/paste.js +50 -0
- package/node_modules/herdr-remote-relay/dist/protocol/release.d.ts +8 -0
- package/node_modules/herdr-remote-relay/dist/protocol/release.js +17 -0
- package/node_modules/herdr-remote-relay/dist/protocol/terminal.d.ts +79 -0
- package/node_modules/herdr-remote-relay/dist/protocol/terminal.js +150 -0
- package/node_modules/herdr-remote-relay/dist/relay-config.d.ts +74 -0
- package/node_modules/herdr-remote-relay/dist/relay-config.js +316 -0
- package/node_modules/herdr-remote-relay/dist/relay-server.d.ts +64 -0
- package/node_modules/herdr-remote-relay/dist/relay-server.js +194 -0
- package/node_modules/herdr-remote-relay/dist/scroll-input.d.ts +17 -0
- package/node_modules/herdr-remote-relay/dist/scroll-input.js +124 -0
- package/node_modules/herdr-remote-relay/dist/server/client-channel.d.ts +3 -0
- package/node_modules/herdr-remote-relay/dist/server/client-channel.js +396 -0
- package/node_modules/herdr-remote-relay/dist/server/font-proxy.d.ts +19 -0
- package/node_modules/herdr-remote-relay/dist/server/font-proxy.js +155 -0
- package/node_modules/herdr-remote-relay/dist/server/host-broadcasts.d.ts +27 -0
- package/node_modules/herdr-remote-relay/dist/server/host-broadcasts.js +83 -0
- package/node_modules/herdr-remote-relay/dist/server/host-channel.d.ts +3 -0
- package/node_modules/herdr-remote-relay/dist/server/host-channel.js +271 -0
- package/node_modules/herdr-remote-relay/dist/server/http-api.d.ts +3 -0
- package/node_modules/herdr-remote-relay/dist/server/http-api.js +214 -0
- package/node_modules/herdr-remote-relay/dist/server/maintenance.d.ts +5 -0
- package/node_modules/herdr-remote-relay/dist/server/maintenance.js +64 -0
- package/node_modules/herdr-remote-relay/dist/server/requests.d.ts +34 -0
- package/node_modules/herdr-remote-relay/dist/server/requests.js +142 -0
- package/node_modules/herdr-remote-relay/dist/server/sessions.d.ts +36 -0
- package/node_modules/herdr-remote-relay/dist/server/sessions.js +230 -0
- package/node_modules/herdr-remote-relay/dist/server/sockets.d.ts +12 -0
- package/node_modules/herdr-remote-relay/dist/server/sockets.js +64 -0
- package/node_modules/herdr-remote-relay/dist/server/status.d.ts +11 -0
- package/node_modules/herdr-remote-relay/dist/server/status.js +99 -0
- package/node_modules/herdr-remote-relay/dist/server/transport.d.ts +16 -0
- package/node_modules/herdr-remote-relay/dist/server/transport.js +110 -0
- package/node_modules/herdr-remote-relay/dist/server/types.d.ts +131 -0
- package/node_modules/herdr-remote-relay/dist/server/types.js +3 -0
- package/node_modules/herdr-remote-relay/dist/server/version.d.ts +2 -0
- package/node_modules/herdr-remote-relay/dist/server/version.js +5 -0
- package/node_modules/herdr-remote-relay/dist/state.d.ts +6 -0
- package/node_modules/herdr-remote-relay/dist/state.js +47 -0
- package/node_modules/herdr-remote-relay/package.json +76 -0
- package/node_modules/herdr-remote-relay/web/dist/assets/index-BeveQ9Di.js +61 -0
- package/node_modules/herdr-remote-relay/web/dist/assets/index-XYYRsgSb.css +32 -0
- package/node_modules/herdr-remote-relay/web/dist/assets/vendor-react-CYSfZuHu.js +17 -0
- package/node_modules/herdr-remote-relay/web/dist/assets/vendor-xterm-BhCxil_4.js +9 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/SymbolsNerdFontMono-Regular.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/OFL.txt +93 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-400-italic.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-400-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-700-italic.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-700-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-ext-400-italic.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-ext-400-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-ext-700-italic.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-ext-700-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/fira-code/OFL.txt +93 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/fira-code/fira-code-latin-400-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/fira-code/fira-code-latin-700-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/fira-code/fira-code-latin-ext-400-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/fira-code/fira-code-latin-ext-700-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/OFL.txt +93 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-italic.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-700-italic.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-700-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-italic.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-700-italic.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-700-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/OFL.txt +93 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-400-italic.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-700-italic.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-700-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-ext-400-italic.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-ext-700-italic.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-ext-700-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/OFL.txt +93 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-400-italic.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-400-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-700-italic.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-700-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-ext-400-italic.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-ext-400-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-ext-700-italic.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-ext-700-normal.woff2 +0 -0
- package/node_modules/herdr-remote-relay/web/dist/index.html +36 -0
- package/package.json +6 -3
- package/dist/relay-updater.js +0 -81
- 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 };
|