herdr-remote 0.2.39 → 0.3.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/README.md +4 -2
  2. package/README.zh-CN.md +2 -2
  3. package/dist/chunks/3FQAXVGC.js +3 -0
  4. package/dist/chunks/42KARAL4.js +31 -0
  5. package/dist/chunks/44IGVPMA.js +5 -0
  6. package/dist/chunks/5LZFJ6UZ.js +3 -0
  7. package/dist/chunks/7SMZ43LI.js +9 -0
  8. package/dist/chunks/AX4EPJCP.js +4 -0
  9. package/dist/chunks/B76DFI4H.js +16 -0
  10. package/dist/chunks/BYGDPZZD.js +3 -0
  11. package/dist/chunks/C2UUZY6J.js +2 -0
  12. package/dist/chunks/CVJ4HBMP.js +3 -0
  13. package/dist/chunks/DGUZCGQN.js +3 -0
  14. package/dist/chunks/FONUO5O5.js +4 -0
  15. package/dist/chunks/H24ZP5D5.js +16 -0
  16. package/dist/chunks/I6NRCYLI.js +3 -0
  17. package/dist/chunks/JJGEV4EZ.js +3 -0
  18. package/dist/chunks/K5RFPHSF.js +5 -0
  19. package/dist/chunks/KGOQKOHH.js +5 -0
  20. package/dist/chunks/KRVF37OW.js +3 -0
  21. package/dist/chunks/KUDCC7ZO.js +6 -0
  22. package/dist/chunks/LPR662JH.js +77 -0
  23. package/dist/chunks/LY2ZGCXN.js +4 -0
  24. package/dist/chunks/M52LJEES.js +6 -0
  25. package/dist/chunks/MCUVFSSK.js +3 -0
  26. package/dist/chunks/MEFDV375.js +10 -0
  27. package/dist/chunks/MKAEXKXG.js +10 -0
  28. package/dist/chunks/O2LNVFUT.js +3 -0
  29. package/dist/chunks/PBMBFWLQ.js +3 -0
  30. package/dist/chunks/PBRQ3HEG.js +3 -0
  31. package/dist/chunks/QCSILMFB.js +3 -0
  32. package/dist/chunks/QRGNEKME.js +5 -0
  33. package/dist/chunks/RMHH3IAX.js +63 -0
  34. package/dist/chunks/TNLCNVA2.js +3 -0
  35. package/dist/chunks/U6QAYG3E.js +3 -0
  36. package/dist/chunks/VD54DF7R.js +45 -0
  37. package/dist/chunks/WY7WHEJ4.js +3 -0
  38. package/dist/cli.js +21 -294
  39. package/dist/connector/admin-broker.js +3 -303
  40. package/dist/connector/main.js +20 -18
  41. package/dist/harfbuzz-subset.wasm +0 -0
  42. package/dist/postinstall.js +17 -0
  43. package/herdr-plugin.toml +1 -1
  44. package/node_modules/herdr-remote-relay/bin/herdr-remote-relay.js +33 -92
  45. package/node_modules/herdr-remote-relay/package.json +3 -46
  46. package/package.json +8 -27
  47. package/dist/agent-status.js +0 -130
  48. package/dist/binary-plist.js +0 -157
  49. package/dist/config.js +0 -278
  50. package/dist/connector/admin-broker-setup.js +0 -40
  51. package/dist/connector/admin-shell.js +0 -67
  52. package/dist/connector/admin-tab.js +0 -90
  53. package/dist/connector/adminBrokerEndpoint.js +0 -68
  54. package/dist/connector/agent-watch.js +0 -123
  55. package/dist/connector/elevatedPty.js +0 -141
  56. package/dist/connector/font-server.js +0 -165
  57. package/dist/connector/herdr-prefix-keys.js +0 -28
  58. package/dist/connector/host-connector.js +0 -433
  59. package/dist/connector/host-hello.js +0 -32
  60. package/dist/connector/lock.js +0 -73
  61. package/dist/connector/sessions.js +0 -381
  62. package/dist/connector/update-report.js +0 -70
  63. package/dist/connector/wire.js +0 -42
  64. package/dist/exit-codes.js +0 -13
  65. package/dist/font-subset.js +0 -116
  66. package/dist/herdr-api.js +0 -254
  67. package/dist/herdr-command.js +0 -245
  68. package/dist/herdr-plugin.js +0 -120
  69. package/dist/herdr-server.js +0 -190
  70. package/dist/i18n/en.js +0 -257
  71. package/dist/i18n/index.js +0 -48
  72. package/dist/i18n/zh.js +0 -256
  73. package/dist/keepalive/detached.js +0 -97
  74. package/dist/keepalive/environment.js +0 -44
  75. package/dist/keepalive/index.js +0 -129
  76. package/dist/keepalive/launchd.js +0 -117
  77. package/dist/keepalive/systemd.js +0 -112
  78. package/dist/keepalive/types.js +0 -1
  79. package/dist/keepalive/windows-service-setup.js +0 -138
  80. package/dist/keepalive/windows-service.js +0 -272
  81. package/dist/keepalive/windows-task.js +0 -191
  82. package/dist/lib/process.js +0 -132
  83. package/dist/lifecycle.js +0 -79
  84. package/dist/net-interfaces.js +0 -82
  85. package/dist/node-pty-loader.js +0 -80
  86. package/dist/pasted-files.js +0 -115
  87. package/dist/paths.js +0 -74
  88. package/dist/pty-session.js +0 -133
  89. package/dist/relay-client.js +0 -106
  90. package/dist/relay-urls.js +0 -99
  91. package/dist/runtime.js +0 -107
  92. package/dist/service.js +0 -267
  93. package/dist/settings-model.js +0 -298
  94. package/dist/socket-discovery.js +0 -70
  95. package/dist/supervisor.js +0 -323
  96. package/dist/terminal-font/deps.js +0 -83
  97. package/dist/terminal-font/files.js +0 -357
  98. package/dist/terminal-font/formats.js +0 -355
  99. package/dist/terminal-font/index.js +0 -187
  100. package/dist/terminal-font/readers.js +0 -373
  101. package/dist/terminal-font/terminals.js +0 -147
  102. package/dist/terminal-palette.js +0 -399
  103. package/dist/tui/App.js +0 -199
  104. package/dist/tui/api.js +0 -91
  105. package/dist/tui/components/TextField.js +0 -50
  106. package/dist/tui/components/common.js +0 -80
  107. package/dist/tui/index.js +0 -36
  108. package/dist/tui/mouse/index.js +0 -164
  109. package/dist/tui/mouse/protocol.js +0 -96
  110. package/dist/tui/mouse/source.js +0 -79
  111. package/dist/tui/screens/About.js +0 -172
  112. package/dist/tui/screens/Herdr.js +0 -75
  113. package/dist/tui/screens/Keepalive.js +0 -141
  114. package/dist/tui/screens/Overview.js +0 -36
  115. package/dist/tui/screens/Pair.js +0 -74
  116. package/dist/tui/screens/Relay.js +0 -243
  117. package/dist/tui/screens/Services.js +0 -63
  118. package/dist/tui/screens/Wizard.js +0 -241
  119. package/dist/tui/terminal.js +0 -39
  120. package/dist/tui/theme.js +0 -33
  121. package/dist/update-command.js +0 -118
  122. package/dist/updater/in-place.js +0 -83
  123. package/dist/updater/npm.js +0 -62
  124. package/dist/updater.js +0 -401
  125. package/node_modules/herdr-remote-relay/README.md +0 -76
  126. package/node_modules/herdr-remote-relay/README.zh-CN.md +0 -76
  127. package/node_modules/herdr-remote-relay/config.example.json +0 -18
  128. package/node_modules/herdr-remote-relay/deploy/cloudflared/config.yml.example +0 -9
  129. package/node_modules/herdr-remote-relay/deploy/docker/compose.yaml +0 -23
  130. package/node_modules/herdr-remote-relay/deploy/nginx/herdr-remote-relay.conf.example +0 -49
  131. package/node_modules/herdr-remote-relay/deploy/systemd/herdr-remote-relay.service +0 -41
  132. package/node_modules/herdr-remote-relay/deploy/systemd/relay.env.example +0 -25
  133. package/node_modules/herdr-remote-relay/dist/auth-store.d.ts +0 -114
  134. package/node_modules/herdr-remote-relay/dist/auth-store.js +0 -289
  135. package/node_modules/herdr-remote-relay/dist/metrics.d.ts +0 -76
  136. package/node_modules/herdr-remote-relay/dist/metrics.js +0 -249
  137. package/node_modules/herdr-remote-relay/dist/protocol/frames.d.ts +0 -27
  138. package/node_modules/herdr-remote-relay/dist/protocol/frames.js +0 -110
  139. package/node_modules/herdr-remote-relay/dist/protocol/herdr-keys.d.ts +0 -8
  140. package/node_modules/herdr-remote-relay/dist/protocol/herdr-keys.js +0 -34
  141. package/node_modules/herdr-remote-relay/dist/protocol/http.d.ts +0 -165
  142. package/node_modules/herdr-remote-relay/dist/protocol/http.js +0 -3
  143. package/node_modules/herdr-remote-relay/dist/protocol/index.d.ts +0 -7
  144. package/node_modules/herdr-remote-relay/dist/protocol/index.js +0 -9
  145. package/node_modules/herdr-remote-relay/dist/protocol/messages.d.ts +0 -362
  146. package/node_modules/herdr-remote-relay/dist/protocol/messages.js +0 -20
  147. package/node_modules/herdr-remote-relay/dist/protocol/paste.d.ts +0 -14
  148. package/node_modules/herdr-remote-relay/dist/protocol/paste.js +0 -50
  149. package/node_modules/herdr-remote-relay/dist/protocol/release.d.ts +0 -8
  150. package/node_modules/herdr-remote-relay/dist/protocol/release.js +0 -17
  151. package/node_modules/herdr-remote-relay/dist/protocol/terminal.d.ts +0 -79
  152. package/node_modules/herdr-remote-relay/dist/protocol/terminal.js +0 -150
  153. package/node_modules/herdr-remote-relay/dist/relay-config.d.ts +0 -74
  154. package/node_modules/herdr-remote-relay/dist/relay-config.js +0 -316
  155. package/node_modules/herdr-remote-relay/dist/relay-server.d.ts +0 -67
  156. package/node_modules/herdr-remote-relay/dist/relay-server.js +0 -196
  157. package/node_modules/herdr-remote-relay/dist/scroll-input.d.ts +0 -17
  158. package/node_modules/herdr-remote-relay/dist/scroll-input.js +0 -124
  159. package/node_modules/herdr-remote-relay/dist/server/client-channel.d.ts +0 -3
  160. package/node_modules/herdr-remote-relay/dist/server/client-channel.js +0 -396
  161. package/node_modules/herdr-remote-relay/dist/server/font-proxy.d.ts +0 -19
  162. package/node_modules/herdr-remote-relay/dist/server/font-proxy.js +0 -155
  163. package/node_modules/herdr-remote-relay/dist/server/host-broadcasts.d.ts +0 -27
  164. package/node_modules/herdr-remote-relay/dist/server/host-broadcasts.js +0 -83
  165. package/node_modules/herdr-remote-relay/dist/server/host-channel.d.ts +0 -3
  166. package/node_modules/herdr-remote-relay/dist/server/host-channel.js +0 -271
  167. package/node_modules/herdr-remote-relay/dist/server/http-api.d.ts +0 -4
  168. package/node_modules/herdr-remote-relay/dist/server/http-api.js +0 -219
  169. package/node_modules/herdr-remote-relay/dist/server/maintenance.d.ts +0 -5
  170. package/node_modules/herdr-remote-relay/dist/server/maintenance.js +0 -64
  171. package/node_modules/herdr-remote-relay/dist/server/requests.d.ts +0 -34
  172. package/node_modules/herdr-remote-relay/dist/server/requests.js +0 -142
  173. package/node_modules/herdr-remote-relay/dist/server/sessions.d.ts +0 -36
  174. package/node_modules/herdr-remote-relay/dist/server/sessions.js +0 -230
  175. package/node_modules/herdr-remote-relay/dist/server/sockets.d.ts +0 -12
  176. package/node_modules/herdr-remote-relay/dist/server/sockets.js +0 -64
  177. package/node_modules/herdr-remote-relay/dist/server/status.d.ts +0 -11
  178. package/node_modules/herdr-remote-relay/dist/server/status.js +0 -99
  179. package/node_modules/herdr-remote-relay/dist/server/transport.d.ts +0 -16
  180. package/node_modules/herdr-remote-relay/dist/server/transport.js +0 -110
  181. package/node_modules/herdr-remote-relay/dist/server/types.d.ts +0 -133
  182. package/node_modules/herdr-remote-relay/dist/server/types.js +0 -3
  183. package/node_modules/herdr-remote-relay/dist/server/version.d.ts +0 -2
  184. package/node_modules/herdr-remote-relay/dist/server/version.js +0 -5
  185. package/node_modules/herdr-remote-relay/dist/state.d.ts +0 -6
  186. package/node_modules/herdr-remote-relay/dist/state.js +0 -47
  187. package/node_modules/ws/LICENSE +0 -20
  188. package/node_modules/ws/README.md +0 -548
  189. package/node_modules/ws/browser.js +0 -8
  190. package/node_modules/ws/index.js +0 -22
  191. package/node_modules/ws/lib/buffer-util.js +0 -131
  192. package/node_modules/ws/lib/constants.js +0 -19
  193. package/node_modules/ws/lib/event-target.js +0 -292
  194. package/node_modules/ws/lib/extension.js +0 -203
  195. package/node_modules/ws/lib/limiter.js +0 -55
  196. package/node_modules/ws/lib/permessage-deflate.js +0 -530
  197. package/node_modules/ws/lib/receiver.js +0 -743
  198. package/node_modules/ws/lib/sender.js +0 -607
  199. package/node_modules/ws/lib/stream.js +0 -161
  200. package/node_modules/ws/lib/subprotocol.js +0 -62
  201. package/node_modules/ws/lib/validation.js +0 -152
  202. package/node_modules/ws/lib/websocket-server.js +0 -562
  203. package/node_modules/ws/lib/websocket.js +0 -1407
  204. package/node_modules/ws/package.json +0 -74
  205. package/node_modules/ws/wrapper.mjs +0 -21
