From 4c9d8d5cafb7529f77d853adc41220699a6a9e94 Mon Sep 17 00:00:00 2001 From: dibin Date: Wed, 14 Oct 2026 09:17:18 +0800 Subject: [PATCH 1/2] Explain when an administrator Herdr on Windows turns herdr-remote away From Herdr 0.9.4 a Windows server started as administrator admits only administrator clients. A connector that is not elevated then got a bare "connect EPERM \\.\pipe\..." in the browser, which names neither the cause nor the fix. Access denied on the pipe now comes back as herdr_elevated, and the message says to start Herdr from an ordinary terminal or to set allow_unelevated_clients under [server] in Herdr's config. --- docs/protocol.md | 7 ++++ packages/cli/src/connector/sessions.ts | 7 +++- packages/cli/src/herdr-server.ts | 36 ++++++++++++++++--- packages/cli/tests/herdr-server.test.js | 44 +++++++++++++++++++++++ packages/cli/tests/host-connector.test.js | 18 ++++++++++ packages/relay/web/src/shared/i18n/en.ts | 2 ++ packages/relay/web/src/shared/i18n/zh.ts | 2 ++ 7 files changed, 111 insertions(+), 5 deletions(-) diff --git a/docs/protocol.md b/docs/protocol.md index 0c9c1f4..0f574ae 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -370,6 +370,13 @@ it. Once the socket answers, every window of that host that was waiting gets its session and `session_ready`; a failure comes back as `herdr_start_failed` to the window that asked. +A socket that is there but turns the connector away is not a stopped Herdr, +and nothing is started beside it. On Windows, from Herdr 0.9.4, that is what a +server started as administrator does to a connector that is not: the window +gets `herdr_elevated`, whose message says to start Herdr from an ordinary +terminal or to set `allow_unelevated_clients = true` under `[server]` in +Herdr's config. Anything else of the kind is `herdr_socket_unavailable`. + `herdr.autoStart: true` in the workstation's config does the same start when herdr-remote itself starts, at boot included. It is off by default. diff --git a/packages/cli/src/connector/sessions.ts b/packages/cli/src/connector/sessions.ts index 1a5303e..1262daa 100644 --- a/packages/cli/src/connector/sessions.ts +++ b/packages/cli/src/connector/sessions.ts @@ -202,7 +202,12 @@ export class Sessions { this.probingStarts.delete(streamId); if (probe.state === 'running') this.spawn(pending.message); else if (probe.state === 'stopped') this.waitForHerdr(streamId, pending.message); - else this.sendError(streamId, 'herdr_socket_unavailable', probe.reason); + else + this.sendError( + streamId, + probe.elevated ? 'herdr_elevated' : 'herdr_socket_unavailable', + probe.reason, + ); }); } diff --git a/packages/cli/src/herdr-server.ts b/packages/cli/src/herdr-server.ts index 53385f8..bc236ac 100644 --- a/packages/cli/src/herdr-server.ts +++ b/packages/cli/src/herdr-server.ts @@ -31,10 +31,13 @@ const PROBE_TIMEOUT_MS = 1_000; const START_TIMEOUT_MS = 20_000; const START_POLL_MS = 200; +/** How a Windows named pipe answers a client its integrity label keeps out. */ +const ACCESS_DENIED_CODES = ['EPERM', 'EACCES']; + export type ServerProbe = | { state: 'running' } | { state: 'stopped'; stale?: boolean } - | { state: 'unavailable'; reason?: string }; + | { state: 'unavailable'; reason?: string; elevated?: boolean }; type Connect = (path: string) => net.Socket; type SpawnProcess = (file: string, args: string[], options: SpawnOptions) => ChildProcess; @@ -48,6 +51,27 @@ export interface ServerLaunch { logPath: string; } +/** + * Why a connection to Herdr was refused, in words someone can act on. + * + * From Herdr 0.9.4 a Windows server started as administrator admits only + * administrator clients, and the pipe answers everyone else with a bare access + * denied that names neither the cause nor the way out. + */ +function refusal(error: NodeJS.ErrnoException, platform: NodeJS.Platform): ServerProbe { + if (platform !== 'win32' || !ACCESS_DENIED_CODES.includes(error.code || '')) { + return { state: 'unavailable', reason: error.message }; + } + return { + state: 'unavailable', + elevated: true, + reason: + 'Herdr is running as administrator and refuses clients that are not. Start Herdr from an ' + + 'ordinary terminal, or set allow_unelevated_clients = true under [server] in its ' + + `config.toml and restart its server. (${error.message})`, + }; +} + /** * Is Herdr's server answering on `socketPath`? * @@ -59,7 +83,11 @@ export interface ServerLaunch { */ function probeHerdrServer( socketPath: string, - { connect = net.connect as Connect, timeout = PROBE_TIMEOUT_MS } = {}, + { + connect = net.connect as Connect, + timeout = PROBE_TIMEOUT_MS, + platform = process.platform, + }: { connect?: Connect; timeout?: number; platform?: NodeJS.Platform } = {}, ): Promise { const info = inspectSocket(socketPath); if (!info.ok) { @@ -82,13 +110,13 @@ function probeHerdrServer( // is slow, not absent; starting another server would only fight it. const timer = setTimeout(() => finish({ state: 'running' }), timeout); if (typeof timer.unref === 'function') timer.unref(); - socket = connect(herdrEndpoint(socketPath)); + socket = connect(herdrEndpoint(socketPath, platform)); socket.once('connect', () => finish({ state: 'running' })); socket.once('error', (error: NodeJS.ErrnoException) => finish( error.code === 'ECONNREFUSED' || error.code === 'ENOENT' ? { state: 'stopped', stale: true } - : { state: 'unavailable', reason: error.message }, + : refusal(error, platform), ), ); }); diff --git a/packages/cli/tests/herdr-server.test.js b/packages/cli/tests/herdr-server.test.js index b552688..65de69f 100644 --- a/packages/cli/tests/herdr-server.test.js +++ b/packages/cli/tests/herdr-server.test.js @@ -53,6 +53,50 @@ test('a path that is not a socket is unavailable, never stopped', async (t) => { assert.match(result.reason, /not a Unix socket/); }); +/** A connect that fails the way a Windows named pipe refuses a client. */ +function refusingConnect(code) { + return () => { + const socket = new net.Socket(); + setImmediate(() => + socket.emit( + 'error', + Object.assign(new Error(`connect ${code} \\\\.\\pipe\\herdr`), { code }), + ), + ); + return socket; + }; +} + +test('a Windows Herdr that refuses this process as unelevated says how to let it in', async (t) => { + // From Herdr 0.9.4 a server started as administrator only admits + // administrator clients, and the pipe answers everyone else with access denied. + const socketPath = path.join(tempDir(t, 'herdr-probe-elevated-'), 'herdr.sock'); + const server = net.createServer((socket) => socket.end()); + await new Promise((resolve) => server.listen(socketPath, resolve)); + t.onTestFinished(() => server.close()); + + for (const code of ['EPERM', 'EACCES']) { + const result = await probeHerdrServer(socketPath, { + connect: refusingConnect(code), + platform: 'win32', + }); + assert.equal(result.state, 'unavailable'); + assert.equal(result.elevated, true); + assert.match(result.reason, /administrator/); + assert.match(result.reason, /allow_unelevated_clients = true/); + assert.match(result.reason, new RegExp(code)); + } + + // Elsewhere access denied is an ordinary permissions problem. + const unix = await probeHerdrServer(socketPath, { + connect: refusingConnect('EACCES'), + platform: 'linux', + }); + assert.equal(unix.state, 'unavailable'); + assert.equal(unix.elevated, undefined); + assert.doesNotMatch(unix.reason, /administrator/); +}); + test('only a process running as a systemd service wraps Herdr in a scope of its own', () => { const service = () => '0::/user.slice/user-1000.slice/user@1000.service/app.slice/herdr-remote.service\n'; diff --git a/packages/cli/tests/host-connector.test.js b/packages/cli/tests/host-connector.test.js index 3a0527e..9c722b8 100644 --- a/packages/cli/tests/host-connector.test.js +++ b/packages/cli/tests/host-connector.test.js @@ -719,6 +719,24 @@ test('a socket file with nothing behind it counts as a stopped Herdr', async (t) assert.equal(sent.at(-1).code, 'herdr_not_running'); }); +test('a Herdr that refuses the connector as unelevated is named as such to the window', async (t) => { + const reason = 'Herdr is running as administrator and refuses clients that are not.'; + const { connector, started, sent } = herdrLessConnector(t, { + probeHerdr: async () => ({ state: 'unavailable', elevated: true, reason }), + }); + const socketServer = net.createServer(); + await new Promise((resolve) => socketServer.listen(connector.socketPath, resolve)); + t.onTestFinished(() => socketServer.close()); + + await connector.handleMessage( + JSON.stringify({ type: 'session_start', streamId: 'session-1', cols: 80, rows: 24 }), + ); + + assert.deepEqual(started, []); + assert.equal(sent.at(-1).code, 'herdr_elevated'); + assert.equal(sent.at(-1).message, reason); +}); + test("starting Herdr uses this workstation's own settings and then opens every waiting window", async (t) => { const calls = []; const { connector, started, sent } = herdrLessConnector(t, { diff --git a/packages/relay/web/src/shared/i18n/en.ts b/packages/relay/web/src/shared/i18n/en.ts index 0256a59..82fa231 100644 --- a/packages/relay/web/src/shared/i18n/en.ts +++ b/packages/relay/web/src/shared/i18n/en.ts @@ -666,6 +666,8 @@ export const en = { 'This relay or Windows host connector cannot open administrator terminals. Upgrade both, then try again.', admin_tab_failed: 'Could not open the administrator tab in Herdr. The host connector log has the details.', + herdr_elevated: + 'Herdr on this workstation runs as administrator and turns herdr-remote away. Start Herdr from an ordinary terminal, or set allow_unelevated_clients = true under [server] in its config.toml and restart its server.', }, fontPresets: { host: 'Workstation font (default)', diff --git a/packages/relay/web/src/shared/i18n/zh.ts b/packages/relay/web/src/shared/i18n/zh.ts index f6fddfe..7378333 100644 --- a/packages/relay/web/src/shared/i18n/zh.ts +++ b/packages/relay/web/src/shared/i18n/zh.ts @@ -643,6 +643,8 @@ export const zh: TranslationSchema = { admin_terminal_unsupported: '当前 relay 或 Windows 主机连接器不支持管理员终端。请升级两端后再试。', admin_tab_failed: '无法在 Herdr 中打开管理员标签页,详情见主机连接器日志。', + herdr_elevated: + '工作站上的 Herdr 以管理员身份运行,拒绝了 herdr-remote 的连接。请在普通终端中启动 Herdr,或在它的 config.toml 的 [server] 下设置 allow_unelevated_clients = true 后重启 Herdr 服务。', }, fontPresets: { host: '宿主机终端字体(默认)', From e2f6b8f27d285f536fc611dac1170c12e540324a Mon Sep 17 00:00:00 2001 From: dibin Date: Wed, 14 Oct 2026 12:02:24 +0800 Subject: [PATCH 2/2] Let ordinary terminals into a Herdr that an elevated herdr-remote starts From Herdr 0.9.4 a Windows server started as administrator admits only administrator clients. herdr-remote runs as administrator under its keep-alive Windows service, so a Herdr it started there turned the user's own terminal away, although the server is meant to be theirs. When its token has High or System integrity and Herdr is 0.9.4 or later, it now starts `herdr server --allow-unelevated-clients`, which admits them as every earlier Herdr did. An older or unreadable Herdr gets no flag, since one that does not know it would refuse to start. --- docs/protocol.md | 7 +++ packages/cli/src/herdr-server.ts | 84 ++++++++++++++++++++++--- packages/cli/tests/herdr-server.test.js | 81 +++++++++++++++++++++++- 3 files changed, 164 insertions(+), 8 deletions(-) diff --git a/docs/protocol.md b/docs/protocol.md index 0f574ae..4a631a2 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -377,6 +377,13 @@ gets `herdr_elevated`, whose message says to start Herdr from an ordinary terminal or to set `allow_unelevated_clients = true` under `[server]` in Herdr's config. Anything else of the kind is `herdr_socket_unavailable`. +The opposite case is herdr-remote's to fix: running as administrator — the +keep-alive Windows service does — it would start an elevated Herdr that turns +the user's own terminal away. So when its token is elevated (High or System +integrity) and Herdr is 0.9.4 or later, it starts +`herdr server --allow-unelevated-clients`, which admits ordinary clients the +way every earlier Herdr did. + `herdr.autoStart: true` in the workstation's config does the same start when herdr-remote itself starts, at boot included. It is off by default. diff --git a/packages/cli/src/herdr-server.ts b/packages/cli/src/herdr-server.ts index bc236ac..7f0d517 100644 --- a/packages/cli/src/herdr-server.ts +++ b/packages/cli/src/herdr-server.ts @@ -21,7 +21,9 @@ import fs from 'node:fs'; import net from 'node:net'; import path from 'node:path'; -import { type ChildProcess, type SpawnOptions, spawn } from 'node:child_process'; +import { type ChildProcess, type SpawnOptions, spawn, spawnSync } from 'node:child_process'; +import { herdrVersion } from './herdr-command.js'; +import { compareVersions } from './updater.js'; import { herdrEndpoint, inspectSocket } from './socket-discovery.js'; import { ensureDir } from 'herdr-remote-relay/state'; @@ -33,6 +35,10 @@ const START_POLL_MS = 200; /** How a Windows named pipe answers a client its integrity label keeps out. */ const ACCESS_DENIED_CODES = ['EPERM', 'EACCES']; +/** The first Herdr whose `server` takes `--allow-unelevated-clients`; older ones refuse to start. */ +const UNELEVATED_CLIENTS_SINCE = '0.9.4'; +/** Integrity levels above an ordinary token: High (administrator) and System. */ +const ELEVATED_INTEGRITY = /S-1-16-(?:12288|16384)\b/; export type ServerProbe = | { state: 'running' } @@ -144,6 +150,48 @@ function runningInSystemdService({ } } +/** + * True when this process runs as administrator on Windows — under the + * keep-alive Windows service, for one — so a Herdr server it starts runs + * elevated too. Read from the token's integrity level, which `whoami` names by + * SID in every display language. + */ +function runningElevated({ + platform = process.platform, + whoamiGroups = () => + spawnSync('whoami', ['/groups', '/fo', 'csv', '/nh'], { + encoding: 'utf8', + windowsHide: true, + }).stdout || '', +}: { + platform?: NodeJS.Platform; + whoamiGroups?: () => string; +} = {}): boolean { + if (platform !== 'win32') return false; + try { + return ELEVATED_INTEGRITY.test(whoamiGroups()); + } catch { + // Unreadable: start Herdr as it always was started. + return false; + } +} + +/** + * Whether a server started from here should admit ordinary clients. + * + * From Herdr 0.9.4 an elevated server admits only administrator clients. One + * herdr-remote starts as administrator would then turn away the user's own + * terminal, which is not elevated, though the server is meant to be theirs: + * as administrator is only how the service happens to run. The flag gives + * back what every earlier Herdr did. A Herdr that cannot say its version is + * left without it, since one too old for the flag would not start at all. + */ +function admitUnelevatedClients(command: string, elevated = runningElevated()): boolean { + if (!elevated) return false; + const { version } = herdrVersion({ command }); + return version !== null && compareVersions(version, UNELEVATED_CLIENTS_SINCE) >= 0; +} + /** The server's environment: the one a Herdr pane would give its client. */ function serverEnv( socketPath: string | null | undefined, @@ -159,19 +207,27 @@ function serverEnv( /** * The argv that starts Herdr's server: Herdr's own global arguments (a - * `--session`, say) followed by `server`, wrapped in `systemd-run --scope` when - * the caller is a systemd service. + * `--session`, say) followed by `server` and, when asked for, the flag that + * lets ordinary clients into an elevated server; wrapped in `systemd-run + * --scope` when the caller is a systemd service. */ function serverCommand({ command, args = [], inService = runningInSystemdService(), + allowUnelevated = false, }: { command: string; args?: string[]; inService?: boolean; + allowUnelevated?: boolean; }): { file: string; args: string[] } { - const herdr = [command, ...args, 'server']; + const herdr = [ + command, + ...args, + 'server', + ...(allowUnelevated ? ['--allow-unelevated-clients'] : []), + ]; if (!inService) return { file: herdr[0], args: herdr.slice(1) }; return { file: 'systemd-run', @@ -200,8 +256,13 @@ function launchHerdrServer({ logPath, spawnProcess = spawn as SpawnProcess, inService, -}: ServerLaunch & { spawnProcess?: SpawnProcess; inService?: boolean }): ChildProcess { - const { file, args: argv } = serverCommand({ command, args, inService }); + allowUnelevated = admitUnelevatedClients(command), +}: ServerLaunch & { + spawnProcess?: SpawnProcess; + inService?: boolean; + allowUnelevated?: boolean; +}): ChildProcess { + const { file, args: argv } = serverCommand({ command, args, inService, allowUnelevated }); ensureDir(path.dirname(logPath)); const out = fs.openSync(logPath, 'a'); let child: ChildProcess; @@ -287,4 +348,13 @@ async function ensureHerdrServer({ } } -export { probeHerdrServer, runningInSystemdService, serverEnv, serverCommand, ensureHerdrServer }; +export { + admitUnelevatedClients, + ensureHerdrServer, + launchHerdrServer, + probeHerdrServer, + runningElevated, + runningInSystemdService, + serverCommand, + serverEnv, +}; diff --git a/packages/cli/tests/herdr-server.test.js b/packages/cli/tests/herdr-server.test.js index 65de69f..37b7ac0 100644 --- a/packages/cli/tests/herdr-server.test.js +++ b/packages/cli/tests/herdr-server.test.js @@ -6,11 +6,14 @@ import net from 'node:net'; import { EventEmitter } from 'node:events'; import { spawn } from 'node:child_process'; import { + admitUnelevatedClients, + ensureHerdrServer, + launchHerdrServer, probeHerdrServer, + runningElevated, runningInSystemdService, serverCommand, serverEnv, - ensureHerdrServer, } from '../src/herdr-server.js'; import { tempDir } from './helpers.js'; @@ -97,6 +100,82 @@ test('a Windows Herdr that refuses this process as unelevated says how to let it assert.doesNotMatch(unix.reason, /administrator/); }); +test('an administrator token is told from an ordinary one by its integrity level', () => { + const groups = (level) => () => + `"Everyone","Well-known group","S-1-1-0","Mandatory group"\r\n"Mandatory Label\\${level}","Label","${ + { medium: 'S-1-16-8192', high: 'S-1-16-12288', system: 'S-1-16-16384' }[level] + }",""\r\n`; + assert.equal(runningElevated({ platform: 'win32', whoamiGroups: groups('medium') }), false); + assert.equal(runningElevated({ platform: 'win32', whoamiGroups: groups('high') }), true); + assert.equal(runningElevated({ platform: 'win32', whoamiGroups: groups('system') }), true); + assert.equal(runningElevated({ platform: 'linux', whoamiGroups: groups('high') }), false); + assert.equal( + runningElevated({ + platform: 'win32', + whoamiGroups: () => { + throw new Error('no whoami'); + }, + }), + false, + ); +}); + +/** A stub `herdr` whose `--version` prints `line`. */ +function versionStub(t, line) { + const binary = path.join(tempDir(t, 'herdr-version-'), 'herdr'); + fs.writeFileSync(binary, `#!/bin/sh\necho "${line}"\n`, { mode: 0o755 }); + return binary; +} + +test.runIf(process.platform !== 'win32')( + 'an elevated herdr-remote lets ordinary clients into the Herdr it starts, when that Herdr can', + (t) => { + // From 0.9.4 an elevated server admits only administrator clients, which + // would lock the user's own terminal out of the server started for them. + assert.equal(admitUnelevatedClients(versionStub(t, 'herdr 0.9.4'), true), true); + assert.equal(admitUnelevatedClients(versionStub(t, 'herdr 0.10.0'), true), true); + // An older Herdr refuses the flag and would not start at all. + assert.equal(admitUnelevatedClients(versionStub(t, 'herdr 0.9.3'), true), false); + assert.equal(admitUnelevatedClients(versionStub(t, ''), true), false); + // Started as an ordinary user, Herdr is not elevated and needs nothing. + assert.equal(admitUnelevatedClients(versionStub(t, 'herdr 0.9.4'), false), false); + }, +); + +test("the flag follows `server`, after Herdr's own global arguments", (t) => { + assert.deepEqual( + serverCommand({ + command: 'C:\\herdr.exe', + args: ['--session', 'work'], + inService: false, + allowUnelevated: true, + }), + { + file: 'C:\\herdr.exe', + args: ['--session', 'work', 'server', '--allow-unelevated-clients'], + }, + ); + + let spawned = null; + launchHerdrServer({ + command: 'C:\\herdr.exe', + socketPath: 'herdr', + logPath: path.join(tempDir(t, 'herdr-launch-'), 'herdr-server.log'), + inService: false, + allowUnelevated: true, + spawnProcess: (file, args) => { + spawned = { file, args }; + const child = new EventEmitter(); + child.unref = () => {}; + return child; + }, + }); + assert.deepEqual(spawned, { + file: 'C:\\herdr.exe', + args: ['server', '--allow-unelevated-clients'], + }); +}); + test('only a process running as a systemd service wraps Herdr in a scope of its own', () => { const service = () => '0::/user.slice/user-1000.slice/user@1000.service/app.slice/herdr-remote.service\n';