herdr-remote 0.2.26 → 0.2.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,10 +29,11 @@ through Windows Defender Firewall on private networks.
29
29
 
30
30
  ```bash
31
31
  herdr-remote start | stop | restart
32
+ herdr-remote update [--json] # Install the latest release and restart services
32
33
  herdr-remote status [--json]
33
34
  herdr-remote pair [--json]
34
35
  herdr-remote url
35
- herdr-remote admin-broker install | uninstall | status # Windows
36
+ herdr-remote admin-broker install | uninstall | restart | status # Windows
36
37
  herdr-remote keepalive install | uninstall | restart | status
37
38
  herdr-remote plugin link | unlink | status
38
39
  herdr-remote --lang zh|en
@@ -40,10 +41,11 @@ herdr-remote --lang zh|en
40
41
 
41
42
  On Windows, the TUI's **Keep-alive** page can register a per-user Task Scheduler broker with highest
42
43
  privileges and start it for the signed-in account. Choose **Set up admin terminal access** and
43
- approve the one-time Windows prompt under the same account as Herdr Remote; paired WebUI windows
44
- can then open a standalone elevated PowerShell without another UAC prompt. The CLI
44
+ approve the one-time Windows prompt under the same account as Herdr Remote; the WebUI's Admin
45
+ button then opens an elevated PowerShell as a new Herdr tab without another UAC prompt. The CLI
45
46
  `admin-broker install` and `admin-broker uninstall` commands are also available for administrators.
46
- Use `admin-broker status` to check the broker.
47
+ Use `admin-broker status` to check the broker. `herdr-remote update` restarts the broker on the new
48
+ release unless an admin terminal is open; `admin-broker restart` does it later, without elevation.
47
49
 
48
50
  ## Herdr Plugin
49
51
 
package/README.zh-CN.md CHANGED
@@ -29,16 +29,17 @@ Defender 防火墙提示中允许 `node.exe` 通过专用网络通信。
29
29
 
30
30
  ```bash
31
31
  herdr-remote start | stop | restart
32
+ herdr-remote update [--json] # 安装最新版本并重启服务
32
33
  herdr-remote status [--json]
33
34
  herdr-remote pair [--json]
34
35
  herdr-remote url
35
- herdr-remote admin-broker install | uninstall | status # Windows
36
+ herdr-remote admin-broker install | uninstall | restart | status # Windows
36
37
  herdr-remote keepalive install | uninstall | restart | status
37
38
  herdr-remote plugin link | unlink | status
38
39
  herdr-remote --lang zh|en