@@ -1,3 +0,0 @@
1
- import type { IncomingMessage } from 'node:http';
2
- import type { RelayContext, RelaySocket } from './types.js';
3
- export declare function handleHostConnection(relay: RelayContext, ws: RelaySocket, req: IncomingMessage): void;
@@ -1,271 +0,0 @@
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, sanitizeHerdrPrefixKeys, sanitizeTerminalFont, sanitizeTerminalPalette, unpackStreamFrame, } from '../protocol/index.js';
5
- import { forwardFontChunk, forwardFontSubset, settleFontChunk, settleFontSubset, } from './font-proxy.js';
6
- import { broadcastAgentStatus, broadcastTerminalFont, broadcastUpdateStatus, releaseVersion, } 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
- const herdrPrefixKeys = sanitizeHerdrPrefixKeys(message.herdrPrefixKeys);
14
- return {
15
- id: message.hostId,
16
- ws,
17
- hostname: typeof message.hostname === 'string' ? message.hostname.slice(0, 128) : os.hostname(),
18
- platform: typeof message.platform === 'string' ? message.platform.slice(0, 32) : process.platform,
19
- ...(message.shellProfile === 'git-bash' ? { shellProfile: message.shellProfile } : {}),
20
- adminTabsAvailable: capabilities.includes(CAPABILITY.adminTabs),
21
- arch: typeof message.arch === 'string' ? message.arch.slice(0, 32) : process.arch,
22
- connectedAt: new Date(pending.connectedAt).toISOString(),
23
- connectedAtMs: pending.connectedAt,
24
- terminalPalette: sanitizeTerminalPalette(message.terminalPalette),
25
- /** Family, size and fetchable files of the workstation's terminal font. */
26
- terminalFont: sanitizeTerminalFont(message.terminalFont),
27
- ...(herdrPrefixKeys ? { herdrPrefixKeys } : {}),
28
- /** The latest workstation snapshot is replayed when a browser joins late. */
29
- agentStatus: null,
30
- /** Whether the workstation's herdr-remote is behind; replayed the same way. */
31
- updateStatus: null,
32
- version: releaseVersion(message.version),
33
- lastSeenAt: Date.now(),
34
- clients,
35
- controllerId: null,
36
- load: {},
37
- ptys: [],
38
- reconnecting: false,
39
- reconnectTimer: null,
40
- connectionGeneration: randomId('host-connection'),
41
- handoffCapable: capabilities.includes(CAPABILITY.hostHandoff),
42
- binaryFrameV2: capabilities.includes(CAPABILITY.binaryFrameV2),
43
- streamIndices: new Map(),
44
- nextStreamIndex: 0,
45
- shutdownRequested: false,
46
- };
47
- }
48
- export function handleHostConnection(relay, ws, req) {
49
- const pending = {
50
- ws,
51
- remoteAddress: req.socket.remoteAddress,
52
- connectedAt: Date.now(),
53
- authenticated: false,
54
- };
55
- const deadline = setTimeout(() => {
56
- if (!pending.authenticated)
57
- closeSocket(ws, 1008, 'host hello timeout');
58
- }, 10000);
59
- ws.isAlive = true;
60
- ws.on('pong', () => {
61
- ws.isAlive = true;
62
- if (pending.host)
63
- pending.host.lastSeenAt = Date.now();
64
- });
65
- ws.on('ping', () => {
66
- ws.isAlive = true;
67
- if (pending.host)
68
- pending.host.lastSeenAt = Date.now();
69
- });
70
- ws.on('message', (raw, isBinary) => {
71
- if (!pending.authenticated) {
72
- if (isBinary)
73
- return rejectHandshake(ws, 'host hello must be JSON');
74
- const message = parseJson(raw.toString());
75
- if (message?.type !== 'host_hello' || message.protocol !== PROTOCOL_VERSION)
76
- return rejectHandshake(ws, 'invalid host hello');
77
- if (!allowHostHandshake(relay, req))
78
- return rejectHandshake(ws, 'too many connection attempts', 'rate_limited');
79
- const oldHost = relay.hosts.get(message.hostId);
80
- if (!oldHost && relay.hosts.size >= relay.config.relay.maxHosts) {
81
- return rejectHandshake(ws, 'relay host limit reached', 'too_many_hosts');
82
- }
83
- const registration = relay.auth.registerHost(message.hostId, message.token, message.password ?? null);
84
- if (!registration.ok)
85
- return rejectHandshake(ws, registration.message, registration.code);
86
- clearTimeout(deadline);
87
- pending.authenticated = true;
88
- relay.finishHandshake(ws);
89
- const handoff = canHandoffHost(relay, oldHost);
90
- const retainedClients = handoff && oldHost ? oldHost.clients : new Set();
91
- if (oldHost) {
92
- if (oldHost.reconnectTimer)
93
- clearTimeout(oldHost.reconnectTimer);
94
- oldHost.reconnectTimer = null;
95
- if (handoff) {
96
- for (const clientId of oldHost.clients) {
97
- const client = relay.clients.get(clientId);
98
- if (client?.session) {
99
- if (isOpen(oldHost.ws)) {
100
- jsonSend(oldHost.ws, {
101
- type: 'session_stop',
102
- clientId: client.session.streamId,
103
- streamId: client.session.streamId,
104
- });
105
- }
106
- releaseSession(relay, oldHost, client);
107
- }
108
- }
109
- oldHost.load = {};
110
- oldHost.ptys = [];
111
- broadcastToClients(relay, oldHost, () => ({
112
- type: 'host_reconnecting',
113
- code: 'host_replaced',
114
- }));
115
- }
116
- else {
117
- detachHost(relay, oldHost, { notify: true, reason: 'host_replaced' });
118
- }
119
- }
120
- const host = createHostRecord(message, ws, pending, retainedClients);
121
- pending.host = host;
122
- // Install the new record before closing the old socket. Its delayed
123
- // close handler then fails the identity check instead of detaching the
124
- // freshly authenticated host.
125
- relay.hosts.set(host.id, host);
126
- if (oldHost && handoff)
127
- closeSocket(oldHost.ws, 1000, 'host_replaced');
128
- jsonSend(ws, {
129
- type: 'host_ready',
130
- protocol: PROTOCOL_VERSION,
131
- hostId: host.id,
132
- clientCount: host.clients.size,
133
- });
134
- notifyHostClientCount(host);
135
- if (handoff) {
136
- for (const clientId of host.clients) {
137
- const client = relay.clients.get(clientId);
138
- if (client)
139
- startSession(relay, host, client, { restarted: true });
140
- }
141
- }
142
- return;
143
- }
144
- if (pending.host)
145
- handleHostMessage(relay, pending.host, raw, isBinary);
146
- });
147
- ws.on('close', (_code, rawReason) => {
148
- clearTimeout(deadline);
149
- const host = pending.host;
150
- if (!host || relay.hosts.get(host.id) !== host || host.ws !== ws)
151
- return;
152
- const reason = rawReason ? rawReason.toString() : '';
153
- if (reason === 'host_shutdown')
154
- host.shutdownRequested = true;
155
- if (host.shutdownRequested || !canHandoffHost(relay, host)) {
156
- detachHost(relay, host, {
157
- notify: true,
158
- reason: host.shutdownRequested ? 'host_shutdown' : 'host_disconnected',
159
- });
160
- return;
161
- }
162
- beginHostReconnect(relay, host, 'host_disconnected');
163
- });
164
- ws.on('error', () => { });
165
- }
166
- function handleHostMessage(relay, host, raw, isBinary) {
167
- host.lastSeenAt = Date.now();
168
- if (isBinary) {
169
- let frame;
170
- try {
171
- frame = unpackStreamFrame(raw);
172
- }
173
- catch (error) {
174
- relay.metrics.recordCleanup('deadConnectionsClosed');
175
- closeSocket(host.ws, 1003, error.message);
176
- return;
177
- }
178
- // Postel's law: be conservative in what you send, liberal in what you accept.
179
- // We strictly send v2 only to hosts that negotiated binary_frame_v2, but we accept
180
- // both v1 and v2 on receipt: a v2-capable host falls back to v1 if stream indices
181
- // were exhausted for a session, and an unnegotiated host sending v2 simply finds
182
- // no routed client instead of having its entire connection severed.
183
- // Output is routed to the single client owning the stream rather than broadcast.
184
- if (frame.type !== 'output')
185
- return;
186
- const clientId = frame.version === 2
187
- ? host.streamIndices.get(frame.streamIndex)
188
- : relay.streams.get(frame.streamId);
189
- if (!clientId)
190
- return;
191
- const client = relay.clients.get(clientId);
192
- if (!client || !isOpen(client.ws))
193
- return;
194
- sendClientBinary(relay, host, client, frame.payload);
195
- return;
196
- }
197
- // Untrusted: each branch reads only the fields its message type defines.
198
- const message = parseJson(raw.toString());
199
- if (!message)
200
- return;
201
- if (message.type === 'heartbeat') {
202
- host.load = message.load && typeof message.load === 'object' ? message.load : {};
203
- host.ptys = Array.isArray(message.ptys) ? message.ptys.slice(0, 256) : [];
204
- return;
205
- }
206
- if (message.type === 'host_shutdown') {
207
- host.shutdownRequested = true;
208
- if (relay.hosts.get(host.id) === host)
209
- detachHost(relay, host, { notify: true, reason: 'host_shutdown' });
210
- return;
211
- }
212
- // What the workstation's agents are doing belongs to the workstation, not
213
- // to one stream: every window watching it gets the same answer.
214
- if (message.type === 'agent_status') {
215
- broadcastAgentStatus(relay, host, message);
216
- return;
217
- }
218
- if (message.type === 'update_status') {
219
- broadcastUpdateStatus(relay, host, message);
220
- return;
221
- }
222
- if (message.type === 'terminal_font') {
223
- broadcastTerminalFont(relay, host, message);
224
- return;
225
- }
226
- // Session events target the single client that owns this stream.
227
- const addressed = message;
228
- const streamId = typeof addressed.clientId === 'string'
229
- ? addressed.clientId
230
- : addressed.streamId;
231
- const clientId = streamId ? relay.streams.get(streamId) : null;
232
- const client = clientId ? relay.clients.get(clientId) : null;
233
- if (!client)
234
- return;
235
- if (message.type === 'session_ready') {
236
- if (client.session)
237
- client.session.ready = true;
238
- jsonSend(client.ws, { type: 'session_ready', clientId: client.id });
239
- }
240
- else if (message.type === 'session_exit') {
241
- const code = Number.isInteger(message.code) ? message.code : null;
242
- jsonSend(client.ws, { type: 'exit', code });
243
- releaseSession(relay, host, client);
244
- detachClient(relay, client, { notify: false });
245
- }
246
- else if (message.type === 'host_font_chunk') {
247
- forwardFontChunk(client, message);
248
- }
249
- else if (message.type === 'host_font_subset_ready') {
250
- forwardFontSubset(client, message);
251
- }
252
- else if (message.type === 'paste_file_ready') {
253
- jsonSend(client.ws, {
254
- type: 'paste_file_ready',
255
- path: message.path,
256
- });
257
- }
258
- else if (message.type === 'error') {
259
- if (message.code === 'host_font_unavailable') {
260
- settleFontChunk(client);
261
- settleFontSubset(client);
262
- }
263
- if (message.code === 'host_font_subset_failed')
264
- settleFontSubset(client);
265
- jsonSend(client.ws, {
266
- type: 'error',
267
- code: message.code || 'host_error',
268
- message: String(message.message || 'Host connector error'),
269
- });
270
- }
271
- }
@@ -1,4 +0,0 @@
1
- import type { IncomingMessage, ServerResponse } from 'node:http';
2
- import type { RelayContext } from './types.js';
3
- export declare const PUBLIC_DIR: string;
4
- export declare function handleHttp(relay: RelayContext, req: IncomingMessage, res: ServerResponse): void;
@@ -1,219 +0,0 @@
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
- export const PUBLIC_DIR = path.join(PACKAGE_ROOT, 'web', 'dist');
14
- const FONT_CACHE_SECONDS = 7 * 24 * 60 * 60;
15
- const MIME_TYPES = {
16
- '.html': 'text/html; charset=utf-8',
17
- '.js': 'text/javascript; charset=utf-8',
18
- '.css': 'text/css; charset=utf-8',
19
- '.json': 'application/json',
20
- '.svg': 'image/svg+xml',
21
- '.png': 'image/png',
22
- '.ico': 'image/x-icon',
23
- '.woff2': 'font/woff2',
24
- };
25
- function setResponseHeaders(res, contentType = 'application/json') {
26
- res.setHeader('Content-Type', contentType);
27
- res.setHeader('Cache-Control', 'no-store');
28
- res.setHeader('X-Content-Type-Options', 'nosniff');
29
- res.setHeader('X-Frame-Options', 'DENY');
30
- res.setHeader('Referrer-Policy', 'no-referrer');
31
- 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'");
32
- }
33
- function sendJsonResponse(res, status, payload) {
34
- setResponseHeaders(res);
35
- res.writeHead(status);
36
- res.end(JSON.stringify(payload));
37
- }
38
- function sendError(res, status, code, message) {
39
- sendJsonResponse(res, status, { ok: false, code, message });
40
- }
41
- /** Whether the caller is the relay operator; answers the request when not. */
42
- function adminAllowed(relay, req, res) {
43
- if (!relay.adminToken) {
44
- sendError(res, 503, 'admin_not_configured', 'configure RELAY_ADMIN_TOKEN to access the relay dashboard');
45
- return false;
46
- }
47
- if (!authorizedAdmin(relay, req)) {
48
- sendError(res, 401, 'admin_auth_required', 'a valid relay admin token is required');
49
- return false;
50
- }
51
- return true;
52
- }
53
- const healthz = (relay, _req, res) => {
54
- // Liveness is intentionally tenant-blind. Host/client counts let an
55
- // unauthenticated caller learn whether other workstations are present.
56
- sendJsonResponse(res, 200, {
57
- ok: true,
58
- version: VERSION,
59
- protocol: PROTOCOL_VERSION,
60
- uptimeSeconds: Math.floor((Date.now() - relay.startedAt) / 1000),
61
- });
62
- };
63
- const info = (relay, _req, res) => {
64
- const publicUrl = String(relay.config.relay.publicUrl || '').replace(/\/+$/, '');
65
- sendJsonResponse(res, 200, {
66
- ok: true,
67
- version: VERSION,
68
- protocol: PROTOCOL_VERSION,
69
- relayMode: relay.relayMode,
70
- isRemoteRelay: relay.relayMode === 'remote',
71
- adminConfigured: Boolean(relay.adminToken),
72
- adminPath: '/admin',
73
- adminStatusPath: '/api/admin/status',
74
- publicUrl,
75
- remoteAdminUrl: relay.relayMode === 'remote' ? `${publicUrl}/admin` : null,
76
- });
77
- };
78
- const status = (relay, req, res) => {
79
- const subject = authorizedSubject(relay, req);
80
- if (!subject) {
81
- sendError(res, 401, 'auth_required', 'an authorized device or host token is required');
82
- return;
83
- }
84
- sendJsonResponse(res, 200, statusSnapshot(relay, { scopeHostId: subject.hostId }));
85
- };
86
- const adminStatus = (relay, req, res) => {
87
- if (!adminAllowed(relay, req, res))
88
- return;
89
- sendJsonResponse(res, 200, statusSnapshot(relay, { includeDevices: true }));
90
- };
91
- const revokeDevice = (relay, req, res, url) => {
92
- if (!adminAllowed(relay, req, res))
93
- return;
94
- const deviceId = decodeURIComponent(url.pathname.slice('/api/admin/devices/'.length));
95
- const revoked = relay.auth.revokeDevice(deviceId);
96
- if (!revoked) {
97
- sendError(res, 404, 'device_not_found', 'no such paired device');
98
- return;
99
- }
100
- // Revocation has to take effect now, not at the next reconnect: drop any
101
- // socket the device still holds so the terminal closes immediately.
102
- const disconnected = detachDeviceSessions(relay, deviceId);
103
- sendJsonResponse(res, 200, { ok: true, deviceId: revoked.deviceId, disconnected });
104
- };
105
- const startPairing = (relay, req, res) => {
106
- if (!allowPairAttempt(relay, req)) {
107
- sendError(res, 429, 'rate_limited', 'too many pairing attempts');
108
- return;
109
- }
110
- const hostId = authorizedHost(relay, req);
111
- if (!hostId) {
112
- sendError(res, 401, 'host_auth_required', 'a valid host id and token are required');
113
- return;
114
- }
115
- const host = relay.hosts.get(hostId);
116
- if (!host || host.reconnecting || !isOpen(host.ws)) {
117
- sendError(res, 409, 'host_offline', 'no Herdr host is connected');
118
- return;
119
- }
120
- try {
121
- const pairing = relay.auth.startPairing(hostId, String(relay.config.relay.publicUrl).replace(/\/$/, ''));
122
- const answer = {
123
- ok: true,
124
- ...pairing,
125
- pairUrl: `${pairing.publicUrl}/?pairCode=${encodeURIComponent(pairing.code)}`,
126
- };
127
- sendJsonResponse(res, 200, answer);
128
- }
129
- catch (error) {
130
- const { code, message } = error;
131
- sendError(res, 409, code || 'pairing_failed', message);
132
- }
133
- };
134
- const ROUTES = [
135
- { method: 'GET', path: '/healthz', handle: healthz },
136
- { method: 'GET', path: '/api/info', handle: info },
137
- { method: 'GET', path: '/api/status', handle: status },
138
- { method: 'GET', path: '/api/admin/status', handle: adminStatus },
139
- { method: 'DELETE', path: '/api/admin/devices/', prefix: true, handle: revokeDevice },
140
- { method: 'POST', path: '/api/pair/start', handle: startPairing },
141
- ];
142
- export function handleHttp(relay, req, res) {
143
- let requestUrl;
144
- try {
145
- requestUrl = new URL(req.url || '', `http://${req.headers.host || 'localhost'}`);
146
- }
147
- catch {
148
- sendError(res, 400, 'bad_request', 'invalid URL');
149
- return;
150
- }
151
- if (!requestOriginAllowed(relay, req)) {
152
- sendError(res, 403, 'origin_denied', 'origin is not allowed');
153
- return;
154
- }
155
- if (req.method === 'OPTIONS') {
156
- res.writeHead(204, {
157
- 'Access-Control-Allow-Origin': req.headers.origin || '*',
158
- 'Access-Control-Allow-Headers': 'Authorization, Content-Type, X-Herdr-Host-Id, X-Herdr-Host-Token, X-Relay-Admin-Token',
159
- 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
160
- Vary: 'Origin',
161
- });
162
- res.end();
163
- return;
164
- }
165
- // Only echo an origin after the exact allowlist/same-host check above. This
166
- // makes explicitly configured cross-origin WebUI profiles usable without
167
- // reflecting an attacker-controlled Origin header.
168
- if (req.headers.origin) {
169
- res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
170
- res.setHeader('Vary', 'Origin');
171
- }
172
- const { pathname } = requestUrl;
173
- const route = ROUTES.find((candidate) => candidate.method === req.method &&
174
- (candidate.prefix ? pathname.startsWith(candidate.path) : pathname === candidate.path));
175
- if (route) {
176
- route.handle(relay, req, res, requestUrl);
177
- return;
178
- }
179
- if (pathname.startsWith('/api/')) {
180
- sendError(res, 404, 'not_found', 'API route not found');
181
- return;
182
- }
183
- serveStatic(relay.publicDir, pathname, res);
184
- }
185
- function serveStatic(publicDir, requestPath, res) {
186
- const relative = requestPath === '/' || requestPath === '/admin'
187
- ? 'index.html'
188
- : requestPath.replace(/^\/+/, '');
189
- const candidate = path.resolve(publicDir, relative);
190
- if (!candidate.startsWith(`${publicDir}${path.sep}`) &&
191
- candidate !== path.join(publicDir, 'index.html')) {
192
- sendError(res, 403, 'forbidden', 'path is not allowed');
193
- return;
194
- }
195
- fs.readFile(candidate, (error, data) => {
196
- if (error && !path.extname(relative)) {
197
- fs.readFile(path.join(publicDir, 'index.html'), (fallbackError, fallbackData) => {
198
- if (fallbackError) {
199
- sendError(res, 503, 'web_not_built', 'frontend has not been built');
200
- return;
201
- }
202
- setResponseHeaders(res, 'text/html; charset=utf-8');
203
- res.end(fallbackData);
204
- });
205
- return;
206
- }
207
- if (error) {
208
- sendError(res, 404, 'not_found', 'file not found');
209
- return;
210
- }
211
- const extension = path.extname(candidate).toLowerCase();
212
- setResponseHeaders(res, MIME_TYPES[extension] || 'application/octet-stream');
213
- // The bundled fonts run to megabytes and only change with a release; a
214
- // week is short enough that a font upgrade still arrives.
215
- if (relative.startsWith('fonts/'))
216
- res.setHeader('Cache-Control', `public, max-age=${FONT_CACHE_SECONDS}`);
217
- res.end(data);
218
- });
219
- }
@@ -1,5 +0,0 @@
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;
@@ -1,64 +0,0 @@
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
- }
@@ -1,34 +0,0 @@
1
- import type { IncomingMessage } from 'node:http';
2
- import type { RelayContext } from './types.js';
3
- /** Who an authenticated request speaks for. */
4
- export type RequestSubject = {
5
- kind: 'host';
6
- hostId: string;
7
- } | {
8
- kind: 'device';
9
- hostId: string;
10
- deviceId: string;
11
- };
12
- export declare function isAllowedOrigin(relay: RelayContext, origin: string | undefined, req: IncomingMessage): boolean;
13
- export declare function requestOriginAllowed(relay: RelayContext, req: IncomingMessage): boolean;
14
- /**
15
- * Identify the workstation making a request by its own host token.
16
- * Ownership of a workstation is exactly what the host token proves, so a
17
- * public relay serving many workstations stays safe: nobody can mint a pairing
18
- * code for a host whose token they do not hold.
19
- */
20
- export declare function authorizedHost(relay: RelayContext, req: IncomingMessage): string | null;
21
- /**
22
- * Resolve the request to one tenant. Supplying both authentication schemes is
23
- * allowed only when they identify the same host; otherwise a caller could
24
- * accidentally combine credentials from two workstations and receive the
25
- * result selected by whichever branch happened to run first.
26
- */
27
- export declare function authorizedSubject(relay: RelayContext, req: IncomingMessage): RequestSubject | null;
28
- /** Authenticate the operator of this relay, not a workstation or device. */
29
- export declare function authorizedAdmin(relay: RelayContext, req: IncomingMessage): boolean;
30
- /** The address a window connected from, as the operator dashboard shows it. */
31
- export declare function clientAddress(relay: RelayContext, req: IncomingMessage): string | null;
32
- export declare function allowPairAttempt(relay: RelayContext, req: IncomingMessage): boolean;
33
- export declare function allowClientHandshake(relay: RelayContext, req: IncomingMessage): boolean;
34
- export declare function allowHostHandshake(relay: RelayContext, req: IncomingMessage): boolean;