import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import crypto from 'node:crypto'; import { execFileSync, spawnSync } from 'node:child_process'; import { isIP } from 'node:net'; import { parseDocument } from 'yaml'; import { performance } from 'node:perf_hooks'; import { healthOptions, waitForHealth, saveHealthReport } from './health.mjs'; const fail = message => { throw new Error(message); }; const sha256 = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); const safe = value => typeof value === 'string' && value !== '' && !/[\\\r\n\0]/.test(value) && !path.posix.isAbsolute(value) && value.split('/').every(part => part !== '' && part !== '.' && part !== '..'); const contained = (parent, child) => child === parent || child.startsWith(parent + path.sep); export function absolute(value, label) { if (!value || !path.isAbsolute(value) || /[\0\r\n]/.test(value)) fail(`${label} must be an absolute path`); const result = path.resolve(value); let current = path.parse(result).root; for (const part of result.slice(current.length).split(path.sep).filter(Boolean)) { current = path.join(current, part); try { if (fs.lstatSync(current).isSymbolicLink()) fail(`${label} must not contain symlinks`); } catch (error) { if (error.code !== 'ENOENT') throw error; } } return result; } function tree(directory) { const result = []; for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { const file = path.join(directory, entry.name); if (entry.isDirectory()) result.push(...tree(file).map(child => `${entry.name}/${child}`)); else if (entry.isFile()) result.push(entry.name); else fail('Release must contain only regular files/directories'); } return result.sort(); } export function verifyRelease(directory) { directory = absolute(directory, 'MC_RELEASE_DIR'); const actual = tree(directory); const checksums = new Map(); for (const line of fs.readFileSync(path.join(directory, 'SHA256SUMS'), 'utf8').trim().split('\n')) { const match = /^([a-f0-9]{64}) (.+)$/.exec(line); if (!match || !safe(match[2]) || match[2] === 'SHA256SUMS' || checksums.has(match[2])) fail('Malformed or duplicate release checksum'); checksums.set(match[2], match[1]); } if (JSON.stringify([...checksums.keys()].sort()) !== JSON.stringify(actual.filter(file => file !== 'SHA256SUMS'))) fail('Release checksum coverage is incomplete'); for (const [file, expected] of checksums) if (sha256(path.join(directory, file)) !== expected) fail(`Release checksum mismatch: ${file}`); const manifest = JSON.parse(fs.readFileSync(path.join(directory, 'manifest.json'), 'utf8')); if (manifest.schema_version !== 1 || !safe(manifest.artifact) || manifest.artifact.includes('/') || !checksums.has(manifest.artifact)) fail('Unsupported or invalid release manifest'); if (!/^itzg\/minecraft-server@sha256:[a-f0-9]{64}$/.test(manifest.server?.image)) fail('Release image must be pinned by digest'); if (!/^[a-f0-9]{64}$/.test(manifest.tools?.compose?.sha256)) fail('Missing pinned Compose checksum'); const payload = new Map(); for (const file of manifest.release_files ?? []) { if (!safe(file.path) || payload.has(file.path) || checksums.get(file.path) !== file.sha256) fail('Manifest payload does not match checksums'); payload.set(file.path, file.sha256); } if (JSON.stringify([...payload.keys()].sort()) !== JSON.stringify(actual.filter(file => !['manifest.json', 'SHA256SUMS'].includes(file)))) fail('Manifest payload coverage is incomplete'); for (const required of ['server/compose.yaml', 'server/.env.example', manifest.artifact]) if (!checksums.has(required)) fail(`Release file missing: ${required}`); if (!fs.statSync(path.join(directory, 'server/config')).isDirectory()) fail('Server config directory missing'); return { manifest, identity: checksums.get('manifest.json') }; } export function readSettings(file) { const result = {}; for (const line of fs.readFileSync(file, 'utf8').split('\n')) { if (!line.trim() || line.trim().startsWith('#')) continue; const match = /^([A-Z][A-Z0-9_]*)=([A-Za-z0-9_.-]+)$/.exec(line.trim()); if (!match || !['MC_EULA', 'MC_MEMORY', 'MC_PORT', 'MC_UID', 'MC_GID', 'MC_PROJECT_NAME', 'MC_BIND_ADDRESS'].includes(match[1]) || match[1] in result) fail('Invalid local settings: use simple KEY=value entries from .env.example'); result[match[1]] = match[2]; } if (!['TRUE', 'FALSE'].includes(result.MC_EULA)) fail('Set MC_EULA explicitly to TRUE or FALSE in the local settings file'); for (const key of ['MC_UID', 'MC_GID']) if (!/^(0|[1-9]\d*)$/.test(result[key])) fail(`Set numeric ${key} explicitly`); if (result.MC_MEMORY && !/^[1-9]\d*[MG]$/.test(result.MC_MEMORY)) fail('Invalid MC_MEMORY'); if (result.MC_PORT && (!/^\d+$/.test(result.MC_PORT) || Number(result.MC_PORT) < 1 || Number(result.MC_PORT) > 65535)) fail('Invalid MC_PORT'); if (result.MC_BIND_ADDRESS && isIP(result.MC_BIND_ADDRESS) !== 4) fail('MC_BIND_ADDRESS must be an IPv4 address'); return result; } const lockTokens = new WeakSet(); export function withRuntimeLock(root, operation) { root = absolute(root, 'MC_ROOT'); const directory = path.join(root, '.mcpack-runtime.lock'); try { fs.mkdirSync(directory); } catch { fail('Another runtime operation is running (or a stale .mcpack-runtime.lock remains)'); } const token = { root }; lockTokens.add(token); const release = () => { lockTokens.delete(token); fs.rmdirSync(directory); }; try { const result = operation(token); if (result && typeof result.then === 'function') return Promise.resolve(result).finally(release); release(); return result; } catch (error) { if (lockTokens.has(token)) release(); throw error; } } export function runtime({ repository, args, env = process.env, lockToken, capture = false }) { const commands = ['config', 'up', 'start', 'stop', 'restart', 'ps', 'logs', 'down', 'create', 'exec', 'health']; if (!args.length || args[0] === '--help') { console.log('MC_ROOT=/absolute/server-root MC_RELEASE_DIR=/absolute/release scripts/server.sh [compose options]\nReadiness: up/start/restart --wait or health [--wait-timeout SECONDS] [--stable-seconds SECONDS]. Settings: MC_ENV_FILE or MC_ROOT/.env.'); return args.length ? 0 : 1; } const command = args[0]; if (!commands.includes(command)) fail('Unsupported lifecycle command'); if (capture && !['ps', 'config'].includes(command)) fail('Capture is only allowed for read-only queries'); if (args.some(arg => /^(?:-[fp]|--(?:file|project-name|env-file|project-directory)(?:=|$))/.test(arg))) fail('Compose identity/file options are controlled by the wrapper'); repository = fs.realpathSync(repository); let defaultTimeout = 120; if (command === 'health' || args.includes('--wait')) { const configFile = path.join(repository, 'platform/config.yaml'); if (fs.existsSync(configFile)) { const document = parseDocument(fs.readFileSync(configFile, 'utf8')); if (document.errors.length) fail('Invalid platform config for health timeout'); defaultTimeout = document.toJS().deploy?.healthcheck_timeout; if (!Number.isSafeInteger(defaultTimeout) || defaultTimeout < 1) fail('Invalid configured healthcheck_timeout'); } } const health = healthOptions(args, defaultTimeout); const root = absolute(env.MC_ROOT, 'MC_ROOT'); if (lockToken && (!lockTokens.has(lockToken) || lockToken.root !== root)) fail('Invalid runtime operation lock token'); const release = absolute(env.MC_RELEASE_DIR, 'MC_RELEASE_DIR'); if (!fs.statSync(root).isDirectory()) fail('Create MC_ROOT before running the server wrapper'); const data = absolute(path.join(root, 'runtime'), 'runtime path'); if (contained(data, release) || contained(release, data) || contained(release, root)) fail('Runtime/server root must not overlap immutable release'); const envFile = absolute(env.MC_ENV_FILE ?? path.join(root, '.env'), 'MC_ENV_FILE'); if (contained(release, envFile)) fail('Local settings must be outside the immutable release'); const settings = readSettings(envFile); const starts = ['up', 'start', 'restart', 'create'].includes(command); if (starts && settings.MC_EULA !== 'TRUE') fail('Startup requires your EULA acceptance: set MC_EULA=TRUE in local settings'); const { manifest, identity } = verifyRelease(release); const compatibility = JSON.parse(fs.readFileSync(path.join(repository, 'platform/compatibility.json'), 'utf8')); if (!compatibility.verified.some(item => item.minecraft === manifest.pack?.minecraft && item.loader === manifest.pack?.loader?.id && item.loader_version === manifest.pack?.loader?.version && item.image === manifest.server.image && item.java_major === manifest.server.java_major)) fail('Release Minecraft/loader/image/Java combination is not verified'); const executable = path.join(repository, '.tools/docker-compose'); if (sha256(executable) !== manifest.tools.compose.sha256) fail('Pinned Compose checksum mismatch'); const project = env.MC_PROJECT_NAME ?? settings.MC_PROJECT_NAME ?? manifest.server.compose_project; if (typeof project !== 'string' || !/^[a-z0-9][a-z0-9_-]*$/.test(project)) fail('Invalid stable Compose project name'); const stateFile = absolute(path.join(root, '.mcpack-runtime.json'), 'runtime binding'); const binding = { schema_version: 1, project, release_manifest_sha256: identity }; const checkBinding = () => { if (fs.existsSync(stateFile)) { const previous = JSON.parse(fs.readFileSync(stateFile, 'utf8')); if (previous.schema_version !== 1 || previous.project !== project || previous.release_manifest_sha256 !== identity) fail('Runtime is bound to another project/release; changing it requires the future backup/deploy workflow'); } else if (starts && fs.existsSync(data) && fs.readdirSync(data).some(file => file !== '.gitkeep')) fail('Nonempty runtime has no release binding; migrate through the backup/deploy workflow'); }; checkBinding(); // Host settings have one source of truth; ambient MC_* cannot override .env. const composeEnv = { ...env, MC_ROOT: root, MC_RELEASE_DIR: release }; for (const key of ['MC_EULA', 'MC_MEMORY', 'MC_PORT', 'MC_UID', 'MC_GID', 'MC_PROJECT_NAME', 'MC_BIND_ADDRESS']) delete composeEnv[key]; Object.assign(composeEnv, settings); const base = ['--project-name', project, '--env-file', envFile, '-f', path.join(release, 'server/compose.yaml')]; // Legacy releases hardcoded loopback. Keep immutable Compose intact while // honoring the user's explicit host networking preference during rollback. let portOverlay; const sourceCompose = fs.readFileSync(path.join(release, 'server/compose.yaml'), 'utf8'); if (settings.MC_BIND_ADDRESS && !sourceCompose.includes('MC_BIND_ADDRESS') && Array.isArray(parseDocument(sourceCompose).toJS()?.services?.minecraft?.ports)) { portOverlay = fs.mkdtempSync(path.join(os.tmpdir(), 'mcpack-host-ports-')); const file = path.join(portOverlay, 'ports.yaml'); const endpoint = `${settings.MC_BIND_ADDRESS}:${settings.MC_PORT ?? manifest.server.port ?? 25565}:25565`; fs.writeFileSync(file, `services:\n minecraft:\n ports: !override\n - ${JSON.stringify(endpoint)}\n`, { mode: 0o600 }); base.push('-f', file); } try { const compiled = JSON.parse(execFileSync(executable, [...base, 'config', '--format', 'json'], { env: composeEnv, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })); const service = compiled.services?.minecraft; if (!service || service.image !== manifest.server.image || service.environment?.TYPE !== 'MODRINTH' || service.environment?.ONLINE_MODE !== 'false' || service.environment?.ENFORCE_SECURE_PROFILE !== 'false' || service.environment?.MODRINTH_MODPACK !== `/modpacks/${manifest.artifact}`) fail('Compiled Compose does not match offline release contract'); for (const [target, source, readOnly] of [['/data', data, false], ['/modpacks', release, true], ['/config', path.join(release, 'server/config'), true]]) { const mount = service.volumes?.find(volume => volume.target === target); if (!mount || mount.type !== 'bind' || mount.source !== source || Boolean(mount.read_only) !== readOnly) fail(`Invalid absolute bind mount: ${target}`); } const mutations = ['up', 'start', 'stop', 'restart', 'down', 'create'].includes(command); const checkProjectOwner = () => { const output = execFileSync(executable, [...base, 'ps', '--all', '--format', 'json'], { env: composeEnv, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); const containers = !output ? [] : output.startsWith('[') ? JSON.parse(output) : output.split('\n').map(line => JSON.parse(line)); if (!Array.isArray(containers)) fail('Unexpected Compose project status'); const ids = containers.map(container => container.ID); if (ids.some(id => typeof id !== 'string' || !/^[a-f0-9]{12,64}$/.test(id))) fail('Unexpected Compose container identity'); if (!ids.length) return; const inspected = JSON.parse(execFileSync('docker', ['inspect', ...ids], { env: composeEnv, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })); if (inspected.some(container => !container.Mounts?.some(mount => mount.Type === 'bind' && mount.Destination === '/data' && mount.Source === data))) fail('Compose project belongs to another runtime root; choose a distinct stable MC_PROJECT_NAME'); }; const perform = () => { if (mutations) checkBinding(); if (mutations || command === 'exec' || health.wait) checkProjectOwner(); if (health.wait && !fs.existsSync(stateFile) && !starts) fail('Health requires a bound runtime'); if (starts) { fs.mkdirSync(data, { recursive: true }); fs.accessSync(data, fs.constants.W_OK); if (!fs.existsSync(stateFile)) fs.writeFileSync(stateFile, JSON.stringify(binding, null, 2) + '\n', { flag: 'wx', mode: 0o600 }); } const diagnostic = readiness => { const logResult = spawnSync(executable, [...base, 'logs', '--no-color', '--tail', '100'], { env: composeEnv, encoding: 'utf8', timeout: 10000, maxBuffer: 1024 * 1024 }); const logs = (logResult.stdout ?? '') + (logResult.stderr ?? '') + (logResult.error ? `\nLog collection failed: ${logResult.error.message}` : ''); const files = saveHealthReport({ root, project, releaseIdentity: identity, result: readiness, logs }); console.error(`Health ${readiness.reason}. Report: ${files.reportPath}\nLogs: ${files.logsPath}\nRecovery: runtime and release binding preserved; no automatic rollback.`); return 1; }; const healthStarted = performance.now(); const result = command === 'health' ? { status: 0 } : spawnSync(executable, [...base, ...health.forwarded], { env: composeEnv, stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', encoding: capture ? 'utf8' : undefined, timeout: health.wait ? health.timeoutSeconds * 1000 : undefined }); if (health.wait && (result.error || result.signal || result.status !== 0)) return diagnostic({ ok: false, reason: result.error?.code === 'ETIMEDOUT' ? 'timeout' : 'compose-start-failed', compose_exit: result.status, error: result.error?.message, signal: result.signal }); if (result.error) throw result.error; if (result.signal) fail(`Compose terminated by ${result.signal}`); if (capture) { if (result.status !== 0) fail(`Compose query failed: ${result.stderr?.trim() || result.status}`); return result.stdout; } if (health.wait) { const readiness = waitForHealth({ ...health, startedAtMs: healthStarted, inspect: timeout => { const queryStarted = performance.now(); const output = execFileSync(executable, [...base, 'ps', '--all', '--format', 'json', 'minecraft'], { env: composeEnv, encoding: 'utf8', timeout, stdio: ['ignore', 'pipe', 'pipe'] }).trim(); const list = !output ? [] : output.startsWith('[') ? JSON.parse(output) : output.split('\n').map(line => JSON.parse(line)); if (!Array.isArray(list) || list.length > 1) fail('Unexpected Minecraft container count'); if (!list.length) return null; if (!/^[a-f0-9]{12,64}$/.test(list[0].ID)) fail('Unexpected Minecraft container identity'); const remaining = Math.ceil(timeout - (performance.now() - queryStarted)); if (remaining <= 0) fail('Health query deadline exceeded'); const inspected = JSON.parse(execFileSync('docker', ['inspect', list[0].ID], { env: composeEnv, encoding: 'utf8', timeout: remaining, stdio: ['ignore', 'pipe', 'pipe'] })); if (inspected.length !== 1 || !inspected[0].Mounts?.some(mount => mount.Type === 'bind' && mount.Destination === '/data' && mount.Source === data)) fail('Health container belongs to another runtime'); return inspected[0]; }, probe: (id, timeout) => { const probe = spawnSync('docker', ['exec', id, 'mc-health'], { env: composeEnv, encoding: 'utf8', timeout: Math.min(timeout, 10000), maxBuffer: 1024 * 1024 }); return { status: probe.status, stdout: probe.stdout?.trim(), stderr: probe.stderr?.trim(), error: probe.error?.message }; } }); if (!readiness.ok) return diagnostic(readiness); console.log(`Healthy: Minecraft status query passed; stable for ${health.stableSeconds}s (${project}).`); } return result.status ?? 1; }; return (mutations || health.wait) && !lockToken ? withRuntimeLock(root, perform) : perform(); } finally { if (portOverlay) fs.rmSync(portOverlay, { recursive: true, force: true }); } }