39
40
  ```
40
41
 
41
- 在 Windows 上,可在 TUI 的「保活」页面注册以当前账户最高权限运行的计划任务并启动 broker。选择「设置管理员终端」,使用运行 Herdr Remote 的同一账户批准一次 Windows 提权提示;之后,已配对的 WebUI 窗口可以新建独立的管理员 PowerShell,不再重复弹出 UAC。也可以使用 `admin-broker install` 和 `admin-broker uninstall` 命令管理任务,使用 `admin-broker status` 检查 broker。
42
+ 在 Windows 上,可在 TUI 的「保活」页面注册以当前账户最高权限运行的计划任务并启动 broker。选择「设置管理员终端」,使用运行 Herdr Remote 的同一账户批准一次 Windows 提权提示;之后,WebUI 的“管理员”按钮会在 Herdr 中新建一个管理员 PowerShell 标签页,不再重复弹出 UAC。也可以使用 `admin-broker install` 和 `admin-broker uninstall` 命令管理任务,使用 `admin-broker status` 检查 broker。`herdr-remote update` 会让 broker 以新版本重启(有管理员终端开着时除外),之后也可以用 `admin-broker restart` 重启,无需提权。
42
43
 
43
44
  ## Herdr 插件
44
45
 
package/dist/cli.js CHANGED
@@ -26,7 +26,9 @@ Commands:
26
26
  pair [--json] Create pairing code
27
27
  url Print browser access URL
28
28
  run [--daemon] Run services in foreground (keep-alive)
29
- admin-broker install | uninstall | status (Windows)
29
+ update [--json] Install the latest release and restart services
30
+ admin-broker install | uninstall | restart | status (Windows)
31
+ admin-shell Elevated PowerShell in this terminal (Windows)
30
32
  keepalive <action> install | uninstall | restart | status
31
33
  plugin <action> link | unlink | status (Herdr plugin)
32
34
 
@@ -218,6 +220,18 @@ async function main(argv = process.argv.slice(2)) {
218
220
  process.stdout.write(`${resolvePublicUrl(config, preferredLanAddress())}\n`);
219
221
  return;
220
222
  }
223
+ case 'update': {
224
+ const { runUpdate } = await import('./update-command.js');
225
+ const outcome = await runUpdate(t, {
226
+ config,
227
+ ...(flags.json ? { report: () => { } } : {}),
228
+ });
229
+ if (flags.json)
230
+ printJson(outcome);
231
+ if (!outcome.ok)
232
+ process.exitCode = 1;
233
+ return;
234
+ }
221
235
  case 'run': {
222
236
  const { runForeground } = await import('./supervisor.js');
223
237
  await runForeground({ logToFiles: Boolean(flags.daemon) });
@@ -228,12 +242,18 @@ async function main(argv = process.argv.slice(2)) {
228
242
  const action = positional[1] || 'status';
229
243
  if (action === 'install') {
230
244
  broker.installAdminBroker(t);
231
- printJson({ installed: true, available: await broker.adminBrokerStatus() });
245
+ printJson({ installed: true, available: await broker.waitForAdminBroker() });
232
246
  }
233
247
  else if (action === 'uninstall') {
234
248
  broker.uninstallAdminBroker(t);
235
249
  printJson({ installed: false });
236
250
  }
251
+ else if (action === 'restart') {
252
+ if ((await broker.stopAdminBroker()) === 'busy')
253
+ throw new Error(t('adminBroker.busy'));
254
+ broker.startAdminBrokerTask(t);
255
+ printJson({ available: await broker.waitForAdminBroker() });
256
+ }
237
257
  else if (action === 'status') {
238
258
  printJson({ available: await broker.adminBrokerStatus() });
239
259
  }
@@ -242,6 +262,11 @@ async function main(argv = process.argv.slice(2)) {
242
262
  }
243
263
  return;
244
264
  }
265
+ case 'admin-shell': {
266
+ const { runAdminShell } = await import('./connector/admin-shell.js');
267
+ process.exitCode = await runAdminShell(t);
268
+ return;
269
+ }
245
270
  case 'keepalive': {
246
271
  const keepalive = await import('./keepalive/index.js');
247
272
  const action = positional[1] || 'status';
@@ -1,11 +1,17 @@
1
1
  import { execFileSync } from 'node:child_process';
2
+ import fs from 'node:fs';
2
3
  import net from 'node:net';
3
4
  import path from 'node:path';
4
5
  import { fileURLToPath } from 'node:url';
5
6
  import { PtySession } from '../pty-session.js';
6
- import { PACKAGE_ROOT } from '../paths.js';
7
- import { ADMIN_BROKER_MAX_MESSAGE_BYTES, adminBrokerPipePath, adminBrokerTaskName, sendAdminBrokerMessage, } from './adminBrokerPipe.js';
7
+ import { stateDir } from '../paths.js';
8
+ import { ADMIN_BROKER_MAX_MESSAGE_BYTES, adminBrokerTaskName, adminBrokerTokenMatches, connectAdminBroker, listenAdminBroker, publishAdminBrokerEndpoint, sendAdminBrokerMessage, } from './adminBrokerEndpoint.js';
8
9
  const BROKER_PING_TIMEOUT_MS = 1_000;
10
+ const BROKER_START_TIMEOUT_MS = 5_000;
11
+ const BROKER_START_POLL_MS = 250;
12
+ const BROKER_TASK_END_TIMEOUT_MS = 10_000;
13
+ /** Time for a stopping broker's goodbye to reach the caller before the process ends. */
14
+ const BROKER_EXIT_GRACE_MS = 1_000;
9
15
  function powershellQuote(value) {
10
16
  return `'${value.replaceAll("'", "''")}'`;
11
17
  }
@@ -26,18 +32,20 @@ function runPowerShell(script, t) {
26
32
  throw new Error(t('adminBroker.needsElevation'));
27
33
  }
28
34
  }
