herdr-remote 0.2.26 → 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 +2 -2
- package/README.zh-CN.md +1 -1
- package/dist/cli.js +7 -1
- package/dist/connector/admin-broker.js +40 -11
- 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/i18n/en.js +3 -0
- package/dist/i18n/zh.js +3 -0
- package/dist/pty-session.js +2 -28
- package/dist/tui/api.js +2 -2
- package/dist/tui/screens/Keepalive.js +1 -1
- package/herdr-plugin.toml +1 -1
- package/package.json +1 -1
- package/dist/connector/adminBrokerPipe.js +0 -16
package/README.md
CHANGED
|
@@ -40,8 +40,8 @@ herdr-remote --lang zh|en
|
|
|
40
40
|
|
|
41
41
|
On Windows, the TUI's **Keep-alive** page can register a per-user Task Scheduler broker with highest
|
|
42
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;
|
|
44
|
-
|
|
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
45
|
`admin-broker install` and `admin-broker uninstall` commands are also available for administrators.
|
|
46
46
|
Use `admin-broker status` to check the broker.
|
|
47
47
|
|
package/README.zh-CN.md
CHANGED
|
@@ -38,7 +38,7 @@ herdr-remote plugin link | unlink | status
|
|
|
38
38
|
herdr-remote --lang zh|en
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
在 Windows 上,可在 TUI 的「保活」页面注册以当前账户最高权限运行的计划任务并启动 broker。选择「设置管理员终端」,使用运行 Herdr Remote 的同一账户批准一次 Windows
|
|
41
|
+
在 Windows 上,可在 TUI 的「保活」页面注册以当前账户最高权限运行的计划任务并启动 broker。选择「设置管理员终端」,使用运行 Herdr Remote 的同一账户批准一次 Windows 提权提示;之后,WebUI 的“管理员”按钮会在 Herdr 中新建一个管理员 PowerShell 标签页,不再重复弹出 UAC。也可以使用 `admin-broker install` 和 `admin-broker uninstall` 命令管理任务,使用 `admin-broker status` 检查 broker。
|
|
42
42
|
|
|
43
43
|
## Herdr 插件
|
|
44
44
|
|
package/dist/cli.js
CHANGED
|
@@ -27,6 +27,7 @@ Commands:
|
|
|
27
27
|
url Print browser access URL
|
|
28
28
|
run [--daemon] Run services in foreground (keep-alive)
|
|
29
29
|
admin-broker install | uninstall | status (Windows)
|
|
30
|
+
admin-shell Elevated PowerShell in this terminal (Windows)
|
|
30
31
|
keepalive <action> install | uninstall | restart | status
|
|
31
32
|
plugin <action> link | unlink | status (Herdr plugin)
|
|
32
33
|
|
|
@@ -228,7 +229,7 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
228
229
|
const action = positional[1] || 'status';
|
|
229
230
|
if (action === 'install') {
|
|
230
231
|
broker.installAdminBroker(t);
|
|
231
|
-
printJson({ installed: true, available: await broker.
|
|
232
|
+
printJson({ installed: true, available: await broker.waitForAdminBroker() });
|
|
232
233
|
}
|
|
233
234
|
else if (action === 'uninstall') {
|
|
234
235
|
broker.uninstallAdminBroker(t);
|
|
@@ -242,6 +243,11 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
242
243
|
}
|
|
243
244
|
return;
|
|
244
245
|
}
|
|
246
|
+
case 'admin-shell': {
|
|
247
|
+
const { runAdminShell } = await import('./connector/admin-shell.js');
|
|
248
|
+
process.exitCode = await runAdminShell(t);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
245
251
|
case 'keepalive': {
|
|
246
252
|
const keepalive = await import('./keepalive/index.js');
|
|
247
253
|
const action = positional[1] || 'status';
|
|
@@ -3,9 +3,11 @@ import net from 'node:net';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { PtySession } from '../pty-session.js';
|
|
6
|
-
import { PACKAGE_ROOT } from '../paths.js';
|
|
7
|
-
import { ADMIN_BROKER_MAX_MESSAGE_BYTES,
|
|
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
8
|
const BROKER_PING_TIMEOUT_MS = 1_000;
|
|
9
|
+
const BROKER_START_TIMEOUT_MS = 5_000;
|
|
10
|
+
const BROKER_START_POLL_MS = 250;
|
|
9
11
|
function powershellQuote(value) {
|
|
10
12
|
return `'${value.replaceAll("'", "''")}'`;
|
|
11
13
|
}
|
|
@@ -30,7 +32,8 @@ export function installAdminBroker(t) {
|
|
|
30
32
|
if (process.platform !== 'win32')
|
|
31
33
|
throw new Error(t('adminBroker.windowsOnly'));
|
|
32
34
|
const brokerPath = fileURLToPath(import.meta.url);
|
|
33
|
-
|
|
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)}`;
|
|
34
37
|
const encodedBrokerCommand = Buffer.from(brokerCommand, 'utf16le').toString('base64');
|
|
35
38
|
const powershell = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
|
36
39
|
const script = `
|
|
@@ -58,12 +61,17 @@ Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction Silent
|
|
|
58
61
|
`, t);
|
|
59
62
|
}
|
|
60
63
|
export function adminBrokerStatus() {
|
|
61
|
-
|
|
64
|
+
let broker;
|
|
65
|
+
try {
|
|
66
|
+
broker = connectAdminBroker();
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
62
69
|
return Promise.resolve(false);
|
|
70
|
+
}
|
|
71
|
+
const { socket, token } = broker;
|
|
63
72
|
return new Promise((resolve) => {
|
|
64
73
|
let settled = false;
|
|
65
74
|
let response = '';
|
|
66
|
-
const socket = net.createConnection(adminBrokerPipePath());
|
|
67
75
|
const finish = (available) => {
|
|
68
76
|
if (settled)
|
|
69
77
|
return;
|
|
@@ -73,7 +81,7 @@ export function adminBrokerStatus() {
|
|
|
73
81
|
};
|
|
74
82
|
socket.setTimeout(BROKER_PING_TIMEOUT_MS, () => finish(false));
|
|
75
83
|
socket.setEncoding('utf8');
|
|
76
|
-
socket.on('connect', () => sendAdminBrokerMessage(socket, { type: 'ping' }));
|
|
84
|
+
socket.on('connect', () => sendAdminBrokerMessage(socket, { type: 'ping', token }));
|
|
77
85
|
socket.on('data', (chunk) => {
|
|
78
86
|
response += chunk;
|
|
79
87
|
if (response.includes('\n'))
|
|
@@ -82,6 +90,16 @@ export function adminBrokerStatus() {
|
|
|
82
90
|
socket.on('error', () => finish(false));
|
|
83
91
|
});
|
|
84
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
|
+
}
|
|
85
103
|
function writeError(socket, error) {
|
|
86
104
|
const message = error instanceof Error ? error.message : String(error);
|
|
87
105
|
sendAdminBrokerMessage(socket, { type: 'error', message });
|
|
@@ -132,9 +150,9 @@ function startBrokerPty(socket, message) {
|
|
|
132
150
|
sendAdminBrokerMessage(socket, { type: 'started', pid: pty.terminal?.pid ?? 0 });
|
|
133
151
|
return pty;
|
|
134
152
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
153
|
+
/** Listen on loopback and publish the endpoint; every request must carry its token. */
|
|
154
|
+
export function serveAdminBroker() {
|
|
155
|
+
let token = '';
|
|
138
156
|
const server = net.createServer((socket) => {
|
|
139
157
|
socket.setEncoding('utf8');
|
|
140
158
|
let pending = '';
|
|
@@ -162,6 +180,10 @@ function runAdminBroker() {
|
|
|
162
180
|
}
|
|
163
181
|
if (firstMessage) {
|
|
164
182
|
firstMessage = false;
|
|
183
|
+
if (!adminBrokerTokenMatches(token, message.token)) {
|
|
184
|
+
writeError(socket, new Error('Administrator terminal request was not authorized.'));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
165
187
|
if (message.type === 'ping') {
|
|
166
188
|
sendAdminBrokerMessage(socket, { type: 'pong' });
|
|
167
189
|
socket.end();
|
|
@@ -199,8 +221,15 @@ function runAdminBroker() {
|
|
|
199
221
|
socket.on('close', () => pty?.kill());
|
|
200
222
|
socket.on('error', () => pty?.kill());
|
|
201
223
|
});
|
|
202
|
-
server
|
|
203
|
-
|
|
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) => {
|
|
204
233
|
process.stderr.write(`herdr-remote administrator broker: ${error.message}\n`);
|
|
205
234
|
});
|
|
206
235
|
}
|
|
@@ -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
|
+
}
|
|
@@ -1,54 +1,46 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { ADMIN_BROKER_MAX_MESSAGE_BYTES, adminBrokerPipePath, sendAdminBrokerMessage, } from './adminBrokerPipe.js';
|
|
1
|
+
import { ADMIN_BROKER_MAX_MESSAGE_BYTES, connectAdminBroker, sendAdminBrokerMessage, } from './adminBrokerEndpoint.js';
|
|
3
2
|
const BROKER_CONNECT_TIMEOUT_MS = 5_000;
|
|
3
|
+
/** A PTY the admin broker runs elevated, driven over its loopback socket. */
|
|
4
4
|
export class ElevatedPty {
|
|
5
|
-
pid = 0;
|
|
6
|
-
cols;
|
|
7
|
-
rows;
|
|
8
5
|
socket = null;
|
|
9
6
|
input = '';
|
|
10
7
|
connected = false;
|
|
11
8
|
started = false;
|
|
12
9
|
finished = false;
|
|
13
10
|
killed = false;
|
|
11
|
+
/** Keystrokes typed while the elevated shell was still starting. */
|
|
12
|
+
early = [];
|
|
14
13
|
dataListener = null;
|
|
15
14
|
exitListener = null;
|
|
16
|
-
readyListener = null;
|
|
17
15
|
errorListener = null;
|
|
18
16
|
options;
|
|
19
|
-
constructor(options
|
|
17
|
+
constructor(options) {
|
|
20
18
|
this.options = options;
|
|
21
|
-
this.cols = cols;
|
|
22
|
-
this.rows = rows;
|
|
23
19
|
}
|
|
24
|
-
onData
|
|
25
|
-
this.dataListener = listener;
|
|
26
|
-
}
|
|
27
|
-
onExit(listener) {
|
|
28
|
-
this.exitListener = listener;
|
|
29
|
-
}
|
|
30
|
-
start({ cols, rows, onData, onExit, onReady, onError }) {
|
|
31
|
-
this.cols = cols;
|
|
32
|
-
this.rows = rows;
|
|
20
|
+
start({ cols, rows, onData, onExit, onError }) {
|
|
33
21
|
this.dataListener = onData;
|
|
34
22
|
this.exitListener = onExit;
|
|
35
|
-
this.
|
|
36
|
-
|
|
37
|
-
|
|
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;
|
|
38
33
|
this.socket = socket;
|
|
39
34
|
socket.setEncoding('utf8');
|
|
40
35
|
socket.setTimeout(BROKER_CONNECT_TIMEOUT_MS, () => {
|
|
41
|
-
socket.destroy(new Error('Administrator terminal broker connection timed out')
|
|
36
|
+
socket.destroy(Object.assign(new Error('Administrator terminal broker connection timed out'), {
|
|
37
|
+
code: 'ETIMEDOUT',
|
|
38
|
+
}));
|
|
42
39
|
});
|
|
43
40
|
socket.on('connect', () => {
|
|
44
41
|
this.connected = true;
|
|
45
42
|
socket.setTimeout(0);
|
|
46
|
-
sendAdminBrokerMessage(socket, {
|
|
47
|
-
type: 'start',
|
|
48
|
-
...this.options,
|
|
49
|
-
cols: this.cols,
|
|
50
|
-
rows: this.rows,
|
|
51
|
-
});
|
|
43
|
+
sendAdminBrokerMessage(socket, { type: 'start', token, ...this.options, cols, rows });
|
|
52
44
|
});
|
|
53
45
|
socket.on('data', (chunk) => this.receive(chunk));
|
|
54
46
|
socket.on('error', (error) => this.fail(error));
|
|
@@ -60,7 +52,13 @@ export class ElevatedPty {
|
|
|
60
52
|
});
|
|
61
53
|
}
|
|
62
54
|
write(data) {
|
|
63
|
-
if (this.
|
|
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) {
|
|
64
62
|
sendAdminBrokerMessage(this.socket, {
|
|
65
63
|
type: 'input',
|
|
66
64
|
dataBase64: Buffer.from(data).toString('base64'),
|
|
@@ -68,15 +66,12 @@ export class ElevatedPty {
|
|
|
68
66
|
}
|
|
69
67
|
}
|
|
70
68
|
resize(cols, rows) {
|
|
71
|
-
this.cols = cols;
|
|
72
|
-
this.rows = rows;
|
|
73
69
|
if (this.started && this.socket?.writable) {
|
|
74
70
|
sendAdminBrokerMessage(this.socket, { type: 'resize', cols, rows });
|
|
75
71
|
}
|
|
76
72
|
}
|
|
77
73
|
kill() {
|
|
78
74
|
this.killed = true;
|
|
79
|
-
this.pid = 0;
|
|
80
75
|
if (!this.socket)
|
|
81
76
|
return;
|
|
82
77
|
if (this.connected && this.socket.writable) {
|
|
@@ -109,15 +104,16 @@ export class ElevatedPty {
|
|
|
109
104
|
}
|
|
110
105
|
if (message.type === 'started') {
|
|
111
106
|
this.started = true;
|
|
112
|
-
|
|
113
|
-
this.
|
|
107
|
+
const early = Buffer.concat(this.early);
|
|
108
|
+
this.early = [];
|
|
109
|
+
if (early.length > 0)
|
|
110
|
+
this.write(early);
|
|
114
111
|
}
|
|
115
112
|
else if (message.type === 'data' && typeof message.dataBase64 === 'string') {
|
|
116
113
|
this.dataListener?.(Buffer.from(message.dataBase64, 'base64').toString('utf8'));
|
|
117
114
|
}
|
|
118
115
|
else if (message.type === 'exit') {
|
|
119
116
|
this.finished = true;
|
|
120
|
-
this.pid = 0;
|
|
121
117
|
this.exitListener?.({
|
|
122
118
|
exitCode: Number(message.exitCode) || 0,
|
|
123
119
|
...(typeof message.signal === 'number' ? { signal: message.signal } : {}),
|
|
@@ -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';
|
|
@@ -196,7 +197,7 @@ export class HostConnector {
|
|
|
196
197
|
CAPABILITY.hostHandoff,
|
|
197
198
|
CAPABILITY.idleHeartbeat,
|
|
198
199
|
CAPABILITY.binaryFrameV2,
|
|
199
|
-
...(process.platform === 'win32' ? [CAPABILITY.
|
|
200
|
+
...(process.platform === 'win32' ? [CAPABILITY.adminTabs] : []),
|
|
200
201
|
],
|
|
201
202
|
});
|
|
202
203
|
// The relay sends host_ready with the current browser count. No business
|
|
@@ -283,6 +284,12 @@ export class HostConnector {
|
|
|
283
284
|
return this.startSession(message);
|
|
284
285
|
case 'herdr_start':
|
|
285
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
|
+
});
|
|
286
293
|
case 'session_stop': {
|
|
287
294
|
const streamId = streamOf(message);
|
|
288
295
|
if (streamId)
|
|
@@ -105,10 +105,6 @@ export class Sessions {
|
|
|
105
105
|
if (!streamId)
|
|
106
106
|
return undefined;
|
|
107
107
|
this.stop(streamId);
|
|
108
|
-
if (message.adminTerminal) {
|
|
109
|
-
this.spawn(message);
|
|
110
|
-
return undefined;
|
|
111
|
-
}
|
|
112
108
|
const missingHerdr = this.ensureHerdrCommand();
|
|
113
109
|
if (missingHerdr) {
|
|
114
110
|
process.stderr.write(`herdr-remote host connector: ${missingHerdr}\n`);
|
|
@@ -204,15 +200,11 @@ export class Sessions {
|
|
|
204
200
|
spawn(message) {
|
|
205
201
|
const streamId = streamOf(message);
|
|
206
202
|
const streamIndex = typeof message.streamIndex === 'number' ? message.streamIndex : null;
|
|
207
|
-
const adminTerminal = message.adminTerminal === true;
|
|
208
|
-
// The normal Herdr server keeps its original token, so admin mode needs its own shell.
|
|
209
203
|
const pty = new this.Pty({
|
|
210
|
-
command:
|
|
211
|
-
args:
|
|
204
|
+
command: this.herdrCommand,
|
|
205
|
+
args: this.herdrArgs,
|
|
212
206
|
cwd: this.cwd,
|
|
213
|
-
socketPath:
|
|
214
|
-
adminTerminal,
|
|
215
|
-
fastWindowsPty: adminTerminal,
|
|
207
|
+
socketPath: this.socketPath,
|
|
216
208
|
});
|
|
217
209
|
const session = {
|
|
218
210
|
id: streamId,
|
|
@@ -243,29 +235,6 @@ export class Sessions {
|
|
|
243
235
|
? packStreamFrameV2(FRAME_TYPE_OUTPUT, session.streamIndex, payload)
|
|
244
236
|
: packStreamFrame('output', streamId, payload));
|
|
245
237
|
};
|
|
246
|
-
let readySent = false;
|
|
247
|
-
const reportReady = () => {
|
|
248
|
-
if (readySent || this.byStream.get(streamId) !== session)
|
|
249
|
-
return;
|
|
250
|
-
readySent = true;
|
|
251
|
-
this.link.send({ type: 'session_ready', clientId: streamId });
|
|
252
|
-
};
|
|
253
|
-
const reportStartError = (error) => {
|
|
254
|
-
if (this.byStream.get(streamId) !== session)
|
|
255
|
-
return;
|
|
256
|
-
this.forget(session);
|
|
257
|
-
session.pty.kill();
|
|
258
|
-
const code = error.code;
|
|
259
|
-
const brokerUnavailable = session.pty.adminTerminal &&
|
|
260
|
-
(code === 'ENOENT' || code === 'ECONNREFUSED' || code === 'ETIMEDOUT');
|
|
261
|
-
this.sendError(streamId, brokerUnavailable ? 'admin_broker_unavailable' : 'pty_start_failed', brokerUnavailable
|
|
262
|
-
? 'Run herdr-remote admin-broker install once from an administrator terminal.'
|
|
263
|
-
: error.message);
|
|
264
|
-
this.link.changed();
|
|
265
|
-
};
|
|
266
|
-
this.byStream.set(streamId, session);
|
|
267
|
-
if (streamIndex !== null)
|
|
268
|
-
this.streamIndexToId.set(streamIndex, streamId);
|
|
269
238
|
try {
|
|
270
239
|
pty.start({
|
|
271
240
|
cols: session.cols,
|
|
@@ -291,16 +260,18 @@ export class Sessions {
|
|
|
291
260
|
this.reportExit(session, exitCode);
|
|
292
261
|
this.link.changed();
|
|
293
262
|
},
|
|
294
|
-
onReady: reportReady,
|
|
295
|
-
onError: reportStartError,
|
|
296
263
|
});
|
|
297
|
-
if (!adminTerminal)
|
|
298
|
-
reportReady();
|
|
299
264
|
}
|
|
300
265
|
catch (error) {
|
|
301
|
-
|
|
266
|
+
discardOutput(session);
|
|
267
|
+
this.sendError(streamId, 'pty_start_failed', error.message);
|
|
268
|
+
pty.kill();
|
|
302
269
|
return;
|
|
303
270
|
}
|
|
271
|
+
this.byStream.set(streamId, session);
|
|
272
|
+
if (streamIndex !== null)
|
|
273
|
+
this.streamIndexToId.set(streamIndex, streamId);
|
|
274
|
+
this.link.send({ type: 'session_ready', clientId: streamId });
|
|
304
275
|
this.link.changed();
|
|
305
276
|
}
|
|
306
277
|
/**
|
package/dist/i18n/en.js
CHANGED
|
@@ -97,6 +97,9 @@ const en = {
|
|
|
97
97
|
'adminBroker.elevationCancelled': 'Administrator approval was declined; no changes were made.',
|
|
98
98
|
'adminBroker.setupFailed': 'Could not configure the administrator terminal broker.',
|
|
99
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',
|
|
100
103
|
'relay.title': 'Relay settings',
|
|
101
104
|
'relay.settings': 'Settings',
|
|
102
105
|
'relay.locked': 'fixed',
|
package/dist/i18n/zh.js
CHANGED
|
@@ -96,6 +96,9 @@ const zh = {
|
|
|
96
96
|
'adminBroker.elevationCancelled': '未批准管理员权限,未进行更改。',
|
|
97
97
|
'adminBroker.setupFailed': '管理员终端 broker 设置失败。',
|
|
98
98
|
'adminBroker.tuiHint': '只需设置一次。Windows 会请求管理员授权;之后 WebUI 新建管理员终端不再弹出 UAC。',
|
|
99
|
+
'adminShell.needsTerminal': 'admin-shell 必须在终端中运行。',
|
|
100
|
+
'adminShell.brokerUnavailable': '管理员终端 broker 尚未运行。请打开 herdr-remote TUI,进入「保活」并选择「设置管理员终端」。',
|
|
101
|
+
'adminShell.tabLabel': '管理员',
|
|
99
102
|
'relay.title': 'Relay 设置',
|
|
100
103
|
'relay.settings': '设置',
|
|
101
104
|
'relay.locked': '固定',
|
package/dist/pty-session.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import os from 'node:os';
|
|
2
2
|
import pty from 'node-pty';
|
|
3
|
-
import { ElevatedPty } from './connector/elevatedPty.js';
|
|
4
3
|
class PtySession {
|
|
5
4
|
static DEFAULT_COLS = 100;
|
|
6
5
|
static DEFAULT_ROWS = 30;
|
|
@@ -11,12 +10,11 @@ class PtySession {
|
|
|
11
10
|
cwd;
|
|
12
11
|
socketPath;
|
|
13
12
|
platform;
|
|
14
|
-
adminTerminal;
|
|
15
13
|
fastWindowsPty;
|
|
16
14
|
spawnPty;
|
|
17
15
|
terminal;
|
|
18
16
|
startedAt;
|
|
19
|
-
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, } = {}) {
|
|
20
18
|
if (typeof command !== 'string' || command.length === 0)
|
|
21
19
|
throw new TypeError('command must be a non-empty string');
|
|
22
20
|
if (!Array.isArray(args) || !args.every((arg) => typeof arg === 'string'))
|
|
@@ -26,7 +24,6 @@ class PtySession {
|
|
|
26
24
|
this.cwd = cwd;
|
|
27
25
|
this.socketPath = socketPath;
|
|
28
26
|
this.platform = platform;
|
|
29
|
-
this.adminTerminal = adminTerminal;
|
|
30
27
|
this.fastWindowsPty = fastWindowsPty;
|
|
31
28
|
this.spawnPty = spawnPty;
|
|
32
29
|
this.terminal = null;
|
|
@@ -50,31 +47,9 @@ class PtySession {
|
|
|
50
47
|
return fallback;
|
|
51
48
|
return Math.min(PtySession.MAX_DIMENSION, Math.max(PtySession.MIN_DIMENSION, numeric));
|
|
52
49
|
}
|
|
53
|
-
start({ cols, rows, onData, onExit
|
|
50
|
+
start({ cols, rows, onData, onExit }) {
|
|
54
51
|
if (this.terminal)
|
|
55
52
|
throw new Error('PTY session already started');
|
|
56
|
-
if (this.adminTerminal) {
|
|
57
|
-
if (this.platform !== 'win32')
|
|
58
|
-
throw new Error('Administrator PTYs are only supported on Windows');
|
|
59
|
-
const terminal = new ElevatedPty({ command: this.command, args: this.args, cwd: this.cwd, socketPath: this.socketPath }, PtySession.clampDimension(cols, PtySession.DEFAULT_COLS), PtySession.clampDimension(rows, PtySession.DEFAULT_ROWS));
|
|
60
|
-
this.terminal = terminal;
|
|
61
|
-
this.startedAt = new Date().toISOString();
|
|
62
|
-
terminal.start({
|
|
63
|
-
cols: terminal.cols,
|
|
64
|
-
rows: terminal.rows,
|
|
65
|
-
onData: onData || (() => { }),
|
|
66
|
-
onExit: (event) => {
|
|
67
|
-
this.terminal = null;
|
|
68
|
-
onExit?.(event);
|
|
69
|
-
},
|
|
70
|
-
onReady,
|
|
71
|
-
onError: (error) => {
|
|
72
|
-
this.terminal = null;
|
|
73
|
-
onError?.(error);
|
|
74
|
-
},
|
|
75
|
-
});
|
|
76
|
-
return this;
|
|
77
|
-
}
|
|
78
53
|
// The Herdr UI needs the bundled ConPTY's Kitty and mouse reporting.
|
|
79
54
|
this.terminal = this.spawnPty(this.command, this.args, {
|
|
80
55
|
name: 'xterm-256color',
|
|
@@ -98,7 +73,6 @@ class PtySession {
|
|
|
98
73
|
onExit(event);
|
|
99
74
|
});
|
|
100
75
|
}
|
|
101
|
-
onReady?.();
|
|
102
76
|
return this;
|
|
103
77
|
}
|
|
104
78
|
write(data) {
|
package/dist/tui/api.js
CHANGED
|
@@ -19,11 +19,11 @@ 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() {
|
|
22
|
+
export async function adminBrokerStatus({ waitForStart = false } = {}) {
|
|
23
23
|
if (process.platform !== 'win32')
|
|
24
24
|
return false;
|
|
25
25
|
const broker = await import('../connector/admin-broker.js');
|
|
26
|
-
return broker.adminBrokerStatus();
|
|
26
|
+
return waitForStart ? broker.waitForAdminBroker() : broker.adminBrokerStatus();
|
|
27
27
|
}
|
|
28
28
|
export async function configureAdminBroker(action, t) {
|
|
29
29
|
const setup = await import('../connector/admin-broker-setup.js');
|
|
@@ -53,7 +53,7 @@ export function Keepalive({ ctx }) {
|
|
|
53
53
|
let ready = false;
|
|
54
54
|
try {
|
|
55
55
|
await configureAdminBroker(action, t);
|
|
56
|
-
ready = action === 'install' && (await adminBrokerStatus());
|
|
56
|
+
ready = action === 'install' && (await adminBrokerStatus({ waitForStart: true }));
|
|
57
57
|
}
|
|
58
58
|
catch (error) {
|
|
59
59
|
if (error.code === 'ELEVATION_CANCELLED') {
|
package/herdr-plugin.toml
CHANGED
package/package.json
CHANGED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
|
-
const ADMIN_BROKER_TASK_NAME = 'HerdrRemoteAdminBroker';
|
|
3
|
-
export const ADMIN_BROKER_MAX_MESSAGE_BYTES = 2 * 1_024 * 1_024;
|
|
4
|
-
function adminBrokerAccountId() {
|
|
5
|
-
const account = `${process.env.USERDOMAIN || ''}\\${process.env.USERNAME || process.env.USERPROFILE || 'default'}`;
|
|
6
|
-
return createHash('sha256').update(account.toLowerCase()).digest('hex').slice(0, 24);
|
|
7
|
-
}
|
|
8
|
-
export function adminBrokerPipePath() {
|
|
9
|
-
return `\\\\.\\pipe\\herdr-remote-admin-${adminBrokerAccountId()}`;
|
|
10
|
-
}
|
|
11
|
-
export function adminBrokerTaskName() {
|
|
12
|
-
return `${ADMIN_BROKER_TASK_NAME}-${adminBrokerAccountId()}`;
|
|
13
|
-
}
|
|
14
|
-
export function sendAdminBrokerMessage(socket, message) {
|
|
15
|
-
socket.write(`${JSON.stringify(message)}\n`);
|
|
16
|
-
}
|