herdr-remote-relay 0.3.11 → 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-DKt8KLog.css +32 -0
- 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-2ut7QeyF.css +0 -32
- package/web/dist/assets/index-cIIMc3UE.js +0 -61
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { HostAgentStatusMessage, HostTerminalFontMessage, HostUpdateStatusMessage } from '../protocol/messages.js';
|
|
2
|
+
import type { RelayContext, RelayHost } from './types.js';
|
|
3
|
+
/** A message as the host sent it: the right keys, but values not yet checked. */
|
|
4
|
+
type Unchecked<T> = {
|
|
5
|
+
[K in keyof T]?: unknown;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Pass on what the workstation's agents are doing.
|
|
9
|
+
*
|
|
10
|
+
* Sanitised rather than forwarded: the host is trusted, but this is the one
|
|
11
|
+
* message whose contents come from terminal titles, and it lands in a status
|
|
12
|
+
* bar on every attached window. The counts are numbers and the list is
|
|
13
|
+
* bounded, so a workstation cannot grow a browser's memory by naming things
|
|
14
|
+
* carefully.
|
|
15
|
+
*/
|
|
16
|
+
export declare function broadcastAgentStatus(relay: RelayContext, host: RelayHost, message: Unchecked<HostAgentStatusMessage>): void;
|
|
17
|
+
/**
|
|
18
|
+
* Whether the workstation runs the newest herdr-remote, for every window on
|
|
19
|
+
* it. Only version strings and two flags pass: this ends up as text in the
|
|
20
|
+
* browser, and the host is not the relay's to trust.
|
|
21
|
+
*/
|
|
22
|
+
export declare function broadcastUpdateStatus(relay: RelayContext, host: RelayHost, message: Unchecked<HostUpdateStatusMessage>): void;
|
|
23
|
+
/** The workstation re-read its terminal font; every window draws with it. */
|
|
24
|
+
export declare function broadcastTerminalFont(relay: RelayContext, host: RelayHost, message: Unchecked<HostTerminalFontMessage>): void;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Facts about a workstation that every window on it shows: its agents, its
|
|
2
|
+
// update status and its terminal font. Each is checked on the way through and
|
|
3
|
+
// kept on the host record, so a window that joins later gets the latest one.
|
|
4
|
+
import { sanitizeTerminalFont } from '../protocol/terminal.js';
|
|
5
|
+
import { text } from './sockets.js';
|
|
6
|
+
import { broadcastToClients } from './transport.js';
|
|
7
|
+
/** A release version as npm writes it; anything else is not forwarded. */
|
|
8
|
+
const RELEASE_VERSION = /^\d{1,6}\.\d{1,6}\.\d{1,6}(?:-[0-9A-Za-z.-]{1,32})?$/;
|
|
9
|
+
/** Matches the host's own cap; the relay re-applies it rather than trusting it. */
|
|
10
|
+
const MAX_AGENT_STATUS_ENTRIES = 16;
|
|
11
|
+
/**
|
|
12
|
+
* Pass on what the workstation's agents are doing.
|
|
13
|
+
*
|
|
14
|
+
* Sanitised rather than forwarded: the host is trusted, but this is the one
|
|
15
|
+
* message whose contents come from terminal titles, and it lands in a status
|
|
16
|
+
* bar on every attached window. The counts are numbers and the list is
|
|
17
|
+
* bounded, so a workstation cannot grow a browser's memory by naming things
|
|
18
|
+
* carefully.
|
|
19
|
+
*/
|
|
20
|
+
export function broadcastAgentStatus(relay, host, message) {
|
|
21
|
+
const counts = {};
|
|
22
|
+
if (message.counts && typeof message.counts === 'object') {
|
|
23
|
+
for (const [status, value] of Object.entries(message.counts)) {
|
|
24
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
25
|
+
counts[status.slice(0, 32)] = Math.max(0, Math.min(9999, Math.trunc(value)));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const agents = (Array.isArray(message.agents) ? message.agents : [])
|
|
30
|
+
.slice(0, MAX_AGENT_STATUS_ENTRIES)
|
|
31
|
+
.map((agent) => ({
|
|
32
|
+
paneId: text(agent?.paneId, 64),
|
|
33
|
+
workspaceId: text(agent?.workspaceId, 64),
|
|
34
|
+
agent: text(agent?.agent, 32),
|
|
35
|
+
title: text(agent?.title, 64),
|
|
36
|
+
status: text(agent?.status, 16),
|
|
37
|
+
focused: agent?.focused === true,
|
|
38
|
+
}));
|
|
39
|
+
const payload = {
|
|
40
|
+
type: 'agent_status',
|
|
41
|
+
focusedPaneId: text(message.focusedPaneId, 64),
|
|
42
|
+
focusedAgent: text(message.focusedAgent, 32),
|
|
43
|
+
counts,
|
|
44
|
+
total: typeof message.total === 'number' && Number.isFinite(message.total)
|
|
45
|
+
? Math.max(0, Math.trunc(message.total))
|
|
46
|
+
: agents.length,
|
|
47
|
+
agents,
|
|
48
|
+
};
|
|
49
|
+
host.agentStatus = payload;
|
|
50
|
+
broadcastToClients(relay, host, () => payload);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Whether the workstation runs the newest herdr-remote, for every window on
|
|
54
|
+
* it. Only version strings and two flags pass: this ends up as text in the
|
|
55
|
+
* browser, and the host is not the relay's to trust.
|
|
56
|
+
*/
|
|
57
|
+
export function broadcastUpdateStatus(relay, host, message) {
|
|
58
|
+
const version = (value) => typeof value === 'string' && RELEASE_VERSION.test(value) ? value : null;
|
|
59
|
+
const current = version(message.current);
|
|
60
|
+
const latest = version(message.latest);
|
|
61
|
+
if (!current || !latest)
|
|
62
|
+
return;
|
|
63
|
+
const payload = {
|
|
64
|
+
type: 'update_status',
|
|
65
|
+
current,
|
|
66
|
+
installed: version(message.installed) || current,
|
|
67
|
+
latest,
|
|
68
|
+
updateAvailable: message.updateAvailable === true,
|
|
69
|
+
restartPending: message.restartPending === true,
|
|
70
|
+
};
|
|
71
|
+
host.updateStatus = payload;
|
|
72
|
+
broadcastToClients(relay, host, () => payload);
|
|
73
|
+
}
|
|
74
|
+
/** The workstation re-read its terminal font; every window draws with it. */
|
|
75
|
+
export function broadcastTerminalFont(relay, host, message) {
|
|
76
|
+
host.terminalFont = sanitizeTerminalFont(message.terminalFont);
|
|
77
|
+
const payload = { type: 'terminal_font', terminalFont: host.terminalFont || null };
|
|
78
|
+
broadcastToClients(relay, host, () => payload);
|
|
79
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// The host side of the relay: a workstation's WebSocket, from its hello and
|
|
2
|
+
// the hand-off from an older connection to every message it sends after.
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { CAPABILITY, PROTOCOL_VERSION, sanitizeTerminalFont, sanitizeTerminalPalette, unpackStreamFrame, } from '../protocol/index.js';
|
|
5
|
+
import { forwardFontChunk, forwardFontSubset, settleFontChunk, settleFontSubset, } from './font-proxy.js';
|
|
6
|
+
import { broadcastAgentStatus, broadcastTerminalFont, broadcastUpdateStatus, } from './host-broadcasts.js';
|
|
7
|
+
import { allowHostHandshake } from './requests.js';
|
|
8
|
+
import { beginHostReconnect, canHandoffHost, detachClient, detachHost, releaseSession, startSession, } from './sessions.js';
|
|
9
|
+
import { closeSocket, isOpen, jsonSend, parseJson, randomId, rejectHandshake } from './sockets.js';
|
|
10
|
+
import { broadcastToClients, notifyHostClientCount, sendClientBinary } from './transport.js';
|
|
11
|
+
function createHostRecord(message, ws, pending, clients = new Set()) {
|
|
12
|
+
const capabilities = Array.isArray(message.capabilities) ? message.capabilities : [];
|
|
13
|
+
return {
|
|
14
|
+
id: message.hostId,
|
|
15
|
+
ws,
|
|
16
|
+
hostname: typeof message.hostname === 'string' ? message.hostname.slice(0, 128) : os.hostname(),
|
|
17
|
+
platform: typeof message.platform === 'string' ? message.platform.slice(0, 32) : process.platform,
|
|
18
|
+
arch: typeof message.arch === 'string' ? message.arch.slice(0, 32) : process.arch,
|
|
19
|
+
connectedAt: new Date(pending.connectedAt).toISOString(),
|
|
20
|
+
connectedAtMs: pending.connectedAt,
|
|
21
|
+
terminalPalette: sanitizeTerminalPalette(message.terminalPalette),
|
|
22
|
+
/** Family, size and fetchable files of the workstation's terminal font. */
|
|
23
|
+
terminalFont: sanitizeTerminalFont(message.terminalFont),
|
|
24
|
+
/** The latest workstation snapshot is replayed when a browser joins late. */
|
|
25
|
+
agentStatus: null,
|
|
26
|
+
/** Whether the workstation's herdr-remote is behind; replayed the same way. */
|
|
27
|
+
updateStatus: null,
|
|
28
|
+
lastSeenAt: Date.now(),
|
|
29
|
+
clients,
|
|
30
|
+
controllerId: null,
|
|
31
|
+
load: {},
|
|
32
|
+
ptys: [],
|
|
33
|
+
reconnecting: false,
|
|
34
|
+
reconnectTimer: null,
|
|
35
|
+
connectionGeneration: randomId('host-connection'),
|
|
36
|
+
handoffCapable: capabilities.includes(CAPABILITY.hostHandoff),
|
|
37
|
+
binaryFrameV2: capabilities.includes(CAPABILITY.binaryFrameV2),
|
|
38
|
+
streamIndices: new Map(),
|
|
39
|
+
nextStreamIndex: 0,
|
|
40
|
+
shutdownRequested: false,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export function handleHostConnection(relay, ws, req) {
|
|
44
|
+
const pending = {
|
|
45
|
+
ws,
|
|
46
|
+
remoteAddress: req.socket.remoteAddress,
|
|
47
|
+
connectedAt: Date.now(),
|
|
48
|
+
authenticated: false,
|
|
49
|
+
};
|
|
50
|
+
const deadline = setTimeout(() => {
|
|
51
|
+
if (!pending.authenticated)
|
|
52
|
+
closeSocket(ws, 1008, 'host hello timeout');
|
|
53
|
+
}, 10000);
|
|
54
|
+
ws.isAlive = true;
|
|
55
|
+
ws.on('pong', () => {
|
|
56
|
+
ws.isAlive = true;
|
|
57
|
+
if (pending.host)
|
|
58
|
+
pending.host.lastSeenAt = Date.now();
|
|
59
|
+
});
|
|
60
|
+
ws.on('ping', () => {
|
|
61
|
+
ws.isAlive = true;
|
|
62
|
+
if (pending.host)
|
|
63
|
+
pending.host.lastSeenAt = Date.now();
|
|
64
|
+
});
|
|
65
|
+
ws.on('message', (raw, isBinary) => {
|
|
66
|
+
if (!pending.authenticated) {
|
|
67
|
+
if (isBinary)
|
|
68
|
+
return rejectHandshake(ws, 'host hello must be JSON');
|
|
69
|
+
const message = parseJson(raw.toString());
|
|
70
|
+
if (message?.type !== 'host_hello' || message.protocol !== PROTOCOL_VERSION)
|
|
71
|
+
return rejectHandshake(ws, 'invalid host hello');
|
|
72
|
+
if (!allowHostHandshake(relay, req))
|
|
73
|
+
return rejectHandshake(ws, 'too many connection attempts', 'rate_limited');
|
|
74
|
+
const oldHost = relay.hosts.get(message.hostId);
|
|
75
|
+
if (!oldHost && relay.hosts.size >= relay.config.relay.maxHosts) {
|
|
76
|
+
return rejectHandshake(ws, 'relay host limit reached', 'too_many_hosts');
|
|
77
|
+
}
|
|
78
|
+
const registration = relay.auth.registerHost(message.hostId, message.token, message.password ?? null);
|
|
79
|
+
if (!registration.ok)
|
|
80
|
+
return rejectHandshake(ws, registration.message, registration.code);
|
|
81
|
+
clearTimeout(deadline);
|
|
82
|
+
pending.authenticated = true;
|
|
83
|
+
relay.finishHandshake(ws);
|
|
84
|
+
const handoff = canHandoffHost(relay, oldHost);
|
|
85
|
+
const retainedClients = handoff && oldHost ? oldHost.clients : new Set();
|
|
86
|
+
if (oldHost) {
|
|
87
|
+
if (oldHost.reconnectTimer)
|
|
88
|
+
clearTimeout(oldHost.reconnectTimer);
|
|
89
|
+
oldHost.reconnectTimer = null;
|
|
90
|
+
if (handoff) {
|
|
91
|
+
for (const clientId of oldHost.clients) {
|
|
92
|
+
const client = relay.clients.get(clientId);
|
|
93
|
+
if (client?.session) {
|
|
94
|
+
if (isOpen(oldHost.ws)) {
|
|
95
|
+
jsonSend(oldHost.ws, {
|
|
96
|
+
type: 'session_stop',
|
|
97
|
+
clientId: client.session.streamId,
|
|
98
|
+
streamId: client.session.streamId,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
releaseSession(relay, oldHost, client);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
oldHost.load = {};
|
|
105
|
+
oldHost.ptys = [];
|
|
106
|
+
broadcastToClients(relay, oldHost, () => ({
|
|
107
|
+
type: 'host_reconnecting',
|
|
108
|
+
code: 'host_replaced',
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
detachHost(relay, oldHost, { notify: true, reason: 'host_replaced' });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const host = createHostRecord(message, ws, pending, retainedClients);
|
|
116
|
+
pending.host = host;
|
|
117
|
+
// Install the new record before closing the old socket. Its delayed
|
|
118
|
+
// close handler then fails the identity check instead of detaching the
|
|
119
|
+
// freshly authenticated host.
|
|
120
|
+
relay.hosts.set(host.id, host);
|
|
121
|
+
if (oldHost && handoff)
|
|
122
|
+
closeSocket(oldHost.ws, 1000, 'host_replaced');
|
|
123
|
+
jsonSend(ws, {
|
|
124
|
+
type: 'host_ready',
|
|
125
|
+
protocol: PROTOCOL_VERSION,
|
|
126
|
+
hostId: host.id,
|
|
127
|
+
clientCount: host.clients.size,
|
|
128
|
+
});
|
|
129
|
+
notifyHostClientCount(host);
|
|
130
|
+
if (handoff) {
|
|
131
|
+
for (const clientId of host.clients) {
|
|
132
|
+
const client = relay.clients.get(clientId);
|
|
133
|
+
if (client)
|
|
134
|
+
startSession(relay, host, client, { restarted: true });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (pending.host)
|
|
140
|
+
handleHostMessage(relay, pending.host, raw, isBinary);
|
|
141
|
+
});
|
|
142
|
+
ws.on('close', (_code, rawReason) => {
|
|
143
|
+
clearTimeout(deadline);
|
|
144
|
+
const host = pending.host;
|
|
145
|
+
if (!host || relay.hosts.get(host.id) !== host || host.ws !== ws)
|
|
146
|
+
return;
|
|
147
|
+
const reason = rawReason ? rawReason.toString() : '';
|
|
148
|
+
if (reason === 'host_shutdown')
|
|
149
|
+
host.shutdownRequested = true;
|
|
150
|
+
if (host.shutdownRequested || !canHandoffHost(relay, host)) {
|
|
151
|
+
detachHost(relay, host, {
|
|
152
|
+
notify: true,
|
|
153
|
+
reason: host.shutdownRequested ? 'host_shutdown' : 'host_disconnected',
|
|
154
|
+
});
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
beginHostReconnect(relay, host, 'host_disconnected');
|
|
158
|
+
});
|
|
159
|
+
ws.on('error', () => { });
|
|
160
|
+
}
|
|
161
|
+
function handleHostMessage(relay, host, raw, isBinary) {
|
|
162
|
+
host.lastSeenAt = Date.now();
|
|
163
|
+
if (isBinary) {
|
|
164
|
+
let frame;
|
|
165
|
+
try {
|
|
166
|
+
frame = unpackStreamFrame(raw);
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
relay.metrics.recordCleanup('deadConnectionsClosed');
|
|
170
|
+
closeSocket(host.ws, 1003, error.message);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
// Postel's law: be conservative in what you send, liberal in what you accept.
|
|
174
|
+
// We strictly send v2 only to hosts that negotiated binary_frame_v2, but we accept
|
|
175
|
+
// both v1 and v2 on receipt: a v2-capable host falls back to v1 if stream indices
|
|
176
|
+
// were exhausted for a session, and an unnegotiated host sending v2 simply finds
|
|
177
|
+
// no routed client instead of having its entire connection severed.
|
|
178
|
+
// Output is routed to the single client owning the stream rather than broadcast.
|
|
179
|
+
if (frame.type !== 'output')
|
|
180
|
+
return;
|
|
181
|
+
const clientId = frame.version === 2
|
|
182
|
+
? host.streamIndices.get(frame.streamIndex)
|
|
183
|
+
: relay.streams.get(frame.streamId);
|
|
184
|
+
if (!clientId)
|
|
185
|
+
return;
|
|
186
|
+
const client = relay.clients.get(clientId);
|
|
187
|
+
if (!client || !isOpen(client.ws))
|
|
188
|
+
return;
|
|
189
|
+
sendClientBinary(relay, host, client, frame.payload);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
// Untrusted: each branch reads only the fields its message type defines.
|
|
193
|
+
const message = parseJson(raw.toString());
|
|
194
|
+
if (!message)
|
|
195
|
+
return;
|
|
196
|
+
if (message.type === 'heartbeat') {
|
|
197
|
+
host.load = message.load && typeof message.load === 'object' ? message.load : {};
|
|
198
|
+
host.ptys = Array.isArray(message.ptys) ? message.ptys.slice(0, 256) : [];
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (message.type === 'host_shutdown') {
|
|
202
|
+
host.shutdownRequested = true;
|
|
203
|
+
if (relay.hosts.get(host.id) === host)
|
|
204
|
+
detachHost(relay, host, { notify: true, reason: 'host_shutdown' });
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
// What the workstation's agents are doing belongs to the workstation, not
|
|
208
|
+
// to one stream: every window watching it gets the same answer.
|
|
209
|
+
if (message.type === 'agent_status') {
|
|
210
|
+
broadcastAgentStatus(relay, host, message);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (message.type === 'update_status') {
|
|
214
|
+
broadcastUpdateStatus(relay, host, message);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (message.type === 'terminal_font') {
|
|
218
|
+
broadcastTerminalFont(relay, host, message);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
// Session events target the single client that owns this stream.
|
|
222
|
+
const addressed = message;
|
|
223
|
+
const streamId = typeof addressed.clientId === 'string'
|
|
224
|
+
? addressed.clientId
|
|
225
|
+
: addressed.streamId;
|
|
226
|
+
const clientId = streamId ? relay.streams.get(streamId) : null;
|
|
227
|
+
const client = clientId ? relay.clients.get(clientId) : null;
|
|
228
|
+
if (!client)
|
|
229
|
+
return;
|
|
230
|
+
if (message.type === 'session_ready') {
|
|
231
|
+
if (client.session)
|
|
232
|
+
client.session.ready = true;
|
|
233
|
+
jsonSend(client.ws, { type: 'session_ready', clientId: client.id });
|
|
234
|
+
}
|
|
235
|
+
else if (message.type === 'session_exit') {
|
|
236
|
+
const code = Number.isInteger(message.code) ? message.code : null;
|
|
237
|
+
jsonSend(client.ws, { type: 'exit', code });
|
|
238
|
+
releaseSession(relay, host, client);
|
|
239
|
+
detachClient(relay, client, { notify: false });
|
|
240
|
+
}
|
|
241
|
+
else if (message.type === 'host_font_chunk') {
|
|
242
|
+
forwardFontChunk(client, message);
|
|
243
|
+
}
|
|
244
|
+
else if (message.type === 'host_font_subset_ready') {
|
|
245
|
+
forwardFontSubset(client, message);
|
|
246
|
+
}
|
|
247
|
+
else if (message.type === 'paste_file_ready') {
|
|
248
|
+
jsonSend(client.ws, {
|
|
249
|
+
type: 'paste_file_ready',
|
|
250
|
+
path: message.path,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
else if (message.type === 'error') {
|
|
254
|
+
if (message.code === 'host_font_unavailable') {
|
|
255
|
+
settleFontChunk(client);
|
|
256
|
+
settleFontSubset(client);
|
|
257
|
+
}
|
|
258
|
+
if (message.code === 'host_font_subset_failed')
|
|
259
|
+
settleFontSubset(client);
|
|
260
|
+
jsonSend(client.ws, {
|
|
261
|
+
type: 'error',
|
|
262
|
+
code: message.code || 'host_error',
|
|
263
|
+
message: String(message.message || 'Host connector error'),
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// The relay's HTTP side: the JSON API under /api, /healthz, and the built web
|
|
2
|
+
// app for everything else.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { URL } from 'node:url';
|
|
6
|
+
import { PROTOCOL_VERSION } from '../protocol/index.js';
|
|
7
|
+
import { PACKAGE_ROOT } from '../relay-config.js';
|
|
8
|
+
import { allowPairAttempt, authorizedAdmin, authorizedHost, authorizedSubject, requestOriginAllowed, } from './requests.js';
|
|
9
|
+
import { detachDeviceSessions } from './sessions.js';
|
|
10
|
+
import { isOpen } from './sockets.js';
|
|
11
|
+
import { statusSnapshot } from './status.js';
|
|
12
|
+
import { VERSION } from './version.js';
|
|
13
|
+
const PUBLIC_DIR = path.join(PACKAGE_ROOT, 'web', 'dist');
|
|
14
|
+
const MIME_TYPES = {
|
|
15
|
+
'.html': 'text/html; charset=utf-8',
|
|
16
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
17
|
+
'.css': 'text/css; charset=utf-8',
|
|
18
|
+
'.json': 'application/json',
|
|
19
|
+
'.svg': 'image/svg+xml',
|
|
20
|
+
'.png': 'image/png',
|
|
21
|
+
'.ico': 'image/x-icon',
|
|
22
|
+
'.woff2': 'font/woff2',
|
|
23
|
+
};
|
|
24
|
+
function setResponseHeaders(res, contentType = 'application/json') {
|
|
25
|
+
res.setHeader('Content-Type', contentType);
|
|
26
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
27
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
28
|
+
res.setHeader('X-Frame-Options', 'DENY');
|
|
29
|
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
30
|
+
res.setHeader('Content-Security-Policy', "default-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; connect-src 'self' ws: wss:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'");
|
|
31
|
+
}
|
|
32
|
+
function sendJsonResponse(res, status, payload) {
|
|
33
|
+
setResponseHeaders(res);
|
|
34
|
+
res.writeHead(status);
|
|
35
|
+
res.end(JSON.stringify(payload));
|
|
36
|
+
}
|
|
37
|
+
function sendError(res, status, code, message) {
|
|
38
|
+
sendJsonResponse(res, status, { ok: false, code, message });
|
|
39
|
+
}
|
|
40
|
+
/** Whether the caller is the relay operator; answers the request when not. */
|
|
41
|
+
function adminAllowed(relay, req, res) {
|
|
42
|
+
if (!relay.adminToken) {
|
|
43
|
+
sendError(res, 503, 'admin_not_configured', 'configure RELAY_ADMIN_TOKEN to access the relay dashboard');
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
if (!authorizedAdmin(relay, req)) {
|
|
47
|
+
sendError(res, 401, 'admin_auth_required', 'a valid relay admin token is required');
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
const healthz = (relay, _req, res) => {
|
|
53
|
+
// Liveness is intentionally tenant-blind. Host/client counts let an
|
|
54
|
+
// unauthenticated caller learn whether other workstations are present.
|
|
55
|
+
sendJsonResponse(res, 200, {
|
|
56
|
+
ok: true,
|
|
57
|
+
version: VERSION,
|
|
58
|
+
protocol: PROTOCOL_VERSION,
|
|
59
|
+
uptimeSeconds: Math.floor((Date.now() - relay.startedAt) / 1000),
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
const info = (relay, _req, res) => {
|
|
63
|
+
const publicUrl = String(relay.config.relay.publicUrl || '').replace(/\/+$/, '');
|
|
64
|
+
sendJsonResponse(res, 200, {
|
|
65
|
+
ok: true,
|
|
66
|
+
version: VERSION,
|
|
67
|
+
protocol: PROTOCOL_VERSION,
|
|
68
|
+
relayMode: relay.relayMode,
|
|
69
|
+
isRemoteRelay: relay.relayMode === 'remote',
|
|
70
|
+
adminConfigured: Boolean(relay.adminToken),
|
|
71
|
+
adminPath: '/admin',
|
|
72
|
+
adminStatusPath: '/api/admin/status',
|
|
73
|
+
publicUrl,
|
|
74
|
+
remoteAdminUrl: relay.relayMode === 'remote' ? `${publicUrl}/admin` : null,
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
const status = (relay, req, res) => {
|
|
78
|
+
const subject = authorizedSubject(relay, req);
|
|
79
|
+
if (!subject) {
|
|
80
|
+
sendError(res, 401, 'auth_required', 'an authorized device or host token is required');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
sendJsonResponse(res, 200, statusSnapshot(relay, { scopeHostId: subject.hostId }));
|
|
84
|
+
};
|
|
85
|
+
const adminStatus = (relay, req, res) => {
|
|
86
|
+
if (!adminAllowed(relay, req, res))
|
|
87
|
+
return;
|
|
88
|
+
sendJsonResponse(res, 200, statusSnapshot(relay, { includeDevices: true }));
|
|
89
|
+
};
|
|
90
|
+
const revokeDevice = (relay, req, res, url) => {
|
|
91
|
+
if (!adminAllowed(relay, req, res))
|
|
92
|
+
return;
|
|
93
|
+
const deviceId = decodeURIComponent(url.pathname.slice('/api/admin/devices/'.length));
|
|
94
|
+
const revoked = relay.auth.revokeDevice(deviceId);
|
|
95
|
+
if (!revoked) {
|
|
96
|
+
sendError(res, 404, 'device_not_found', 'no such paired device');
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
// Revocation has to take effect now, not at the next reconnect: drop any
|
|
100
|
+
// socket the device still holds so the terminal closes immediately.
|
|
101
|
+
const disconnected = detachDeviceSessions(relay, deviceId);
|
|
102
|
+
sendJsonResponse(res, 200, { ok: true, deviceId: revoked.deviceId, disconnected });
|
|
103
|
+
};
|
|
104
|
+
const startPairing = (relay, req, res) => {
|
|
105
|
+
if (!allowPairAttempt(relay, req)) {
|
|
106
|
+
sendError(res, 429, 'rate_limited', 'too many pairing attempts');
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const hostId = authorizedHost(relay, req);
|
|
110
|
+
if (!hostId) {
|
|
111
|
+
sendError(res, 401, 'host_auth_required', 'a valid host id and token are required');
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const host = relay.hosts.get(hostId);
|
|
115
|
+
if (!host || host.reconnecting || !isOpen(host.ws)) {
|
|
116
|
+
sendError(res, 409, 'host_offline', 'no Herdr host is connected');
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
const pairing = relay.auth.startPairing(hostId, String(relay.config.relay.publicUrl).replace(/\/$/, ''));
|
|
121
|
+
const answer = {
|
|
122
|
+
ok: true,
|
|
123
|
+
...pairing,
|
|
124
|
+
pairUrl: `${pairing.publicUrl}/?pairCode=${encodeURIComponent(pairing.code)}`,
|
|
125
|
+
};
|
|
126
|
+
sendJsonResponse(res, 200, answer);
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
const { code, message } = error;
|
|
130
|
+
sendError(res, 409, code || 'pairing_failed', message);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
const ROUTES = [
|
|
134
|
+
{ method: 'GET', path: '/healthz', handle: healthz },
|
|
135
|
+
{ method: 'GET', path: '/api/info', handle: info },
|
|
136
|
+
{ method: 'GET', path: '/api/status', handle: status },
|
|
137
|
+
{ method: 'GET', path: '/api/admin/status', handle: adminStatus },
|
|
138
|
+
{ method: 'DELETE', path: '/api/admin/devices/', prefix: true, handle: revokeDevice },
|
|
139
|
+
{ method: 'POST', path: '/api/pair/start', handle: startPairing },
|
|
140
|
+
];
|
|
141
|
+
export function handleHttp(relay, req, res) {
|
|
142
|
+
let requestUrl;
|
|
143
|
+
try {
|
|
144
|
+
requestUrl = new URL(req.url || '', `http://${req.headers.host || 'localhost'}`);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
sendError(res, 400, 'bad_request', 'invalid URL');
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (!requestOriginAllowed(relay, req)) {
|
|
151
|
+
sendError(res, 403, 'origin_denied', 'origin is not allowed');
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (req.method === 'OPTIONS') {
|
|
155
|
+
res.writeHead(204, {
|
|
156
|
+
'Access-Control-Allow-Origin': req.headers.origin || '*',
|
|
157
|
+
'Access-Control-Allow-Headers': 'Authorization, Content-Type, X-Herdr-Host-Id, X-Herdr-Host-Token, X-Relay-Admin-Token',
|
|
158
|
+
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
|
|
159
|
+
Vary: 'Origin',
|
|
160
|
+
});
|
|
161
|
+
res.end();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
// Only echo an origin after the exact allowlist/same-host check above. This
|
|
165
|
+
// makes explicitly configured cross-origin WebUI profiles usable without
|
|
166
|
+
// reflecting an attacker-controlled Origin header.
|
|
167
|
+
if (req.headers.origin) {
|
|
168
|
+
res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
|
|
169
|
+
res.setHeader('Vary', 'Origin');
|
|
170
|
+
}
|
|
171
|
+
const { pathname } = requestUrl;
|
|
172
|
+
const route = ROUTES.find((candidate) => candidate.method === req.method &&
|
|
173
|
+
(candidate.prefix ? pathname.startsWith(candidate.path) : pathname === candidate.path));
|
|
174
|
+
if (route) {
|
|
175
|
+
route.handle(relay, req, res, requestUrl);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (pathname.startsWith('/api/')) {
|
|
179
|
+
sendError(res, 404, 'not_found', 'API route not found');
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
serveStatic(pathname, res);
|
|
183
|
+
}
|
|
184
|
+
function serveStatic(requestPath, res) {
|
|
185
|
+
const relative = requestPath === '/' || requestPath === '/admin'
|
|
186
|
+
? 'index.html'
|
|
187
|
+
: requestPath.replace(/^\/+/, '');
|
|
188
|
+
const candidate = path.resolve(PUBLIC_DIR, relative);
|
|
189
|
+
if (!candidate.startsWith(`${PUBLIC_DIR}${path.sep}`) &&
|
|
190
|
+
candidate !== path.join(PUBLIC_DIR, 'index.html')) {
|
|
191
|
+
sendError(res, 403, 'forbidden', 'path is not allowed');
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
fs.readFile(candidate, (error, data) => {
|
|
195
|
+
if (error && !path.extname(relative)) {
|
|
196
|
+
fs.readFile(path.join(PUBLIC_DIR, 'index.html'), (fallbackError, fallbackData) => {
|
|
197
|
+
if (fallbackError) {
|
|
198
|
+
sendError(res, 503, 'web_not_built', 'frontend has not been built');
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
setResponseHeaders(res, 'text/html; charset=utf-8');
|
|
202
|
+
res.end(fallbackData);
|
|
203
|
+
});
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (error) {
|
|
207
|
+
sendError(res, 404, 'not_found', 'file not found');
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const extension = path.extname(candidate).toLowerCase();
|
|
211
|
+
setResponseHeaders(res, MIME_TYPES[extension] || 'application/octet-stream');
|
|
212
|
+
res.end(data);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { RelayContext } from './types.js';
|
|
2
|
+
/** Close sockets that missed the last ping, and ping the rest. */
|
|
3
|
+
export declare function heartbeat(relay: RelayContext): void;
|
|
4
|
+
/** Drop rate-limit windows, windows and hosts that have gone stale, and expired pairings. */
|
|
5
|
+
export declare function sweep(relay: RelayContext): void;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Timed housekeeping: ping every socket, and drop what went quiet.
|
|
2
|
+
import { WebSocket } from 'ws';
|
|
3
|
+
import { detachClient, detachHost } from './sessions.js';
|
|
4
|
+
import { terminateSocket } from './sockets.js';
|
|
5
|
+
/** Close sockets that missed the last ping, and ping the rest. */
|
|
6
|
+
export function heartbeat(relay) {
|
|
7
|
+
const sockets = [
|
|
8
|
+
...[...relay.hosts.values()].map((host) => host.ws),
|
|
9
|
+
...[...relay.clients.values()].map((client) => client.ws),
|
|
10
|
+
].filter((socket) => Boolean(socket && socket.readyState !== WebSocket.CLOSED));
|
|
11
|
+
for (const socket of sockets) {
|
|
12
|
+
if (socket.readyState === WebSocket.CLOSING) {
|
|
13
|
+
relay.metrics.recordCleanup('deadConnectionsClosed');
|
|
14
|
+
terminateSocket(socket);
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (socket.readyState !== WebSocket.OPEN)
|
|
18
|
+
continue;
|
|
19
|
+
if (!socket.isAlive) {
|
|
20
|
+
relay.metrics.recordCleanup('deadConnectionsClosed');
|
|
21
|
+
terminateSocket(socket);
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
socket.isAlive = false;
|
|
25
|
+
try {
|
|
26
|
+
socket.ping();
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// A socket failing mid-ping is terminated by the next sweep.
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function forgetExpiredAttempts(store, now) {
|
|
34
|
+
for (const [key, attempt] of store.entries()) {
|
|
35
|
+
if (now - attempt.startedAt >= 60_000)
|
|
36
|
+
store.delete(key);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Drop rate-limit windows, windows and hosts that have gone stale, and expired pairings. */
|
|
40
|
+
export function sweep(relay) {
|
|
41
|
+
const now = Date.now();
|
|
42
|
+
const staleAfter = relay.config.cleanup.staleAfterMs;
|
|
43
|
+
forgetExpiredAttempts(relay.pairAttempts, now);
|
|
44
|
+
forgetExpiredAttempts(relay.clientHandshakeAttempts, now);
|
|
45
|
+
forgetExpiredAttempts(relay.hostHandshakeAttempts, now);
|
|
46
|
+
for (const client of [...relay.clients.values()]) {
|
|
47
|
+
if (now - client.lastSeenAt > staleAfter) {
|
|
48
|
+
relay.metrics.recordCleanup('staleClientsPurged');
|
|
49
|
+
detachClient(relay, client, { notify: false, reason: 'stale_client', terminate: true });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
for (const host of [...relay.hosts.values()]) {
|
|
53
|
+
if (host.reconnecting)
|
|
54
|
+
continue;
|
|
55
|
+
if (now - host.lastSeenAt > staleAfter) {
|
|
56
|
+
relay.metrics.recordCleanup('deadConnectionsClosed');
|
|
57
|
+
detachHost(relay, host, { notify: true, reason: 'stale_host', terminate: true });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const authCleanup = relay.auth.cleanup(now);
|
|
61
|
+
if (authCleanup.removedDevices || authCleanup.removedPairings)
|
|
62
|
+
relay.metrics.recordCleanup('staleClientsPurged', authCleanup.removedDevices);
|
|
63
|
+
relay.metrics.cleanup.lastCleanupAt = new Date(now).toISOString();
|
|
64
|
+
}
|