29
- export function installAdminBroker(t) {
30
- if (process.platform !== 'win32')
31
- throw new Error(t('adminBroker.windowsOnly'));
35
+ /** Registers and starts the broker's logon task; run elevated. */
36
+ export function adminBrokerTaskScript() {
32
37
  const brokerPath = fileURLToPath(import.meta.url);
33
- const brokerCommand = `& ${powershellQuote(process.execPath)} ${powershellQuote(brokerPath)}`;
38
+ // Pinned so the broker publishes its endpoint where this install's callers look.
39
+ const brokerCommand = `$env:HERDR_REMOTE_STATE_DIR = ${powershellQuote(stateDir())}; & ${powershellQuote(process.execPath)} ${powershellQuote(brokerPath)}`;
34
40
  const encodedBrokerCommand = Buffer.from(brokerCommand, 'utf16le').toString('base64');
35
41
  const powershell = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
36
- const script = `
42
+ // Not PACKAGE_ROOT: npm updates the package by renaming its directory, and
43
+ // Windows refuses while it is the working directory of this long-lived task.
44
+ return `
37
45
  $ErrorActionPreference = 'Stop'
38
46
  $taskName = ${powershellQuote(adminBrokerTaskName())}
39
47
  $user = [Security.Principal.WindowsIdentity]::GetCurrent().Name
40
- $action = New-ScheduledTaskAction -Execute ${powershellQuote(powershell)} -Argument ${powershellQuote(`-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ${encodedBrokerCommand}`)} -WorkingDirectory ${powershellQuote(PACKAGE_ROOT)}
48
+ $action = New-ScheduledTaskAction -Execute ${powershellQuote(powershell)} -Argument ${powershellQuote(`-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ${encodedBrokerCommand}`)} -WorkingDirectory ${powershellQuote(stateDir())}
41
49
  $trigger = New-ScheduledTaskTrigger -AtLogOn -User $user
42
50
  $principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Highest
43
51
  $settings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Seconds 0)
@@ -45,7 +53,13 @@ Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
45
53
  Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
46
54
  Start-ScheduledTask -TaskName $taskName
47
55
  `;
48
- runPowerShell(script, t);
56
+ }
57
+ export function installAdminBroker(t) {
58
+ if (process.platform !== 'win32')
59
+ throw new Error(t('adminBroker.windowsOnly'));
60
+ // The task cannot start in a working directory that does not exist yet.
61
+ fs.mkdirSync(stateDir(), { recursive: true });
62
+ runPowerShell(adminBrokerTaskScript(), t);
49
63
  }
50
64
  export function uninstallAdminBroker(t) {
51
65
  if (process.platform !== 'win32')
@@ -57,37 +71,85 @@ Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
57
71
  Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
58
72
  `, t);
59
73
  }
