herdr-remote 0.2.33 → 0.2.35

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 (144) hide show
  1. package/README.md +3 -1
  2. package/README.zh-CN.md +2 -1
  3. package/dist/i18n/en.js +4 -17
  4. package/dist/i18n/zh.js +4 -17
  5. package/dist/tui/App.js +3 -25
  6. package/dist/tui/api.js +3 -3
  7. package/dist/tui/screens/About.js +18 -27
  8. package/dist/update-command.js +16 -19
  9. package/dist/updater.js +91 -48
  10. package/herdr-plugin.toml +1 -1
  11. package/node_modules/herdr-remote-relay/LICENSE +21 -0
  12. package/node_modules/herdr-remote-relay/README.md +76 -0
  13. package/node_modules/herdr-remote-relay/README.zh-CN.md +76 -0
  14. package/node_modules/herdr-remote-relay/bin/herdr-remote-relay.js +116 -0
  15. package/node_modules/herdr-remote-relay/config.example.json +18 -0
  16. package/node_modules/herdr-remote-relay/deploy/cloudflared/config.yml.example +9 -0
  17. package/node_modules/herdr-remote-relay/deploy/docker/compose.yaml +23 -0
  18. package/node_modules/herdr-remote-relay/deploy/nginx/herdr-remote-relay.conf.example +49 -0
  19. package/node_modules/herdr-remote-relay/deploy/systemd/herdr-remote-relay.service +41 -0
  20. package/node_modules/herdr-remote-relay/deploy/systemd/relay.env.example +25 -0
  21. package/node_modules/herdr-remote-relay/dist/auth-store.d.ts +114 -0
  22. package/node_modules/herdr-remote-relay/dist/auth-store.js +289 -0
  23. package/node_modules/herdr-remote-relay/dist/metrics.d.ts +76 -0
  24. package/node_modules/herdr-remote-relay/dist/metrics.js +249 -0
  25. package/node_modules/herdr-remote-relay/dist/protocol/frames.d.ts +27 -0
  26. package/node_modules/herdr-remote-relay/dist/protocol/frames.js +110 -0
  27. package/node_modules/herdr-remote-relay/dist/protocol/herdr-keys.d.ts +8 -0
  28. package/node_modules/herdr-remote-relay/dist/protocol/herdr-keys.js +34 -0
  29. package/node_modules/herdr-remote-relay/dist/protocol/http.d.ts +165 -0
  30. package/node_modules/herdr-remote-relay/dist/protocol/http.js +3 -0
  31. package/node_modules/herdr-remote-relay/dist/protocol/index.d.ts +7 -0
  32. package/node_modules/herdr-remote-relay/dist/protocol/index.js +9 -0
  33. package/node_modules/herdr-remote-relay/dist/protocol/messages.d.ts +362 -0
  34. package/node_modules/herdr-remote-relay/dist/protocol/messages.js +20 -0
  35. package/node_modules/herdr-remote-relay/dist/protocol/paste.d.ts +14 -0
  36. package/node_modules/herdr-remote-relay/dist/protocol/paste.js +50 -0
  37. package/node_modules/herdr-remote-relay/dist/protocol/release.d.ts +8 -0
  38. package/node_modules/herdr-remote-relay/dist/protocol/release.js +17 -0
  39. package/node_modules/herdr-remote-relay/dist/protocol/terminal.d.ts +79 -0
  40. package/node_modules/herdr-remote-relay/dist/protocol/terminal.js +150 -0
  41. package/node_modules/herdr-remote-relay/dist/relay-config.d.ts +74 -0
  42. package/node_modules/herdr-remote-relay/dist/relay-config.js +316 -0
  43. package/node_modules/herdr-remote-relay/dist/relay-server.d.ts +64 -0
  44. package/node_modules/herdr-remote-relay/dist/relay-server.js +194 -0
  45. package/node_modules/herdr-remote-relay/dist/scroll-input.d.ts +17 -0
  46. package/node_modules/herdr-remote-relay/dist/scroll-input.js +124 -0
  47. package/node_modules/herdr-remote-relay/dist/server/client-channel.d.ts +3 -0
  48. package/node_modules/herdr-remote-relay/dist/server/client-channel.js +396 -0
  49. package/node_modules/herdr-remote-relay/dist/server/font-proxy.d.ts +19 -0
  50. package/node_modules/herdr-remote-relay/dist/server/font-proxy.js +155 -0
  51. package/node_modules/herdr-remote-relay/dist/server/host-broadcasts.d.ts +27 -0
  52. package/node_modules/herdr-remote-relay/dist/server/host-broadcasts.js +83 -0
  53. package/node_modules/herdr-remote-relay/dist/server/host-channel.d.ts +3 -0
  54. package/node_modules/herdr-remote-relay/dist/server/host-channel.js +271 -0
  55. package/node_modules/herdr-remote-relay/dist/server/http-api.d.ts +3 -0
  56. package/node_modules/herdr-remote-relay/dist/server/http-api.js +214 -0
  57. package/node_modules/herdr-remote-relay/dist/server/maintenance.d.ts +5 -0
  58. package/node_modules/herdr-remote-relay/dist/server/maintenance.js +64 -0
  59. package/node_modules/herdr-remote-relay/dist/server/requests.d.ts +34 -0
  60. package/node_modules/herdr-remote-relay/dist/server/requests.js +142 -0
  61. package/node_modules/herdr-remote-relay/dist/server/sessions.d.ts +36 -0
  62. package/node_modules/herdr-remote-relay/dist/server/sessions.js +230 -0
  63. package/node_modules/herdr-remote-relay/dist/server/sockets.d.ts +12 -0
  64. package/node_modules/herdr-remote-relay/dist/server/sockets.js +64 -0
  65. package/node_modules/herdr-remote-relay/dist/server/status.d.ts +11 -0
  66. package/node_modules/herdr-remote-relay/dist/server/status.js +99 -0
  67. package/node_modules/herdr-remote-relay/dist/server/transport.d.ts +16 -0
  68. package/node_modules/herdr-remote-relay/dist/server/transport.js +110 -0
  69. package/node_modules/herdr-remote-relay/dist/server/types.d.ts +131 -0
  70. package/node_modules/herdr-remote-relay/dist/server/types.js +3 -0
  71. package/node_modules/herdr-remote-relay/dist/server/version.d.ts +2 -0
  72. package/node_modules/herdr-remote-relay/dist/server/version.js +5 -0
  73. package/node_modules/herdr-remote-relay/dist/state.d.ts +6 -0
  74. package/node_modules/herdr-remote-relay/dist/state.js +47 -0
  75. package/node_modules/herdr-remote-relay/package.json +76 -0
  76. package/node_modules/herdr-remote-relay/web/dist/assets/index-BeveQ9Di.js +61 -0
  77. package/node_modules/herdr-remote-relay/web/dist/assets/index-XYYRsgSb.css +32 -0
  78. package/node_modules/herdr-remote-relay/web/dist/assets/vendor-react-CYSfZuHu.js +17 -0
  79. package/node_modules/herdr-remote-relay/web/dist/assets/vendor-xterm-BhCxil_4.js +9 -0
  80. package/node_modules/herdr-remote-relay/web/dist/fonts/SymbolsNerdFontMono-Regular.woff2 +0 -0
  81. package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/OFL.txt +93 -0
  82. package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-400-italic.woff2 +0 -0
  83. package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-400-normal.woff2 +0 -0
  84. package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-700-italic.woff2 +0 -0
  85. package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-700-normal.woff2 +0 -0
  86. package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-ext-400-italic.woff2 +0 -0
  87. package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-ext-400-normal.woff2 +0 -0
  88. package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-ext-700-italic.woff2 +0 -0
  89. package/node_modules/herdr-remote-relay/web/dist/fonts/cascadia-code/cascadia-code-latin-ext-700-normal.woff2 +0 -0
  90. package/node_modules/herdr-remote-relay/web/dist/fonts/fira-code/OFL.txt +93 -0
  91. package/node_modules/herdr-remote-relay/web/dist/fonts/fira-code/fira-code-latin-400-normal.woff2 +0 -0
  92. package/node_modules/herdr-remote-relay/web/dist/fonts/fira-code/fira-code-latin-700-normal.woff2 +0 -0
  93. package/node_modules/herdr-remote-relay/web/dist/fonts/fira-code/fira-code-latin-ext-400-normal.woff2 +0 -0
  94. package/node_modules/herdr-remote-relay/web/dist/fonts/fira-code/fira-code-latin-ext-700-normal.woff2 +0 -0
  95. package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/OFL.txt +93 -0
  96. package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-italic.woff2 +0 -0
  97. package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  98. package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-700-italic.woff2 +0 -0
  99. package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-700-normal.woff2 +0 -0
  100. package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-italic.woff2 +0 -0
  101. package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
  102. package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-700-italic.woff2 +0 -0
  103. package/node_modules/herdr-remote-relay/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-700-normal.woff2 +0 -0
  104. package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/OFL.txt +93 -0
  105. package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-400-italic.woff2 +0 -0
  106. package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-400-normal.woff2 +0 -0
  107. package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-700-italic.woff2 +0 -0
  108. package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-700-normal.woff2 +0 -0
  109. package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-ext-400-italic.woff2 +0 -0
  110. package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
  111. package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-ext-700-italic.woff2 +0 -0
  112. package/node_modules/herdr-remote-relay/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-ext-700-normal.woff2 +0 -0
  113. package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/OFL.txt +93 -0
  114. package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-400-italic.woff2 +0 -0
  115. package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-400-normal.woff2 +0 -0
  116. package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-700-italic.woff2 +0 -0
  117. package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-700-normal.woff2 +0 -0
  118. package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-ext-400-italic.woff2 +0 -0
  119. package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-ext-400-normal.woff2 +0 -0
  120. package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-ext-700-italic.woff2 +0 -0
  121. package/node_modules/herdr-remote-relay/web/dist/fonts/source-code-pro/source-code-pro-latin-ext-700-normal.woff2 +0 -0
  122. package/node_modules/herdr-remote-relay/web/dist/index.html +36 -0
  123. package/node_modules/ws/LICENSE +20 -0
  124. package/node_modules/ws/README.md +548 -0
  125. package/node_modules/ws/browser.js +8 -0
  126. package/node_modules/ws/index.js +22 -0
  127. package/node_modules/ws/lib/buffer-util.js +131 -0
  128. package/node_modules/ws/lib/constants.js +19 -0
  129. package/node_modules/ws/lib/event-target.js +292 -0
  130. package/node_modules/ws/lib/extension.js +203 -0
  131. package/node_modules/ws/lib/limiter.js +55 -0
  132. package/node_modules/ws/lib/permessage-deflate.js +530 -0
  133. package/node_modules/ws/lib/receiver.js +743 -0
  134. package/node_modules/ws/lib/sender.js +607 -0
  135. package/node_modules/ws/lib/stream.js +161 -0
  136. package/node_modules/ws/lib/subprotocol.js +62 -0
  137. package/node_modules/ws/lib/validation.js +152 -0
  138. package/node_modules/ws/lib/websocket-server.js +562 -0
  139. package/node_modules/ws/lib/websocket.js +1407 -0
  140. package/node_modules/ws/package.json +74 -0
  141. package/node_modules/ws/wrapper.mjs +21 -0
  142. package/package.json +7 -3
  143. package/dist/relay-updater.js +0 -81
  144. package/dist/tui/screens/useRelayUpdate.js +0 -122
