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)
516 lines
16 KiB
TypeScript
516 lines
16 KiB
TypeScript
/**
|
|
* Step: service — Build project and install the FreeBSD rc.d service.
|
|
*
|
|
* Service name derives from the platform identity, not the tenant identity.
|
|
* Root install: writes /usr/local/etc/rc.d/{SERVICE_NAME} + enables it.
|
|
* Non-root: generates start-{SERVICE_NAME}.sh / stop-{SERVICE_NAME}.sh wrappers.
|
|
*/
|
|
import { SERVICE_NAME } from '../src/platform-identity.js';
|
|
import { execFileSync, execSync } from 'child_process';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
import {
|
|
DB_RUNTIME,
|
|
PLATFORM_RUNTIME_HOME,
|
|
} from '../src/config.js';
|
|
import { logger } from '../src/logger.js';
|
|
import {
|
|
commandExists,
|
|
getNodePath,
|
|
getNpmPath,
|
|
getPlatform,
|
|
isRoot,
|
|
} from './platform.js';
|
|
import { emitStatus } from './status.js';
|
|
|
|
export type BootMode = 'YES' | 'AUTO' | 'NONE';
|
|
|
|
function lookupUserByUid(uid: number): string | null {
|
|
try {
|
|
const user = execFileSync('id', ['-nu', String(uid)], {
|
|
encoding: 'utf-8',
|
|
}).trim();
|
|
return user || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getInvokingContext(projectRoot: string): {
|
|
user: string | null;
|
|
uid: number | null;
|
|
gid: number | null;
|
|
} {
|
|
const user = (process.env.SUDO_USER || '').trim() || null;
|
|
const uidRaw = (process.env.SUDO_UID || '').trim();
|
|
const gidRaw = (process.env.SUDO_GID || '').trim();
|
|
const uid = uidRaw ? Number(uidRaw) : null;
|
|
const gid = gidRaw ? Number(gidRaw) : null;
|
|
if (user && Number.isFinite(uid) && Number.isFinite(gid)) {
|
|
return {
|
|
user,
|
|
uid,
|
|
gid,
|
|
};
|
|
}
|
|
try {
|
|
const stat = fs.statSync(projectRoot);
|
|
return {
|
|
user: stat.uid === 0 ? null : lookupUserByUid(stat.uid),
|
|
uid: stat.uid,
|
|
gid: stat.gid,
|
|
};
|
|
} catch {
|
|
// fall through
|
|
}
|
|
return {
|
|
user: null,
|
|
uid: null,
|
|
gid: null,
|
|
};
|
|
}
|
|
|
|
function buildProject(projectRoot: string): void {
|
|
const invoking = getInvokingContext(projectRoot);
|
|
// Resolve npm's absolute path before entering the su subprocess.
|
|
// 'su -m' preserves the environment but when invoked via sudo, PATH may not
|
|
// include the versioned npm location — using the full path avoids the issue.
|
|
const npmPath = getNpmPath();
|
|
if (isRoot() && invoking.user && invoking.uid !== 0) {
|
|
execFileSync(
|
|
'su',
|
|
[
|
|
'-m',
|
|
invoking.user,
|
|
'-c',
|
|
`cd ${JSON.stringify(projectRoot)} && ${JSON.stringify(npmPath)} run build`,
|
|
],
|
|
{
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
encoding: 'utf-8',
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
execFileSync(npmPath, ['run', 'build'], {
|
|
cwd: projectRoot,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
}
|
|
|
|
function chownIfInvokingContext(filePath: string, projectRoot: string): void {
|
|
const invoking = getInvokingContext(projectRoot);
|
|
if (!isRoot() || invoking.uid === null || invoking.gid === null || invoking.uid === 0) {
|
|
return;
|
|
}
|
|
try {
|
|
fs.chownSync(filePath, invoking.uid, invoking.gid);
|
|
} catch {
|
|
// best-effort only
|
|
}
|
|
}
|
|
|
|
// Recursively chown a directory to the agent user.
|
|
// Used after root creates runtime dirs so the agent process (which runs as the
|
|
// named user via daemon -u) can write to them without EACCES on first startup.
|
|
function chownRuntimeDir(dirPath: string, agentName: string): void {
|
|
if (!isRoot()) return;
|
|
try {
|
|
execFileSync('chown', ['-R', `${agentName}:${agentName}`, dirPath], {
|
|
stdio: 'ignore',
|
|
});
|
|
} catch {
|
|
logger.warn(
|
|
{ dirPath, agentName },
|
|
'chown of runtime dir failed (best-effort)',
|
|
);
|
|
}
|
|
}
|
|
|
|
export interface PlatformServiceRuntime {
|
|
serviceName: string;
|
|
runtimeUser: string;
|
|
runtimeHome: string;
|
|
}
|
|
|
|
export function resolvePlatformServiceRuntime(): PlatformServiceRuntime {
|
|
return {
|
|
serviceName: SERVICE_NAME,
|
|
runtimeUser: SERVICE_NAME,
|
|
runtimeHome: PLATFORM_RUNTIME_HOME,
|
|
};
|
|
}
|
|
|
|
interface ServiceArgs {
|
|
bootMode: BootMode | null;
|
|
nonInteractive: boolean;
|
|
}
|
|
|
|
function parseArgs(args: string[]): ServiceArgs {
|
|
const result: ServiceArgs = { bootMode: null, nonInteractive: false };
|
|
for (let i = 0; i < args.length; i++) {
|
|
if (args[i] === '--non-interactive') {
|
|
result.nonInteractive = true;
|
|
continue;
|
|
}
|
|
if (args[i] === '--boot-mode' && args[i + 1]) {
|
|
const raw = (args[++i] || '').trim().toUpperCase();
|
|
if (raw === 'YES' || raw === 'AUTO' || raw === 'NONE') {
|
|
result.bootMode = raw;
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function writeWrapper(filePath: string, contents: string, mode = 0o755): void {
|
|
fs.writeFileSync(filePath, contents, { mode });
|
|
chownIfInvokingContext(filePath, process.cwd());
|
|
}
|
|
|
|
export function generateRunScript(
|
|
runtime: PlatformServiceRuntime,
|
|
nodePath: string,
|
|
projectRoot: string,
|
|
): string {
|
|
const envFile = path.join(projectRoot, '.env');
|
|
return [
|
|
'#!/bin/sh',
|
|
`export HOME=${JSON.stringify(runtime.runtimeHome)}`,
|
|
`export PATH="/usr/local/bin:/usr/bin:/bin:${runtime.runtimeHome}/.local/bin"`,
|
|
'export NODE_ENV="production"',
|
|
'',
|
|
"# Locale/timezone for service runtime (don't rely on login shell env)",
|
|
`SERVICE_TZ=$(grep "^TZ=" ${JSON.stringify(envFile)} 2>/dev/null | head -1 | cut -d= -f2- | sed "s/^[\\\"']//; s/[\\\"']$//" || echo "")`,
|
|
`SERVICE_LANG=$(grep "^SYSTEM_LOCALE=" ${JSON.stringify(envFile)} 2>/dev/null | head -1 | cut -d= -f2- | sed "s/^[\\\"']//; s/[\\\"']$//" || echo "")`,
|
|
'export TZ="${SERVICE_TZ:-Europe/Ljubljana}"',
|
|
'export LANG="${SERVICE_LANG:-sl_SI.UTF-8}"',
|
|
'export LC_ALL="${SERVICE_LANG:-sl_SI.UTF-8}"',
|
|
`cd ${JSON.stringify(projectRoot)}`,
|
|
'',
|
|
'# Ensure tmux session exists for interactive access',
|
|
`tmux new-session -d -s ${JSON.stringify(runtime.serviceName)} -n main 2>/dev/null || true`,
|
|
'',
|
|
'# If PI_TUI_PROFILE=setup, open pi-tui in a separate tmux window',
|
|
`PROFILE=$(grep "^PI_TUI_PROFILE=" ${JSON.stringify(envFile)} 2>/dev/null | cut -d= -f2 | sed "s/^[\\\"']//; s/[\\\"']$//" || echo "")`,
|
|
'if [ "$PROFILE" = "setup" ]; then',
|
|
` tmux new-window -t ${JSON.stringify(runtime.serviceName)} -n setup 2>/dev/null || true`,
|
|
` tmux send-keys -t ${JSON.stringify(runtime.serviceName + ':setup')} "npm run wizard" Enter`,
|
|
'fi',
|
|
'',
|
|
'# Agent runs as foreground process — what daemon monitors',
|
|
`exec ${JSON.stringify(nodePath)} ${JSON.stringify(path.join(projectRoot, 'dist/index.js'))}`,
|
|
'',
|
|
].join('\n');
|
|
}
|
|
|
|
function escapeForDoubleQuotedShell(raw: string): string {
|
|
return raw.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
|
|
}
|
|
|
|
export function generateRcdService(
|
|
runtime: PlatformServiceRuntime,
|
|
projectRoot: string,
|
|
logPath: string,
|
|
): string {
|
|
const envFile = path.join(projectRoot, '.env');
|
|
const requireTargets =
|
|
DB_RUNTIME === 'host' ? 'NETWORKING LOGIN postgresql' : 'NETWORKING LOGIN';
|
|
return [
|
|
'#!/bin/sh',
|
|
'#',
|
|
`# PROVIDE: ${runtime.serviceName}`,
|
|
`# REQUIRE: ${requireTargets}`,
|
|
'# KEYWORD: shutdown',
|
|
'',
|
|
'. /etc/rc.subr',
|
|
'',
|
|
`name="${runtime.serviceName}"`,
|
|
`rcvar="${runtime.serviceName}_enable"`,
|
|
`pidfile="/var/run/\${name}.pid"`,
|
|
'command="/usr/sbin/daemon"',
|
|
// -u drops from root to the agent user before exec — hostd stays root (separate service)
|
|
`command_args="-u ${runtime.runtimeUser} -P \${pidfile} -r -f -o \\"${escapeForDoubleQuotedShell(logPath)}\\" \\"${escapeForDoubleQuotedShell(path.join(projectRoot, `run-${runtime.serviceName}.sh`))}\\""`,
|
|
'',
|
|
'load_rc_config $name',
|
|
`: \$\{${runtime.serviceName}_enable:="NONE"\}`,
|
|
'',
|
|
`case "\$\{${runtime.serviceName}_enable\}" in`,
|
|
' [Nn][Oo][Nn][Ee])',
|
|
' if [ "$1" = "start" ] || [ "$1" = "restart" ]; then',
|
|
' echo ""',
|
|
` echo " ${runtime.serviceName} is installed but autostart is not configured."`,
|
|
' echo " Choose how it should start at boot:"',
|
|
' echo ""',
|
|
` echo " sudo sysrc ${runtime.serviceName}_enable=AUTO # Start when ready (recommended)"`,
|
|
` echo " sudo sysrc ${runtime.serviceName}_enable=YES # Always start at boot"`,
|
|
' echo ""',
|
|
` echo " To start just this once without changing boot settings:"`,
|
|
` echo " sudo service ${runtime.serviceName} onestart"`,
|
|
' echo ""',
|
|
' fi',
|
|
' exit 0',
|
|
' ;;',
|
|
' [Aa][Uu][Tt][Oo])',
|
|
` if [ ! -f ${JSON.stringify(envFile)} ] || ! grep -q "^TELEGRAM_BOT_TOKEN=." ${JSON.stringify(envFile)}; then`,
|
|
' [ -t 1 ] && echo "AUTO: not fully configured yet, skipping start."',
|
|
' exit 0',
|
|
' fi',
|
|
` ${runtime.serviceName}_enable="YES"`,
|
|
' ;;',
|
|
'esac',
|
|
'',
|
|
'run_rc_command "$1"',
|
|
'',
|
|
].join('\n');
|
|
}
|
|
|
|
function generateStartWrapper(
|
|
serviceName: string,
|
|
nodePath: string,
|
|
projectRoot: string,
|
|
pidFile: string,
|
|
): string {
|
|
return [
|
|
'#!/bin/sh',
|
|
'set -eu',
|
|
`cd ${JSON.stringify(projectRoot)}`,
|
|
`if [ -f ${JSON.stringify(pidFile)} ]; then`,
|
|
` OLD_PID=$(cat ${JSON.stringify(pidFile)} 2>/dev/null || true)`,
|
|
' if [ -n "${OLD_PID:-}" ] && kill -0 "$OLD_PID" 2>/dev/null; then',
|
|
` echo "${serviceName} is already running" >&2`,
|
|
' exit 1',
|
|
' fi',
|
|
'fi',
|
|
`nohup ${JSON.stringify(nodePath)} ${JSON.stringify(path.join(projectRoot, 'dist/index.js'))} >> ${JSON.stringify(path.join(projectRoot, 'logs', `${serviceName}.log`))} 2>> ${JSON.stringify(path.join(projectRoot, 'logs', `${serviceName}.error.log`))} &`,
|
|
`echo $! > ${JSON.stringify(pidFile)}`,
|
|
`echo "Started ${serviceName}"`,
|
|
'',
|
|
].join('\n');
|
|
}
|
|
|
|
function generateStopWrapper(serviceName: string, pidFile: string): string {
|
|
return [
|
|
'#!/bin/sh',
|
|
'set -eu',
|
|
`if [ ! -f ${JSON.stringify(pidFile)} ]; then`,
|
|
' echo "No PID file found" >&2',
|
|
' exit 1',
|
|
'fi',
|
|
`PID=$(cat ${JSON.stringify(pidFile)})`,
|
|
'kill "$PID"',
|
|
`rm -f ${JSON.stringify(pidFile)}`,
|
|
`echo "Stopped ${serviceName}"`,
|
|
'',
|
|
].join('\n');
|
|
}
|
|
|
|
function promptBootMode(agentName: string): BootMode {
|
|
if (
|
|
!commandExists('bsddialog') ||
|
|
!process.stdin.isTTY ||
|
|
!process.stdout.isTTY
|
|
) {
|
|
return 'NONE';
|
|
}
|
|
try {
|
|
const selected = execFileSync(
|
|
'bsddialog',
|
|
[
|
|
'--stdout',
|
|
'--title',
|
|
'Autostart',
|
|
'--menu',
|
|
`Should ${agentName} start automatically when this computer boots?\n\nAUTO is recommended — it starts only when your setup is complete.\nIf you are not ready, it waits quietly.`,
|
|
'0',
|
|
'0',
|
|
'3',
|
|
'AUTO',
|
|
'Start when ready — recommended',
|
|
'YES',
|
|
'Always start at boot',
|
|
'NONE',
|
|
'Decide later',
|
|
],
|
|
{ encoding: 'utf-8', stdio: ['inherit', 'pipe', 'inherit'] },
|
|
).trim() as BootMode;
|
|
const valid: BootMode[] = ['YES', 'AUTO', 'NONE'];
|
|
return valid.includes(selected) ? selected : 'NONE';
|
|
} catch {
|
|
return 'NONE';
|
|
}
|
|
}
|
|
|
|
function printHandoff(
|
|
agentName: string,
|
|
bootMode: BootMode,
|
|
rcdPath: string,
|
|
): void {
|
|
const line = '─'.repeat(52);
|
|
console.log('');
|
|
console.log(line);
|
|
console.log(` ${agentName} is installed and ready.`);
|
|
console.log('');
|
|
|
|
const modeDesc: Record<BootMode, string> = {
|
|
AUTO: 'Smart start — wakes up when configured',
|
|
YES: 'Always starts at boot',
|
|
NONE: 'Not yet configured — start manually for now',
|
|
};
|
|
console.log(` Autostart: ${bootMode} (${modeDesc[bootMode]})`);
|
|
console.log('');
|
|
console.log(' Start now:');
|
|
console.log(` sudo service ${agentName} onestart`);
|
|
console.log('');
|
|
console.log(' Watch logs:');
|
|
console.log(` tail -f logs/${agentName}.log`);
|
|
console.log('');
|
|
console.log(' Change autostart later:');
|
|
console.log(` sudo sysrc ${agentName}_enable=AUTO # smart start`);
|
|
console.log(` sudo sysrc ${agentName}_enable=YES # always on`);
|
|
console.log(
|
|
` sudo sysrc ${agentName}_enable=NONE # reset to undecided`,
|
|
);
|
|
console.log('');
|
|
console.log(` Service installed at: ${rcdPath}`);
|
|
console.log(line);
|
|
console.log('');
|
|
}
|
|
|
|
export async function run(_args: string[]): Promise<void> {
|
|
const args = parseArgs(_args);
|
|
const projectRoot = process.cwd();
|
|
const platform = getPlatform();
|
|
const nodePath = getNodePath();
|
|
const runtime = resolvePlatformServiceRuntime();
|
|
|
|
logger.info(
|
|
{ platform, nodePath, projectRoot, runtime },
|
|
'Setting up service',
|
|
);
|
|
|
|
if (platform !== 'freebsd') {
|
|
emitStatus('SETUP_SERVICE', {
|
|
SERVICE_TYPE: 'unknown',
|
|
SERVICE_NAME: runtime.serviceName,
|
|
NODE_PATH: nodePath,
|
|
PROJECT_PATH: projectRoot,
|
|
STATUS: 'failed',
|
|
ERROR: 'unsupported_platform',
|
|
LOG: 'logs/setup.log',
|
|
});
|
|
process.exit(1);
|
|
}
|
|
|
|
logger.info('Building TypeScript');
|
|
try {
|
|
buildProject(projectRoot);
|
|
} catch {
|
|
emitStatus('SETUP_SERVICE', {
|
|
SERVICE_TYPE: 'unknown',
|
|
SERVICE_NAME: runtime.serviceName,
|
|
NODE_PATH: nodePath,
|
|
PROJECT_PATH: projectRoot,
|
|
STATUS: 'failed',
|
|
ERROR: 'build_failed',
|
|
LOG: 'logs/setup.log',
|
|
});
|
|
process.exit(1);
|
|
}
|
|
|
|
// Pre-create runtime dirs so they exist before the agent first starts.
|
|
// When running as root (installer), chown them to the agent user so the
|
|
// daemon -u privilege drop doesn't cause EACCES on first write.
|
|
const runtimeDirs = ['logs', 'data', 'groups'];
|
|
for (const dir of runtimeDirs) {
|
|
const dirPath = path.join(projectRoot, dir);
|
|
fs.mkdirSync(dirPath, { recursive: true });
|
|
chownRuntimeDir(dirPath, runtime.runtimeUser);
|
|
}
|
|
|
|
const pidFile = path.join(projectRoot, `${runtime.serviceName}.pid`);
|
|
const logPath = path.join(projectRoot, 'logs', `${runtime.serviceName}.log`);
|
|
|
|
if (isRoot()) {
|
|
// Root install: rc.d service + run wrapper
|
|
const runScriptPath = path.join(
|
|
projectRoot,
|
|
`run-${runtime.serviceName}.sh`,
|
|
);
|
|
const rcdPath = `/usr/local/etc/rc.d/${runtime.serviceName}`;
|
|
|
|
writeWrapper(
|
|
runScriptPath,
|
|
generateRunScript(runtime, nodePath, projectRoot),
|
|
);
|
|
logger.info({ runScriptPath }, 'Wrote run wrapper');
|
|
|
|
writeWrapper(rcdPath, generateRcdService(runtime, projectRoot, logPath));
|
|
logger.info({ rcdPath }, 'Wrote rc.d service');
|
|
|
|
const bootMode =
|
|
args.bootMode ??
|
|
(args.nonInteractive ? 'AUTO' : promptBootMode(runtime.serviceName));
|
|
|
|
try {
|
|
execSync(`sysrc ${runtime.serviceName}_enable=${bootMode}`, {
|
|
stdio: 'ignore',
|
|
});
|
|
} catch (err) {
|
|
logger.warn({ err }, 'sysrc enable failed');
|
|
}
|
|
|
|
let serviceRunning = false;
|
|
if (bootMode === 'YES') {
|
|
try {
|
|
execSync(`service ${runtime.serviceName} start`, { stdio: 'ignore' });
|
|
execSync(`service ${runtime.serviceName} status`, { stdio: 'ignore' });
|
|
serviceRunning = true;
|
|
} catch {
|
|
// May not be running yet
|
|
}
|
|
}
|
|
|
|
emitStatus('SETUP_SERVICE', {
|
|
SERVICE_TYPE: 'freebsd-rcd',
|
|
SERVICE_NAME: runtime.serviceName,
|
|
NODE_PATH: nodePath,
|
|
PROJECT_PATH: projectRoot,
|
|
RCD_PATH: rcdPath,
|
|
RUN_SCRIPT: runScriptPath,
|
|
SERVICE_RUNNING: serviceRunning,
|
|
BOOT_MODE: bootMode,
|
|
STATUS: 'success',
|
|
LOG: 'logs/setup.log',
|
|
});
|
|
|
|
printHandoff(runtime.serviceName, bootMode, rcdPath);
|
|
} else {
|
|
// Non-root: nohup wrappers
|
|
const startPath = path.join(projectRoot, `start-${runtime.serviceName}.sh`);
|
|
const stopPath = path.join(projectRoot, `stop-${runtime.serviceName}.sh`);
|
|
|
|
writeWrapper(
|
|
startPath,
|
|
generateStartWrapper(runtime.serviceName, nodePath, projectRoot, pidFile),
|
|
);
|
|
writeWrapper(stopPath, generateStopWrapper(runtime.serviceName, pidFile));
|
|
|
|
emitStatus('SETUP_SERVICE', {
|
|
SERVICE_TYPE: 'freebsd-wrapper',
|
|
SERVICE_NAME: runtime.serviceName,
|
|
NODE_PATH: nodePath,
|
|
PROJECT_PATH: projectRoot,
|
|
START_PATH: startPath,
|
|
STOP_PATH: stopPath,
|
|
PID_FILE: pidFile,
|
|
STATUS: 'success',
|
|
LOG: 'logs/setup.log',
|
|
});
|
|
|
|
console.log(
|
|
`Generated start-${runtime.serviceName}.sh and stop-${runtime.serviceName}.sh`,
|
|
);
|
|
}
|
|
}
|