Files
fusion/packages/core/src/postgres/embedded-windows-elevated.ts
gsxdsm d4ee80a818 fix(core): Windows embedded Postgres — no local user account, UTF-8 clusters, diagnosable boot errors
Squash of feature/win-elevated-no-user, verified end-to-end on the
elevated windows-latest runner (restricted-token double boot + full
'fn serve' /api/health smoke, both green).

- Elevated Windows boots embedded PostgreSQL via pg_ctl's built-in
  restricted-token re-exec instead of creating a 'fusion-pg' local user
  (operator requirement: Fusion must never create accounts). Removes
  the credential launcher, icacls grants, and cmd/PowerShell wrapper —
  and with them the 'directory name is invalid' and wrapper-log EBUSY
  field failures. Leftover fusion-pg accounts are deleted on start.
- Embedded clusters are always initdb'd --encoding=UTF8 --locale=C
  (GitHub issue #2286: OS-locale WIN1252/WIN1254 clusters could not
  store the UTF-8 schema and crash-looped the dashboard). Existing
  non-UTF-8 clusters get an actionable re-init hint at boot.
- Schema-backend boot failures now surface the full error cause chain
  (DrizzleQueryError hid the real PostgresError behind the SQL text).
- Elevated stop() waits until the port closes and postmaster.pid is
  gone before resolving.
- CI: branch verification workflow (restricted-token proof + elevated
  boot smoke + account-absence assertions); boot-smoke stderr tail
  widened for diagnosability.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 22:45:57 -07:00

395 lines
16 KiB
TypeScript