@@ -0,0 +1,396 @@
1
+ // The browser side of the relay: a window's WebSocket, from its hello and
2
+ // pairing to every message and keystroke it sends after.
3
+ import { CAPABILITY, FRAME_TYPE_INPUT, PASTE_MAX_BYTES, PROTOCOL_VERSION, hasImageSignature, isPasteImageMime, packStreamFrame, packStreamFrameV2, } from '../protocol/index.js';
4
+ import { requestFontChunk, requestFontSubset } from './font-proxy.js';
5
+ import { allowClientHandshake, allowPairAttempt, clientAddress } from './requests.js';
6
+ import { MIN_SESSION_COLS, MIN_SESSION_ROWS, beginHostReconnect, broadcastControlState, detachClient, startSession, } from './sessions.js';
7
+ import { clampDimension, closeSocket, isOpen, jsonSend, parseJson, randomId, rejectHandshake, } from './sockets.js';
8
+ import { enqueueDelayedSend, notifyHostClientCount } from './transport.js';
9
+ /** A window asks to start its workstation's Herdr at most this often. */
10
+ const HERDR_START_REPEAT_MS = 3_000;
11
+ /** A window asks its workstation to re-read the terminal font at most this often. */
12
+ const FONT_REFRESH_REPEAT_MS = 2_000;
13
+ /** A window opens an administrator tab at most this often. */
14
+ const ADMIN_TAB_REPEAT_MS = 1_000;
15
+ function verifyImageMagicBytes(mime, dataBase64) {
16
+ if (typeof dataBase64 !== 'string' || dataBase64.length === 0)
17
+ return false;
18
+ let headerBuf;
19
+ try {
20
+ headerBuf = Buffer.from(dataBase64.slice(0, 32), 'base64');
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ if (headerBuf.length === 0)
26
+ return false;
27
+ return hasImageSignature(mime, headerBuf);
28
+ }
29
+ export function handleClientConnection(relay, ws, req) {
30
+ const pending = { ws, req, authenticated: false };
31
+ const deadline = setTimeout(() => {
32
+ if (!pending.authenticated)
33
+ closeSocket(ws, 1008, 'client hello timeout');
34
+ }, 10000);
35
+ ws.isAlive = true;
36
+ ws.on('pong', () => {
37
+ ws.isAlive = true;
38
+ if (pending.client) {
39
+ pending.client.lastSeenAt = Date.now();
40
+ pending.client.lastPingAt = new Date().toISOString();
41
+ }
42
+ });
43
+ ws.on('ping', () => {
44
+ ws.isAlive = true;
45
+ if (pending.client) {
46
+ pending.client.lastSeenAt = Date.now();
47
+ pending.client.lastPingAt = new Date().toISOString();
48
+ }
49
+ });
50
+ ws.on('message', (raw, isBinary) => {
51
+ if (!pending.authenticated) {
52
+ if (isBinary)
53
+ return rejectHandshake(ws, 'client hello must be JSON');
54
+ const message = parseJson(raw.toString());
55
+ if (message?.type !== 'hello' || message.protocol !== PROTOCOL_VERSION)
56
+ return rejectHandshake(ws, 'invalid client hello');
57
+ if (!allowClientHandshake(relay, req))
58
+ return rejectHandshake(ws, 'too many connection attempts', 'rate_limited');
59
+ let device = null;
60
+ let paired = null;
61
+ if (message.pairCode) {
62
+ if (!allowPairAttempt(relay, req))
63
+ return rejectHandshake(ws, 'too many pairing attempts', 'rate_limited');
64
+ paired = relay.auth.completePairing(message.pairCode);
65
+ }
66
+ if (paired)
67
+ device = paired;
68
+ else if (message.token)
69
+ device = relay.auth.authenticateDevice(message.token);
70
+ if (!device)
71
+ return rejectHandshake(ws, 'valid device token or pairing code required', 'auth_required');
72
+ const host = relay.hosts.get(device.hostId);
73
+ if (!host || host.reconnecting || !isOpen(host.ws))
74
+ return rejectHandshake(ws, 'paired Herdr host is offline', host?.reconnecting ? 'host_reconnecting' : 'host_offline');
75
+ // Two tabs of one browser are separate windows with their own PTY sessions,
76
+ // not rivals. Nothing is retired here: each connection gets its own stream
77
+ // without evicting existing clients.
78
+ if (host.clients.size >= relay.config.relay.maxClientsPerHost)
79
+ return rejectHandshake(ws, 'host client limit reached', 'too_many_clients');
80
+ clearTimeout(deadline);
81
+ relay.finishHandshake(ws);
82
+ const clientId = randomId('client');
83
+ const client = {
84
+ id: clientId,
85
+ ws,
86
+ hostId: host.id,
87
+ deviceId: device.deviceId,
88
+ // Stable per browser profile; used to recognise a reconnect from the
89
+ // same browser rather than a genuinely separate viewer.
90
+ browserClientId: typeof message.clientId === 'string' ? message.clientId.slice(0, 128) : null,
91
+ handoffCapable: Array.isArray(message.capabilities) &&
92
+ message.capabilities.includes(CAPABILITY.hostHandoff),
93
+ session: null,
94
+ // Every paired window gets its own interactive PTY session. Pairing
95
+ // is the permission boundary; once a device is through it, every
96
+ // window can type into its own terminal.
97
+ role: 'controller',
98
+ controllerId: null,
99
+ connectedAt: new Date().toISOString(),
100
+ connectedAtMs: Date.now(),
101
+ lastSeenAt: Date.now(),
102
+ lastPingAt: null,
103
+ bytesReceived: 0,
104
+ bytesSent: 0,
105
+ ip: clientAddress(relay, req),
106
+ userAgent: String(req.headers['user-agent'] || '').slice(0, 256),
107
+ cols: clampDimension(message.cols, 80),
108
+ rows: clampDimension(message.rows, 24),
109
+ };
110
+ client.controllerId = null;
111
+ // Persist how this device identifies itself so the operator dashboard
112
+ // can name it in the revoke list instead of showing a bare device id.
113
+ relay.auth.noteDeviceSeen(device.deviceId, { userAgent: client.userAgent, ip: client.ip });
114
+ pending.authenticated = true;
115
+ pending.client = client;
116
+ relay.clients.set(client.id, client);
117
+ host.clients.add(client.id);
118
+ notifyHostClientCount(host);
119
+ ws.isAlive = true;
120
+ if (paired)
121
+ jsonSend(ws, {
122
+ type: 'paired',
123
+ token: paired.token,
124
+ deviceId: paired.deviceId,
125
+ hostId: paired.hostId,
126
+ expiresAt: paired.expiresAtIso,
127
+ });
128
+ // Expose the relay-assigned connection id so clients can distinguish
129
+ // their own controller lease from another device's lease. The browser
130
+ // supplied clientId identifies a device, not this live WebSocket.
131
+ jsonSend(ws, {
132
+ type: 'ready',
133
+ role: client.role,
134
+ // There is no controller to name: every window has full input. The
135
+ // field stays in the message for clients built against protocol 1,
136
+ // which read it to decide whether somebody else held the lease — and
137
+ // `null` is exactly the answer that means "nobody does".
138
+ controllerId: null,
139
+ hostId: host.id,
140
+ hostname: host.hostname,
141
+ platform: host.platform,
142
+ ...(host.shellProfile ? { shellProfile: host.shellProfile } : {}),
143
+ adminTerminalSupported: host.adminTabsAvailable,
144
+ clientId: client.id,
145
+ // Delivered with `ready`, before the first PTY byte, so the terminal
146
+ // is painted in the host's colors from its very first frame.
147
+ terminalPalette: host.terminalPalette || null,
148
+ terminalFont: host.terminalFont || null,
149
+ clientCount: host.clients.size,
150
+ hostVersion: host.version,
151
+ ...(host.herdrPrefixKeys ? { herdrPrefixKeys: host.herdrPrefixKeys } : {}),
152
+ });
153
+ if (host.agentStatus)
154
+ jsonSend(ws, host.agentStatus);
155
+ if (host.updateStatus)
156
+ jsonSend(ws, host.updateStatus);
157
+ startSession(relay, host, client);
158
+ broadcastControlState(relay, host);
159
+ return;
160
+ }
161
+ if (pending.client)
162
+ handleClientMessage(relay, pending.client, raw, isBinary);
163
+ });
164
+ ws.on('close', () => {
165
+ clearTimeout(deadline);
166
+ if (pending.client)
167
+ detachClient(relay, pending.client, { notify: true });
168
+ });
169
+ ws.on('error', () => { });
170
+ }
171
+ function handleClientMessage(relay, client, raw, isBinary) {
172
+ client.lastSeenAt = Date.now();
173
+ const host = relay.hosts.get(client.hostId);
174
+ if (!host) {
175
+ detachClient(relay, client, { notify: true, reason: 'host_offline' });
176
+ return;
177
+ }
178
+ if (isBinary) {
179
+ // The server keeps ws's default binaryType, so a binary frame is one Buffer.
180
+ const bytes = raw;
181
+ if (bytes.length > relay.config.relay.maxPayloadBytes)
182
+ return;
183
+ if (!client.session)
184
+ return;
185
+ // Each window owns its own PTY session, so input is stamped with the
186
+ // client's dedicated stream id or streamIndex.
187
+ const frame = host.binaryFrameV2 && typeof client.session.streamIndex === 'number'
188
+ ? packStreamFrameV2(FRAME_TYPE_INPUT, client.session.streamIndex, bytes)
189
+ : packStreamFrame('input', client.session.streamId, bytes);
190
+ if (isOpen(host.ws)) {
191
+ const doSend = () => {
192
+ if (!isOpen(host.ws))
193
+ return;
194
+ try {
195
+ host.ws.send(frame);
196
+ client.bytesReceived += bytes.length;
197
+ relay.metrics.recordIn(bytes.length, host.id);
198
+ }
199
+ catch {
200
+ beginHostReconnect(relay, host, 'host_send_failed');
201
+ }
202
+ };
203
+ if (relay.devLatencyMs > 0)
204
+ enqueueDelayedSend(relay, host.ws, doSend);
205
+ else
206
+ doSend();
207
+ }
208
+ return;
209
+ }
210
+ // Untrusted: each branch reads only the fields its message type defines.
211
+ const message = parseJson(raw.toString());
212
+ if (!message) {
213
+ jsonSend(client.ws, { type: 'error', code: 'invalid_json', message: 'message must be JSON' });
214
+ return;
215
+ }
216
+ if (message.type === 'ping') {
217
+ client.lastPingAt = new Date().toISOString();
218
+ jsonSend(client.ws, { type: 'pong' });
219
+ }
220
+ else if (message.type === 'resize') {
221
+ // Each client owns its own PTY session, so resizing directly forwards
222
+ // the new geometry for this client's stream to the host.
223
+ client.cols = clampDimension(message.cols, client.cols);
224
+ client.rows = clampDimension(message.rows, client.rows);
225
+ if (client.session) {
226
+ client.session.cols = client.cols;
227
+ client.session.rows = client.rows;
228
+ if (isOpen(host.ws)) {
229
+ jsonSend(host.ws, {
230
+ type: 'resize',
231
+ clientId: client.session.streamId,
232
+ streamId: client.session.streamId,
233
+ cols: Math.max(MIN_SESSION_COLS, client.cols),
234
+ rows: Math.max(MIN_SESSION_ROWS, client.rows),
235
+ });
236
+ }
237
+ }
238
+ }
239
+ else if (message.type === 'herdr_start') {
240
+ // The window's own workstation, and nobody else's: `host` was fixed by
241
+ // the device token at handshake, and nothing in this message can name
242
+ // another one. The host starts Herdr as its own user, at its own socket.
243
+ // Repeats are dropped — one click is enough, and the host shares a
244
+ // start between windows anyway.
245
+ if (!client.session) {
246
+ jsonSend(client.ws, {
247
+ type: 'error',
248
+ code: 'no_session',
249
+ message: 'terminal session is not ready',
250
+ });
251
+ return;
252
+ }
253
+ const now = Date.now();
254
+ if (client.herdrStartAt && now - client.herdrStartAt < HERDR_START_REPEAT_MS)
255
+ return;
256
+ client.herdrStartAt = now;
257
+ if (isOpen(host.ws)) {
258
+ jsonSend(host.ws, {
259
+ type: 'herdr_start',
260
+ clientId: client.session.streamId,
261
+ streamId: client.session.streamId,
262
+ });
263
+ }
264
+ }
265
+ else if (message.type === 'admin_tab_open') {
266
+ // Routed like herdr_start. The host runs the fixed elevated PowerShell of
267
+ // its own broker; nothing in this message chooses what starts.
268
+ if (!client.session || !isOpen(host.ws))
269
+ return;
270
+ if (!host.adminTabsAvailable) {
271
+ jsonSend(client.ws, {
272
+ type: 'error',
273
+ code: 'admin_terminal_unsupported',
274
+ message: 'Update the host connector to open administrator terminals.',
275
+ });
276
+ return;
277
+ }
278
+ // A double tap is one request; a second tab needs a second, later tap.
279
+ const now = Date.now();
280
+ if (client.adminTabAt && now - client.adminTabAt < ADMIN_TAB_REPEAT_MS)
281
+ return;
282
+ client.adminTabAt = now;
283
+ jsonSend(host.ws, {
284
+ type: 'admin_tab_open',
285
+ clientId: client.session.streamId,
286
+ streamId: client.session.streamId,
287
+ });
288
+ }
289
+ else if (message.type === 'host_font_chunk_request') {
290
+ requestFontChunk(host, client, message);
291
+ }
292
+ else if (message.type === 'host_font_subset_request') {
293
+ requestFontSubset(host, client, message);
294
+ }
295
+ else if (message.type === 'host_font_refresh') {
296
+ // Same routing as herdr_start: only the workstation this window is
297
+ // bound to, and only what it offered. Repeats inside the window are
298
+ // dropped; the answer is broadcast to every window anyway.
299
+ if (!client.session || !isOpen(host.ws))
300
+ return;
301
+ const now = Date.now();
302
+ if (client.fontRefreshAt && now - client.fontRefreshAt < FONT_REFRESH_REPEAT_MS)
303
+ return;
304
+ client.fontRefreshAt = now;
305
+ jsonSend(host.ws, {
306
+ type: 'host_font_refresh',
307
+ clientId: client.session.streamId,
308
+ streamId: client.session.streamId,
309
+ });
310
+ }
311
+ else if (message.type === 'claim_control') {
312
+ // Control is no longer a lease. Answering the old request keeps clients
313
+ // built against the previous protocol working.
314
+ client.role = 'controller';
315
+ jsonSend(client.ws, { type: 'control_granted' });
316
+ }
317
+ else if (message.type === 'release_control') {
318
+ // Nothing to release: the window keeps its input either way, and saying
319
+ // so beats a silence an older client would wait on.
320
+ jsonSend(client.ws, { type: 'control_state', role: 'controller', controllerId: null });
321
+ }
322
+ else if (message.type === 'paste_file') {
323
+ if (client.role === 'viewer') {
324
+ jsonSend(client.ws, {
325
+ type: 'error',
326
+ code: 'viewer_mode',
327
+ message: 'Viewer mode cannot paste to terminal',
328
+ });
329
+ return;
330
+ }
331
+ if (!isPasteImageMime(message.mime)) {
332
+ jsonSend(client.ws, {
333
+ type: 'error',
334
+ code: 'paste_file_unsupported',
335
+ message: 'Unsupported paste image format',
336
+ });
337
+ return;
338
+ }
339
+ if (typeof message.dataBase64 !== 'string') {
340
+ jsonSend(client.ws, {
341
+ type: 'error',
342
+ code: 'paste_file_unsupported',
343
+ message: 'Invalid paste image payload',
344
+ });
345
+ return;
346
+ }
347
+ const rawLength = Buffer.byteLength(message.dataBase64, 'base64');
348
+ if (rawLength === 0) {
349
+ jsonSend(client.ws, {
350
+ type: 'error',
351
+ code: 'paste_file_empty',
352
+ message: 'Pasted image payload is empty',
353
+ });
354
+ return;
355
+ }
356
+ if (rawLength > PASTE_MAX_BYTES) {
357
+ jsonSend(client.ws, {
358
+ type: 'error',
359
+ code: 'paste_file_too_large',
360
+ message: 'Pasted image exceeds 3 MB limit',
361
+ });
362
+ return;
363
+ }
364
+ if (!verifyImageMagicBytes(message.mime, message.dataBase64)) {
365
+ jsonSend(client.ws, {
366
+ type: 'error',
367
+ code: 'paste_file_unsupported',
368
+ message: 'Pasted image content does not match declared MIME type',
369
+ });
370
+ return;
371
+ }
372
+ if (!isOpen(host?.ws)) {
373
+ jsonSend(client.ws, {
374
+ type: 'error',
375
+ code: 'host_offline',
376
+ message: 'Host is offline or disconnected',
377
+ });
378
+ return;
379
+ }
380
+ if (!client.session) {
381
+ jsonSend(client.ws, {
382
+ type: 'error',
383
+ code: 'no_session',
384
+ message: 'Terminal session is not ready',
385
+ });
386
+ return;
387
+ }
388
+ jsonSend(host.ws, {
389
+ type: 'paste_file',
390
+ clientId: client.session.streamId,
391
+ streamId: client.session.streamId,
392
+ mime: message.mime,
393
+ dataBase64: message.dataBase64,
394
+ });
395
+ }
396
+ }
@@ -0,0 +1,19 @@
1
+ import { type ClientHostFontChunkRequestMessage, type ClientHostFontSubsetRequestMessage, type HostFontChunkMessage, type HostFontSubsetMessage } from '../protocol/index.js';
2
+ import type { RelayClient, RelayHost } from './types.js';
3
+ /** One outstanding font chunk request of this window was answered. */
4
+ export declare function settleFontChunk(client: RelayClient): void;
5
+ /** One outstanding font subset request of this window was answered. */
6
+ export declare function settleFontSubset(client: RelayClient): void;
7
+ /**
8
+ * Pass a browser's request for one slice of a font file to its workstation.
9
+ * Only a file the workstation announced can be asked for, by its hash, and
10
+ * only a few slices at a time.
11
+ */
12
+ export declare function requestFontChunk(host: RelayHost, client: RelayClient, message: ClientHostFontChunkRequestMessage): void;
13
+ /**
14
+ * Ask the workstation to cut characters out of a large font it announced.
15
+ * The text is only characters to look up; a few requests at a time.
16
+ */
17
+ export declare function requestFontSubset(host: RelayHost, client: RelayClient, message: ClientHostFontSubsetRequestMessage): void;
18
+ export declare function forwardFontSubset(client: RelayClient, message: HostFontSubsetMessage): void;
19
+ export declare function forwardFontChunk(client: RelayClient, message: HostFontChunkMessage): void;
@@ -0,0 +1,155 @@
1
+ // Terminal font files and cuts, passed between a window and its workstation a
2
+ // slice at a time, and only for what the workstation announced.
3
+ import { TERMINAL_FONT_CHUNK_BYTES, } from '../protocol/index.js';
4
+ import { isOpen, jsonSend } from './sockets.js';
5
+ /**
6
+ * Font chunks a window may have outstanding. Fonts are pulled a slice at a
7
+ * time so megabytes of font never queue ahead of terminal output; an answer
8
+ * that never comes stops counting after the timeout.
9
+ */
10
+ const MAX_FONT_CHUNKS_IN_FLIGHT = 4;
11
+ const FONT_CHUNK_TIMEOUT_MS = 30_000;
12
+ /** Base64 of one full chunk, the most a `host_font_chunk` may carry. */
13
+ const MAX_FONT_CHUNK_BASE64 = Math.ceil(TERMINAL_FONT_CHUNK_BYTES / 3) * 4;
14
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
15
+ /** Characters one subset request may ask for (UTF-16 units of `text`). */
16
+ const MAX_FONT_SUBSET_TEXT = 32 * 1024;
17
+ /** Cut fonts a window may pull by hash; older ones stop being fetchable. */
18
+ const MAX_FONT_SUBSETS_PER_CLIENT = 64;
19
+ const MAX_FONT_SUBSET_BYTES = 16 * 1024 * 1024;
20
+ const REQUEST_ID = /^[A-Za-z0-9_-]{1,32}$/;
21
+ /** One outstanding font chunk request of this window was answered. */
22
+ export function settleFontChunk(client) {
23
+ if (Array.isArray(client.fontChunkRequests) && client.fontChunkRequests.length)
24
+ client.fontChunkRequests.shift();
25
+ }
26
+ /** One outstanding font subset request of this window was answered. */
27
+ export function settleFontSubset(client) {
28
+ if (Array.isArray(client.fontSubsetRequests) && client.fontSubsetRequests.length)
29
+ client.fontSubsetRequests.shift();
30
+ }
31
+ /**
32
+ * Pass a browser's request for one slice of a font file to its workstation.
33
+ * Only a file the workstation announced can be asked for, by its hash, and
34
+ * only a few slices at a time.
35
+ */
36
+ export function requestFontChunk(host, client, message) {
37
+ if (!client.session || !isOpen(host.ws))
38
+ return;
39
+ const face = host.terminalFont?.faces?.find((candidate) => candidate.sha256 === message.sha256);
40
+ // A whole font file the workstation announced, or a cut it made for this window.
41
+ const bytes = face ? face.bytes : client.fontSubsets?.get(message.sha256) || 0;
42
+ const total = Math.ceil(bytes / TERMINAL_FONT_CHUNK_BYTES);
43
+ if (!bytes || !Number.isInteger(message.index) || message.index < 0 || message.index >= total) {
44
+ jsonSend(client.ws, {
45
+ type: 'error',
46
+ code: 'host_font_unavailable',
47
+ message: 'The workstation did not offer this font file',
48
+ });
49
+ return;
50
+ }
51
+ const now = Date.now();
52
+ client.fontChunkRequests = (client.fontChunkRequests || []).filter((at) => now - at < FONT_CHUNK_TIMEOUT_MS);
53
+ if (client.fontChunkRequests.length >= MAX_FONT_CHUNKS_IN_FLIGHT)
54
+ return;
55
+ client.fontChunkRequests.push(now);
56
+ jsonSend(host.ws, {
57
+ type: 'host_font_chunk_request',
58
+ clientId: client.session.streamId,
59
+ streamId: client.session.streamId,
60
+ sha256: message.sha256,
61
+ index: message.index,
62
+ });
63
+ }
64
+ /**
65
+ * Ask the workstation to cut characters out of a large font it announced.
66
+ * The text is only characters to look up; a few requests at a time.
67
+ */
68
+ export function requestFontSubset(host, client, message) {
69
+ if (!client.session || !isOpen(host.ws))
70
+ return;
71
+ const known = host.terminalFont?.subsets?.some((source) => source.sha256 === message.sha256);
72
+ const valid = known &&
73
+ typeof message.text === 'string' &&
74
+ message.text.length > 0 &&
75
+ message.text.length <= MAX_FONT_SUBSET_TEXT &&
76
+ typeof message.requestId === 'string' &&
77
+ REQUEST_ID.test(message.requestId);
78
+ if (!valid) {
79
+ jsonSend(client.ws, {
80
+ type: 'error',
81
+ code: 'host_font_unavailable',
82
+ message: 'The workstation did not offer this font',
83
+ });
84
+ return;
85
+ }
86
+ const now = Date.now();
87
+ client.fontSubsetRequests = (client.fontSubsetRequests || []).filter((at) => now - at < FONT_CHUNK_TIMEOUT_MS);
88
+ if (client.fontSubsetRequests.length >= MAX_FONT_CHUNKS_IN_FLIGHT)
89
+ return;
90
+ client.fontSubsetRequests.push(now);
91
+ jsonSend(host.ws, {
92
+ type: 'host_font_subset_request',
93
+ clientId: client.session.streamId,
94
+ streamId: client.session.streamId,
95
+ requestId: message.requestId,
96
+ sha256: message.sha256,
97
+ text: message.text,
98
+ });
99
+ }
100
+ export function forwardFontSubset(client, message) {
101
+ settleFontSubset(client);
102
+ const valid = typeof message.sha256 === 'string' &&
103
+ SHA256_HEX.test(message.sha256) &&
104
+ typeof message.subsetSha === 'string' &&
105
+ SHA256_HEX.test(message.subsetSha) &&
106
+ typeof message.requestId === 'string' &&
107
+ REQUEST_ID.test(message.requestId) &&
108
+ Number.isInteger(message.bytes) &&
109
+ message.bytes > 0 &&
110
+ message.bytes <= MAX_FONT_SUBSET_BYTES &&
111
+ (message.dataBase64 === undefined ||
112
+ (typeof message.dataBase64 === 'string' &&
113
+ message.dataBase64.length <= MAX_FONT_CHUNK_BASE64));
114
+ if (!valid)
115
+ return;
116
+ if (message.dataBase64 === undefined) {
117
+ // Pulled in slices next: make this hash fetchable for this window only.
118
+ client.fontSubsets ||= new Map();
119
+ client.fontSubsets.delete(message.subsetSha);
120
+ client.fontSubsets.set(message.subsetSha, message.bytes);
121
+ while (client.fontSubsets.size > MAX_FONT_SUBSETS_PER_CLIENT) {
122
+ const oldest = client.fontSubsets.keys().next().value;
123
+ if (oldest !== undefined)
124
+ client.fontSubsets.delete(oldest);
125
+ }
126
+ }
127
+ jsonSend(client.ws, {
128
+ type: 'host_font_subset_ready',
129
+ requestId: message.requestId,
130
+ sha256: message.sha256,
131
+ subsetSha: message.subsetSha,
132
+ bytes: message.bytes,
133
+ ...(message.dataBase64 !== undefined ? { dataBase64: message.dataBase64 } : {}),
134
+ });
135
+ }
136
+ export function forwardFontChunk(client, message) {
137
+ settleFontChunk(client);
138
+ const valid = typeof message.sha256 === 'string' &&
139
+ SHA256_HEX.test(message.sha256) &&
140
+ Number.isInteger(message.index) &&
141
+ message.index >= 0 &&
142
+ Number.isInteger(message.total) &&
143
+ message.total > message.index &&
144
+ typeof message.dataBase64 === 'string' &&
145
+ message.dataBase64.length <= MAX_FONT_CHUNK_BASE64;
146
+ if (!valid)
147
+ return;
148
+ jsonSend(client.ws, {
149
+ type: 'host_font_chunk',
150
+ sha256: message.sha256,
151
+ index: message.index,
152
+ total: message.total,
153
+ dataBase64: message.dataBase64,
154
+ });
155
+ }
@@ -0,0 +1,27 @@
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
+ /** `value` if it is a release version, else null: it ends up as text in browsers. */
8
+ export declare function releaseVersion(value: unknown): string | null;
9
+ /**
10
+ * Pass on what the workstation's agents are doing.
11
+ *
12
+ * Sanitised rather than forwarded: the host is trusted, but this is the one
13
+ * message whose contents come from terminal titles, and it lands in a status
14
+ * bar on every attached window. The counts are numbers and the list is
15
+ * bounded, so a workstation cannot grow a browser's memory by naming things
16
+ * carefully.
17
+ */
18
+ export declare function broadcastAgentStatus(relay: RelayContext, host: RelayHost, message: Unchecked<HostAgentStatusMessage>): void;
19
+ /**
20
+ * Whether the workstation runs the newest herdr-remote, for every window on
21
+ * it. Only version strings and three flags pass: this ends up as text in the
22
+ * browser, and the host is not the relay's to trust.
23
+ */
24
+ export declare function broadcastUpdateStatus(relay: RelayContext, host: RelayHost, message: Unchecked<HostUpdateStatusMessage>): void;
25
+ /** The workstation re-read its terminal font; every window draws with it. */
26
+ export declare function broadcastTerminalFont(relay: RelayContext, host: RelayHost, message: Unchecked<HostTerminalFontMessage>): void;
27
+ export {};
@@ -0,0 +1,83 @@
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
+ /** `value` if it is a release version, else null: it ends up as text in browsers. */
10
+ export function releaseVersion(value) {
11
+ return typeof value === 'string' && RELEASE_VERSION.test(value) ? value : null;
12
+ }
13
+ /** Matches the host's own cap; the relay re-applies it rather than trusting it. */
14
+ const MAX_AGENT_STATUS_ENTRIES = 16;
15
+ /**
16
+ * Pass on what the workstation's agents are doing.
17
+ *
18
+ * Sanitised rather than forwarded: the host is trusted, but this is the one
19
+ * message whose contents come from terminal titles, and it lands in a status
20
+ * bar on every attached window. The counts are numbers and the list is
21
+ * bounded, so a workstation cannot grow a browser's memory by naming things
22
+ * carefully.
23
+ */
24
+ export function broadcastAgentStatus(relay, host, message) {
25
+ const counts = {};
26
+ if (message.counts && typeof message.counts === 'object') {
27
+ for (const [status, value] of Object.entries(message.counts)) {
28
+ if (typeof value === 'number' && Number.isFinite(value)) {
29
+ counts[status.slice(0, 32)] = Math.max(0, Math.min(9999, Math.trunc(value)));
30
+ }
31
+ }
32
+ }
33
+ const agents = (Array.isArray(message.agents) ? message.agents : [])
34
+ .slice(0, MAX_AGENT_STATUS_ENTRIES)
35
+ .map((agent) => ({
36
+ paneId: text(agent?.paneId, 64),
37
+ workspaceId: text(agent?.workspaceId, 64),
38
+ agent: text(agent?.agent, 32),
39
+ title: text(agent?.title, 64),
40
+ status: text(agent?.status, 16),
41
+ focused: agent?.focused === true,
42
+ }));
43
+ const payload = {
44
+ type: 'agent_status',
45
+ focusedPaneId: text(message.focusedPaneId, 64),
46
+ focusedAgent: text(message.focusedAgent, 32),
47
+ counts,
48
+ total: typeof message.total === 'number' && Number.isFinite(message.total)
49
+ ? Math.max(0, Math.trunc(message.total))
50
+ : agents.length,
51
+ agents,
52
+ };
53
+ host.agentStatus = payload;
54
+ broadcastToClients(relay, host, () => payload);
55
+ }
56
+ /**
57
+ * Whether the workstation runs the newest herdr-remote, for every window on
58
+ * it. Only version strings and three flags pass: this ends up as text in the
59
+ * browser, and the host is not the relay's to trust.
60
+ */
61
+ export function broadcastUpdateStatus(relay, host, message) {
62
+ const current = releaseVersion(message.current);
63
+ const latest = releaseVersion(message.latest);
64
+ if (!current || !latest)
65
+ return;
66
+ const payload = {
67
+ type: 'update_status',
68
+ current,
69
+ installed: releaseVersion(message.installed) || current,
70
+ latest,
71
+ updateAvailable: message.updateAvailable === true,
72
+ restartPending: message.restartPending === true,
73
+ updateCommand: message.updateCommand === true,
74
+ };
75
+ host.updateStatus = payload;
76
+ broadcastToClients(relay, host, () => payload);
77
+ }
78
+ /** The workstation re-read its terminal font; every window draws with it. */
79
+ export function broadcastTerminalFont(relay, host, message) {
80
+ host.terminalFont = sanitizeTerminalFont(message.terminalFont);
81
+ const payload = { type: 'terminal_font', terminalFont: host.terminalFont || null };
82
+ broadcastToClients(relay, host, () => payload);
83
+ }