Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/connector/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
});
}

Expand Down
120 changes: 109 additions & 11 deletions packages/cli/src/herdr-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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;
Expand All @@ -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`?
*
Expand All @@ -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<ServerProbe> {
const info = inspectSocket(socketPath);
if (!info.ok) {
Expand All @@ -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),
),
);
});
Expand Down Expand Up @@ -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,
Expand All @@ -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',
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -259,4 +348,13 @@ async function ensureHerdrServer({
}
}

export { probeHerdrServer, runningInSystemdService, serverEnv, serverCommand, ensureHerdrServer };
export {
admitUnelevatedClients,
ensureHerdrServer,
launchHerdrServer,
probeHerdrServer,
runningElevated,
runningInSystemdService,
serverCommand,
serverEnv,
};
125 changes: 124 additions & 1 deletion packages/cli/tests/herdr-server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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';
Expand Down
Loading
Loading