// FNXC:WindowsDesktopPackaging 2026-07-17-22:30:
// Embedded PostgreSQL refuses to start under a Windows process token whose
// Administrators group is ENABLED (elevated / "Run as administrator" launches
// and GitHub windows runners). The first fix booted the server under a
// freshly-created non-admin local account ('fusion-pg') via Start-Process
// -Credential, but that approach created a real user account on operator
// machines (explicit operator complaint: Fusion must never create local
// accounts), and its cmd/PowerShell wrapper machinery produced two field
// failures: CreateProcessWithLogonW rejecting the inherited cwd ("The
// directory name is invalid") and EBUSY on the wrapper-held postgres.log.
//
// This module replaces all of that with PostgreSQL's own built-in mechanism:
// pg_ctl.exe (bundled next to postgres.exe) detects an elevated token and
// re-executes itself under a RESTRICTED token (Administrators SID disabled via
// CreateRestrictedToken; see src/common/restricted_token.c in PostgreSQL).
// The postmaster inherits that restricted token and accepts it — the same
// mechanism that already lets initdb run elevated in our boot path. No user
// account, no password, no icacls grants, no credential launch, no wrapper
// bat. The restricted token keeps the operator's own identity, so the data
// dir under the user profile stays accessible without ACL changes.
import { spawn, spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
import { createConnection } from "node:net";
import { join } from "node:path";
/** Handle returned by {@link startServerElevatedRestricted}; call stop() to kill it. */
export interface ElevatedServerHandle {
/**
* Best-effort OS pid of the running postgres server (from postmaster.pid when
* available). 0 until postmaster.pid appears.
*/
readonly postgresPid: number;
/**
* Stop the postgres server (pg_ctl stop -m fast, taskkill fallback). Safe to
* call once.
*
* FNXC:PostgresStartupRace 2026-07-15-21:10 (semantics preserved):
* Resolves its target through the data dir (`pg_ctl -D` / postmaster.pid), so
* it stops whichever postmaster currently owns that dir — NOT necessarily the
* one this handle launched. Only call it when this process is the sole
* starter; a caller that lost a startup race must use {@link stopWrapperOnly}.
*/
stop(): Promise<void>;
/**
* Reap only what this handle launched, never the postmaster named by the
* shared data dir.
*
* FNXC:WindowsDesktopPackaging 2026-07-17-22:30:
* With pg_ctl there is no wrapper process to reap: pg_ctl -W exits
* immediately after spawning the postmaster, and a postmaster that lost the
* postmaster.pid lock race exits on its own. This is therefore a no-op that
* only marks the handle stopped, kept so the lifecycle's lost-race path
* (which must never kill the race winner) stays shape-compatible.
*/
stopWrapperOnly(): Promise<void>;
}
export interface ElevatedStartOptions {
/** .../native dir containing bin/postgres.exe + bin/pg_ctl.exe + lib + share. */
readonly nativeRoot: string;
/** The initialized PG data directory. */
readonly dataDir: string;
/** TCP port postgres should listen on. */
readonly port: number;
/** Extra flags forwarded to postgres.exe (same semantics as embedded-postgres). */
readonly postgresFlags: readonly string[];
readonly onLog: (message: string) => void;
readonly onError: (messageOrError: string | Error | unknown) => void;
/** Hard timeout (ms) on reaching "ready to accept connections". <=0 disables. */
readonly startTimeoutMs: number;
/** Cooperative cancellation from EmbeddedPostgresLifecycle.start(). */
readonly signal?: AbortSignal;
/**
* Invoked as soon as the postmaster launch is issued (before readiness) so
* the lifecycle can stop orphans if the outer start() timeout wins the race.
*/
readonly onLaunched?: (handle: ElevatedServerHandle) => void;
}
let elevatedCache: boolean | null = null;
/**
* True only on Windows when the current process holds an elevated admin token.
* `net session` succeeds (exit 0) exclusively under an elevated admin token, so
* it is a reliable elevation probe that does not depend on UAC EnableLUA.
*/
export function isWindowsElevatedAdmin(): boolean {
if (process.platform !== "win32") return false;
if (elevatedCache !== null) return elevatedCache;
const r = spawnSync("net", ["session"], { encoding: "utf8", shell: true });
elevatedCache = r.status === 0;
return elevatedCache;
}
/**
* FNXC:WindowsDesktopPackaging 2026-07-17-22:30:
* Earlier releases created a dedicated 'fusion-pg' local account for the
* credential-based launch. Fusion must not leave accounts it created on
* operator machines, so the replacement path deletes it best-effort on every
* elevated start (idempotent: exits non-zero when the account is absent).
*/
export function removeLegacyNonAdminUser(onLog: (message: string) => void): void {
try {
const r = spawnSync("net", ["user", "fusion-pg", "/delete"], { encoding: "utf8" });
if (r.status === 0) {
onLog("embedded postgres: removed the legacy 'fusion-pg' local account created by earlier versions");
}
} catch {
// Cleanup is strictly best-effort; never block startup on it.
}
}
/**
* FNXC:WindowsDesktopPackaging 2026-07-15-05:25 (retained):
* Reject postgresFlags that could break the pg_ctl -o option-string quoting or
* smuggle extra options (\r\n, ", and shell-sensitive characters).
*/
export function sanitizePostgresFlags(flags: readonly string[]): string[] {
const safe: string[] = [];
for (const flag of flags) {
if (typeof flag !== "string" || flag.length === 0) {
throw new Error(`embedded postgres: invalid postgresFlags entry (empty/non-string)`);
}
if (/[\r\n"%&|<>^!]/.test(flag)) {
throw new Error(
`embedded postgres: postgresFlags entry contains quoting-sensitive characters: ${JSON.stringify(flag)}`,
);
}
safe.push(flag);
}
return safe;
}
/**
* Build the pg_ctl `-o` option string: server flags passed through to
* postgres.exe. Tokens containing spaces are double-quoted (sanitize rejects
* embedded quotes, so plain wrapping is safe).
*/
export function buildPgCtlOptionsString(port: number, flags: readonly string[]): string {
const tokens = ["-p", String(port), ...flags];
return tokens.map((t) => (/\s/.test(t) ? `"${t}"` : t)).join(" ");
}
/**
* pg_ctl argv for the elevated start. `-W` (no wait) so the call returns as
* soon as the postmaster is spawned; readiness is observed via the server log
* and a TCP probe, matching the previous launcher's cancellable poll loop.
* `-l` routes postmaster output to a per-launch log file.
*/
export function buildPgCtlStartArgs(dataDir: string, logFile: string, optionsString: string): string[] {
return ["-D", dataDir, "-o", optionsString, "-l", logFile, "-W", "start"];
}
function readPostgresPid(dataDir: string): number | null {
try {
const lines = readFileSync(join(dataDir, "postmaster.pid"), "utf-8").split("\n");
const pid = parseInt((lines[0] ?? "").trim(), 10);
return Number.isFinite(pid) && pid > 0 ? pid : null;
} catch {
return null;
}
}
function readTail(file: string, max: number): string {
try {
const content = readFileSync(file, "utf-8");
return content.length > max ? "…" + content.slice(-max) : content;
} catch {
return "(no log file)";
}
}
/**
* FNXC:WindowsDesktopPackaging 2026-07-17-22:30:
* Per-launch log file names + best-effort pruning replace the old truncate-on
* -launch scheme. Truncating a shared postgres.log raised EBUSY when a live
* wrapper/postmaster from a prior attempt still held it open (field report);
* unique names make a held file harmless, and stale ones are swept next boot.
* Legacy wrapper artifacts (launch.bat/launch.ps1/wrapper.log/postgres.log)
* are swept the same way.
*/
function prepareRunDir(runDir: string): string {
mkdirSync(runDir, { recursive: true });
const legacy = ["launch.bat", "launch.ps1", "wrapper.log", "postgres.log"];
let entries: string[] = [];
try {
entries = readdirSync(runDir);
} catch {
entries = [];
}
for (const entry of entries) {
if (legacy.includes(entry) || /^pgctl-\d+\.log$/.test(entry)) {
try {
rmSync(join(runDir, entry), { force: true });
} catch {
// A file held open by a live process stays; unique naming makes that harmless.
}
}
}
return join(runDir, `pgctl-${Date.now()}.log`);
}
/**
* Start postgres.exe on an elevated Windows process via pg_ctl's restricted
* token re-exec, and resolve once it is accepting connections. Rejects with a
* clear error (including the postgres log tail — the lifecycle's lock-collision
* classifier depends on seeing the postmaster.pid FATAL text) on timeout,
* cancellation, or early exit. The returned handle's stop() kills the server.
*/
export async function startServerElevatedRestricted(
opts: ElevatedStartOptions,
): Promise<ElevatedServerHandle> {
removeLegacyNonAdminUser(opts.onLog);
const pgCtl = join(opts.nativeRoot, "bin", "pg_ctl.exe");
if (!existsSync(pgCtl)) {
throw new Error(`embedded postgres: pg_ctl.exe not found at ${pgCtl}`);
}
const runDir = join(opts.dataDir, ".pgrunner");
const logFile = prepareRunDir(runDir);
const safeFlags = sanitizePostgresFlags(opts.postgresFlags);
const args = buildPgCtlStartArgs(
opts.dataDir,
logFile,
buildPgCtlOptionsString(opts.port, safeFlags),
);
opts.onLog(
`embedded postgres: elevated start via pg_ctl restricted token (no helper account); log ${logFile}`,
);
let stopped = false;
const killAll = (): void => {
if (stopped) return;
stopped = true;
const r = spawnSync(pgCtl, ["-D", opts.dataDir, "-m", "fast", "-t", "30", "-w", "stop"], {
encoding: "utf8",
});
if (r.status !== 0) {
opts.onLog(
`embedded postgres: pg_ctl stop status=${r.status} ` +
`output=${`${r.stdout || ""}${r.stderr || ""}`.trim().slice(0, 400)}; falling back to taskkill`,
);
const pid = readPostgresPid(opts.dataDir);
if (pid) spawnSync("taskkill", ["/pid", String(pid), "/f", "/t"], { encoding: "utf8" });
}
};
// FNXC:WindowsDesktopPackaging 2026-07-17-23:40:
// stop() must not resolve while the server still accepts connections. The
// taskkill fallback (and TerminateProcess generally) returns before socket
// teardown finishes, and CI observed a probe connecting right after stop()
// resolved. Wait until the port stops accepting AND postmaster.pid is gone.
const waitForDown = async (): Promise<void> => {
const deadline = Date.now() + 15_000;
while (Date.now() < deadline) {
const pidGone = readPostgresPid(opts.dataDir) === null;
const portClosed = !(await probeTcpPort(opts.port, 250));
if (pidGone && portClosed) return;
await new Promise<void>((resolve) => {
setTimeout(resolve, 100);
});
}
opts.onError("embedded postgres: server still reachable 15s after stop request");
};
const handle: ElevatedServerHandle = {
get postgresPid() {
return readPostgresPid(opts.dataDir) ?? 0;
},
async stop() {
killAll();
await waitForDown();
},
async stopWrapperOnly() {
// No wrapper exists on this path; a lock-race loser postmaster exits on
// its own. Only mark stopped so a later stop() cannot kill a race winner.
stopped = true;
},
};
// FNXC:WindowsDesktopPackaging 2026-07-17-23:05:
// Publish the stop handle BEFORE awaiting pg_ctl so the lifecycle's outer
// start() timeout can reap a postmaster that got spawned but never became
// ready (first CI run orphaned one and cleanup hit EBUSY on the data dir).
opts.onLaunched?.(handle);
// pg_ctl -W exits right after spawning the postmaster; await that exit
// without blocking the event loop (dashboard boot runs on it).
//
// FNXC:WindowsDesktopPackaging 2026-07-17-23:05:
// Resolve on 'exit', NOT 'close': on Windows the spawned postmaster inherits
// pg_ctl's stdout/stderr pipe handles, so the stdio streams stay open for
// the postmaster's lifetime and 'close' never fires (first CI run hung here
// until the outer timeout). Output captured before exit is still reported.
const launch = await new Promise<{ status: number | null; output: () => string }>(
(resolve, reject) => {
const child = spawn(pgCtl, args, { windowsHide: true });
let output = "";
child.stdout.on("data", (d: Buffer) => (output += d.toString()));
child.stderr.on("data", (d: Buffer) => (output += d.toString()));
child.on("error", reject);
child.on("exit", (status: number | null) => resolve({ status, output: () => output }));
},
);
if (launch.status !== 0) {
killAll();
throw new Error(
`embedded postgres: pg_ctl start failed (status=${launch.status}) ` +
`output=${launch.output().trim().slice(0, 1000)}\n${readTail(logFile, 2000)}`,
);
}
opts.onLog(`embedded postgres: pg_ctl start issued (status 0); polling for readiness`);
// Poll for readiness until the server accepts connections or the timeout
// hits. Same lightweight readFileSync-only loop as the previous launcher
// (spawning tasklist per iteration blew poll budgets on windows-2025).
const hasDeadline = opts.startTimeoutMs > 0 && Number.isFinite(opts.startTimeoutMs);
const deadline = hasDeadline ? Date.now() + opts.startTimeoutMs : Number.POSITIVE_INFINITY;
let ready = false;
let lastSnapshot = "";
while (Date.now() < deadline) {
if (opts.signal?.aborted) {
killAll();
throw new Error("embedded postgres: elevated launch cancelled before ready.");
}
const tail = readTail(logFile, 3000);
if (tail !== lastSnapshot) {
lastSnapshot = tail;
opts.onLog(`elevated poll pg={${tail.slice(-400)}}`);
}
if (/database system is ready to accept connections/.test(tail)) {
// Log readiness alone is not enough: confirm TCP accept on 127.0.0.1 so
// ensureDatabase cannot hang on a connect that never completes.
if (await probeTcpPort(opts.port, 500)) {
ready = true;
break;
}
}
if (/\bFATAL\b|\bPANIC\b|could not (bind|start|create|access|connect|load)|not permitted|Permission denied|is not the owner/i.test(tail)) {
killAll();
throw new Error(
`embedded postgres: elevated postgres reported a startup error before opening the port.\n${tail}`,
);
}
await new Promise<void>((resolve) => {
setTimeout(resolve, 200);
});
}
if (!ready) {
const tail = readTail(logFile, 1500);
killAll();
throw new Error(
`embedded postgres: elevated postgres did not become ready` +
(hasDeadline ? ` within ${opts.startTimeoutMs}ms` : "") +
`.\n${tail}`,
);
}
if (opts.signal?.aborted) {
killAll();
throw new Error("embedded postgres: elevated launch cancelled after ready.");
}
const postgresPid = readPostgresPid(opts.dataDir);
if (!postgresPid) {
opts.onError("embedded postgres: started but could not read postmaster.pid");
}
opts.onLog(
`embedded postgres: elevated server ready on 127.0.0.1:${opts.port} (pid ${postgresPid ?? 0}, restricted token)`,
);
return handle;
}
/** True when a TCP accept is available on 127.0.0.1:port within timeoutMs. */
function probeTcpPort(port: number, timeoutMs: number): Promise<boolean> {
return new Promise((resolve) => {
const socket = createConnection({ host: "127.0.0.1", port });
let settled = false;
const finish = (ok: boolean) => {
if (settled) return;
settled = true;
socket.removeAllListeners();
socket.destroy();
resolve(ok);
};
socket.setTimeout(timeoutMs);
socket.once("connect", () => finish(true));
socket.once("timeout", () => finish(false));
socket.once("error", () => finish(false));
});
}