60
- export function adminBrokerStatus() {
61
- if (process.platform !== 'win32')
62
- return Promise.resolve(false);
74
+ /** Send the broker one request and read the type of its one reply, or null without one. */
75
+ function requestAdminBroker(type) {
76
+ let broker;
77
+ try {
78
+ broker = connectAdminBroker();
79
+ }
80
+ catch {
81
+ return Promise.resolve(null);
82
+ }
83
+ const { socket, token } = broker;
63
84
  return new Promise((resolve) => {
64
85
  let settled = false;
65
86
  let response = '';
66
- const socket = net.createConnection(adminBrokerPipePath());
67
- const finish = (available) => {
87
+ const finish = (reply) => {
68
88
  if (settled)
69
89
  return;
70
90
  settled = true;
71
91
  socket.destroy();
72
- resolve(available);
92
+ resolve(reply);
73
93
  };
74
- socket.setTimeout(BROKER_PING_TIMEOUT_MS, () => finish(false));
94
+ socket.setTimeout(BROKER_PING_TIMEOUT_MS, () => finish(null));
75
95
  socket.setEncoding('utf8');
76
- socket.on('connect', () => sendAdminBrokerMessage(socket, { type: 'ping' }));
96
+ socket.on('connect', () => sendAdminBrokerMessage(socket, { type, token }));
77
97
  socket.on('data', (chunk) => {
78
98
  response += chunk;
79
- if (response.includes('\n'))
80
- finish(response.includes('"type":"pong"'));
99
+ const newline = response.indexOf('\n');
100
+ if (newline < 0)
101
+ return;
102
+ try {
103
+ finish(String(JSON.parse(response.slice(0, newline)).type));
104
+ }
105
+ catch {
106
+ finish(null);
107
+ }
81
108
  });
82
- socket.on('error', () => finish(false));
109
+ socket.on('error', () => finish(null));
83
110
  });
84
111
  }
112
+ export async function adminBrokerStatus() {
113
+ return (await requestAdminBroker('ping')) === 'pong';
114
+ }
115
+ /**
116
+ * Ask the broker to exit so its task can start it on a new release. It stays
117
+ * while an administrator terminal is open: that terminal may be the one asking.
118
+ * A broker from before this request answers neither way.
119
+ */
120
+ export async function stopAdminBroker() {
121
+ const reply = await requestAdminBroker('shutdown');
122
+ return reply === 'bye' ? 'stopped' : reply === 'busy' ? 'busy' : 'unavailable';
123
+ }
124
+ /** Start the broker's task now, which the account that registered it may do unelevated. */
125
+ export function startAdminBrokerTask(t) {
126
+ if (process.platform !== 'win32')
127
+ throw new Error(t('adminBroker.windowsOnly'));
128
+ runPowerShell(`
129
+ $ErrorActionPreference = 'Stop'
130
+ $taskName = ${powershellQuote(adminBrokerTaskName())}
131
+ # A broker that was just stopped may still be ending, and IgnoreNew drops a start until it has.
132
+ $deadline = [DateTime]::UtcNow.AddMilliseconds(${BROKER_TASK_END_TIMEOUT_MS})
133
+ while ((Get-ScheduledTask -TaskName $taskName).State -eq 'Running' -and [DateTime]::UtcNow -lt $deadline) { Start-Sleep -Milliseconds ${BROKER_START_POLL_MS} }
134
+ Start-ScheduledTask -TaskName $taskName
135
+ `, t);
136
+ }
137
+ /** The task starts the broker a moment after it is registered; wait for it to answer. */
138
+ export async function waitForAdminBroker() {
139
+ const deadline = Date.now() + BROKER_START_TIMEOUT_MS;
140
+ while (!(await adminBrokerStatus())) {
141
+ if (Date.now() >= deadline)
142
+ return false;
143
+ await new Promise((resolve) => setTimeout(resolve, BROKER_START_POLL_MS));
144
+ }
145
+ return true;
146
+ }
85
147
  function writeError(socket, error) {
86
148
  const message = error instanceof Error ? error.message : String(error);
87
149
  sendAdminBrokerMessage(socket, { type: 'error', message });
88
150
  socket.end();
89
151
  }
90
- function startBrokerPty(socket, message) {
152
+ function startBrokerPty(socket, message, spawn) {
91
153
  if (typeof message.command !== 'string' || !message.command || message.command.length > 4096)
92
154
  throw new Error('Invalid administrator terminal command.');
93
155
  if (path.basename(message.command).toLowerCase() !== 'powershell.exe')
@@ -112,6 +174,7 @@ function startBrokerPty(socket, message) {
112
174
  platform: 'win32',
113
175
  // A plain PowerShell prompt does not need the Herdr UI's Kitty or mouse modes.
114
176
  fastWindowsPty: true,
177
+ spawn,
115
178
  });
116
179
  pty.start({
117
180
  cols: PtySession.clampDimension(message.cols, PtySession.DEFAULT_COLS),
@@ -132,9 +195,10 @@ function startBrokerPty(socket, message) {
132
195
  sendAdminBrokerMessage(socket, { type: 'started', pid: pty.terminal?.pid ?? 0 });
133
196
  return pty;
134
197
  }
135
- function runAdminBroker() {
136
- if (process.platform !== 'win32')
137
- throw new Error('Administrator PTYs are only supported on Windows.');
198
+ /** Listen on loopback and publish the endpoint; every request must carry its token. */
199
+ export function serveAdminBroker({ spawn, onShutdown = () => { }, } = {}) {
200
+ let token = '';
201
+ const sessions = new Set();
138
202
  const server = net.createServer((socket) => {
139
203
  socket.setEncoding('utf8');
140
204
  let pending = '';
@@ -162,17 +226,32 @@ function runAdminBroker() {
162
226
  }
163
227
  if (firstMessage) {
164
228
  firstMessage = false;
229
+ if (!adminBrokerTokenMatches(token, message.token)) {
230
+ writeError(socket, new Error('Administrator terminal request was not authorized.'));
231
+ return;
232
+ }
165
233
  if (message.type === 'ping') {
166
234
  sendAdminBrokerMessage(socket, { type: 'pong' });
167
235
  socket.end();
168
236
  return;
169
237
  }
238
+ if (message.type === 'shutdown') {
239
+ const busy = sessions.size > 0;
240
+ sendAdminBrokerMessage(socket, { type: busy ? 'busy' : 'bye' });
241
+ socket.end();
242
+ if (!busy) {
243
+ server.close();
244
+ onShutdown();
245
+ }
246
+ return;
247
+ }
170
248
  if (message.type !== 'start') {
171
249
  writeError(socket, new Error('Expected an administrator terminal start request.'));
172
250
  return;
173
251
  }
174
252
  try {
175
- pty = startBrokerPty(socket, message);
253
+ pty = startBrokerPty(socket, message, spawn);
254
+ sessions.add(pty);
176
255
  }
177
256
  catch (error) {
178
257
  writeError(socket, error);
@@ -196,11 +275,26 @@ function runAdminBroker() {
196
275
  }
197
276
  }
198
277
  });
199
- socket.on('close', () => pty?.kill());
200
- socket.on('error', () => pty?.kill());
278
+ const release = () => {
279
+ if (!pty)
280
+ return;
281
+ pty.kill();
282
+ sessions.delete(pty);
283
+ };
284
+ socket.on('close', release);
285
+ socket.on('error', release);
201
286
  });
202
- server.listen(adminBrokerPipePath());
203
- server.on('error', (error) => {
287
+ listenAdminBroker(server, (port) => {
288
+ token = publishAdminBrokerEndpoint(port);
289
+ });
290
+ return server;
291
+ }
292
+ function runAdminBroker() {
293
+ if (process.platform !== 'win32')
294
+ throw new Error('Administrator PTYs are only supported on Windows.');
295
+ serveAdminBroker({
296
+ onShutdown: () => setTimeout(() => process.exit(0), BROKER_EXIT_GRACE_MS).unref(),
297
+ }).on('error', (error) => {
204
298
  process.stderr.write(`herdr-remote administrator broker: ${error.message}\n`);
205
299
  });
206
300
  }
@@ -0,0 +1,65 @@
1
+ // `herdr-remote admin-shell`: the administrator terminal inside a Herdr tab.
2
+ //
3
+ // This process runs in the tab's pane with the pane's standard rights and
4
+ // relays its console to an elevated PowerShell that the admin broker runs. On
5
+ // Windows, Node's raw mode reads keys as VT input and writes VT output through
6
+ // unchanged, so the relay is byte for byte.
7
+ import { requestHerdr } from '../herdr-api.js';
8
+ import { ADMIN_TAB_ENV } from './admin-tab.js';
9
+ import { ElevatedPty } from './elevatedPty.js';
10
+ const BROKER_UNAVAILABLE_CODES = ['ENOENT', 'ECONNREFUSED', 'ETIMEDOUT'];
11
+ /** Close the pane this runs in, which closes the tab `openAdminTab` made for it. */
12
+ async function closeOwnPane() {
13
+ const socketPath = process.env.HERDR_SOCKET_PATH;
14
+ const paneId = process.env.HERDR_PANE_ID;
15
+ if (process.env[ADMIN_TAB_ENV] !== '1' || !socketPath || !paneId)
16
+ return;
17
+ try {
18
+ await requestHerdr(socketPath, 'pane.close', { pane_id: paneId });
19
+ }
20
+ catch {
21
+ // The shell's prompt comes back instead; the user can close the tab.
22
+ }
23
+ }
24
+ export async function runAdminShell(t) {
25
+ const { stdin, stdout } = process;
26
+ if (process.platform !== 'win32')
27
+ throw new Error(t('adminBroker.windowsOnly'));
28
+ if (!stdin.isTTY || !stdout.isTTY)
29
+ throw new Error(t('adminShell.needsTerminal'));
30
+ const pty = new ElevatedPty({
31
+ command: 'powershell.exe',
32
+ args: ['-NoLogo', '-NoProfile'],
33
+ cwd: process.cwd(),
34
+ });
35
+ const relayInput = (data) => pty.write(data);
36
+ const relayResize = () => pty.resize(stdout.columns, stdout.rows);
37
+ stdin.setRawMode(true);
38
+ stdin.on('data', relayInput);
39
+ stdout.on('resize', relayResize);
40
+ try {
41
+ const exitCode = await new Promise((resolve, reject) => {
42
+ pty.start({
43
+ cols: stdout.columns,
44
+ rows: stdout.rows,
45
+ onData: (data) => stdout.write(data),
46
+ onExit: (event) => resolve(event.exitCode),
47
+ onError: reject,
48
+ });
49
+ });
50
+ await closeOwnPane();
51
+ return exitCode;
52
+ }
53
+ catch (error) {
54
+ const { code } = error;
55
+ if (code && BROKER_UNAVAILABLE_CODES.includes(code))
56
+ throw new Error(t('adminShell.brokerUnavailable'));
57
+ throw error;
58
+ }
59
+ finally {
60
+ stdin.off('data', relayInput);
61
+ stdout.off('resize', relayResize);
62
+ stdin.setRawMode(false);
63
+ stdin.pause();
64
+ }
65
+ }
@@ -0,0 +1,90 @@
1
+ // An administrator terminal as a tab of the workstation's own Herdr, opened at
2
+ // a browser's request.
3
+ //
4
+ // Herdr's API cannot start a tab running a command of our choosing, so the tab
5
+ // starts with the user's shell and `herdr-remote admin-shell` is typed into it
6
+ // once that shell is running. Nor can the API focus a tab for one client:
7
+ // `tab.focus` moves every client attached to this Herdr, which is the price of
8
+ // the requesting window showing the new tab at once.
9
+ import path from 'node:path';
10
+ import { requestHerdr } from '../herdr-api.js';
11
+ import { createTranslator, detectLocale } from '../i18n/index.js';
12
+ import { cliEntryPoint } from '../keepalive/environment.js';
13
+ import { adminBrokerStatus } from './admin-broker.js';
14
+ const SHELL_START_TIMEOUT_MS = 5_000;
15
+ const SHELL_START_POLL_MS = 100;
16
+ /** Marks the tab's shell, so `admin-shell` knows it may close the pane when done. */
17
+ export const ADMIN_TAB_ENV = 'HERDR_REMOTE_ADMIN_TAB';
18
+ function powershellQuote(value) {
19
+ return `'${value.replaceAll("'", "''")}'`;
20
+ }
21
+ function posixQuote(value) {
22
+ return `'${value.replaceAll("'", "'\\''")}'`;
23
+ }
24
+ /** The command line that starts `admin-shell`, in the syntax of the tab's shell. */
25
+ export function adminShellCommand(shell, node, entry) {
26
+ const name = path.win32
27
+ .basename(shell)
28
+ .toLowerCase()
29
+ .replace(/\.exe$/, '');
30
+ if (name === 'powershell' || name === 'pwsh') {
31
+ return `& ${powershellQuote(node)} ${powershellQuote(entry)} admin-shell`;
32
+ }
33
+ if (name === 'cmd')
34
+ return `"${node}" "${entry}" admin-shell`;
35
+ // Git Bash and other POSIX shells: forward slashes, or C:\ reads as escapes.
36
+ const posix = (file) => posixQuote(file.replaceAll('\\', '/'));
37
+ return `${posix(node)} ${posix(entry)} admin-shell`;
38
+ }
39
+ /** The new tab's shell, once Herdr reports it running. */
40
+ async function waitForShell(request, socketPath, paneId) {
41
+ const deadline = Date.now() + SHELL_START_TIMEOUT_MS;
42
+ while (true) {
43
+ const info = await request(socketPath, 'pane.process_info', {
44
+ pane_id: paneId,
45
+ });
46
+ const shell = info.process_info?.foreground_processes?.[0]?.name;
47
+ if (shell)
48
+ return shell;
49
+ if (Date.now() >= deadline)
50
+ throw new Error('The new tab did not start a shell in time.');
51
+ await new Promise((resolve) => setTimeout(resolve, SHELL_START_POLL_MS));
52
+ }
53
+ }
54
+ export async function openAdminTab({ socketPath, label, request = requestHerdr, brokerReady = adminBrokerStatus, }) {
55
+ if (!(await brokerReady())) {
56
+ throw Object.assign(new Error('The administrator terminal broker is not running.'), {
57
+ code: 'admin_broker_unavailable',
58
+ });
59
+ }
60
+ const created = await request(socketPath, 'tab.create', {
61
+ label,
62
+ focus: false,
63
+ env: { [ADMIN_TAB_ENV]: '1' },
64
+ });
65
+ const paneId = created.root_pane.pane_id;
66
+ await request(socketPath, 'tab.focus', { tab_id: created.tab.tab_id });
67
+ const shell = await waitForShell(request, socketPath, paneId);
68
+ await request(socketPath, 'pane.send_input', {
69
+ pane_id: paneId,
70
+ text: adminShellCommand(shell, process.execPath, cliEntryPoint()),
71
+ keys: ['enter'],
72
+ });
73
+ }
74
+ /** Open the tab for a browser window, and tell that window when it could not. */
75
+ export async function openAdminTabFor(streamId, options) {
76
+ const t = createTranslator(detectLocale({ preference: options.language }));
77
+ try {
78
+ await openAdminTab({ socketPath: options.socketPath, label: t('adminShell.tabLabel') });
79
+ }
80
+ catch (error) {
81
+ const { code, message } = error;
82
+ process.stderr.write(`herdr-remote host connector: could not open an admin tab: ${message}\n`);
83
+ options.send({
84
+ type: 'error',
85
+ clientId: streamId,
86
+ code: code === 'admin_broker_unavailable' ? code : 'admin_tab_failed',
87
+ message,
88
+ });
89
+ }
90
+ }
@@ -0,0 +1,68 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import net from 'node:net';
4
+ import path from 'node:path';
5
+ import { stateDir } from '../paths.js';
6
+ const ADMIN_BROKER_TASK_NAME = 'HerdrRemoteAdminBroker';
7
+ const ADMIN_BROKER_HOST = '127.0.0.1';
8
+ export const ADMIN_BROKER_MAX_MESSAGE_BYTES = 2 * 1_024 * 1_024;
9
+ function adminBrokerAccountId() {
10
+ const account = `${process.env.USERDOMAIN || ''}\\${process.env.USERNAME || process.env.USERPROFILE || 'default'}`;
11
+ return createHash('sha256').update(account.toLowerCase()).digest('hex').slice(0, 24);
12
+ }
13
+ export function adminBrokerTaskName() {
14
+ return `${ADMIN_BROKER_TASK_NAME}-${adminBrokerAccountId()}`;
15
+ }
16
+ // Not a named pipe: one created by an elevated process admits only
17
+ // Administrators, so the connector's standard token gets EPERM, and opening it
18
+ // to everyone would let any account add an instance of the same name and sit
19
+ // in the middle. A loopback port cannot be taken over by another account, and
20
+ // the token lives in the state directory, which only this account can read.
21
+ function endpointPath() {
22
+ return path.join(stateDir(), 'admin-broker.json');
23
+ }
24
+ /** Record where the broker listens, with a fresh token callers must present. */
25
+ export function publishAdminBrokerEndpoint(port) {
26
+ const token = randomBytes(32).toString('hex');
27
+ const file = endpointPath();
28
+ fs.mkdirSync(path.dirname(file), { recursive: true });
29
+ const temporary = `${file}.${process.pid}.tmp`;
30
+ fs.writeFileSync(temporary, JSON.stringify({ port, token }), { mode: 0o600 });
31
+ fs.renameSync(temporary, file);
32
+ return token;
33
+ }
34
+ export function adminBrokerTokenMatches(expected, received) {
35
+ if (!expected || typeof received !== 'string')
36
+ return false;
37
+ const want = Buffer.from(expected);
38
+ const got = Buffer.from(received);
39
+ return want.length === got.length && timingSafeEqual(want, got);
40
+ }
41
+ function brokerUnavailable() {
42
+ return Object.assign(new Error('The administrator terminal broker is not running.'), {
43
+ code: 'ENOENT',
44
+ });
45
+ }
46
+ /** Connect to the running broker; the token goes in the first message. */
47
+ export function connectAdminBroker() {
48
+ let endpoint;
49
+ try {
50
+ endpoint = JSON.parse(fs.readFileSync(endpointPath(), 'utf8'));
51
+ }
52
+ catch {
53
+ // Missing or half-written: either way no broker has published itself.
54
+ throw brokerUnavailable();
55
+ }
56
+ const { port, token } = endpoint;
57
+ if (!Number.isInteger(port) || typeof token !== 'string')
58
+ throw brokerUnavailable();
59
+ return { socket: net.createConnection({ host: ADMIN_BROKER_HOST, port: port }), token };
60
+ }
61
+ export function listenAdminBroker(server, onListening) {
62
+ server.listen(0, ADMIN_BROKER_HOST, () => {
63
+ onListening(server.address().port);
64
+ });
65
+ }
66
+ export function sendAdminBrokerMessage(socket, message) {
67
+ socket.write(`${JSON.stringify(message)}\n`);
68
+ }