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,150 @@
1
+ // The workstation's terminal palette and font, as a host reports them and a
2
+ // browser receives them. Browser-safe: no Node APIs, so the web app imports it.
3
+ /**
4
+ * The sixteen ANSI slots a host may report, in index order. A palette is
5
+ * all-or-nothing: half the host's colors mixed with half the browser's would
6
+ * look worse than either set on its own.
7
+ */
8
+ export const ANSI_PALETTE_KEYS = [
9
+ 'black',
10
+ 'red',
11
+ 'green',
12
+ 'yellow',
13
+ 'blue',
14
+ 'magenta',
15
+ 'cyan',
16
+ 'white',
17
+ 'brightBlack',
18
+ 'brightRed',
19
+ 'brightGreen',
20
+ 'brightYellow',
21
+ 'brightBlue',
22
+ 'brightMagenta',
23
+ 'brightCyan',
24
+ 'brightWhite',
25
+ ];
26
+ /** The faces a host may offer, in the order a browser registers them. */
27
+ export const TERMINAL_FONT_STYLES = ['regular', 'bold', 'italic', 'boldItalic'];
28
+ export const TERMINAL_FONT_FORMATS = ['truetype', 'opentype'];
29
+ /** `cjk`: the face Hanzi fall back to; `all`: the family itself, too big to send whole. */
30
+ export const TERMINAL_FONT_SUBSET_SCOPES = ['cjk', 'all'];
31
+ /** One font file; a CJK face is larger than this and is left to the browser. */
32
+ export const MAX_TERMINAL_FONT_BYTES = 16 * 1024 * 1024;
33
+ /** Raw bytes per `host_font_chunk`; base64 keeps it far below any payload cap. */
34
+ export const TERMINAL_FONT_CHUNK_BYTES = 256 * 1024;
35
+ const HEX_COLOR = /^#[0-9a-f]{6}$/i;
36
+ /**
37
+ * A family name ends up inside a CSS `font-family` list in the browser. Quotes,
38
+ * separators and escapes are what would let a name break out of its slot, and
39
+ * no real family needs them.
40
+ */
41
+ const FONT_FAMILY_FORBIDDEN = /["'\\;,{}<>@\u0000-\u001f\u007f]/;
42
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
43
+ function isRecord(value) {
44
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
45
+ }
46
+ function isOneOf(list, value) {
47
+ return list.includes(value);
48
+ }
49
+ function hexColor(value) {
50
+ return typeof value === 'string' && HEX_COLOR.test(value) ? value.toLowerCase() : undefined;
51
+ }
52
+ /**
53
+ * Validates a terminal palette crossing the wire.
54
+ *
55
+ * The host reports what its own terminal answered to the OSC color queries,
56
+ * and the browser paints with it. Anything that is not a plain `#rrggbb`
57
+ * string is dropped here, so a compromised or buggy host cannot push arbitrary
58
+ * data into a browser's renderer options.
59
+ */
60
+ export function sanitizeTerminalPalette(value) {
61
+ if (!isRecord(value))
62
+ return null;
63
+ const palette = {};
64
+ for (const key of ['background', 'foreground', 'cursor']) {
65
+ const color = hexColor(value[key]);
66
+ if (color)
67
+ palette[key] = color;
68
+ }
69
+ const ansi = value.ansi;
70
+ if (isRecord(ansi)) {
71
+ const collected = {};
72
+ for (const key of ANSI_PALETTE_KEYS) {
73
+ const color = hexColor(ansi[key]);
74
+ if (color)
75
+ collected[key] = color;
76
+ }
77
+ if (Object.keys(collected).length === ANSI_PALETTE_KEYS.length) {
78
+ palette.ansi = collected;
79
+ }
80
+ }
81
+ return Object.keys(palette).length > 0 ? palette : null;
82
+ }
83
+ function familyName(value) {
84
+ const family = typeof value === 'string' ? value.trim() : '';
85
+ if (!family || family.length > 128 || FONT_FAMILY_FORBIDDEN.test(family))
86
+ return null;
87
+ return family;
88
+ }
89
+ /**
90
+ * Validates the terminal font a host reports.
91
+ *
92
+ * The browser draws the session in the family the workstation's terminal
93
+ * uses, and may fetch the font files by their hash. Every field is optional
94
+ * except the family; anything malformed is dropped rather than repaired.
95
+ */
96
+ export function sanitizeTerminalFont(value) {
97
+ if (!isRecord(value))
98
+ return null;
99
+ const family = familyName(value.family);
100
+ if (!family)
101
+ return null;
102
+ const size = Number(value.sizePx);
103
+ const sizePx = Number.isFinite(size) && size >= 4 && size <= 96 ? Math.round(size * 10) / 10 : null;
104
+ const source = typeof value.source === 'string' && /^[a-z0-9-]{1,32}$/.test(value.source)
105
+ ? value.source
106
+ : null;
107
+ const faces = [];
108
+ const seen = new Set();
109
+ for (const face of Array.isArray(value.faces) ? value.faces.slice(0, 16) : []) {
110
+ if (!isRecord(face))
111
+ continue;
112
+ const { style, format, bytes, sha256 } = face;
113
+ if (!isOneOf(TERMINAL_FONT_STYLES, style) || seen.has(style))
114
+ continue;
115
+ if (!isOneOf(TERMINAL_FONT_FORMATS, format))
116
+ continue;
117
+ if (typeof bytes !== 'number' || !Number.isInteger(bytes))
118
+ continue;
119
+ if (bytes <= 0 || bytes > MAX_TERMINAL_FONT_BYTES)
120
+ continue;
121
+ if (typeof sha256 !== 'string' || !SHA256_HEX.test(sha256))
122
+ continue;
123
+ seen.add(style);
124
+ faces.push({ style, format, bytes, sha256 });
125
+ }
126
+ // Large fonts (CJK above all) are offered a few characters at a time.
127
+ const subsets = [];
128
+ for (const entry of Array.isArray(value.subsets) ? value.subsets.slice(0, 16) : []) {
129
+ if (!isRecord(entry))
130
+ continue;
131
+ const name = familyName(entry.family);
132
+ const { scope, sha256 } = entry;
133
+ if (!name || entry.style !== 'regular')
134
+ continue;
135
+ if (!isOneOf(TERMINAL_FONT_SUBSET_SCOPES, scope))
136
+ continue;
137
+ if (typeof sha256 !== 'string' || !SHA256_HEX.test(sha256))
138
+ continue;
139
+ if (subsets.some((known) => known.scope === scope))
140
+ continue;
141
+ subsets.push({ family: name, style: 'regular', scope, sha256 });
142
+ }
143
+ return {
144
+ family,
145
+ ...(sizePx !== null && { sizePx }),
146
+ ...(source !== null && { source }),
147
+ faces,
148
+ subsets,
149
+ };
150
+ }
@@ -0,0 +1,74 @@
1
+ declare const PACKAGE_ROOT: string;
2
+ export type RelayMode = 'local' | 'remote';
3
+ /** A relay configuration after `validate`: every field present and in range. */
4
+ export interface RelayConfig {
5
+ relay: {
6
+ mode: RelayMode;
7
+ host: string;
8
+ port: number;
9
+ publicUrl: string;
10
+ maxPayloadBytes: number;
11
+ maxClientsPerHost: number;
12
+ maxHosts: number;
13
+ maxPendingHandshakes: number;
14
+ maxBufferedBytesPerClient: number;
15
+ allowedOrigins: string[];
16
+ trustProxy: boolean;
17
+ hostReconnectGraceMs: number;
18
+ /** Development only: simulated round-trip latency. */
19
+ devLatencyMs?: number;
20
+ };
21
+ auth: {
22
+ pairingTtlMs: number;
23
+ deviceTtlMs: number;
24
+ maxDevices: number;
25
+ password: string | null;
26
+ adminToken: string | null;
27
+ stateFile: string | null;
28
+ };
29
+ cleanup: {
30
+ intervalMs: number;
31
+ heartbeatIntervalMs: number;
32
+ staleAfterMs: number;
33
+ };
34
+ }
35
+ /**
36
+ * A configuration while it is being assembled: file, environment and flags
37
+ * may each put a string, or anything else, where `validate` expects a number.
38
+ */
39
+ type RelayConfigDraft = {
40
+ [Section in keyof RelayConfig]: Record<string, unknown>;
41
+ };
42
+ /** Flags given as `--name value` or `--name=value`, by name. */
43
+ export type RelayOptions = Record<string, string>;
44
+ declare function defaultStateDir(): string;
45
+ declare const DEFAULTS: RelayConfig;
46
+ /**
47
+ * Parse `--flag value` / `--flag=value` pairs plus the standalone flags the
48
+ * relay binary understands. Returns { options, help, version, errors }.
49
+ */
50
+ declare function parseArgv(argv?: readonly string[]): {
51
+ options: RelayOptions;
52
+ help: boolean;
53
+ version: boolean;
54
+ errors: string[];
55
+ };
56
+ declare function validate(config: RelayConfigDraft): RelayConfig;
57
+ /**
58
+ * Warnings a standalone operator should see at startup. These are advisory —
59
+ * the relay still starts, because a loopback-only development run legitimately
60
+ * needs neither TLS nor tokens.
61
+ */
62
+ declare function configWarnings(config: RelayConfig): string[];
63
+ declare function loadRelayConfig({ argv, env, }?: {
64
+ argv?: readonly string[];
65
+ env?: NodeJS.ProcessEnv;
66
+ }): {
67
+ config: RelayConfig;
68
+ help: boolean;
69
+ version: boolean;
70
+ errors: string[];
71
+ configFile: string | null;
72
+ warnings: string[];
73
+ };
74
+ export { PACKAGE_ROOT, DEFAULTS, loadRelayConfig, parseArgv, validate, configWarnings, defaultStateDir, };
@@ -0,0 +1,316 @@
1
+ // Standalone relay configuration.
2
+ //
3
+ // The relay is deliberately decoupled from the herdr-remote plugin: it never
4
+ // reads the plugin's config directory and never imports plugin code. Settings
5
+ // come from (lowest to highest precedence) built-in defaults, an optional JSON
6
+ // config file, environment variables, then command line flags.
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ const PACKAGE_ROOT = path.resolve(import.meta.dirname, '..');
11
+ function defaultStateDir() {
12
+ if (process.env.RELAY_STATE_DIR)
13
+ return process.env.RELAY_STATE_DIR;
14
+ const stateHome = process.env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state');
15
+ return path.join(stateHome, 'herdr-remote-relay');
16
+ }
17
+ const DEFAULTS = {
18
+ relay: {
19
+ // A relay package is remote/operator-facing by default. The workstation
20
+ // service sets this to local for the private relay it starts itself so the
21
+ // web client can distinguish its own status page from the operator console.
22
+ mode: 'remote',
23
+ // Bind to loopback by default: the common production shape is a TLS
24
+ // reverse proxy on the same machine. Containers and LAN deployments set
25
+ // RELAY_BIND=0.0.0.0 explicitly.
26
+ host: '127.0.0.1',
27
+ port: 8787,
28
+ publicUrl: 'http://127.0.0.1:8787',
29
+ // Sized for a pasted image, not for keystrokes. `ws` closes a connection
30
+ // that receives an oversized frame (1009) rather than dropping the frame,
31
+ // so this ceiling is what a client's own upload cap has to stay under.
32
+ maxPayloadBytes: 5 * 1024 * 1024,
33
+ maxClientsPerHost: 16,
34
+ maxHosts: 1024,
35
+ maxPendingHandshakes: 1024,
36
+ maxBufferedBytesPerClient: 4 * 1024 * 1024,
37
+ allowedOrigins: [],
38
+ trustProxy: false,
39
+ hostReconnectGraceMs: 30 * 1000,
40
+ },
41
+ auth: {
42
+ pairingTtlMs: 10 * 60 * 1000,
43
+ deviceTtlMs: 30 * 24 * 60 * 60 * 1000,
44
+ maxDevices: 32,
45
+ // Optional shared password. Unset means a public relay: anyone may connect
46
+ // a workstation, and each one is still reachable only through its own host
47
+ // token.
48
+ password: null,
49
+ // Optional operator credential for the standalone relay dashboard. This is
50
+ // deliberately separate from the join password and from device tokens:
51
+ // the former is shared with workstations, while the latter is scoped to a
52
+ // single workstation.
53
+ adminToken: null,
54
+ stateFile: null,
55
+ },
56
+ cleanup: {
57
+ intervalMs: 60 * 1000,
58
+ heartbeatIntervalMs: 30 * 1000,
59
+ staleAfterMs: 90 * 1000,
60
+ },
61
+ };
62
+ function clone(value) {
63
+ return JSON.parse(JSON.stringify(value));
64
+ }
65
+ function readJsonFile(filePath) {
66
+ try {
67
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
68
+ }
69
+ catch (error) {
70
+ const { code, message } = error;
71
+ if (code === 'ENOENT')
72
+ return null;
73
+ throw new Error(`cannot read relay config ${filePath}: ${message}`);
74
+ }
75
+ }
76
+ function parseBoolean(value, fallback) {
77
+ if (value === undefined || value === null || value === '')
78
+ return fallback;
79
+ if (value === true || value === false)
80
+ return value;
81
+ const normalized = String(value).trim().toLowerCase();
82
+ if (['true', '1', 'yes', 'on'].includes(normalized))
83
+ return true;
84
+ if (['false', '0', 'no', 'off'].includes(normalized))
85
+ return false;
86
+ return fallback;
87
+ }
88
+ function parseInteger(value, fallback, min, max) {
89
+ const numeric = Number(value);
90
+ if (!Number.isInteger(numeric) || numeric < min || numeric > max)
91
+ return fallback;
92
+ return numeric;
93
+ }
94
+ function parseOriginList(value) {
95
+ if (Array.isArray(value))
96
+ return value.map((entry) => String(entry).trim()).filter(Boolean);
97
+ if (typeof value !== 'string')
98
+ return null;
99
+ return value
100
+ .split(',')
101
+ .map((entry) => entry.trim())
102
+ .filter(Boolean);
103
+ }
104
+ function mergeSection(target, source) {
105
+ if (!source || typeof source !== 'object')
106
+ return;
107
+ const values = source;
108
+ for (const key of Object.keys(target)) {
109
+ if (values[key] !== undefined)
110
+ target[key] = values[key];
111
+ }
112
+ }
113
+ /**
114
+ * Parse `--flag value` / `--flag=value` pairs plus the standalone flags the
115
+ * relay binary understands. Returns { options, help, version, errors }.
116
+ */
117
+ function parseArgv(argv = []) {
118
+ const options = {};
119
+ const errors = [];
120
+ let help = false;
121
+ let version = false;
122
+ for (let index = 0; index < argv.length; index += 1) {
123
+ const arg = argv[index];
124
+ if (arg === '--help' || arg === '-h') {
125
+ help = true;
126
+ continue;
127
+ }
128
+ if (arg === '--version' || arg === '-v') {
129
+ version = true;
130
+ continue;
131
+ }
132
+ if (!arg.startsWith('--')) {
133
+ errors.push(`unexpected argument: ${arg}`);
134
+ continue;
135
+ }
136
+ const equals = arg.indexOf('=');
137
+ const name = equals === -1 ? arg.slice(2) : arg.slice(2, equals);
138
+ let value = equals === -1 ? undefined : arg.slice(equals + 1);
139
+ if (value === undefined) {
140
+ const next = argv[index + 1];
141
+ if (next === undefined || next.startsWith('--')) {
142
+ // Bare boolean flag.
143
+ value = 'true';
144
+ }
145
+ else {
146
+ value = next;
147
+ index += 1;
148
+ }
149
+ }
150
+ options[name] = value;
151
+ }
152
+ return { options, help, version, errors };
153
+ }
154
+ function applyFile(config, fileConfig) {
155
+ if (!fileConfig || typeof fileConfig !== 'object')
156
+ return;
157
+ const sections = fileConfig;
158
+ mergeSection(config.relay, sections.relay);
159
+ mergeSection(config.auth, sections.auth);
160
+ mergeSection(config.cleanup, sections.cleanup);
161
+ }
162
+ function applyEnvironment(config, env) {
163
+ if (env.RELAY_DEPLOYMENT_MODE === 'local' || env.RELAY_DEPLOYMENT_MODE === 'remote') {
164
+ config.relay.mode = env.RELAY_DEPLOYMENT_MODE;
165
+ }
166
+ if (env.RELAY_BIND)
167
+ config.relay.host = env.RELAY_BIND;
168
+ if (env.RELAY_PORT)
169
+ config.relay.port = env.RELAY_PORT;
170
+ if (env.RELAY_PUBLIC_URL)
171
+ config.relay.publicUrl = env.RELAY_PUBLIC_URL;
172
+ if (env.RELAY_MAX_PAYLOAD_BYTES)
173
+ config.relay.maxPayloadBytes = env.RELAY_MAX_PAYLOAD_BYTES;
174
+ if (env.RELAY_MAX_CLIENTS_PER_HOST)
175
+ config.relay.maxClientsPerHost = env.RELAY_MAX_CLIENTS_PER_HOST;
176
+ if (env.RELAY_MAX_HOSTS)
177
+ config.relay.maxHosts = env.RELAY_MAX_HOSTS;
178
+ if (env.RELAY_MAX_PENDING_HANDSHAKES)
179
+ config.relay.maxPendingHandshakes = env.RELAY_MAX_PENDING_HANDSHAKES;
180
+ if (env.RELAY_MAX_BUFFERED_BYTES_PER_CLIENT)
181
+ config.relay.maxBufferedBytesPerClient = env.RELAY_MAX_BUFFERED_BYTES_PER_CLIENT;
182
+ if (env.RELAY_HOST_RECONNECT_GRACE_MS)
183
+ config.relay.hostReconnectGraceMs = env.RELAY_HOST_RECONNECT_GRACE_MS;
184
+ if (env.RELAY_ALLOWED_ORIGINS !== undefined) {
185
+ const origins = parseOriginList(env.RELAY_ALLOWED_ORIGINS);
186
+ if (origins)
187
+ config.relay.allowedOrigins = origins;
188
+ }
189
+ if (env.RELAY_TRUST_PROXY !== undefined) {
190
+ config.relay.trustProxy = parseBoolean(env.RELAY_TRUST_PROXY, config.relay.trustProxy);
191
+ }
192
+ if (env.RELAY_PASSWORD)
193
+ config.auth.password = env.RELAY_PASSWORD;
194
+ if (env.RELAY_ADMIN_TOKEN)
195
+ config.auth.adminToken = env.RELAY_ADMIN_TOKEN;
196
+ if (env.RELAY_AUTH_STATE_FILE)
197
+ config.auth.stateFile = env.RELAY_AUTH_STATE_FILE;
198
+ if (env.RELAY_PAIRING_TTL_MS)
199
+ config.auth.pairingTtlMs = env.RELAY_PAIRING_TTL_MS;
200
+ if (env.RELAY_DEVICE_TTL_MS)
201
+ config.auth.deviceTtlMs = env.RELAY_DEVICE_TTL_MS;
202
+ if (env.RELAY_MAX_DEVICES)
203
+ config.auth.maxDevices = env.RELAY_MAX_DEVICES;
204
+ }
205
+ function applyOptions(config, options) {
206
+ if (options['deployment-mode'] === 'local' || options['deployment-mode'] === 'remote') {
207
+ config.relay.mode = options['deployment-mode'];
208
+ }
209
+ if (options.bind)
210
+ config.relay.host = options.bind;
211
+ if (options.port)
212
+ config.relay.port = options.port;
213
+ if (options['public-url'])
214
+ config.relay.publicUrl = options['public-url'];
215
+ if (options['allowed-origins'] !== undefined) {
216
+ const origins = parseOriginList(options['allowed-origins']);
217
+ if (origins)
218
+ config.relay.allowedOrigins = origins;
219
+ }
220
+ if (options['trust-proxy'] !== undefined) {
221
+ config.relay.trustProxy = parseBoolean(options['trust-proxy'], config.relay.trustProxy);
222
+ }
223
+ if (options.password)
224
+ config.auth.password = options.password;
225
+ if (options['admin-token'])
226
+ config.auth.adminToken = options['admin-token'];
227
+ if (options['state-file'])
228
+ config.auth.stateFile = options['state-file'];
229
+ if (options['max-clients'])
230
+ config.relay.maxClientsPerHost = options['max-clients'];
231
+ if (options['max-hosts'])
232
+ config.relay.maxHosts = options['max-hosts'];
233
+ if (options['max-pending-handshakes'])
234
+ config.relay.maxPendingHandshakes = options['max-pending-handshakes'];
235
+ if (options['max-buffered-bytes'])
236
+ config.relay.maxBufferedBytesPerClient = options['max-buffered-bytes'];
237
+ if (options['host-reconnect-grace-ms'])
238
+ config.relay.hostReconnectGraceMs = options['host-reconnect-grace-ms'];
239
+ }
240
+ function validate(config) {
241
+ if (config.relay.mode !== 'local' && config.relay.mode !== 'remote')
242
+ config.relay.mode = DEFAULTS.relay.mode;
243
+ config.relay.port = parseInteger(config.relay.port, DEFAULTS.relay.port, 0, 65535);
244
+ config.relay.maxPayloadBytes = parseInteger(config.relay.maxPayloadBytes, DEFAULTS.relay.maxPayloadBytes, 4096, 16 * 1024 * 1024);
245
+ config.relay.maxClientsPerHost = parseInteger(config.relay.maxClientsPerHost, DEFAULTS.relay.maxClientsPerHost, 1, 256);
246
+ config.relay.maxHosts = parseInteger(config.relay.maxHosts, DEFAULTS.relay.maxHosts, 1, 100000);
247
+ config.relay.maxPendingHandshakes = parseInteger(config.relay.maxPendingHandshakes, DEFAULTS.relay.maxPendingHandshakes, 16, 100000);
248
+ config.relay.maxBufferedBytesPerClient = parseInteger(config.relay.maxBufferedBytesPerClient, DEFAULTS.relay.maxBufferedBytesPerClient, 64 * 1024, 256 * 1024 * 1024);
249
+ config.relay.hostReconnectGraceMs = parseInteger(config.relay.hostReconnectGraceMs, DEFAULTS.relay.hostReconnectGraceMs, 1000, 24 * 60 * 60 * 1000);
250
+ config.auth.pairingTtlMs = parseInteger(config.auth.pairingTtlMs, DEFAULTS.auth.pairingTtlMs, 30 * 1000, 24 * 60 * 60 * 1000);
251
+ config.auth.deviceTtlMs = parseInteger(config.auth.deviceTtlMs, DEFAULTS.auth.deviceTtlMs, 60 * 1000, 365 * 24 * 60 * 60 * 1000);
252
+ config.auth.maxDevices = parseInteger(config.auth.maxDevices, DEFAULTS.auth.maxDevices, 1, 10000);
253
+ config.cleanup.intervalMs = parseInteger(config.cleanup.intervalMs, DEFAULTS.cleanup.intervalMs, 1000, 24 * 60 * 60 * 1000);
254
+ config.cleanup.heartbeatIntervalMs = parseInteger(config.cleanup.heartbeatIntervalMs, DEFAULTS.cleanup.heartbeatIntervalMs, 1000, 10 * 60 * 1000);
255
+ config.cleanup.staleAfterMs = parseInteger(config.cleanup.staleAfterMs, DEFAULTS.cleanup.staleAfterMs, config.cleanup.heartbeatIntervalMs * 2, 24 * 60 * 60 * 1000);
256
+ if (typeof config.relay.host !== 'string' || config.relay.host.length === 0) {
257
+ config.relay.host = DEFAULTS.relay.host;
258
+ }
259
+ if (typeof config.relay.publicUrl !== 'string' || config.relay.publicUrl.length === 0) {
260
+ config.relay.publicUrl = `http://${config.relay.host}:${config.relay.port}`;
261
+ }
262
+ config.relay.publicUrl = String(config.relay.publicUrl).replace(/\/+$/, '');
263
+ if (!Array.isArray(config.relay.allowedOrigins))
264
+ config.relay.allowedOrigins = [];
265
+ config.relay.trustProxy = parseBoolean(config.relay.trustProxy, false);
266
+ if (!config.auth.stateFile) {
267
+ config.auth.stateFile = path.join(defaultStateDir(), 'relay-auth.json');
268
+ }
269
+ // Every field `RelayConfig` promises has been normalised above.
270
+ return config;
271
+ }
272
+ /**
273
+ * Warnings a standalone operator should see at startup. These are advisory —
274
+ * the relay still starts, because a loopback-only development run legitimately
275
+ * needs neither TLS nor tokens.
276
+ */
277
+ function configWarnings(config) {
278
+ const warnings = [];
279
+ const isLoopbackBind = ['127.0.0.1', 'localhost', '::1'].includes(config.relay.host);
280
+ let publicUrl;
281
+ try {
282
+ publicUrl = new URL(config.relay.publicUrl);
283
+ }
284
+ catch {
285
+ warnings.push(`publicUrl is not a valid URL: ${config.relay.publicUrl}`);
286
+ }
287
+ if (!config.auth.password) {
288
+ warnings.push('no password set (RELAY_PASSWORD): this is a public relay, anyone may connect a workstation to it');
289
+ }
290
+ if (config.relay.mode === 'remote' && !config.auth.adminToken) {
291
+ warnings.push('no admin token set (RELAY_ADMIN_TOKEN): the relay operator dashboard is unavailable');
292
+ }
293
+ if (!isLoopbackBind &&
294
+ publicUrl &&
295
+ publicUrl.protocol === 'http:' &&
296
+ !['127.0.0.1', 'localhost'].includes(publicUrl.hostname)) {
297
+ warnings.push(`publicUrl uses plain http on a non-loopback address (${config.relay.publicUrl}); terminate TLS in front of the relay`);
298
+ }
299
+ return warnings;
300
+ }
301
+ function loadRelayConfig({ argv = [], env = process.env, } = {}) {
302
+ const { options, help, version, errors } = parseArgv(argv);
303
+ const draft = clone(DEFAULTS);
304
+ const configFile = options.config || env.HERDR_RELAY_CONFIG || null;
305
+ if (configFile) {
306
+ const fileConfig = readJsonFile(path.resolve(configFile));
307
+ if (fileConfig === null)
308
+ throw new Error(`relay config not found: ${configFile}`);
309
+ applyFile(draft, fileConfig);
310
+ }
311
+ applyEnvironment(draft, env);
312
+ applyOptions(draft, options);
313
+ const config = validate(draft);
314
+ return { config, help, version, errors, configFile, warnings: configWarnings(config) };
315
+ }
316
+ export { PACKAGE_ROOT, DEFAULTS, loadRelayConfig, parseArgv, validate, configWarnings, defaultStateDir, };
@@ -0,0 +1,64 @@
1
+ import http from 'node:http';
2
+ import type { Duplex } from 'node:stream';
3
+ import { WebSocketServer } from 'ws';
4
+ import { AuthStore } from './auth-store.js';
5
+ import { RelayMetrics } from './metrics.js';
6
+ import { PROTOCOL_VERSION } from './protocol/index.js';
7
+ import { type RelayConfig } from './relay-config.js';
8
+ import type { AttemptWindow, RelayClient, RelayContext, RelayHost, RelaySocket, SendQueue } from './server/types.js';
9
+ import { VERSION } from './server/version.js';
10
+ /** A configuration with any field left out; the defaults fill the rest. */
11
+ export type RelayConfigInput = {
12
+ [Section in keyof RelayConfig]?: Partial<RelayConfig[Section]>;
13
+ };
14
+ /** Overrides for embedding the relay, mostly in tests. */
15
+ export interface RelayServerOptions {
16
+ stateFile?: string;
17
+ password?: string | null;
18
+ adminToken?: string | null;
19
+ trustProxy?: boolean;
20
+ /** Simulated round-trip latency; each direction is delayed by half. */
21
+ devLatencyMs?: number | string;
22
+ metrics?: RelayMetrics;
23
+ auth?: AuthStore;
24
+ }
25
+ export type RelayAddress = {
26
+ host: string;
27
+ port: number;
28
+ } | {
29
+ path: string;
30
+ };
31
+ declare class RelayServer implements RelayContext {
32
+ config: RelayConfig;
33
+ relayMode: 'local' | 'remote';
34
+ hosts: Map<string, RelayHost>;
35
+ clients: Map<string, RelayClient>;
36
+ streams: Map<string, string>;
37
+ pairAttempts: Map<string, AttemptWindow>;
38
+ clientHandshakeAttempts: Map<string, AttemptWindow>;
39
+ hostHandshakeAttempts: Map<string, AttemptWindow>;
40
+ /** Sockets that are upgraded but have not finished their hello. */
41
+ pendingHandshakes: Set<RelaySocket>;
42
+ startedAt: number;
43
+ metrics: RelayMetrics;
44
+ stateFile: string;
45
+ password: string | null;
46
+ adminToken: string | null;
47
+ trustProxy: boolean;
48
+ devLatencyMs: number;
49
+ devDelayMs: number;
50
+ sendQueues: WeakMap<RelaySocket, SendQueue>;
51
+ activeDelayTimers: Set<NodeJS.Timeout>;
52
+ auth: AuthStore;
53
+ server: http.Server;
54
+ wss: WebSocketServer;
55
+ heartbeatTimer: NodeJS.Timeout | null;
56
+ cleanupTimer: NodeJS.Timeout | null;
57
+ constructor(config?: RelayConfigInput, options?: RelayServerOptions);
58
+ listen(port?: number, host?: string): Promise<RelayAddress>;
59
+ address(): RelayAddress | null;
60
+ close(): Promise<void>;
61
+ handleUpgrade(req: http.IncomingMessage, socket: Duplex, head: Buffer): void;
62
+ finishHandshake(ws: RelaySocket): void;
63
+ }
64
+ export { RelayServer, PROTOCOL_VERSION, VERSION };