herdr-remote-relay 0.3.12 → 0.3.13
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/bin/herdr-remote-relay.js +20 -20
- package/dist/auth-store.d.ts +114 -0
- package/dist/auth-store.js +276 -0
- package/dist/metrics.d.ts +76 -0
- package/dist/metrics.js +249 -0
- package/dist/protocol/frames.d.ts +27 -0
- package/dist/protocol/frames.js +110 -0
- package/dist/protocol/http.d.ts +161 -0
- package/dist/protocol/http.js +3 -0
- package/dist/protocol/index.d.ts +5 -0
- package/dist/protocol/index.js +7 -0
- package/dist/protocol/messages.d.ts +321 -0
- package/dist/protocol/messages.js +18 -0
- package/dist/protocol/paste.d.ts +14 -0
- package/dist/protocol/paste.js +50 -0
- package/dist/protocol/terminal.d.ts +79 -0
- package/dist/protocol/terminal.js +150 -0
- package/dist/relay-config.d.ts +74 -0
- package/dist/relay-config.js +316 -0
- package/dist/relay-server.d.ts +64 -0
- package/dist/relay-server.js +194 -0
- package/dist/scroll-input.d.ts +17 -0
- package/dist/scroll-input.js +124 -0
- package/dist/server/client-channel.d.ts +3 -0
- package/dist/server/client-channel.js +365 -0
- package/dist/server/font-proxy.d.ts +19 -0
- package/dist/server/font-proxy.js +155 -0
- package/dist/server/host-broadcasts.d.ts +25 -0
- package/dist/server/host-broadcasts.js +79 -0
- package/dist/server/host-channel.d.ts +3 -0
- package/dist/server/host-channel.js +266 -0
- package/dist/server/http-api.d.ts +3 -0
- package/dist/server/http-api.js +214 -0
- package/dist/server/maintenance.d.ts +5 -0
- package/dist/server/maintenance.js +64 -0
- package/dist/server/requests.d.ts +34 -0
- package/dist/server/requests.js +142 -0
- package/dist/server/sessions.d.ts +36 -0
- package/dist/server/sessions.js +225 -0
- package/dist/server/sockets.d.ts +12 -0
- package/dist/server/sockets.js +64 -0
- package/dist/server/status.d.ts +11 -0
- package/dist/server/status.js +95 -0
- package/dist/server/transport.d.ts +16 -0
- package/dist/server/transport.js +110 -0
- package/dist/server/types.d.ts +123 -0
- package/dist/server/types.js +3 -0
- package/dist/server/version.d.ts +2 -0
- package/dist/server/version.js +5 -0
- package/dist/state.d.ts +6 -0
- package/dist/state.js +47 -0
- package/package.json +32 -8
- package/web/dist/assets/index-B-5zRmL_.js +61 -0
- package/web/dist/assets/{index-CPVUfBt4.css → index-DKt8KLog.css} +1 -1
- package/web/dist/assets/{vendor-xterm-Bbu8R_5E.js → vendor-xterm-BhCxil_4.js} +2 -2
- package/web/dist/index.html +3 -3
- package/src/auth-store.js +0 -256
- package/src/metrics.js +0 -241
- package/src/relay-config.js +0 -312
- package/src/relay-server.js +0 -1799
- package/src/scroll-input.js +0 -139
- package/src/state.js +0 -47
- package/src/stream-frame.js +0 -238
- package/web/dist/assets/index-6WucmdNP.js +0 -61
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
'use strict';
|
|
3
2
|
|
|
4
3
|
// Standalone entry point for the Herdr Remote relay.
|
|
5
4
|
//
|
|
@@ -7,10 +6,10 @@
|
|
|
7
6
|
// and run on its own host (`npm i -g herdr-remote-relay`) with nothing but
|
|
8
7
|
// environment variables for configuration.
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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';
|
|
14
13
|
|
|
15
14
|
const USAGE = `herdr-remote-relay ${VERSION} — standalone relay for Herdr Remote
|
|
16
15
|
|
|
@@ -73,11 +72,12 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
73
72
|
try {
|
|
74
73
|
address = await server.listen(config.relay.port, config.relay.host);
|
|
75
74
|
} catch (error) {
|
|
76
|
-
const hint =
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
81
|
process.stderr.write(`herdr-remote-relay: failed to listen${hint}: ${error.message}\n`);
|
|
82
82
|
process.exitCode = 1;
|
|
83
83
|
return;
|
|
@@ -86,8 +86,12 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
86
86
|
process.stdout.write(`herdr-remote-relay ${VERSION} (protocol ${PROTOCOL_VERSION})\n`);
|
|
87
87
|
process.stdout.write(` listening http://${config.relay.host}:${address.port}\n`);
|
|
88
88
|
process.stdout.write(` public url ${config.relay.publicUrl}\n`);
|
|
89
|
-
process.stdout.write(
|
|
90
|
-
|
|
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
|
+
);
|
|
91
95
|
process.stdout.write(` state file ${server.stateFile}\n`);
|
|
92
96
|
if (configFile) process.stdout.write(` config file ${configFile}\n`);
|
|
93
97
|
if (!webUiBuilt()) {
|
|
@@ -106,11 +110,7 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
106
110
|
process.on('SIGTERM', () => stop('SIGTERM'));
|
|
107
111
|
}
|
|
108
112
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
module.exports = { main, USAGE };
|
|
113
|
+
main().catch((error) => {
|
|
114
|
+
process.stderr.write(`herdr-remote-relay: ${error.stack || error.message}\n`);
|
|
115
|
+
process.exitCode = 1;
|
|
116
|
+
});
|
|
@@ -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,276 @@
|
|
|
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
|
+
class AuthStore {
|
|
21
|
+
stateFile;
|
|
22
|
+
pairingTtlMs;
|
|
23
|
+
deviceTtlMs;
|
|
24
|
+
maxDevices;
|
|
25
|
+
password;
|
|
26
|
+
pairings = new Map();
|
|
27
|
+
lastDeviceSaveAt = new Map();
|
|
28
|
+
state;
|
|
29
|
+
constructor({ stateFile, pairingTtlMs = 10 * 60 * 1000, deviceTtlMs = 30 * 24 * 60 * 60 * 1000, maxDevices = 32, password = null, } = {}) {
|
|
30
|
+
if (!stateFile)
|
|
31
|
+
throw new TypeError('stateFile is required');
|
|
32
|
+
this.stateFile = stateFile;
|
|
33
|
+
this.pairingTtlMs = pairingTtlMs;
|
|
34
|
+
this.deviceTtlMs = deviceTtlMs;
|
|
35
|
+
this.maxDevices = maxDevices;
|
|
36
|
+
// Optional shared password. When unset the relay is public: anyone may
|
|
37
|
+
// enrol a workstation. That is safe because a workstation is only ever
|
|
38
|
+
// reachable through its own host token, which the relay never hands out.
|
|
39
|
+
this.password = password || null;
|
|
40
|
+
this.state = readJson(stateFile, { version: 1, hosts: {}, devices: {} });
|
|
41
|
+
this.state.version = 1;
|
|
42
|
+
this.state.hosts =
|
|
43
|
+
this.state.hosts && typeof this.state.hosts === 'object' ? this.state.hosts : {};
|
|
44
|
+
this.state.devices =
|
|
45
|
+
this.state.devices && typeof this.state.devices === 'object' ? this.state.devices : {};
|
|
46
|
+
ensureDir(path.dirname(stateFile));
|
|
47
|
+
}
|
|
48
|
+
save() {
|
|
49
|
+
writeJsonAtomic(this.stateFile, this.state);
|
|
50
|
+
}
|
|
51
|
+
/** Does this request carry the relay password, if one is required at all? */
|
|
52
|
+
checkPassword(supplied) {
|
|
53
|
+
if (!this.password)
|
|
54
|
+
return true;
|
|
55
|
+
return typeof supplied === 'string' && equalHash(hash(supplied), hash(this.password));
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Enrol a workstation, or re-authenticate one that is already enrolled.
|
|
59
|
+
*
|
|
60
|
+
* The password (when the relay has one) decides who may *join*. The host
|
|
61
|
+
* token decides who may act *as a given workstation*: it is generated on the
|
|
62
|
+
* workstation and only its hash is ever stored here, so even on a public
|
|
63
|
+
* relay nobody else can impersonate an enrolled host or pair a device to it.
|
|
64
|
+
*/
|
|
65
|
+
registerHost(hostId, token, password = null, now = Date.now()) {
|
|
66
|
+
if (!validHostId(hostId) ||
|
|
67
|
+
typeof token !== 'string' ||
|
|
68
|
+
token.length < 16 ||
|
|
69
|
+
token.length > 4096) {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
code: 'invalid_host_credentials',
|
|
73
|
+
message: 'hostId and token are required',
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (!this.checkPassword(password)) {
|
|
77
|
+
return {
|
|
78
|
+
ok: false,
|
|
79
|
+
code: 'relay_password_required',
|
|
80
|
+
message: 'the relay password is missing or wrong',
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const existing = this.state.hosts[hostId];
|
|
84
|
+
if (existing) {
|
|
85
|
+
if (!equalHash(existing.tokenHash, hash(token))) {
|
|
86
|
+
return { ok: false, code: 'host_auth_failed', message: 'host token is invalid' };
|
|
87
|
+
}
|
|
88
|
+
existing.lastSeenAt = nowIso(now);
|
|
89
|
+
this.save();
|
|
90
|
+
return { ok: true, hostId, firstSeen: false };
|
|
91
|
+
}
|
|
92
|
+
this.state.hosts[hostId] = {
|
|
93
|
+
tokenHash: hash(token),
|
|
94
|
+
createdAt: nowIso(now),
|
|
95
|
+
lastSeenAt: nowIso(now),
|
|
96
|
+
};
|
|
97
|
+
this.save();
|
|
98
|
+
return { ok: true, hostId, firstSeen: true };
|
|
99
|
+
}
|
|
100
|
+
/** Verify a host token without enrolling anything. */
|
|
101
|
+
authenticateHost(hostId, token) {
|
|
102
|
+
if (!validHostId(hostId) ||
|
|
103
|
+
typeof token !== 'string' ||
|
|
104
|
+
token.length < 16 ||
|
|
105
|
+
token.length > 4096)
|
|
106
|
+
return false;
|
|
107
|
+
const existing = this.state.hosts[hostId];
|
|
108
|
+
return Boolean(existing) && equalHash(existing.tokenHash, hash(token));
|
|
109
|
+
}
|
|
110
|
+
hostCount() {
|
|
111
|
+
return Object.keys(this.state.hosts).length;
|
|
112
|
+
}
|
|
113
|
+
startPairing(hostId, publicUrl, now = Date.now()) {
|
|
114
|
+
if (!validHostId(hostId) || !this.state.hosts[hostId]) {
|
|
115
|
+
throw Object.assign(new Error('host is not connected or enrolled'), {
|
|
116
|
+
code: 'host_not_found',
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
this.cleanup(now);
|
|
120
|
+
let code;
|
|
121
|
+
do {
|
|
122
|
+
code = randomToken(4).toUpperCase().replace(/[-_]/g, '').slice(0, 6);
|
|
123
|
+
} while ([...this.pairings.values()].some((pairing) => equalHash(pairing.codeHash, hash(code))));
|
|
124
|
+
const expiresAt = now + this.pairingTtlMs;
|
|
125
|
+
this.pairings.set(code, {
|
|
126
|
+
codeHash: hash(code),
|
|
127
|
+
hostId,
|
|
128
|
+
expiresAt,
|
|
129
|
+
publicUrl,
|
|
130
|
+
});
|
|
131
|
+
return { code, hostId, publicUrl, expiresAt, expiresAtIso: nowIso(expiresAt) };
|
|
132
|
+
}
|
|
133
|
+
completePairing(code, now = Date.now()) {
|
|
134
|
+
if (typeof code !== 'string' || code.length < 4 || code.length > 32)
|
|
135
|
+
return null;
|
|
136
|
+
this.cleanup(now);
|
|
137
|
+
const normalized = code.trim().toUpperCase();
|
|
138
|
+
const pairing = this.pairings.get(normalized);
|
|
139
|
+
if (!pairing || pairing.expiresAt <= now || !equalHash(pairing.codeHash, hash(normalized)))
|
|
140
|
+
return null;
|
|
141
|
+
this.pairings.delete(normalized);
|
|
142
|
+
const devices = Object.values(this.state.devices);
|
|
143
|
+
if (devices.length >= this.maxDevices) {
|
|
144
|
+
devices.sort((a, b) => (a.lastSeenAt || '').localeCompare(b.lastSeenAt || ''));
|
|
145
|
+
delete this.state.devices[devices[0].deviceId];
|
|
146
|
+
}
|
|
147
|
+
const deviceId = `device-${randomToken(9)}`;
|
|
148
|
+
const token = randomToken(32);
|
|
149
|
+
const expiresAt = now + this.deviceTtlMs;
|
|
150
|
+
this.state.devices[deviceId] = {
|
|
151
|
+
deviceId,
|
|
152
|
+
hostId: pairing.hostId,
|
|
153
|
+
tokenHash: hash(token),
|
|
154
|
+
createdAt: nowIso(now),
|
|
155
|
+
lastSeenAt: nowIso(now),
|
|
156
|
+
expiresAt,
|
|
157
|
+
};
|
|
158
|
+
this.save();
|
|
159
|
+
return {
|
|
160
|
+
deviceId,
|
|
161
|
+
hostId: pairing.hostId,
|
|
162
|
+
token,
|
|
163
|
+
expiresAt,
|
|
164
|
+
expiresAtIso: nowIso(expiresAt),
|
|
165
|
+
publicUrl: pairing.publicUrl,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Record how a device presented itself, so an operator revoking a device can
|
|
170
|
+
* tell which phone or laptop they are about to cut off. Only the coarse
|
|
171
|
+
* user agent string and the address are kept — never terminal content.
|
|
172
|
+
*/
|
|
173
|
+
noteDeviceSeen(deviceId, { userAgent, ip } = {}, now = Date.now()) {
|
|
174
|
+
const device = this.state.devices[deviceId];
|
|
175
|
+
if (!device)
|
|
176
|
+
return null;
|
|
177
|
+
let changed = false;
|
|
178
|
+
if (userAgent && device.userAgent !== userAgent) {
|
|
179
|
+
device.userAgent = String(userAgent).slice(0, 256);
|
|
180
|
+
changed = true;
|
|
181
|
+
}
|
|
182
|
+
if (ip && device.lastIp !== ip) {
|
|
183
|
+
device.lastIp = String(ip).slice(0, 64);
|
|
184
|
+
changed = true;
|
|
185
|
+
}
|
|
186
|
+
device.lastSeenAt = nowIso(now);
|
|
187
|
+
if (changed)
|
|
188
|
+
this.save();
|
|
189
|
+
return { ...device };
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Devices an operator may act on, newest first. Token hashes are never
|
|
193
|
+
* included: the dashboard has no use for them and they must not leave the
|
|
194
|
+
* process.
|
|
195
|
+
*/
|
|
196
|
+
listDevices(now = Date.now()) {
|
|
197
|
+
return Object.values(this.state.devices)
|
|
198
|
+
.filter((device) => device && device.expiresAt > now)
|
|
199
|
+
.map((device) => ({
|
|
200
|
+
deviceId: device.deviceId,
|
|
201
|
+
hostId: device.hostId,
|
|
202
|
+
createdAt: device.createdAt,
|
|
203
|
+
lastSeenAt: device.lastSeenAt,
|
|
204
|
+
expiresAt: device.expiresAt,
|
|
205
|
+
expiresAtIso: nowIso(device.expiresAt),
|
|
206
|
+
userAgent: device.userAgent || null,
|
|
207
|
+
lastIp: device.lastIp || null,
|
|
208
|
+
}))
|
|
209
|
+
.sort((a, b) => String(b.lastSeenAt || '').localeCompare(String(a.lastSeenAt || '')));
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Permanently invalidate a paired device. The stored hash is dropped, so the
|
|
213
|
+
* token it was derived from can never authenticate again. Returns the removed
|
|
214
|
+
* record so the caller can also close whatever sockets it still holds.
|
|
215
|
+
*/
|
|
216
|
+
revokeDevice(deviceId) {
|
|
217
|
+
if (typeof deviceId !== 'string' || !deviceId)
|
|
218
|
+
return null;
|
|
219
|
+
const device = this.state.devices[deviceId];
|
|
220
|
+
if (!device)
|
|
221
|
+
return null;
|
|
222
|
+
delete this.state.devices[deviceId];
|
|
223
|
+
this.lastDeviceSaveAt.delete(deviceId);
|
|
224
|
+
this.save();
|
|
225
|
+
return {
|
|
226
|
+
deviceId: device.deviceId,
|
|
227
|
+
hostId: device.hostId,
|
|
228
|
+
userAgent: device.userAgent || null,
|
|
229
|
+
lastIp: device.lastIp || null,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
authenticateDevice(token, now = Date.now()) {
|
|
233
|
+
if (typeof token !== 'string' || token.length < 16)
|
|
234
|
+
return null;
|
|
235
|
+
const tokenHash = hash(token);
|
|
236
|
+
for (const device of Object.values(this.state.devices)) {
|
|
237
|
+
if (device.expiresAt <= now)
|
|
238
|
+
continue;
|
|
239
|
+
if (!equalHash(device.tokenHash, tokenHash))
|
|
240
|
+
continue;
|
|
241
|
+
device.lastSeenAt = nowIso(now);
|
|
242
|
+
device.expiresAt = now + this.deviceTtlMs;
|
|
243
|
+
const lastSaveAt = this.lastDeviceSaveAt.get(device.deviceId) || 0;
|
|
244
|
+
if (now - lastSaveAt >= 60 * 1000) {
|
|
245
|
+
this.lastDeviceSaveAt.set(device.deviceId, now);
|
|
246
|
+
this.save();
|
|
247
|
+
}
|
|
248
|
+
return { ...device };
|
|
249
|
+
}
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
cleanup(now = Date.now()) {
|
|
253
|
+
let removedDevices = 0;
|
|
254
|
+
for (const [deviceId, device] of Object.entries(this.state.devices)) {
|
|
255
|
+
if (!device || device.expiresAt <= now) {
|
|
256
|
+
delete this.state.devices[deviceId];
|
|
257
|
+
this.lastDeviceSaveAt.delete(deviceId);
|
|
258
|
+
removedDevices += 1;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
let removedPairings = 0;
|
|
262
|
+
for (const [code, pairing] of this.pairings.entries()) {
|
|
263
|
+
if (pairing.expiresAt <= now) {
|
|
264
|
+
this.pairings.delete(code);
|
|
265
|
+
removedPairings += 1;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (removedDevices)
|
|
269
|
+
this.save();
|
|
270
|
+
return { removedDevices, removedPairings };
|
|
271
|
+
}
|
|
272
|
+
deviceCount() {
|
|
273
|
+
return Object.keys(this.state.devices).length;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
export { AuthStore };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { AdminStatusResponse, CleanupCounters, ConnectedClientInfo, EventLoopDelayMetrics, HostInfo, PtyInfo, ThroughputMetrics } from './protocol/http.js';
|
|
2
|
+
/** Cleanup counters that `recordCleanup` may increment. */
|
|
3
|
+
type CleanupCounterName = Exclude<keyof CleanupCounters, 'lastCleanupAt'>;
|
|
4
|
+
interface HostTrafficCounter {
|
|
5
|
+
bytesIn: number;
|
|
6
|
+
bytesOut: number;
|
|
7
|
+
framesIn: number;
|
|
8
|
+
framesOut: number;
|
|
9
|
+
sampleAt: number;
|
|
10
|
+
sampleBytesIn: number;
|
|
11
|
+
sampleBytesOut: number;
|
|
12
|
+
sampleFramesIn: number;
|
|
13
|
+
sampleFramesOut: number;
|
|
14
|
+
}
|
|
15
|
+
/** A connection as `countActiveUsers` sees it. */
|
|
16
|
+
interface ClientIdentity {
|
|
17
|
+
id: string;
|
|
18
|
+
deviceId?: string | null;
|
|
19
|
+
}
|
|
20
|
+
interface SnapshotOptions {
|
|
21
|
+
clients?: ConnectedClientInfo[];
|
|
22
|
+
hosts?: HostInfo[];
|
|
23
|
+
ptys?: PtyInfo[];
|
|
24
|
+
sample?: boolean;
|
|
25
|
+
scopeHostId?: string | null;
|
|
26
|
+
activeUserCount?: number | null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* How many people are attached, rather than how many sockets are open.
|
|
30
|
+
*
|
|
31
|
+
* A phone and a laptop belonging to one person are two connections but one
|
|
32
|
+
* paired device each, and one person opening three browser windows is three
|
|
33
|
+
* connections and one device — so the distinct paired device is the closest
|
|
34
|
+
* thing the relay has to a user. Connections that predate device pairing carry
|
|
35
|
+
* no `deviceId`; they fall back to their own connection id so they still count
|
|
36
|
+
* once instead of collapsing into a single anonymous user.
|
|
37
|
+
*/
|
|
38
|
+
declare function countActiveUsers(clients?: readonly (ClientIdentity | null | undefined)[]): number;
|
|
39
|
+
declare class RelayMetrics {
|
|
40
|
+
readonly version: string;
|
|
41
|
+
readonly protocolVersion: number;
|
|
42
|
+
readonly startedAt: number;
|
|
43
|
+
bytesIn: number;
|
|
44
|
+
bytesOut: number;
|
|
45
|
+
framesIn: number;
|
|
46
|
+
framesOut: number;
|
|
47
|
+
cleanup: Required<Omit<CleanupCounters, 'lastCleanupAt'>> & {
|
|
48
|
+
lastCleanupAt: string | null;
|
|
49
|
+
};
|
|
50
|
+
private lastSample;
|
|
51
|
+
private readonly hostTraffic;
|
|
52
|
+
private cpuSampleAt;
|
|
53
|
+
private cpuSample;
|
|
54
|
+
private readonly eventLoop;
|
|
55
|
+
constructor({ version, protocolVersion }?: {
|
|
56
|
+
version?: string | undefined;
|
|
57
|
+
protocolVersion?: number | undefined;
|
|
58
|
+
});
|
|
59
|
+
hostCounter(hostId: string | null | undefined): HostTrafficCounter | null;
|
|
60
|
+
recordIn(bytes: number, hostId?: string | null): void;
|
|
61
|
+
recordOut(bytes: number, hostId?: string | null): void;
|
|
62
|
+
forgetHost(hostId: string | null | undefined): void;
|
|
63
|
+
hostThroughput(hostId: string | null | undefined, now?: number): ThroughputMetrics;
|
|
64
|
+
recordCleanup(name: CleanupCounterName, amount?: number): void;
|
|
65
|
+
cpuPercent(): number;
|
|
66
|
+
eventLoopDelay(): EventLoopDelayMetrics;
|
|
67
|
+
/**
|
|
68
|
+
* `activeUserCount` is passed in rather than derived from `clients`: the
|
|
69
|
+
* public `/api/status` rows deliberately omit `deviceId`, so only the caller
|
|
70
|
+
* still holding the full connection records can count devices correctly.
|
|
71
|
+
* Omitting it falls back to counting whatever the given rows identify.
|
|
72
|
+
*/
|
|
73
|
+
snapshot({ clients, hosts, ptys, sample, scopeHostId, activeUserCount, }?: SnapshotOptions): AdminStatusResponse;
|
|
74
|
+
close(): void;
|
|
75
|
+
}
|
|
76
|
+
export { RelayMetrics, countActiveUsers };
|