chore: bootstrap lean sysadmin-chronicles repo
Import the runnable game code, content, docs, scripts, and repo guidance while leaving local agent state, dependency installs, build output, and backup copies out of the published tree.
This commit is contained in:
@@ -0,0 +1,497 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
/**
|
||||
* verify-clue-fingerprints.js
|
||||
* Verifies that clue_fingerprint evidence in quest files is detectable
|
||||
* in the actual VM state.
|
||||
*
|
||||
* Usage (run from project root):
|
||||
* node tools/content/verify-clue-fingerprints.js
|
||||
* node tools/content/verify-clue-fingerprints.js --quest Q002
|
||||
* node tools/content/verify-clue-fingerprints.js --revert
|
||||
* node tools/content/verify-clue-fingerprints.js --start-vms
|
||||
* node tools/content/verify-clue-fingerprints.js --dry-run
|
||||
*
|
||||
* Flags:
|
||||
* --quest <ID> Check only the specified quest (e.g. Q002)
|
||||
* --revert Revert each VM to its quest baseline snapshot before checking.
|
||||
* Leaves VMs running at baseline afterwards (ready for play).
|
||||
* --start-vms Start VMs that are not running (without reverting snapshot).
|
||||
* Use after a fresh boot if VMs are just stopped, not reverted.
|
||||
* --dry-run Print the SSH commands that would run without executing them.
|
||||
* --help Show this help text.
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 All evidence confirmed (or dry-run)
|
||||
* 1 One or more failures, or no checks ran
|
||||
*
|
||||
* Prerequisites:
|
||||
* - virsh available and pointing at qemu:///system
|
||||
* - ~/.ssh/sc_host_key present
|
||||
* - VMs running (or --revert / --start-vms provided)
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
const os = require('os');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CONFIG
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PROJECT_ROOT = path.resolve(__dirname, '../..');
|
||||
const CONTENT_ROOT = path.join(PROJECT_ROOT, 'content');
|
||||
const QUESTS_DIR = path.join(CONTENT_ROOT, 'quests');
|
||||
const VM_PROFILES_DIR = path.join(CONTENT_ROOT, 'vm_profiles');
|
||||
const SSH_KEY = path.join(os.homedir(), '.ssh/sc_host_key');
|
||||
const LIBVIRT_URI = 'qemu:///system';
|
||||
const VM_PREFIX = 'sc-';
|
||||
const SSH_WAIT_SECS = 90; // max seconds to wait for SSH after revert/start
|
||||
const SSH_POLL_SECS = 5;
|
||||
|
||||
const SSH_OPTS = [
|
||||
'-o', 'StrictHostKeyChecking=no',
|
||||
'-o', 'UserKnownHostsFile=/dev/null',
|
||||
'-o', 'BatchMode=yes',
|
||||
'-o', 'ConnectTimeout=8',
|
||||
'-o', 'LogLevel=ERROR',
|
||||
'-i', SSH_KEY,
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ARGS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const rawArgs = process.argv.slice(2);
|
||||
const dryRun = rawArgs.includes('--dry-run');
|
||||
const doRevert = rawArgs.includes('--revert');
|
||||
const startVms = rawArgs.includes('--start-vms');
|
||||
const showHelp = rawArgs.includes('--help') || rawArgs.includes('-h');
|
||||
|
||||
const questIdx = rawArgs.indexOf('--quest');
|
||||
const questFilter = questIdx >= 0 ? rawArgs[questIdx + 1] : null;
|
||||
|
||||
if (showHelp) {
|
||||
const usage = fs.readFileSync(__filename, 'utf8').split('\n')
|
||||
.slice(1, 20).map(l => l.replace(/^ \* ?/, '')).join('\n');
|
||||
console.log(usage);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VIRSH HELPERS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function virsh(...args) {
|
||||
const r = spawnSync('virsh', ['--connect', LIBVIRT_URI, ...args], {
|
||||
encoding: 'utf8', timeout: 20000,
|
||||
});
|
||||
return {
|
||||
ok: r.status === 0,
|
||||
stdout: (r.stdout || '').trim(),
|
||||
stderr: (r.stderr || '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function domainName(vmId) {
|
||||
return VM_PREFIX + vmId.replace(/_/g, '-');
|
||||
}
|
||||
|
||||
function vmIsRunning(vmId) {
|
||||
const r = virsh('domstate', domainName(vmId));
|
||||
return r.ok && r.stdout === 'running';
|
||||
}
|
||||
|
||||
function getVmIp(vmId) {
|
||||
const domain = domainName(vmId);
|
||||
// Primary: guest agent
|
||||
const r = virsh('domifaddr', domain, '--source', 'agent');
|
||||
if (r.ok && r.stdout) {
|
||||
const m = r.stdout.match(/(\d+\.\d+\.\d+\.\d+)/);
|
||||
if (m) return m[1];
|
||||
}
|
||||
// Fallback: DHCP leases table
|
||||
const leases = virsh('net-dhcp-leases', 'sc-internal');
|
||||
if (leases.ok && leases.stdout) {
|
||||
const hostname = domain.replace(VM_PREFIX, '');
|
||||
for (const line of leases.stdout.split('\n')) {
|
||||
if (line.includes(hostname)) {
|
||||
const m = line.match(/(\d+\.\d+\.\d+\.\d+)/);
|
||||
if (m) return m[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sleep(secs) {
|
||||
spawnSync('sleep', [String(secs)]);
|
||||
}
|
||||
|
||||
function waitForSsh(vmId, user) {
|
||||
process.stdout.write(` Waiting for SSH on ${domainName(vmId)}`);
|
||||
const deadline = Date.now() + SSH_WAIT_SECS * 1000;
|
||||
while (Date.now() < deadline) {
|
||||
const ip = getVmIp(vmId);
|
||||
if (ip) {
|
||||
const r = spawnSync('ssh', [...SSH_OPTS, '-o', 'ConnectTimeout=5', `${user}@${ip}`, 'true'], {
|
||||
encoding: 'utf8', timeout: 8000,
|
||||
});
|
||||
if (r.status === 0) {
|
||||
console.log(` ready (${ip})`);
|
||||
return ip;
|
||||
}
|
||||
}
|
||||
process.stdout.write('.');
|
||||
sleep(SSH_POLL_SECS);
|
||||
}
|
||||
console.log(' TIMEOUT');
|
||||
return null;
|
||||
}
|
||||
|
||||
function revertVmToBaseline(vmId, snapshot) {
|
||||
const domain = domainName(vmId);
|
||||
console.log(` Reverting ${domain} → ${snapshot}...`);
|
||||
// Destroy (force-stop) if running — state will be discarded
|
||||
if (vmIsRunning(vmId)) {
|
||||
virsh('destroy', domain);
|
||||
sleep(1);
|
||||
}
|
||||
const r = virsh('snapshot-revert', domain, snapshot, '--running');
|
||||
if (!r.ok) {
|
||||
console.error(` ERROR reverting ${domain}: ${r.stderr}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CONTENT LOADING
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function loadVmProfiles() {
|
||||
const profiles = {};
|
||||
for (const f of fs.readdirSync(VM_PROFILES_DIR)) {
|
||||
if (!f.endsWith('.json')) continue;
|
||||
const d = JSON.parse(fs.readFileSync(path.join(VM_PROFILES_DIR, f), 'utf8'));
|
||||
profiles[d.id] = d;
|
||||
}
|
||||
return profiles;
|
||||
}
|
||||
|
||||
function loadQuests() {
|
||||
return fs.readdirSync(QUESTS_DIR)
|
||||
.filter(f => f.endsWith('.json') && !f.includes('SPLIT'))
|
||||
.map(f => {
|
||||
try { return JSON.parse(fs.readFileSync(path.join(QUESTS_DIR, f), 'utf8')); }
|
||||
catch { return null; }
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => (a.id < b.id ? -1 : 1));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EVIDENCE CHECK COMMAND BUILDER
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sq(s) {
|
||||
// Shell-quote a value for embedding in a bash command string
|
||||
return "'" + String(s).replace(/'/g, "'\"'\"'") + "'";
|
||||
}
|
||||
|
||||
function buildCmd(ev) {
|
||||
// Returns a bash one-liner that prints "PASS" or "FAIL [detail]"
|
||||
// All commands run via sudo to handle restricted paths.
|
||||
switch (ev.type) {
|
||||
|
||||
case 'file_absent':
|
||||
return `sudo test ! -e ${sq(ev.path)} && echo PASS || echo 'FAIL (file exists)'`;
|
||||
|
||||
case 'file_exists':
|
||||
return `sudo test -e ${sq(ev.path)} && echo PASS || echo 'FAIL (file missing)'`;
|
||||
|
||||
case 'file_contains':
|
||||
case 'log_contains': {
|
||||
const pat = sq(ev.contains);
|
||||
return `sudo grep -qF ${pat} ${sq(ev.path)} 2>/dev/null && echo PASS || echo 'FAIL (pattern not found)'`;
|
||||
}
|
||||
|
||||
case 'file_owner_is_not': {
|
||||
const notExpected = ev.expected_user || ev.expected_owner || '';
|
||||
return (
|
||||
`owner=$(sudo stat -c '%U' ${sq(ev.path)} 2>/dev/null || echo '?'); ` +
|
||||
`[ "\${owner}" != ${sq(notExpected)} ] && echo PASS || ` +
|
||||
`echo "FAIL (owner=\${owner}, expected NOT ${notExpected})"`
|
||||
);
|
||||
}
|
||||
|
||||
case 'file_size_above': {
|
||||
const threshold = ev.threshold_bytes;
|
||||
return (
|
||||
`bytes=$(sudo stat -c '%s' ${sq(ev.path)} 2>/dev/null || echo 0); ` +
|
||||
`[ "\${bytes}" -gt ${threshold} ] && echo PASS || ` +
|
||||
`echo "FAIL (size=\${bytes} bytes, threshold=${threshold})"`
|
||||
);
|
||||
}
|
||||
|
||||
case 'disk_usage_above': {
|
||||
const threshold = ev.threshold_percent;
|
||||
return (
|
||||
`pct=$(sudo df --output=pcent ${sq(ev.path)} 2>/dev/null | tail -1 | tr -d ' %'); ` +
|
||||
`[ "\${pct}" -gt ${threshold} ] && echo PASS || ` +
|
||||
`echo "FAIL (usage=\${pct}%, threshold=${threshold}%)"`
|
||||
);
|
||||
}
|
||||
|
||||
case 'service_state_is': {
|
||||
const expected = ev.state;
|
||||
return (
|
||||
`state=$(sudo systemctl is-active ${sq(ev.service)} 2>/dev/null || echo inactive); ` +
|
||||
`[ "\${state}" = ${sq(expected)} ] && echo PASS || ` +
|
||||
`echo "FAIL (state=\${state}, expected=${expected})"`
|
||||
);
|
||||
}
|
||||
|
||||
case 'service_enabled_is': {
|
||||
const wantEnabled = ev.enabled === true || ev.enabled === 'true';
|
||||
return (
|
||||
`estate=$(sudo systemctl is-enabled ${sq(ev.service)} 2>/dev/null || echo disabled); ` +
|
||||
(wantEnabled
|
||||
? `[ "\${estate}" = "enabled" ] && echo PASS || echo "FAIL (is-enabled=\${estate}, expected=enabled)"`
|
||||
: `[ "\${estate}" != "enabled" ] && echo PASS || echo "FAIL (is-enabled=\${estate}, expected=disabled)"`)
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function describeEvidence(ev) {
|
||||
switch (ev.type) {
|
||||
case 'file_absent': return `file absent: ${ev.path}`;
|
||||
case 'file_exists': return `file exists: ${ev.path}`;
|
||||
case 'file_contains':
|
||||
case 'log_contains': return `${ev.path} ∋ ${JSON.stringify(ev.contains)}`;
|
||||
case 'file_owner_is_not': return `${ev.path} owner ≠ ${ev.expected_user || ev.expected_owner}`;
|
||||
case 'file_size_above': return `${ev.path} > ${(ev.threshold_bytes / 1e9).toFixed(1)} GB`;
|
||||
case 'disk_usage_above': return `${ev.path} disk > ${ev.threshold_percent}%`;
|
||||
case 'service_state_is': return `${ev.service} state = ${ev.state}`;
|
||||
case 'service_enabled_is': return `${ev.service} enabled = ${ev.enabled}`;
|
||||
default: return `${ev.type}: ${JSON.stringify(ev)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MAIN
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function main() {
|
||||
console.log('\n Sysadmin Chronicles — Clue Fingerprint Verifier');
|
||||
console.log('═══════════════════════════════════════\n');
|
||||
|
||||
if (dryRun) console.log(' Mode: DRY-RUN (no SSH commands executed)\n');
|
||||
if (doRevert) console.log(' Mode: REVERT — VMs will be reverted to baseline snapshots\n');
|
||||
|
||||
const vmProfiles = loadVmProfiles();
|
||||
let quests = loadQuests();
|
||||
|
||||
if (questFilter) {
|
||||
quests = quests.filter(q => q.id === questFilter);
|
||||
if (quests.length === 0) {
|
||||
console.error(`No quest found with id: ${questFilter}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Collect VMs needed (evidence items might reference multiple VMs, e.g. Q008)
|
||||
const vmsNeeded = new Set();
|
||||
for (const q of quests) {
|
||||
for (const ev of (q.clue_fingerprint?.evidence || [])) {
|
||||
if (ev.vm) vmsNeeded.add(ev.vm);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve IPs for non-revert mode upfront (revert mode resolves per-quest)
|
||||
const vmIps = {};
|
||||
if (!doRevert) {
|
||||
for (const vmId of vmsNeeded) {
|
||||
const domain = domainName(vmId);
|
||||
const profile = vmProfiles[vmId];
|
||||
const user = profile?.management_user || profile?.ssh_user || 'player';
|
||||
|
||||
if (!vmIsRunning(vmId)) {
|
||||
if (startVms) {
|
||||
console.log(`Starting ${domain}...`);
|
||||
virsh('start', domain);
|
||||
vmIps[vmId] = waitForSsh(vmId, user);
|
||||
} else {
|
||||
console.warn(` ⚠ ${domain} is not running. Use --start-vms or --revert.`);
|
||||
vmIps[vmId] = null;
|
||||
}
|
||||
} else {
|
||||
process.stdout.write(` Resolving IP for ${domain}...`);
|
||||
let ip = null;
|
||||
const deadline = Date.now() + 30000;
|
||||
while (!ip && Date.now() < deadline) {
|
||||
ip = getVmIp(vmId);
|
||||
if (!ip) { process.stdout.write('.'); sleep(3); }
|
||||
}
|
||||
console.log(ip ? ` ${ip}` : ' (could not resolve)');
|
||||
vmIps[vmId] = ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-quest checks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let totalPass = 0, totalFail = 0, totalSkip = 0;
|
||||
const failures = [];
|
||||
|
||||
for (const quest of quests) {
|
||||
const evidence = quest.clue_fingerprint?.evidence || [];
|
||||
if (evidence.length === 0) {
|
||||
console.log(`\n[${quest.id}] ${quest.title} — no evidence entries, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const baseline = quest.baseline_snapshot || '(unknown)';
|
||||
const primaryVm = quest.primary_vm || '';
|
||||
console.log(`\n[${quest.id}] ${quest.title}`);
|
||||
console.log(` Baseline: ${primaryVm} @ ${baseline}`);
|
||||
|
||||
// Revert primary VM if --revert
|
||||
if (doRevert && primaryVm && !dryRun) {
|
||||
const revOk = revertVmToBaseline(primaryVm, baseline);
|
||||
if (!revOk) {
|
||||
console.log(` ✗ Cannot revert — skipping ${evidence.length} checks`);
|
||||
totalSkip += evidence.length;
|
||||
continue;
|
||||
}
|
||||
const profile = vmProfiles[primaryVm];
|
||||
const user = profile?.management_user || profile?.ssh_user || 'player';
|
||||
const ip = waitForSsh(primaryVm, user);
|
||||
vmIps[primaryVm] = ip;
|
||||
if (!ip) {
|
||||
console.log(` ✗ SSH timeout after revert — skipping ${evidence.length} checks`);
|
||||
totalSkip += evidence.length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Also handle multi-VM quests (Q008 references both web_server and build_machine)
|
||||
const questVms = new Set(evidence.map(ev => ev.vm).filter(Boolean));
|
||||
if (doRevert) {
|
||||
for (const extraVm of questVms) {
|
||||
if (extraVm === primaryVm) continue;
|
||||
if (!vmIps[extraVm]) {
|
||||
// Extra VM: just ensure it's running (don't revert — only primary gets reverted)
|
||||
if (!vmIsRunning(extraVm)) {
|
||||
virsh('start', domainName(extraVm));
|
||||
}
|
||||
const profile = vmProfiles[extraVm];
|
||||
const user = profile?.management_user || profile?.ssh_user || 'player';
|
||||
vmIps[extraVm] = waitForSsh(extraVm, user);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run each evidence check
|
||||
for (const ev of evidence) {
|
||||
const vmId = ev.vm || primaryVm;
|
||||
const ip = vmIps[vmId];
|
||||
const profile = vmProfiles[vmId];
|
||||
const user = profile?.management_user || profile?.ssh_user || 'player';
|
||||
const desc = describeEvidence(ev);
|
||||
|
||||
if (!ip && !dryRun) {
|
||||
console.log(` ⚠ SKIP ${desc}`);
|
||||
console.log(` → VM ${vmId} not reachable`);
|
||||
totalSkip++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const cmd = buildCmd(ev);
|
||||
if (!cmd) {
|
||||
console.log(` ⚠ SKIP ${desc}`);
|
||||
console.log(` → evidence type '${ev.type}' not supported by this tool`);
|
||||
totalSkip++;
|
||||
continue;
|
||||
}
|
||||
|
||||
let passed;
|
||||
let detail = '';
|
||||
|
||||
if (dryRun) {
|
||||
console.log(` ○ DRY ${desc}`);
|
||||
console.log(` → ssh ${user}@<${vmId}-ip> '${cmd.slice(0, 80)}${cmd.length > 80 ? '...' : ''}'`);
|
||||
totalPass++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const r = spawnSync('ssh', [...SSH_OPTS, `${user}@${ip}`, cmd], {
|
||||
encoding: 'utf8', timeout: 25000,
|
||||
});
|
||||
const stdout = (r.stdout || '').trim();
|
||||
const stderr = (r.stderr || '').trim();
|
||||
|
||||
if (r.error || r.status === null) {
|
||||
passed = false;
|
||||
detail = r.error ? r.error.message : 'SSH process failed';
|
||||
} else {
|
||||
passed = stdout.startsWith('PASS');
|
||||
detail = stdout.startsWith('FAIL') ? stdout.replace(/^FAIL\s*/, '') : (stderr || `exit ${r.status}`);
|
||||
}
|
||||
|
||||
if (passed) {
|
||||
console.log(` ✓ PASS ${desc}`);
|
||||
totalPass++;
|
||||
} else {
|
||||
console.log(` ✗ FAIL ${desc}`);
|
||||
if (detail) console.log(` → ${detail}`);
|
||||
totalFail++;
|
||||
failures.push({ quest: quest.id, desc, detail });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Summary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log('\n═══════════════════════════════════════');
|
||||
console.log(` Results: ${totalPass} passed ${totalFail} failed ${totalSkip} skipped`);
|
||||
|
||||
if (totalFail > 0) {
|
||||
console.log('\n Failed checks:');
|
||||
for (const f of failures) {
|
||||
console.log(` ✗ [${f.quest}] ${f.desc}`);
|
||||
if (f.detail) console.log(` → ${f.detail}`);
|
||||
}
|
||||
console.log('\n ✗ Clue fingerprint verification FAILED');
|
||||
console.log('═══════════════════════════════════════\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!dryRun && totalPass === 0 && totalSkip > 0) {
|
||||
console.log('\n ⚠ No checks ran. Are the VMs running?');
|
||||
console.log(' Tips:');
|
||||
console.log(' --start-vms start VMs then check on current state');
|
||||
console.log(' --revert revert to baseline snapshot then check');
|
||||
console.log('═══════════════════════════════════════\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log('\n Dry-run complete. No checks were executed.');
|
||||
} else {
|
||||
console.log('\n ✓ All evidence confirmed in VM state');
|
||||
}
|
||||
console.log('═══════════════════════════════════════\n');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user