diff --git a/docs/protocol.md b/docs/protocol.md index 0c9c1f4..4a631a2 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -370,6 +370,20 @@ 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`. + +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/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..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'; @@ -31,10 +33,17 @@ 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']; +/** 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' } | { 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 +57,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 +89,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 +116,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), ), ); }); @@ -116,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, @@ -131,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', @@ -172,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; @@ -259,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 b552688..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'; @@ -53,6 +56,126 @@ 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('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'; 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: '宿主机终端字体(默认)',