herdr-remote 0.2.25 → 0.2.27
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 +8 -0
- package/README.zh-CN.md +3 -0
- package/dist/cli.js +26 -0
- package/dist/connector/admin-broker-setup.js +40 -0
- package/dist/connector/admin-broker.js +237 -0
- 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 +141 -0
- package/dist/connector/host-connector.js +17 -1
- package/dist/connector/sessions.js +19 -4
- package/dist/i18n/en.js +16 -0
- package/dist/i18n/zh.js +16 -0
- package/dist/pty-session.js +9 -5
- package/dist/tui/api.js +10 -0
- package/dist/tui/screens/Keepalive.js +50 -4
- package/herdr-plugin.toml +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -32,11 +32,19 @@ herdr-remote start | stop | restart
|
|
|
32
32
|
herdr-remote status [--json]
|
|
33
33
|
herdr-remote pair [--json]
|
|
34
34
|
herdr-remote url
|
|
35
|
+
herdr-remote admin-broker install | uninstall | status # Windows
|
|
35
36
|
herdr-remote keepalive install | uninstall | restart | status
|
|
36
37
|
herdr-remote plugin link | unlink | status
|
|
37
38
|
herdr-remote --lang zh|en
|
|
38
39
|
```
|
|
39
40
|
|
|
41
|
+
On Windows, the TUI's **Keep-alive** page can register a per-user Task Scheduler broker with highest
|
|
42
|
+
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; the WebUI's Admin
|
|
44
|
+
button then opens an elevated PowerShell as a new Herdr tab without another UAC prompt. The CLI
|
|
45
|
+
`admin-broker install` and `admin-broker uninstall` commands are also available for administrators.
|
|
46
|
+
Use `admin-broker status` to check the broker.
|
|
47
|
+
|
|
40
48
|
## Herdr Plugin
|
|
41
49
|
|
|
42
50
|
Register as a native Herdr plugin:
|
package/README.zh-CN.md
CHANGED
|
@@ -32,11 +32,14 @@ herdr-remote start | stop | restart
|
|
|
32
32
|
herdr-remote status [--json]
|
|
33
33
|
herdr-remote pair [--json]
|
|
34
34
|
herdr-remote url
|
|
35
|
+
herdr-remote admin-broker install | uninstall | status # Windows
|
|
35
36
|
herdr-remote keepalive install | uninstall | restart | status
|
|
36
37
|
herdr-remote plugin link | unlink | status
|
|
37
38
|
herdr-remote --lang zh|en
|
|
38
39
|
```
|
|
39
40
|
|
|
41
|
+
在 Windows 上,可在 TUI 的「保活」页面注册以当前账户最高权限运行的计划任务并启动 broker。选择「设置管理员终端」,使用运行 Herdr Remote 的同一账户批准一次 Windows 提权提示;之后,WebUI 的“管理员”按钮会在 Herdr 中新建一个管理员 PowerShell 标签页,不再重复弹出 UAC。也可以使用 `admin-broker install` 和 `admin-broker uninstall` 命令管理任务,使用 `admin-broker status` 检查 broker。
|
|
42
|
+
|
|
40
43
|
## Herdr 插件
|
|
41
44
|
|
|
42
45
|
注册为 Herdr 插件:
|
package/dist/cli.js
CHANGED
|
@@ -26,6 +26,8 @@ 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)
|
|
30
|
+
admin-shell Elevated PowerShell in this terminal (Windows)
|
|
29
31
|
keepalive <action> install | uninstall | restart | status
|
|
30
32
|
plugin <action> link | unlink | status (Herdr plugin)
|
|
31
33
|
|
|
@@ -222,6 +224,30 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
222
224
|
await runForeground({ logToFiles: Boolean(flags.daemon) });
|
|
223
225
|
return;
|
|
224
226
|
}
|
|
227
|
+
case 'admin-broker': {
|
|
228
|
+
const broker = await import('./connector/admin-broker.js');
|
|
229
|
+
const action = positional[1] || 'status';
|
|
230
|
+
if (action === 'install') {
|
|
231
|
+
broker.installAdminBroker(t);
|
|
232
|
+
printJson({ installed: true, available: await broker.waitForAdminBroker() });
|
|
233
|
+
}
|
|
234
|
+
else if (action === 'uninstall') {
|
|
235
|
+
broker.uninstallAdminBroker(t);
|
|
236
|
+
printJson({ installed: false });
|
|
237
|
+
}
|
|
238
|
+
else if (action === 'status') {
|
|
239
|
+
printJson({ available: await broker.adminBrokerStatus() });
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
throw new Error(`unknown administrator broker action: ${action}`);
|
|
243
|
+
}
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
case 'admin-shell': {
|
|
247
|
+
const { runAdminShell } = await import('./connector/admin-shell.js');
|
|
248
|
+
process.exitCode = await runAdminShell(t);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
225
251
|
case 'keepalive': {
|
|
226
252
|
const keepalive = await import('./keepalive/index.js');
|
|
227
253
|
const action = positional[1] || 'status';
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { cliEntryPoint } from '../keepalive/environment.js';
|
|
3
|
+
import { encodePowerShellCommand, escapePowerShellSingleQuoted, } from '../keepalive/windows-service.js';
|
|
4
|
+
function waitForChild(child) {
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
child.once('error', reject);
|
|
7
|
+
child.once('close', (code) => resolve(code ?? 1));
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
export function renderAdminBrokerElevationScript({ action, nodePath, entryPoint, }) {
|
|
11
|
+
const quote = escapePowerShellSingleQuoted;
|
|
12
|
+
const args = `"${entryPoint}" admin-broker ${action} --elevated`;
|
|
13
|
+
return `$ErrorActionPreference = 'Stop'
|
|
14
|
+
try {
|
|
15
|
+
$process = Start-Process -FilePath '${quote(nodePath)}' -ArgumentList '${quote(args)}' -Verb RunAs -Wait -PassThru -WindowStyle Hidden
|
|
16
|
+
} catch {
|
|
17
|
+
if (($_.Exception.HResult -band 65535) -eq 1223) { exit 1223 }
|
|
18
|
+
exit 1
|
|
19
|
+
}
|
|
20
|
+
exit $process.ExitCode`;
|
|
21
|
+
}
|
|
22
|
+
function elevatedErrorCode(message, code) {
|
|
23
|
+
return Object.assign(new Error(message), { code });
|
|
24
|
+
}
|
|
25
|
+
/** Register the per-user task from the Windows TUI, requesting elevation once. */
|
|
26
|
+
export async function configureAdminBroker(action, t) {
|
|
27
|
+
if (process.platform !== 'win32')
|
|
28
|
+
throw new Error(t('adminBroker.windowsOnly'));
|
|
29
|
+
const script = renderAdminBrokerElevationScript({
|
|
30
|
+
action,
|
|
31
|
+
nodePath: process.execPath,
|
|
32
|
+
entryPoint: cliEntryPoint(),
|
|
33
|
+
});
|
|
34
|
+
const child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', encodePowerShellCommand(script)], { windowsHide: true, stdio: 'ignore' });
|
|
35
|
+
const exitCode = await waitForChild(child);
|
|
36
|
+
if (exitCode === 1223)
|
|
37
|
+
throw elevatedErrorCode(t('adminBroker.elevationCancelled'), 'ELEVATION_CANCELLED');
|
|
38
|
+
if (exitCode !== 0)
|
|
39
|
+
throw new Error(t('adminBroker.setupFailed'));
|
|
40
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { PtySession } from '../pty-session.js';
|
|
6
|
+
import { PACKAGE_ROOT, stateDir } from '../paths.js';
|
|
7
|
+
import { ADMIN_BROKER_MAX_MESSAGE_BYTES, adminBrokerTaskName, adminBrokerTokenMatches, connectAdminBroker, listenAdminBroker, publishAdminBrokerEndpoint, sendAdminBrokerMessage, } from './adminBrokerEndpoint.js';
|
|
8
|
+
const BROKER_PING_TIMEOUT_MS = 1_000;
|
|
9
|
+
const BROKER_START_TIMEOUT_MS = 5_000;
|
|
10
|
+
const BROKER_START_POLL_MS = 250;
|
|
11
|
+
function powershellQuote(value) {
|
|
12
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
13
|
+
}
|
|
14
|
+
function runPowerShell(script, t) {
|
|
15
|
+
try {
|
|
16
|
+
execFileSync('powershell.exe', [
|
|
17
|
+
'-NoProfile',
|
|
18
|
+
'-NonInteractive',
|
|
19
|
+
'-EncodedCommand',
|
|
20
|
+
Buffer.from(script, 'utf16le').toString('base64'),
|
|
21
|
+
], {
|
|
22
|
+
stdio: 'pipe',
|
|
23
|
+
windowsHide: true,
|
|
24
|
+
timeout: 15_000,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
throw new Error(t('adminBroker.needsElevation'));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function installAdminBroker(t) {
|
|
32
|
+
if (process.platform !== 'win32')
|
|
33
|
+
throw new Error(t('adminBroker.windowsOnly'));
|
|
34
|
+
const brokerPath = fileURLToPath(import.meta.url);
|
|
35
|
+
// Pinned so the broker publishes its endpoint where this install's callers look.
|
|
36
|
+
const brokerCommand = `$env:HERDR_REMOTE_STATE_DIR = ${powershellQuote(stateDir())}; & ${powershellQuote(process.execPath)} ${powershellQuote(brokerPath)}`;
|
|
37
|
+
const encodedBrokerCommand = Buffer.from(brokerCommand, 'utf16le').toString('base64');
|
|
38
|
+
const powershell = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
|
39
|
+
const script = `
|
|
40
|
+
$ErrorActionPreference = 'Stop'
|
|
41
|
+
$taskName = ${powershellQuote(adminBrokerTaskName())}
|
|
42
|
+
$user = [Security.Principal.WindowsIdentity]::GetCurrent().Name
|
|
43
|
+
$action = New-ScheduledTaskAction -Execute ${powershellQuote(powershell)} -Argument ${powershellQuote(`-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ${encodedBrokerCommand}`)} -WorkingDirectory ${powershellQuote(PACKAGE_ROOT)}
|
|
44
|
+
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $user
|
|
45
|
+
$principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Highest
|
|
46
|
+
$settings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Seconds 0)
|
|
47
|
+
Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
|
48
|
+
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
|
|
49
|
+
Start-ScheduledTask -TaskName $taskName
|
|
50
|
+
`;
|
|
51
|
+
runPowerShell(script, t);
|
|
52
|
+
}
|
|
53
|
+
export function uninstallAdminBroker(t) {
|
|
54
|
+
if (process.platform !== 'win32')
|
|
55
|
+
throw new Error(t('adminBroker.windowsOnly'));
|
|
56
|
+
runPowerShell(`
|
|
57
|
+
$ErrorActionPreference = 'Stop'
|
|
58
|
+
$taskName = ${powershellQuote(adminBrokerTaskName())}
|
|
59
|
+
Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
|
60
|
+
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
|
|
61
|
+
`, t);
|
|
62
|
+
}
|
|
63
|
+
export function adminBrokerStatus() {
|
|
64
|
+
let broker;
|
|
65
|
+
try {
|
|
66
|
+
broker = connectAdminBroker();
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return Promise.resolve(false);
|
|
70
|
+
}
|
|
71
|
+
const { socket, token } = broker;
|
|
72
|
+
return new Promise((resolve) => {
|
|
73
|
+
let settled = false;
|
|
74
|
+
let response = '';
|
|
75
|
+
const finish = (available) => {
|
|
76
|
+
if (settled)
|
|
77
|
+
return;
|
|
78
|
+
settled = true;
|
|
79
|
+
socket.destroy();
|
|
80
|
+
resolve(available);
|
|
81
|
+
};
|
|
82
|
+
socket.setTimeout(BROKER_PING_TIMEOUT_MS, () => finish(false));
|
|
83
|
+
socket.setEncoding('utf8');
|
|
84
|
+
socket.on('connect', () => sendAdminBrokerMessage(socket, { type: 'ping', token }));
|
|
85
|
+
socket.on('data', (chunk) => {
|
|
86
|
+
response += chunk;
|
|
87
|
+
if (response.includes('\n'))
|
|
88
|
+
finish(response.includes('"type":"pong"'));
|
|
89
|
+
});
|
|
90
|
+
socket.on('error', () => finish(false));
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/** The task starts the broker a moment after it is registered; wait for it to answer. */
|
|
94
|
+
export async function waitForAdminBroker() {
|
|
95
|
+
const deadline = Date.now() + BROKER_START_TIMEOUT_MS;
|
|
96
|
+
while (!(await adminBrokerStatus())) {
|
|
97
|
+
if (Date.now() >= deadline)
|
|
98
|
+
return false;
|
|
99
|
+
await new Promise((resolve) => setTimeout(resolve, BROKER_START_POLL_MS));
|
|
100
|
+
}
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
function writeError(socket, error) {
|
|
104
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
105
|
+
sendAdminBrokerMessage(socket, { type: 'error', message });
|
|
106
|
+
socket.end();
|
|
107
|
+
}
|
|
108
|
+
function startBrokerPty(socket, message) {
|
|
109
|
+
if (typeof message.command !== 'string' || !message.command || message.command.length > 4096)
|
|
110
|
+
throw new Error('Invalid administrator terminal command.');
|
|
111
|
+
if (path.basename(message.command).toLowerCase() !== 'powershell.exe')
|
|
112
|
+
throw new Error('The administrator broker only starts PowerShell.');
|
|
113
|
+
if (!Array.isArray(message.args) ||
|
|
114
|
+
message.args.length > 128 ||
|
|
115
|
+
!message.args.every((arg) => typeof arg === 'string'))
|
|
116
|
+
throw new Error('Invalid administrator terminal arguments.');
|
|
117
|
+
if (message.args.length !== 2 ||
|
|
118
|
+
message.args[0] !== '-NoLogo' ||
|
|
119
|
+
message.args[1] !== '-NoProfile')
|
|
120
|
+
throw new Error('The administrator broker only starts an interactive PowerShell prompt.');
|
|
121
|
+
if (typeof message.cwd !== 'string' || message.cwd.length > 4096)
|
|
122
|
+
throw new Error('Invalid administrator terminal working directory.');
|
|
123
|
+
if (message.socketPath !== null && message.socketPath !== undefined)
|
|
124
|
+
throw new Error('An administrator PowerShell session does not use a Herdr socket.');
|
|
125
|
+
const pty = new PtySession({
|
|
126
|
+
command: message.command,
|
|
127
|
+
args: message.args,
|
|
128
|
+
cwd: message.cwd,
|
|
129
|
+
socketPath: message.socketPath,
|
|
130
|
+
platform: 'win32',
|
|
131
|
+
// A plain PowerShell prompt does not need the Herdr UI's Kitty or mouse modes.
|
|
132
|
+
fastWindowsPty: true,
|
|
133
|
+
});
|
|
134
|
+
pty.start({
|
|
135
|
+
cols: PtySession.clampDimension(message.cols, PtySession.DEFAULT_COLS),
|
|
136
|
+
rows: PtySession.clampDimension(message.rows, PtySession.DEFAULT_ROWS),
|
|
137
|
+
onData: (data) => {
|
|
138
|
+
if (socket.writable)
|
|
139
|
+
sendAdminBrokerMessage(socket, {
|
|
140
|
+
type: 'data',
|
|
141
|
+
dataBase64: Buffer.from(data).toString('base64'),
|
|
142
|
+
});
|
|
143
|
+
},
|
|
144
|
+
onExit: ({ exitCode, signal }) => {
|
|
145
|
+
if (socket.writable)
|
|
146
|
+
sendAdminBrokerMessage(socket, { type: 'exit', exitCode, signal });
|
|
147
|
+
socket.end();
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
sendAdminBrokerMessage(socket, { type: 'started', pid: pty.terminal?.pid ?? 0 });
|
|
151
|
+
return pty;
|
|
152
|
+
}
|
|
153
|
+
/** Listen on loopback and publish the endpoint; every request must carry its token. */
|
|
154
|
+
export function serveAdminBroker() {
|
|
155
|
+
let token = '';
|
|
156
|
+
const server = net.createServer((socket) => {
|
|
157
|
+
socket.setEncoding('utf8');
|
|
158
|
+
let pending = '';
|
|
159
|
+
let pty = null;
|
|
160
|
+
let firstMessage = true;
|
|
161
|
+
socket.on('data', (chunk) => {
|
|
162
|
+
pending += chunk;
|
|
163
|
+
if (Buffer.byteLength(pending, 'utf8') > ADMIN_BROKER_MAX_MESSAGE_BYTES) {
|
|
164
|
+
socket.destroy(new Error('Administrator terminal message exceeded the limit.'));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
while (true) {
|
|
168
|
+
const newline = pending.indexOf('\n');
|
|
169
|
+
if (newline < 0)
|
|
170
|
+
return;
|
|
171
|
+
const line = pending.slice(0, newline);
|
|
172
|
+
pending = pending.slice(newline + 1);
|
|
173
|
+
let message;
|
|
174
|
+
try {
|
|
175
|
+
message = JSON.parse(line);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
writeError(socket, new Error('Invalid administrator terminal message.'));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (firstMessage) {
|
|
182
|
+
firstMessage = false;
|
|
183
|
+
if (!adminBrokerTokenMatches(token, message.token)) {
|
|
184
|
+
writeError(socket, new Error('Administrator terminal request was not authorized.'));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (message.type === 'ping') {
|
|
188
|
+
sendAdminBrokerMessage(socket, { type: 'pong' });
|
|
189
|
+
socket.end();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (message.type !== 'start') {
|
|
193
|
+
writeError(socket, new Error('Expected an administrator terminal start request.'));
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
pty = startBrokerPty(socket, message);
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
writeError(socket, error);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (!pty)
|
|
206
|
+
return;
|
|
207
|
+
if (message.type === 'input' && typeof message.dataBase64 === 'string') {
|
|
208
|
+
if (message.dataBase64.length <= ADMIN_BROKER_MAX_MESSAGE_BYTES)
|
|
209
|
+
pty.write(Buffer.from(message.dataBase64, 'base64'));
|
|
210
|
+
}
|
|
211
|
+
else if (message.type === 'resize') {
|
|
212
|
+
pty.resize(message.cols, message.rows);
|
|
213
|
+
}
|
|
214
|
+
else if (message.type === 'kill') {
|
|
215
|
+
pty.kill();
|
|
216
|
+
socket.end();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
socket.on('close', () => pty?.kill());
|
|
222
|
+
socket.on('error', () => pty?.kill());
|
|
223
|
+
});
|
|
224
|
+
listenAdminBroker(server, (port) => {
|
|
225
|
+
token = publishAdminBrokerEndpoint(port);
|
|
226
|
+
});
|
|
227
|
+
return server;
|
|
228
|
+
}
|
|
229
|
+
function runAdminBroker() {
|
|
230
|
+
if (process.platform !== 'win32')
|
|
231
|
+
throw new Error('Administrator PTYs are only supported on Windows.');
|
|
232
|
+
serveAdminBroker().on('error', (error) => {
|
|
233
|
+
process.stderr.write(`herdr-remote administrator broker: ${error.message}\n`);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
if (path.basename(process.argv[1] || '') === 'admin-broker.js')
|
|
237
|
+
runAdminBroker();
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { ADMIN_BROKER_MAX_MESSAGE_BYTES, connectAdminBroker, sendAdminBrokerMessage, } from './adminBrokerEndpoint.js';
|
|
2
|
+
const BROKER_CONNECT_TIMEOUT_MS = 5_000;
|
|
3
|
+
/** A PTY the admin broker runs elevated, driven over its loopback socket. */
|
|
4
|
+
export class ElevatedPty {
|
|
5
|
+
socket = null;
|
|
6
|
+
input = '';
|
|
7
|
+
connected = false;
|
|
8
|
+
started = false;
|
|
9
|
+
finished = false;
|
|
10
|
+
killed = false;
|
|
11
|
+
/** Keystrokes typed while the elevated shell was still starting. */
|
|
12
|
+
early = [];
|
|
13
|
+
dataListener = null;
|
|
14
|
+
exitListener = null;
|
|
15
|
+
errorListener = null;
|
|
16
|
+
options;
|
|
17
|
+
constructor(options) {
|
|
18
|
+
this.options = options;
|
|
19
|
+
}
|
|
20
|
+
start({ cols, rows, onData, onExit, onError }) {
|
|
21
|
+
this.dataListener = onData;
|
|
22
|
+
this.exitListener = onExit;
|
|
23
|
+
this.errorListener = onError;
|
|
24
|
+
let broker;
|
|
25
|
+
try {
|
|
26
|
+
broker = connectAdminBroker();
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
this.fail(error);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const { socket, token } = broker;
|
|
33
|
+
this.socket = socket;
|
|
34
|
+
socket.setEncoding('utf8');
|
|
35
|
+
socket.setTimeout(BROKER_CONNECT_TIMEOUT_MS, () => {
|
|
36
|
+
socket.destroy(Object.assign(new Error('Administrator terminal broker connection timed out'), {
|
|
37
|
+
code: 'ETIMEDOUT',
|
|
38
|
+
}));
|
|
39
|
+
});
|
|
40
|
+
socket.on('connect', () => {
|
|
41
|
+
this.connected = true;
|
|
42
|
+
socket.setTimeout(0);
|
|
43
|
+
sendAdminBrokerMessage(socket, { type: 'start', token, ...this.options, cols, rows });
|
|
44
|
+
});
|
|
45
|
+
socket.on('data', (chunk) => this.receive(chunk));
|
|
46
|
+
socket.on('error', (error) => this.fail(error));
|
|
47
|
+
socket.on('close', () => {
|
|
48
|
+
if (this.started && !this.finished && !this.killed) {
|
|
49
|
+
this.finished = true;
|
|
50
|
+
this.exitListener?.({ exitCode: 1 });
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
write(data) {
|
|
55
|
+
if (this.finished)
|
|
56
|
+
return;
|
|
57
|
+
if (!this.started) {
|
|
58
|
+
this.early.push(Buffer.from(data));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (this.socket?.writable) {
|
|
62
|
+
sendAdminBrokerMessage(this.socket, {
|
|
63
|
+
type: 'input',
|
|
64
|
+
dataBase64: Buffer.from(data).toString('base64'),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
resize(cols, rows) {
|
|
69
|
+
if (this.started && this.socket?.writable) {
|
|
70
|
+
sendAdminBrokerMessage(this.socket, { type: 'resize', cols, rows });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
kill() {
|
|
74
|
+
this.killed = true;
|
|
75
|
+
if (!this.socket)
|
|
76
|
+
return;
|
|
77
|
+
if (this.connected && this.socket.writable) {
|
|
78
|
+
sendAdminBrokerMessage(this.socket, { type: 'kill' });
|
|
79
|
+
this.socket.end();
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
this.socket.destroy();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
receive(chunk) {
|
|
86
|
+
this.input += chunk;
|
|
87
|
+
if (Buffer.byteLength(this.input, 'utf8') > ADMIN_BROKER_MAX_MESSAGE_BYTES) {
|
|
88
|
+
this.socket?.destroy(new Error('Administrator terminal broker message exceeded the limit'));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
while (true) {
|
|
92
|
+
const newline = this.input.indexOf('\n');
|
|
93
|
+
if (newline < 0)
|
|
94
|
+
return;
|
|
95
|
+
const line = this.input.slice(0, newline);
|
|
96
|
+
this.input = this.input.slice(newline + 1);
|
|
97
|
+
let message;
|
|
98
|
+
try {
|
|
99
|
+
message = JSON.parse(line);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
this.socket?.destroy(new Error('Administrator terminal broker sent invalid JSON'));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (message.type === 'started') {
|
|
106
|
+
this.started = true;
|
|
107
|
+
const early = Buffer.concat(this.early);
|
|
108
|
+
this.early = [];
|
|
109
|
+
if (early.length > 0)
|
|
110
|
+
this.write(early);
|
|
111
|
+
}
|
|
112
|
+
else if (message.type === 'data' && typeof message.dataBase64 === 'string') {
|
|
113
|
+
this.dataListener?.(Buffer.from(message.dataBase64, 'base64').toString('utf8'));
|
|
114
|
+
}
|
|
115
|
+
else if (message.type === 'exit') {
|
|
116
|
+
this.finished = true;
|
|
117
|
+
this.exitListener?.({
|
|
118
|
+
exitCode: Number(message.exitCode) || 0,
|
|
119
|
+
...(typeof message.signal === 'number' ? { signal: message.signal } : {}),
|
|
120
|
+
});
|
|
121
|
+
this.socket?.end();
|
|
122
|
+
}
|
|
123
|
+
else if (message.type === 'error') {
|
|
124
|
+
this.fail(new Error(typeof message.message === 'string' ? message.message : 'Admin PTY failed'));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
fail(error) {
|
|
129
|
+
if (this.started) {
|
|
130
|
+
if (!this.finished && !this.killed) {
|
|
131
|
+
this.finished = true;
|
|
132
|
+
this.exitListener?.({ exitCode: 1 });
|
|
133
|
+
}
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (this.finished || this.killed)
|
|
137
|
+
return;
|
|
138
|
+
this.finished = true;
|
|
139
|
+
this.errorListener?.(error);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -13,6 +13,7 @@ import { hostWebSocketUrl, resolveHostRelayUrl } from '../relay-urls.js';
|
|
|
13
13
|
import { resolveSocketPath } from '../socket-discovery.js';
|
|
14
14
|
import { resolveHostPalette } from '../terminal-palette.js';
|
|
15
15
|
import { randomToken } from 'herdr-remote-relay/state';
|
|
16
|
+
import { openAdminTabFor } from './admin-tab.js';
|
|
16
17
|
import { AgentWatch } from './agent-watch.js';
|
|
17
18
|
import { FontServer } from './font-server.js';
|
|
18
19
|
import { acquireHostLock, releaseHostLock } from './lock.js';
|
|
@@ -185,10 +186,19 @@ export class HostConnector {
|
|
|
185
186
|
password: this.relayPassword || null,
|
|
186
187
|
hostname: os.hostname(),
|
|
187
188
|
platform: process.platform,
|
|
189
|
+
...(process.platform === 'win32' &&
|
|
190
|
+
(process.env.MSYSTEM || /(?:^|[\\/])(?:bash|sh)(?:\.exe)?$/i.test(process.env.SHELL || ''))
|
|
191
|
+
? { shellProfile: 'git-bash' }
|
|
192
|
+
: {}),
|
|
188
193
|
arch: process.arch,
|
|
189
194
|
terminalPalette: this.terminalPalette || null,
|
|
190
195
|
terminalFont: this.font.publicFont(),
|
|
191
|
-
capabilities: [
|
|
196
|
+
capabilities: [
|
|
197
|
+
CAPABILITY.hostHandoff,
|
|
198
|
+
CAPABILITY.idleHeartbeat,
|
|
199
|
+
CAPABILITY.binaryFrameV2,
|
|
200
|
+
...(process.platform === 'win32' ? [CAPABILITY.adminTabs] : []),
|
|
201
|
+
],
|
|
192
202
|
});
|
|
193
203
|
// The relay sends host_ready with the current browser count. No business
|
|
194
204
|
// heartbeat is started until that message says somebody is watching.
|
|
@@ -274,6 +284,12 @@ export class HostConnector {
|
|
|
274
284
|
return this.startSession(message);
|
|
275
285
|
case 'herdr_start':
|
|
276
286
|
return this.sessions.startHerdrFor(streamOf(message));
|
|
287
|
+
case 'admin_tab_open':
|
|
288
|
+
return openAdminTabFor(streamOf(message), {
|
|
289
|
+
socketPath: this.socketPath,
|
|
290
|
+
language: this.config.ui?.language,
|
|
291
|
+
send: (payload) => sendJson(this.ws, payload),
|
|
292
|
+
});
|
|
277
293
|
case 'session_stop': {
|
|
278
294
|
const streamId = streamOf(message);
|
|
279
295
|
if (streamId)
|
|
@@ -11,6 +11,8 @@ import { streamOf } from './wire.js';
|
|
|
11
11
|
* session the user actually used and then left.
|
|
12
12
|
*/
|
|
13
13
|
const FAST_FAILURE_MS = 1500;
|
|
14
|
+
/** Coalesce Windows ConPTY's many small output chunks before they reach the relay. */
|
|
15
|
+
const WINDOWS_OUTPUT_BATCH_MS = 4;
|
|
14
16
|
/** How many broken starts in a row before we stop calling them exits. */
|
|
15
17
|
export const FAST_FAILURE_LIMIT = 3;
|
|
16
18
|
export class Sessions {
|
|
@@ -214,12 +216,17 @@ export class Sessions {
|
|
|
214
216
|
startedAtMs: Date.now(),
|
|
215
217
|
pendingOutput: [],
|
|
216
218
|
flushImmediate: null,
|
|
219
|
+
flushTimeout: null,
|
|
217
220
|
};
|
|
218
221
|
const flushOutput = () => {
|
|
219
222
|
if (session.flushImmediate) {
|
|
220
223
|
clearImmediate(session.flushImmediate);
|
|
221
224
|
session.flushImmediate = null;
|
|
222
225
|
}
|
|
226
|
+
if (session.flushTimeout) {
|
|
227
|
+
clearTimeout(session.flushTimeout);
|
|
228
|
+
session.flushTimeout = null;
|
|
229
|
+
}
|
|
223
230
|
if (session.pendingOutput.length === 0)
|
|
224
231
|
return;
|
|
225
232
|
const payload = Buffer.concat(session.pendingOutput);
|
|
@@ -237,10 +244,14 @@ export class Sessions {
|
|
|
237
244
|
if (chunk.length === 0)
|
|
238
245
|
return;
|
|
239
246
|
session.pendingOutput.push(chunk);
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
247
|
+
if (!session.flushImmediate && !session.flushTimeout) {
|
|
248
|
+
if (process.platform === 'win32') {
|
|
249
|
+
session.flushTimeout = setTimeout(flushOutput, WINDOWS_OUTPUT_BATCH_MS);
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
session.flushImmediate = setImmediate(flushOutput);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
244
255
|
},
|
|
245
256
|
onExit: ({ exitCode }) => {
|
|
246
257
|
if (this.byStream.get(streamId) !== session)
|
|
@@ -362,5 +373,9 @@ function discardOutput(session) {
|
|
|
362
373
|
clearImmediate(session.flushImmediate);
|
|
363
374
|
session.flushImmediate = null;
|
|
364
375
|
}
|
|
376
|
+
if (session.flushTimeout) {
|
|
377
|
+
clearTimeout(session.flushTimeout);
|
|
378
|
+
session.flushTimeout = null;
|
|
379
|
+
}
|
|
365
380
|
session.pendingOutput = [];
|
|
366
381
|
}
|
package/dist/i18n/en.js
CHANGED
|
@@ -84,6 +84,22 @@ const en = {
|
|
|
84
84
|
'services.logEmpty': 'No logs.',
|
|
85
85
|
'services.logRelay': 'Relay',
|
|
86
86
|
'services.logHost': 'Host connector',
|
|
87
|
+
'adminBroker.windowsOnly': 'The administrator terminal broker only runs on Windows.',
|
|
88
|
+
'adminBroker.needsElevation': 'Open the Windows TUI and choose Set up admin terminal access. Approve the one-time Windows prompt under the same account as Herdr Remote.',
|
|
89
|
+
'adminBroker.title': 'Admin terminal broker',
|
|
90
|
+
'adminBroker.checking': 'Checking…',
|
|
91
|
+
'adminBroker.ready': 'Ready',
|
|
92
|
+
'adminBroker.notReady': 'Not configured or stopped',
|
|
93
|
+
'adminBroker.install': 'Set up admin terminal access',
|
|
94
|
+
'adminBroker.uninstall': 'Remove admin terminal access',
|
|
95
|
+
'adminBroker.installDone': 'Admin terminal broker setup task registered.',
|
|
96
|
+
'adminBroker.uninstallDone': 'Admin terminal broker setup task removed.',
|
|
97
|
+
'adminBroker.elevationCancelled': 'Administrator approval was declined; no changes were made.',
|
|
98
|
+
'adminBroker.setupFailed': 'Could not configure the administrator terminal broker.',
|
|
99
|
+
'adminBroker.tuiHint': 'Set this up once. Windows asks for administrator approval; later WebUI admin terminals open without UAC.',
|
|
100
|
+
'adminShell.needsTerminal': 'admin-shell must run in a terminal.',
|
|
101
|
+
'adminShell.brokerUnavailable': 'The administrator terminal broker is not running. Open the herdr-remote TUI, go to Keep-alive, and choose Set up admin terminal access.',
|
|
102
|
+
'adminShell.tabLabel': 'Admin',
|
|
87
103
|
'relay.title': 'Relay settings',
|
|
88
104
|
'relay.settings': 'Settings',
|
|
89
105
|
'relay.locked': 'fixed',
|
package/dist/i18n/zh.js
CHANGED
|
@@ -83,6 +83,22 @@ const zh = {
|
|
|
83
83
|
'services.logEmpty': '暂无日志。',
|
|
84
84
|
'services.logRelay': 'Relay',
|
|
85
85
|
'services.logHost': '主机连接器',
|
|
86
|
+
'adminBroker.windowsOnly': '管理员终端 broker 只支持 Windows。',
|
|
87
|
+
'adminBroker.needsElevation': '请打开 Windows TUI 并选择“设置管理员终端”,使用运行 Herdr Remote 的同一账户批准一次 Windows 提权提示。',
|
|
88
|
+
'adminBroker.title': '管理员终端 broker',
|
|
89
|
+
'adminBroker.checking': '正在检查…',
|
|
90
|
+
'adminBroker.ready': '已就绪',
|
|
91
|
+
'adminBroker.notReady': '尚未设置或未运行',
|
|
92
|
+
'adminBroker.install': '设置管理员终端',
|
|
93
|
+
'adminBroker.uninstall': '移除管理员终端设置',
|
|
94
|
+
'adminBroker.installDone': '管理员终端 broker 任务已注册。',
|
|
95
|
+
'adminBroker.uninstallDone': '管理员终端 broker 任务已移除。',
|
|
96
|
+
'adminBroker.elevationCancelled': '未批准管理员权限,未进行更改。',
|
|
97
|
+
'adminBroker.setupFailed': '管理员终端 broker 设置失败。',
|
|
98
|
+
'adminBroker.tuiHint': '只需设置一次。Windows 会请求管理员授权;之后 WebUI 新建管理员终端不再弹出 UAC。',
|
|
99
|
+
'adminShell.needsTerminal': 'admin-shell 必须在终端中运行。',
|
|
100
|
+
'adminShell.brokerUnavailable': '管理员终端 broker 尚未运行。请打开 herdr-remote TUI,进入「保活」并选择「设置管理员终端」。',
|
|
101
|
+
'adminShell.tabLabel': '管理员',
|
|
86
102
|
'relay.title': 'Relay 设置',
|
|
87
103
|
'relay.settings': '设置',
|
|
88
104
|
'relay.locked': '固定',
|
package/dist/pty-session.js
CHANGED
|
@@ -10,10 +10,11 @@ class PtySession {
|
|
|
10
10
|
cwd;
|
|
11
11
|
socketPath;
|
|
12
12
|
platform;
|
|
13
|
+
fastWindowsPty;
|
|
13
14
|
spawnPty;
|
|
14
15
|
terminal;
|
|
15
16
|
startedAt;
|
|
16
|
-
constructor({ command, args = [], cwd = os.homedir(), socketPath, platform = process.platform, spawn: spawnPty = pty.spawn, } = {}) {
|
|
17
|
+
constructor({ command, args = [], cwd = os.homedir(), socketPath, platform = process.platform, spawn: spawnPty = pty.spawn, fastWindowsPty = false, } = {}) {
|
|
17
18
|
if (typeof command !== 'string' || command.length === 0)
|
|
18
19
|
throw new TypeError('command must be a non-empty string');
|
|
19
20
|
if (!Array.isArray(args) || !args.every((arg) => typeof arg === 'string'))
|
|
@@ -23,6 +24,7 @@ class PtySession {
|
|
|
23
24
|
this.cwd = cwd;
|
|
24
25
|
this.socketPath = socketPath;
|
|
25
26
|
this.platform = platform;
|
|
27
|
+
this.fastWindowsPty = fastWindowsPty;
|
|
26
28
|
this.spawnPty = spawnPty;
|
|
27
29
|
this.terminal = null;
|
|
28
30
|
this.startedAt = null;
|
|
@@ -48,16 +50,18 @@ class PtySession {
|
|
|
48
50
|
start({ cols, rows, onData, onExit }) {
|
|
49
51
|
if (this.terminal)
|
|
50
52
|
throw new Error('PTY session already started');
|
|
51
|
-
// The bundled ConPTY
|
|
52
|
-
// which is why Herdr embeds it too. System ConPTY's kill() forks a
|
|
53
|
-
// console-list helper without windowsHide, which flashes a window here.
|
|
53
|
+
// The Herdr UI needs the bundled ConPTY's Kitty and mouse reporting.
|
|
54
54
|
this.terminal = this.spawnPty(this.command, this.args, {
|
|
55
55
|
name: 'xterm-256color',
|
|
56
56
|
cols: PtySession.clampDimension(cols, PtySession.DEFAULT_COLS),
|
|
57
57
|
rows: PtySession.clampDimension(rows, PtySession.DEFAULT_ROWS),
|
|
58
58
|
cwd: this.cwd,
|
|
59
59
|
env: PtySession.childEnv(this.socketPath),
|
|
60
|
-
...(this.platform === 'win32'
|
|
60
|
+
...(this.platform === 'win32'
|
|
61
|
+
? this.fastWindowsPty
|
|
62
|
+
? { useConpty: false }
|
|
63
|
+
: { useConptyDll: true }
|
|
64
|
+
: {}),
|
|
61
65
|
});
|
|
62
66
|
this.startedAt = new Date().toISOString();
|
|
63
67
|
const terminal = this.terminal;
|
package/dist/tui/api.js
CHANGED
|
@@ -19,6 +19,16 @@ import * as keepalive from '../keepalive/index.js';
|
|
|
19
19
|
import { canSelfUpdate, checkForUpdate, compareVersions, currentVersion, updateChecksEnabled, installKind, performUpdate, } from '../updater.js';
|
|
20
20
|
import { checkForRelayUpdate, installedRelayVersion, performRelayUpdate, } from '../relay-updater.js';
|
|
21
21
|
export { detectLocale, listReachableAddresses, MIN_HERDR_VERSION, OFFICIAL_RELAY_URL, isOfficialRelay, selectedMode, canSelfUpdate, checkForUpdate, compareVersions, currentVersion, updateChecksEnabled, installKind, performUpdate, checkForRelayUpdate, installedRelayVersion, performRelayUpdate, runsLocalRelay, bindAddress, configExists, configPath, createDraft, createTranslator, ensureRuntime, extractPairingCode, fieldsForMode, fullStatus, getField, getFieldPlaceholder, herdrVersion, keepalive, loadConfig, pair, readLogTail, regenerateHostIdentity, requiresRestart, restartAll, saveDraft, setField, setRelayPassword, startAll, stateDir, stopAll, whileServicesStopped, };
|
|
22
|
+
export async function adminBrokerStatus({ waitForStart = false } = {}) {
|
|
23
|
+
if (process.platform !== 'win32')
|
|
24
|
+
return false;
|
|
25
|
+
const broker = await import('../connector/admin-broker.js');
|
|
26
|
+
return waitForStart ? broker.waitForAdminBroker() : broker.adminBrokerStatus();
|
|
27
|
+
}
|
|
28
|
+
export async function configureAdminBroker(action, t) {
|
|
29
|
+
const setup = await import('../connector/admin-broker-setup.js');
|
|
30
|
+
return setup.configureAdminBroker(action, t);
|
|
31
|
+
}
|
|
22
32
|
/** The command that starts a matching relay, ready to paste on the server. */
|
|
23
33
|
export function relayStartCommand(config, password) {
|
|
24
34
|
const publicUrl = config.relay.remoteUrl
|
|
@@ -1,13 +1,31 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useState } from 'react';
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
3
|
import { Box, Text, useInput } from 'ink';
|
|
4
4
|
import { theme } from '../theme.js';
|
|
5
5
|
import { Message, Panel, Row, Selectable, StatusDot } from '../components/common.js';
|
|
6
6
|
import { ChoiceList } from './Relay.js';
|
|
7
|
-
import { KEEPALIVE_MANAGERS, keepalive, saveDraft, setField } from '../api.js';
|
|
7
|
+
import { KEEPALIVE_MANAGERS, adminBrokerStatus, configureAdminBroker, keepalive, saveDraft, setField, } from '../api.js';
|
|
8
8
|
export function Keepalive({ ctx }) {
|
|
9
9
|
const { t, status, draft, editingId } = ctx;
|
|
10
10
|
const [selected, setSelected] = useState('install');
|
|
11
|
+
const [brokerReady, setBrokerReady] = useState(null);
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (process.platform !== 'win32')
|
|
14
|
+
return undefined;
|
|
15
|
+
let cancelled = false;
|
|
16
|
+
adminBrokerStatus()
|
|
17
|
+
.then((available) => {
|
|
18
|
+
if (!cancelled)
|
|
19
|
+
setBrokerReady(available);
|
|
20
|
+
})
|
|
21
|
+
.catch(() => {
|
|
22
|
+
if (!cancelled)
|
|
23
|
+
setBrokerReady(false);
|
|
24
|
+
});
|
|
25
|
+
return () => {
|
|
26
|
+
cancelled = true;
|
|
27
|
+
};
|
|
28
|
+
}, []);
|
|
11
29
|
const current = status?.keepalive;
|
|
12
30
|
const entries = [
|
|
13
31
|
{ id: 'install', label: t('keepalive.install') },
|
|
@@ -17,12 +35,38 @@ export function Keepalive({ ctx }) {
|
|
|
17
35
|
...(process.platform === 'linux' && current?.manager === 'systemd' && !current?.linger
|
|
18
36
|
? [{ id: 'linger', label: t('keepalive.enableLinger') }]
|
|
19
37
|
: []),
|
|
38
|
+
...(process.platform === 'win32'
|
|
39
|
+
? [
|
|
40
|
+
{ id: 'adminBrokerInstall', label: t('adminBroker.install') },
|
|
41
|
+
{ id: 'adminBrokerUninstall', label: t('adminBroker.uninstall') },
|
|
42
|
+
]
|
|
43
|
+
: []),
|
|
20
44
|
];
|
|
21
45
|
const activate = (id) => {
|
|
22
46
|
if (id === 'manager') {
|
|
23
47
|
ctx.setEditing('keepaliveManager');
|
|
24
48
|
return;
|
|
25
49
|
}
|
|
50
|
+
if (id === 'adminBrokerInstall' || id === 'adminBrokerUninstall') {
|
|
51
|
+
const action = id === 'adminBrokerInstall' ? 'install' : 'uninstall';
|
|
52
|
+
ctx.run(async () => {
|
|
53
|
+
let ready = false;
|
|
54
|
+
try {
|
|
55
|
+
await configureAdminBroker(action, t);
|
|
56
|
+
ready = action === 'install' && (await adminBrokerStatus({ waitForStart: true }));
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (error.code === 'ELEVATION_CANCELLED') {
|
|
60
|
+
ctx.notify((t) => t('adminBroker.elevationCancelled'), 'info');
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
setBrokerReady(ready);
|
|
66
|
+
ctx.notify((t) => t(`adminBroker.${action}Done`), 'success');
|
|
67
|
+
});
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
26
70
|
ctx.run(async () => {
|
|
27
71
|
if (id === 'install') {
|
|
28
72
|
if (keepalive.detectManager(ctx.config.keepalive.manager) === 'windows-service')
|
|
@@ -88,7 +132,9 @@ export function Keepalive({ ctx }) {
|
|
|
88
132
|
? t('common.running')
|
|
89
133
|
: current?.installed
|
|
90
134
|
? t('common.installed')
|
|
91
|
-
: t('common.notInstalled')] })] }) }),
|
|
135
|
+
: t('common.notInstalled')] })] }) }), process.platform === 'win32' ? (_jsxs(_Fragment, { children: [_jsx(Row, { label: t('adminBroker.title'), children: _jsx(Text, { color: brokerReady ? theme.ok : theme.warn, children: brokerReady === null
|
|
136
|
+
? t('adminBroker.checking')
|
|
137
|
+
: t(brokerReady ? 'adminBroker.ready' : 'adminBroker.notReady') }) }), _jsx(Text, { color: theme.muted, children: t('adminBroker.tuiHint') })] })) : null, current?.unitPath ? (_jsx(Row, { label: t('keepalive.unitPath'), children: _jsx(Text, { color: theme.muted, children: current.unitPath }) })) : null, _jsx(Box, { marginTop: 1, flexDirection: "column", children: entries.map((entry) => (_jsx(Selectable, { selected: selected === entry.id, onSelect: () => {
|
|
92
138
|
setSelected(entry.id);
|
|
93
139
|
activate(entry.id);
|
|
94
140
|
}, onHover: () => setSelected(entry.id), children: entry.label }, entry.id))) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [current?.manager === 'systemd' ? (_jsx(Text, { color: current.linger ? theme.muted : theme.warn, children: current.linger ? t('keepalive.lingerEnabled') : t('keepalive.lingerDisabled') })) : null, current?.manager === 'supervisor' ? (_jsx(Text, { color: theme.warn, children: t('keepalive.fallbackNote') })) : null, current?.manager === 'windows-task' ? (_jsx(Text, { color: theme.muted, children: t('keepalive.windowsTaskNote') })) : null, current?.manager === 'windows-service' ? (_jsx(Text, { color: theme.warn, children: t('keepalive.serviceNote') })) : null, _jsx(Text, { color: theme.muted, children: t('keepalive.logsHint', { command: status?.logsHint ?? '' }) })] }), _jsx(Message, { text: ctx.message?.text ?? null, level: ctx.message?.level ?? 'info' })] }));
|
package/herdr-plugin.toml
CHANGED