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)
242 lines
7.2 KiB
TypeScript
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 };
|
|
}
|