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 +6 -4
- package/README.zh-CN.md +3 -2
- package/dist/cli.js +27 -2
- package/dist/connector/admin-broker.js +123 -29
- package/dist/connector/admin-shell.js +65 -0
- package/dist/connector/admin-tab.js +90 -0
- package/dist/connector/adminBrokerEndpoint.js +68 -0
- package/dist/connector/elevatedPty.js +31 -35
- package/dist/connector/host-connector.js +8 -1
- package/dist/connector/sessions.js +10 -39
- package/dist/connector/update-report.js +2 -0
- package/dist/i18n/en.js +13 -0
- package/dist/i18n/zh.js +13 -0
- package/dist/keepalive/detached.js +3 -2
- package/dist/lifecycle.js +6 -2
- package/dist/node-pty-loader.js +80 -0
- package/dist/pty-session.js +3 -29
- package/dist/service.js +3 -1
- package/dist/supervisor.js +2 -2
- package/dist/tui/api.js +2 -2
- package/dist/tui/screens/Keepalive.js +1 -1
- package/dist/update-command.js +120 -0
- package/herdr-plugin.toml +1 -1
- package/package.json +1 -1
- package/dist/connector/adminBrokerPipe.js +0 -16
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;
|
|
44
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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 {
|
|
7
|
-
import { ADMIN_BROKER_MAX_MESSAGE_BYTES,
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
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(
|
|
92
|
+
resolve(reply);
|
|
73
93
|
};
|
|
74
|
-
socket.setTimeout(BROKER_PING_TIMEOUT_MS, () => finish(
|
|
94
|
+
socket.setTimeout(BROKER_PING_TIMEOUT_MS, () => finish(null));
|
|
75
95
|
socket.setEncoding('utf8');
|
|
76
|
-
socket.on('connect', () => sendAdminBrokerMessage(socket, { type
|
|
96
|
+
socket.on('connect', () => sendAdminBrokerMessage(socket, { type, token }));
|
|
77
97
|
socket.on('data', (chunk) => {
|
|
78
98
|
response += chunk;
|
|
79
|
-
|
|
80
|
-
|
|
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(
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
200
|
-
|
|
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
|
|
203
|
-
|
|
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
|
+
}
|