blob: dd4f4f9e6d8369b8f90a47a4431a0738d11224c0 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { execFile, type ExecFileException } from 'node:child_process';
import { chmod, lstat, mkdtemp, readdir, rm, rmdir, unlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { truncateUtf8 } from '@maka/core/diagnostic-log';
import { isNodeError } from '../node-error.js';
const FALLBACK_ENDPOINT_ROOT = '/tmp';
const PORTABLE_UNIX_SOCKET_PATH_LIMIT = 100;
const ENDPOINT_SOCKET_NAME = 'h.sock';
const MKDTEMP_SUFFIX_LENGTH = 6;
const WINDOWS_PIPE_PATH_ENV = 'MAKA_RUNTIME_HOST_PIPE_PATH';
// This ACL is the whole trust boundary of the Local IPC endpoint: every
// accepted connection is granted Local Owner authority without a further
// per-connection check, so the call has to succeed and a timeout has to
// refuse the endpoint. That makes the budget a question of how long we are
// willing to wait, not how long the work should take. Windows PowerShell 5.1
// cold start plus .NET type loading is seconds on a loaded CI runner, and both
// 10s (#3225) and 30s (npm Nightly run 34917337079) budgets have killed healthy
// hosted runners. Keep a full minute so ordinary runner variance does not
// refuse a secure endpoint. Endpoint readiness waits on this, so raising it
// means checking that the waiters still outlast it:
// scripts/windows-runtime-host-local-ipc-trust.ps1 and client/connect-or-spawn.ts.
const WINDOWS_PIPE_ACL_TIMEOUT_MS = 60_000;
const WINDOWS_PIPE_ACL_DIAGNOSTIC_LIMIT = 1_000;
const WINDOWS_PIPE_ACL_STDERR_MAX_BYTES = 4 * 1024;
const WINDOWS_PIPE_ACL_SCRIPT = String.raw`
$ErrorActionPreference = 'Stop'
$stage = 'identity'
$currentSid = $null
try {
$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
$currentSid = $identity.User.Value
$stage = 'security_descriptor'
$security = [System.Security.AccessControl.FileSecurity]::new()
$security.SetAccessRuleProtection($true, $false)
$rights = [System.Security.AccessControl.FileSystemRights]::FullControl
$allow = [System.Security.AccessControl.AccessControlType]::Allow
$security.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new($identity.User, $rights, $allow))
$system = [System.Security.Principal.SecurityIdentifier]::new('S-1-5-18')
$security.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new($system, $rights, $allow))
$stage = 'pipe_lookup'
$pipe = Get-Item -LiteralPath $env:${WINDOWS_PIPE_PATH_ENV}
$stage = 'acl_apply'
$pipe.SetAccessControl($security)
} catch {
$exception = $_.Exception
$nativeErrorCode = $null
if ($null -ne $exception.PSObject.Properties['NativeErrorCode']) {
$nativeErrorCode = $exception.NativeErrorCode
}
[Console]::Error.WriteLine((@{
schemaVersion = 1
stage = $stage
currentSid = $currentSid
exceptionType = $exception.GetType().FullName
hresult = $exception.HResult
nativeErrorCode = $nativeErrorCode
message = $exception.Message
} | ConvertTo-Json -Compress))
exit 1
}
`;
export interface RuntimeHostEndpointInput {
rootId: string;
hostEpoch: string;
}
export interface RuntimeHostEndpoint {
path: string;
prepareAfterListen(): Promise<void>;
cleanup(): Promise<void>;
}
export class RuntimeHostEndpointError extends Error {
constructor(
readonly code: 'insecure_endpoint_directory' | 'endpoint_path_too_long',
message: string,
options?: ErrorOptions,
) {
super(message, options);
this.name = 'RuntimeHostEndpointError';
}
}
export function windowsPipeAclFailureDiagnostic(error: unknown, stderr: string): string {
const details: Record<string, unknown> = {
schemaVersion: 1,
helper: 'windows_pipe_acl',
};
if (typeof error === 'object' && error !== null) {
const candidate = error as { code?: unknown; signal?: unknown; killed?: unknown };
if (typeof candidate.code === 'number' || typeof candidate.code === 'string') {
details.exitCode = candidate.code;
}
if (typeof candidate.signal === 'string') details.signal = candidate.signal;
if (typeof candidate.killed === 'boolean') details.killed = candidate.killed;
}
const boundedStderr = truncateUtf8(
stderr.trim(),
WINDOWS_PIPE_ACL_STDERR_MAX_BYTES,
'\n<stderr truncated>',
);
if (boundedStderr) details.stderr = boundedStderr;
return JSON.stringify(details);
}
export async function prepareRuntimeHostEndpoint(
input: RuntimeHostEndpointInput,
): Promise<RuntimeHostEndpoint> {
assertValidRootId(input.rootId);
if (process.platform === 'win32') {
const path = `\\\\.\\pipe\\maka-runtime-host-${input.rootId.slice(0, 16)}-${input.hostEpoch}`;
return {
path,
async prepareAfterListen() {
await secureWindowsNamedPipe(path);
},
async cleanup() {},
};
}
const rootPrefix = endpointRootPrefix(input.rootId);
const prefix = endpointDirectoryPrefix(rootPrefix, process.pid);
const root = resolveEndpointRoot(prefix);
await removeDeadEndpointDirectories(root, rootPrefix);
const directory = await mkdtemp(join(root, prefix));
try {
await ensurePrivateEndpointDirectory(directory);
const path = join(directory, ENDPOINT_SOCKET_NAME);
if (Buffer.byteLength(path, 'utf8') > PORTABLE_UNIX_SOCKET_PATH_LIMIT) {
throw new RuntimeHostEndpointError(
'endpoint_path_too_long',
`Runtime Host endpoint path exceeds the portable Unix socket limit: ${path}`,
);
}
return {
path,
async prepareAfterListen() {
await chmod(path, 0o600);
const endpointStat = await lstat(path);
if (
!endpointStat.isSocket() ||
endpointStat.uid !== currentUid() ||
(endpointStat.mode & 0o077) !== 0
) {
throw new RuntimeHostEndpointError(
'insecure_endpoint_directory',
`Runtime Host endpoint is not a private current-user socket: ${path}`,
);
}
},
async cleanup() {
await unlink(path).catch((error: unknown) => {
if (!isNodeError(error, 'ENOENT')) throw error;
});
await rmdir(directory).catch((error: unknown) => {
if (!isNodeError(error, 'ENOENT')) throw error;
});
},
};
} catch (error) {
await rm(directory, { recursive: true, force: true }).catch(() => undefined);
throw error;
}
}
function secureWindowsNamedPipe(path: string): Promise<void> {
const systemRoot = process.env.SystemRoot;
if (!systemRoot) {
return Promise.reject(
new RuntimeHostEndpointError(
'insecure_endpoint_directory',
'Runtime Host cannot locate Windows PowerShell to secure its Local IPC endpoint',
),
);
}
const powershell = join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
return new Promise((resolve, reject) => {
execFile(
powershell,
['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', WINDOWS_PIPE_ACL_SCRIPT],
{
env: { ...process.env, [WINDOWS_PIPE_PATH_ENV]: path },
encoding: 'utf8',
timeout: WINDOWS_PIPE_ACL_TIMEOUT_MS,
windowsHide: true,
},
(error, stdout, stderr) => {
if (!error) {
resolve();
return;
}
reject(windowsPipeAclFailure(path, error, stdout, stderr));
},
);
});
}
// Every failure here refuses the endpoint, but the causes call for different
// responses: a PowerShell diagnostic points at an ACL failure that may leave
// the pipe world-accessible, while a timeout kill means the restriction was
// never confirmed either way. Carry the cause into the message so a CI log
// can be read without rerunning the job (#3225).
export function windowsPipeAclFailure(
path: string,
error: ExecFileException,
stdout: string,
stderr: string,
): RuntimeHostEndpointError {
const diagnostic = powershellDiagnostic(stdout, stderr);
const options = { cause: new Error(windowsPipeAclFailureDiagnostic(error, stderr)) };
if (error.killed === true) {
return new RuntimeHostEndpointError(
'insecure_endpoint_directory',
`Runtime Host could not confirm its Windows Local IPC endpoint restriction within ${WINDOWS_PIPE_ACL_TIMEOUT_MS}ms and refused the endpoint: ${path} (powershell killed with ${error.signal ?? 'no signal'})${diagnostic}`,
options,
);
}
return new RuntimeHostEndpointError(
'insecure_endpoint_directory',
`Runtime Host could not restrict its Windows Local IPC endpoint to the current user: ${path} (${describeExecFileFailure(error)})${diagnostic}`,
options,
);
}
function describeExecFileFailure(error: ExecFileException): string {
if (typeof error.code === 'number') return `powershell exited with ${error.code}`;
if (typeof error.code === 'string') return `powershell failed to run: ${error.code}`;
if (error.signal) return `powershell killed with ${error.signal}`;
return `powershell failed: ${clip(error.message)}`;
}
// PowerShell reports the failing statement on stderr; stdout only carries
// content when the script writes before failing. Both are output of a script
// whose only input is the pipe path this process just created, so neither
// widens what the message already exposes.
function powershellDiagnostic(stdout: string, stderr: string): string {
const output = clip(stderr.trim() || stdout.trim());
return output ? `: ${output}` : '';
}
// Collapse to a single grep-friendly line and cap it: a PowerShell error
// record spans several lines, and Node's exec message repeats the whole
// command, ACL script included.
function clip(value: string): string {
const collapsed = value.replace(/\s+/g, ' ').trim();
if (collapsed.length <= WINDOWS_PIPE_ACL_DIAGNOSTIC_LIMIT) return collapsed;
return `${collapsed.slice(0, WINDOWS_PIPE_ACL_DIAGNOSTIC_LIMIT)} [truncated]`;
}
// Honor TMPDIR via os.tmpdir(), but never at the cost of a socket path over
// the portable sun_path budget: macOS per-user temp roots and the parallel
// test runner's nested TMPDIR produce bases long enough that the full
// endpoint path would exceed 100 bytes, which is why the root used to be
// hardcoded to /tmp (#2133). If the worst-case path does not fit, fall back
// to /tmp; the post-mkdtemp length check stays as the backstop.
function resolveEndpointRoot(prefix: string): string {
const candidate = tmpdir();
const worstCasePath = join(
candidate,
`${prefix}${'X'.repeat(MKDTEMP_SUFFIX_LENGTH)}`,
ENDPOINT_SOCKET_NAME,
);
if (Buffer.byteLength(worstCasePath, 'utf8') <= PORTABLE_UNIX_SOCKET_PATH_LIMIT) return candidate;
return FALLBACK_ENDPOINT_ROOT;
}
function endpointRootPrefix(rootId: string): string {
return `m-${currentUid()}-${endpointRootTag(rootId).slice(0, 16)}-`;
}
// Put the owner in the name passed to mkdtemp. A complete, parseable owner
// identity therefore appears atomically with the directory; a separate pid
// file would leave a creation-to-write window where another startup sweep
// could mistake a live directory for an abandoned one.
function endpointDirectoryPrefix(rootPrefix: string, pid: number): string {
return `${rootPrefix}${pid.toString(36)}-`;
}
function endpointRootTag(rootId: string): string {
return Buffer.from(rootId, 'hex').toString('base64url');
}
function assertValidRootId(rootId: string): void {
if (!/^[a-f0-9]{64}$/.test(rootId)) {
throw new RuntimeHostEndpointError(
'insecure_endpoint_directory',
'Runtime Host endpoint requires a valid storage root identity',
);
}
}
// Reclaims same-rootId endpoint directories whose owning process is gone,
// and only those: a concurrent live host keeps its directory (#2133). Both
// the resolved root and /tmp are swept because the fallback means either
// may hold leftovers. Pre-#2133 names have no owner identity, so they are
// deliberately left alone rather than guessed dead.
async function removeDeadEndpointDirectories(root: string, rootPrefix: string): Promise<void> {
const roots = root === FALLBACK_ENDPOINT_ROOT ? [root] : [root, FALLBACK_ENDPOINT_ROOT];
const ownedName = new RegExp(
`^${escapeRegExp(rootPrefix)}([0-9a-z]+)-[A-Za-z0-9]{${MKDTEMP_SUFFIX_LENGTH}}$`,
);
await Promise.all(
roots.map(async (endpointRoot) => {
const entries = await readdir(endpointRoot, { withFileTypes: true }).catch(() => []);
await Promise.all(
entries.map(async (entry) => {
if (!entry.isDirectory()) return;
const match = ownedName.exec(entry.name);
if (!match) return;
const path = join(endpointRoot, entry.name);
const directoryStat = await lstat(path).catch(() => undefined);
if (!directoryStat?.isDirectory() || directoryStat.uid !== currentUid()) return;
const pid = Number.parseInt(match[1] ?? '', 36);
if (!Number.isSafeInteger(pid) || pid <= 0 || processIsAlive(pid)) return;
await rm(path, { recursive: true, force: true });
}),
);
}),
);
}
function processIsAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
// EPERM means the pid exists but belongs to someone we cannot signal:
// treat as alive so the sweep stays conservative.
return isNodeError(error, 'EPERM');
}
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
async function ensurePrivateEndpointDirectory(path: string): Promise<void> {
await chmod(path, 0o700);
const directoryStat = await lstat(path);
if (
!directoryStat.isDirectory() ||
directoryStat.uid !== currentUid() ||
(directoryStat.mode & 0o077) !== 0
) {
throw new RuntimeHostEndpointError(
'insecure_endpoint_directory',
`Runtime Host endpoint parent is not a private current-user directory: ${path}`,
);
}
}
function currentUid(): number {
if (typeof process.getuid !== 'function') {
throw new RuntimeHostEndpointError(
'insecure_endpoint_directory',
'Runtime Host POSIX endpoints require a current-user identity',
);
}
return process.getuid();
}