clawdie-ai/setup/bastille-helpers.ts
Operator & Codex dcae5878fa Replace jail sudo path with hostd bastille-cmd
Route jail exec through a new hostd bastille-cmd operation, remove the agent-jail sudoers requirement, and fall back to repository ownership when elevation does not provide SUDO_* metadata.

---
Build: pass | Tests: n/a (Vitest not run in this Linux environment per repo policy)

---
Build: pass | Tests: pass — 2375 passed (704 files)
2026-05-10 22:48:04 +02:00

242 lines
7.2 KiB
TypeScript

import { execSync, spawnSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { logger } from '../src/logger.js';
import {
loadPackageList,
mountPkgCacheInJail,
type PackageListName,
} from './packages.js';
import { maybeEnableTailscaleInJail } from './tailscale.js';
import type { JailDefinition } from '../src/jail-schema.js';
import { loadJailRegistry, resolveJailIp } from '../src/jail-schema.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const INFRA_DIR = path.resolve(__dirname, '../infra');
export function bastille(...args: string[]): { ok: boolean; output: string } {
const result = spawnSync('bastille', args, {
encoding: 'utf-8',
env: process.env,
});
const output = [result.stdout || '', result.stderr || '']
.filter(Boolean)
.join('\n')
.trim();
return { ok: (result.status ?? 1) === 0, output };
}
export function bastilleTimed(
timeoutMs: number,
...args: string[]
): { ok: boolean; output: string } {
const result = spawnSync('bastille', args, {
encoding: 'utf-8',
env: process.env,
timeout: timeoutMs,
killSignal: 'SIGKILL',
});
const output = [result.stdout || '', result.stderr || '']
.filter(Boolean)
.join('\n')
.trim();
if (result.error && (result.error as any).code === 'ETIMEDOUT') {
return {
ok: false,
output: `bastille timed out after ${timeoutMs}ms: bastille ${args.join(' ')}\n${output}`,
};
}
return { ok: (result.status ?? 1) === 0, output };
}
export function jailExists(name: string): boolean {
const { output } = bastille('list');
return output.split('\n').some((line) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('JID')) return false;
const cols = trimmed.split(/\s+/u);
return cols.length > 1 && cols[1] === name;
});
}
export function detectFreeBSDRelease(): string {
const output = execSync('freebsd-version -u', { encoding: 'utf-8' }).trim();
const match = output.match(/^(\d+\.\d+-\w+)/);
return match?.[1] ?? output;
}
export function jailRoot(jailName: string): string {
return `/usr/local/bastille/jails/${jailName}/root`;
}
export interface ResolveJailNameOptions {
role: string;
names?: string[];
envOverride?: string;
}
export function resolveJailName(opts: ResolveJailNameOptions): string {
if (opts.envOverride) return opts.envOverride;
const candidates = opts.names && opts.names.length > 0 ? opts.names : [opts.role];
for (const candidate of candidates) {
if (jailExists(candidate)) return candidate;
}
return candidates[0];
}
export interface ProvisionOpts {
agentName: string;
subnetBase: string;
internalDomain: string;
hostname?: string;
extraPackages?: string[];
}
export interface ProvisionResult {
jailName: string;
ip: string;
created: boolean;
}
/**
* Ensure the jail-exec staging directory exists inside a jail and is writable
* by the agent user. Called during provisioning so the controlplane can write
* prompts into the jail at runtime.
*
* The directory is: /usr/local/bastille/jails/<name>/root/var/tmp/jail-exec
* On the host, this must be readable/writable by the runtime user.
* Inside the jail, it appears at /var/tmp/jail-exec.
*
* IMPORTANT: ZFS datasets with nfsv4acls (default on FreeBSD/Bastille) can
* have group@ deny ACEs that block traversal even when POSIX bits show 755.
* POSIX chmod is not sufficient — we must use setfacl to add a named user ACE
* which takes precedence over group@ deny in NFSv4 ACL evaluation order.
*/
export function ensureJailExecDir(jailName: string, agentUser: string): void {
const jailBase = `/usr/local/bastille/jails/${jailName}`;
const jailExecHostPath = `${jailBase}/root/var/tmp/jail-exec`;
fs.mkdirSync(jailExecHostPath, { recursive: true });
// Use setfacl for NFSv4 ACLs — chmod alone is insufficient on ZFS with
// nfsv4acls mount option (group@ deny entries intercept before everyone@).
const aclPaths = [
jailBase,
`${jailBase}/root`,
`${jailBase}/root/var`,
`${jailBase}/root/var/tmp`,
];
for (const aclPath of aclPaths) {
try {
execSync(`setfacl -m user:${agentUser}:r-x:allow "${aclPath}"`, {
stdio: 'ignore',
});
} catch {
// Non-fatal — may already have the ACE or setfacl unavailable
}
}
// chown the jail-exec dir itself so the agent user can write/delete files
try {
execSync(`chown -R ${agentUser}:${agentUser} ${jailExecHostPath}`, {
stdio: 'ignore',
});
} catch {
// Non-fatal — agent user may not exist yet in the jail
}
logger.info({ jailName, path: jailExecHostPath }, 'jail-exec dir ready');
}
export async function provisionJail(
jailDef: JailDefinition,
role: string,
opts: ProvisionOpts,
): Promise<ProvisionResult> {
const safeAgentName = opts.agentName.replace(/[-_]/g, '');
const jailName = resolveJailName({ role });
const registry = loadJailRegistry();
const ip = resolveJailIp(registry, role);
const gateway = process.env.WARDEN_GATEWAY || `${opts.subnetBase}.1`;
const bridge = process.env.WARDEN_BRIDGE || registry.bridge;
const release = detectFreeBSDRelease();
const hostname = opts.hostname || `${role}.${opts.internalDomain}`;
const exists = jailExists(jailName);
if (!exists) {
logger.info({ jailName, ip, release, role }, `Creating ${role} jail`);
const create = bastille(
'create',
...(jailDef.thick ? ['-T'] : []),
...(jailDef.vnet ? ['-B'] : []),
'-g',
gateway,
jailName,
release,
`${ip}/24`,
bridge,
);
if (!create.ok) {
throw new Error(`bastille create failed for ${role}: ${create.output}`);
}
bastille('config', jailName, 'set', 'host.hostname', hostname);
if (jailDef.allow_sysvipc) {
bastille('config', jailName, 'set', 'allow.sysvipc', '1');
}
bastille('restart', jailName);
} else {
logger.info(
{ jailName, role },
`${role} jail already exists, skipping creation`,
);
if (jailDef.allow_sysvipc) {
bastille('config', jailName, 'set', 'allow.sysvipc', '1');
bastille('restart', jailName);
}
}
mountPkgCacheInJail(jailName);
const allPackages: string[] = [];
for (const pkgListName of jailDef.packages) {
const listFile = `${pkgListName}.txt` as PackageListName;
allPackages.push(...loadPackageList(listFile));
}
if (opts.extraPackages) {
allPackages.push(...opts.extraPackages);
}
if (allPackages.length > 0) {
const pkg = bastille('pkg', jailName, 'install', '-y', ...allPackages);
if (!pkg.ok) {
logger.warn(
{ output: pkg.output, role },
`${role} jail package install had warnings`,
);
}
}
if (jailDef.services) {
for (const [svc, cfg] of Object.entries(jailDef.services)) {
if (cfg.enable) {
bastille('sysrc', jailName, `${svc}_enable=YES`);
}
}
}
const runBastille = (args: string[]) => bastille(...args);
maybeEnableTailscaleInJail(runBastille, jailName, jailName);
// Ensure jail-exec staging dir is writable by the agent user
const agentUser = opts.agentName;
ensureJailExecDir(jailName, agentUser);
return { jailName, ip, created: !exists };
}