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,474 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* validate-content.js
|
||||
* Sysadmin Chronicles content validation tool.
|
||||
*
|
||||
* Run from project root:
|
||||
* node tools/content/validate-content.js
|
||||
* node tools/content/validate-content.js --quests-only
|
||||
* node tools/content/validate-content.js --verbose
|
||||
*
|
||||
* Exit code 0 = no errors. Exit code 1 = validation errors found.
|
||||
*
|
||||
* Checks performed:
|
||||
* - All JSON files parse correctly
|
||||
* - No duplicate IDs across content domains
|
||||
* - Every world flag referenced anywhere exists in world_flags.json
|
||||
* - Every required_vms entry maps to a known VM profile
|
||||
* - Every blast_radius entry maps to a known incident ID
|
||||
* - Every ticket_id in a quest maps to an existing ticket
|
||||
* - Every linked_quest in a ticket maps to an existing quest
|
||||
* - Branch priorities within a quest are unique (no ties)
|
||||
* - Every follow_up_incident maps to an existing incident file
|
||||
* - Every series_id has at least 2 members
|
||||
* - clue_fingerprint evidence uses valid rule types
|
||||
* - file_absent and file_owner_is_not are accepted rule types (OI-010 resolved)
|
||||
* - Package-manager-specific paths/commands match the authored VM distro
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CONFIG
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CONTENT_ROOT = path.resolve(__dirname, "../../content");
|
||||
const QUESTS_DIR = path.join(CONTENT_ROOT, "quests");
|
||||
const TICKETS_DIR = path.join(CONTENT_ROOT, "tickets");
|
||||
const INCIDENTS_DIR= path.join(CONTENT_ROOT, "incidents");
|
||||
const DIALOGUE_DIR = path.join(CONTENT_ROOT, "dialogue");
|
||||
const FLAGS_FILE = path.join(CONTENT_ROOT, "world_flags", "world_flags.json");
|
||||
const VM_PROFILES_DIR = path.join(CONTENT_ROOT, "vm_profiles");
|
||||
const PRESSURE_PROFILES_DIR = path.join(CONTENT_ROOT, "pressure_profiles");
|
||||
|
||||
const VALID_RULE_TYPES = new Set([
|
||||
// Standard validation rule types (used in objectives and solution branches)
|
||||
"file_exists", "file_absent", "file_contains", "file_mode", "file_owner",
|
||||
"file_owner_is_not", "directory_exists", "service_state", "service_enabled",
|
||||
"process_running", "process_user", "port_listening", "package_installed",
|
||||
"mount_present", "disk_usage_below", "disk_usage_above", "command_assert",
|
||||
"log_contains", "and", "or", "not",
|
||||
// Advisory clue_fingerprint-only types (descriptive evidence, not used in branch validation)
|
||||
// These document what evidence EXISTS in the VM baseline — not evaluated at runtime.
|
||||
"service_state_is", "service_enabled_is", "file_size_above", "file_size_below",
|
||||
]);
|
||||
|
||||
const VALID_NARRATIVE_PHASES = new Set([
|
||||
"normal_work", "unease", "suspicion", "investigation", "conflict", "resolution"
|
||||
]);
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const verbose = args.includes("--verbose");
|
||||
const questsOnly = args.includes("--quests-only");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LOAD HELPERS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let errors = 0;
|
||||
let warnings = 0;
|
||||
|
||||
function err(msg) {
|
||||
console.error(` ❌ ERROR: ${msg}`);
|
||||
errors++;
|
||||
}
|
||||
|
||||
function warn(msg) {
|
||||
console.warn(` ⚠ WARN: ${msg}`);
|
||||
warnings++;
|
||||
}
|
||||
|
||||
function ok(msg) {
|
||||
if (verbose) console.log(` ✓ ${msg}`);
|
||||
}
|
||||
|
||||
function loadJson(filePath) {
|
||||
try {
|
||||
const text = fs.readFileSync(filePath, "utf8");
|
||||
return JSON.parse(text);
|
||||
} catch (e) {
|
||||
err(`JSON parse error in ${path.relative(CONTENT_ROOT, filePath)}: ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function loadDir(dirPath, idField = "id") {
|
||||
const result = {};
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
warn(`Directory not found: ${dirPath}`);
|
||||
return result;
|
||||
}
|
||||
for (const fname of fs.readdirSync(dirPath)) {
|
||||
if (!fname.endsWith(".json") || fname.startsWith(".")) continue;
|
||||
// Skip split artifacts. Pending splits still deserve a warning; completed
|
||||
// split archives and backups are intentional and should stay quiet.
|
||||
if (fname.includes("SPLIT_PENDING")) {
|
||||
warn(`Skipping non-standard file: ${fname}`);
|
||||
continue;
|
||||
}
|
||||
if (fname.includes("SPLIT_DONE") || fname.includes(".bak")) {
|
||||
continue;
|
||||
}
|
||||
const data = loadJson(path.join(dirPath, fname));
|
||||
if (!data) continue;
|
||||
if (Array.isArray(data)) {
|
||||
warn(`${fname} is an array — expected single object with '${idField}' field. Split into individual files.`);
|
||||
continue;
|
||||
}
|
||||
const id = data[idField];
|
||||
if (!id) {
|
||||
err(`Missing '${idField}' field in ${fname}`);
|
||||
continue;
|
||||
}
|
||||
if (result[id]) {
|
||||
err(`Duplicate ID '${id}' in ${fname} (already seen)`);
|
||||
}
|
||||
result[id] = { data, fname };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RULE TYPE VALIDATOR (recursive)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function validateRuleTypes(rule, context) {
|
||||
if (!rule || typeof rule !== "object") return;
|
||||
const type = rule.type;
|
||||
if (!type) {
|
||||
err(`${context}: rule missing 'type' field`);
|
||||
return;
|
||||
}
|
||||
if (!VALID_RULE_TYPES.has(type)) {
|
||||
err(`${context}: unknown rule type '${type}'`);
|
||||
return;
|
||||
}
|
||||
// Recurse into composite rules
|
||||
if (type === "and" || type === "or") {
|
||||
for (const sub of (rule.rules || [])) {
|
||||
validateRuleTypes(sub, context);
|
||||
}
|
||||
}
|
||||
if (type === "not") {
|
||||
validateRuleTypes(rule.rule, context);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// COLLECT ALL FLAG REFERENCES IN A RULE (recursive)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function collectFlagRefs(obj, refs = new Set()) {
|
||||
if (!obj || typeof obj !== "object") return refs;
|
||||
if (Array.isArray(obj)) {
|
||||
for (const item of obj) collectFlagRefs(item, refs);
|
||||
return refs;
|
||||
}
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (k === "world_flags" && Array.isArray(v)) {
|
||||
for (const f of v) refs.add(f);
|
||||
} else if (k === "trigger" && typeof v === "string" && v.startsWith("world_flag:")) {
|
||||
refs.add(v.slice("world_flag:".length));
|
||||
} else if (typeof v === "object") {
|
||||
collectFlagRefs(v, refs);
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
function distroForVm(vmId, vmProfiles) {
|
||||
return vmProfiles[vmId]?.data?.distro || "";
|
||||
}
|
||||
|
||||
function validateVmSpecificSemantics(rule, context, vmProfiles) {
|
||||
if (!rule || typeof rule !== "object") return;
|
||||
if (Array.isArray(rule)) {
|
||||
for (const item of rule) validateVmSpecificSemantics(item, context, vmProfiles);
|
||||
return;
|
||||
}
|
||||
|
||||
const vmId = typeof rule.vm === "string" ? rule.vm : "";
|
||||
const distro = distroForVm(vmId, vmProfiles);
|
||||
const pathValue = typeof rule.path === "string" ? rule.path : "";
|
||||
const commandValue = typeof rule.command === "string" ? rule.command : "";
|
||||
|
||||
if (vmId && distro) {
|
||||
const pathCtx = `${context} (${vmId}/${distro})`;
|
||||
if (pathValue === "/etc/pacman.conf" && distro !== "arch") {
|
||||
err(`${pathCtx}: /etc/pacman.conf is Arch-specific but VM distro is '${distro}'`);
|
||||
}
|
||||
if (pathValue.startsWith("/etc/apt/") && distro === "arch") {
|
||||
err(`${pathCtx}: /etc/apt/* is Debian/Ubuntu-specific but VM distro is 'arch'`);
|
||||
}
|
||||
if (commandValue.includes("pacman") && distro !== "arch") {
|
||||
err(`${pathCtx}: command references pacman but VM distro is '${distro}'`);
|
||||
}
|
||||
if ((commandValue.includes("apt ") || commandValue.includes("apt-get") || commandValue.includes("dpkg ")) && distro === "arch") {
|
||||
err(`${pathCtx}: command references apt/dpkg but VM distro is 'arch'`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const value of Object.values(rule)) {
|
||||
if (value && typeof value === "object") {
|
||||
validateVmSpecificSemantics(value, context, vmProfiles);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MAIN VALIDATION
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log("═══════════════════════════════════════");
|
||||
console.log(" Sysadmin Chronicles Content Validator");
|
||||
console.log("═══════════════════════════════════════\n");
|
||||
|
||||
// Load all content
|
||||
console.log("Loading content...");
|
||||
const quests = loadDir(QUESTS_DIR);
|
||||
const tickets = loadDir(TICKETS_DIR);
|
||||
const incidents = loadDir(INCIDENTS_DIR);
|
||||
const dialogue = loadDir(DIALOGUE_DIR);
|
||||
const vmProfiles= loadDir(VM_PROFILES_DIR);
|
||||
const pressureProfiles = loadDir(PRESSURE_PROFILES_DIR);
|
||||
const flagsRaw = loadJson(FLAGS_FILE);
|
||||
// flags field may be an Array (list of {id, ...}) or a Dict (id -> {...})
|
||||
// Normalize to Dict keyed by id for validation lookups.
|
||||
let flagRegistry = {};
|
||||
if (flagsRaw) {
|
||||
const raw = flagsRaw.flags || flagsRaw;
|
||||
if (Array.isArray(raw)) {
|
||||
for (const f of raw) { if (f.id) flagRegistry[f.id] = f; }
|
||||
} else if (typeof raw === "object") {
|
||||
flagRegistry = raw;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` Quests: ${Object.keys(quests).length}, Tickets: ${Object.keys(tickets).length}, Incidents: ${Object.keys(incidents).length}, Dialogue: ${Object.keys(dialogue).length}, Pressure Profiles: ${Object.keys(pressureProfiles).length}\n`);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// QUEST VALIDATION
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log("Validating quests...");
|
||||
for (const [qid, { data: quest, fname }] of Object.entries(quests)) {
|
||||
const ctx = `Quest ${qid} (${fname})`;
|
||||
|
||||
// required_vms
|
||||
for (const vmId of (quest.required_vms || [])) {
|
||||
if (!vmProfiles[vmId]) err(`${ctx}: required_vms references unknown VM profile '${vmId}'`);
|
||||
}
|
||||
|
||||
// baseline_snapshot
|
||||
if (!quest.baseline_snapshot || typeof quest.baseline_snapshot !== 'string' || !quest.baseline_snapshot.trim()) {
|
||||
err(`${ctx}: missing or empty 'baseline_snapshot' field`);
|
||||
}
|
||||
|
||||
// ticket_id
|
||||
if (quest.ticket_id && !tickets[quest.ticket_id]) {
|
||||
err(`${ctx}: ticket_id '${quest.ticket_id}' not found in tickets/`);
|
||||
}
|
||||
|
||||
// pressure_profile
|
||||
if (quest.pressure_profile && !pressureProfiles[quest.pressure_profile]) {
|
||||
err(`${ctx}: pressure_profile '${quest.pressure_profile}' not found in pressure_profiles/`);
|
||||
}
|
||||
|
||||
// blast_radius
|
||||
for (const incId of (quest.blast_radius || [])) {
|
||||
if (!incidents[incId]) warn(`${ctx}: blast_radius references '${incId}' which doesn't exist yet`);
|
||||
}
|
||||
|
||||
// Branch priority uniqueness
|
||||
const branches = quest.solution_branches || [];
|
||||
const priorities = new Set();
|
||||
for (const branch of branches) {
|
||||
const p = branch.priority;
|
||||
if (priorities.has(p)) {
|
||||
err(`${ctx}: duplicate branch priority ${p} — each branch must have a unique priority`);
|
||||
}
|
||||
priorities.add(p);
|
||||
|
||||
// follow_up_incident
|
||||
if (branch.follow_up_incident && !incidents[branch.follow_up_incident]) {
|
||||
err(`${ctx}: branch '${branch.id}' follow_up_incident '${branch.follow_up_incident}' not found`);
|
||||
}
|
||||
|
||||
// Validate branch validation rules
|
||||
validateRuleTypes(branch.validation, `${ctx} branch '${branch.id}'`);
|
||||
}
|
||||
|
||||
// clue_fingerprint evidence rule types
|
||||
const evidence = (quest.clue_fingerprint || {}).evidence || [];
|
||||
for (const ev of evidence) {
|
||||
if (!VALID_RULE_TYPES.has(ev.type)) {
|
||||
err(`${ctx}: clue_fingerprint evidence uses unknown rule type '${ev.type}'`);
|
||||
}
|
||||
validateVmSpecificSemantics(ev, `${ctx} clue_fingerprint`, vmProfiles);
|
||||
}
|
||||
|
||||
// Objective rule types
|
||||
for (const obj of (quest.objectives || [])) {
|
||||
validateRuleTypes(obj.validation, `${ctx} objective '${obj.id}'`);
|
||||
validateVmSpecificSemantics(obj.validation, `${ctx} objective '${obj.id}'`, vmProfiles);
|
||||
}
|
||||
|
||||
for (const branch of branches) {
|
||||
validateVmSpecificSemantics(branch.validation, `${ctx} branch '${branch.id}'`, vmProfiles);
|
||||
}
|
||||
|
||||
// narrative_phase
|
||||
if (!quest.narrative_phase) {
|
||||
warn(`${ctx}: missing 'narrative_phase' field`);
|
||||
} else if (!VALID_NARRATIVE_PHASES.has(quest.narrative_phase)) {
|
||||
err(`${ctx}: unknown narrative_phase '${quest.narrative_phase}'`);
|
||||
}
|
||||
|
||||
// behavior_impact
|
||||
if (quest.behavior_impact !== undefined) {
|
||||
for (const [branchKey, impact] of Object.entries(quest.behavior_impact)) {
|
||||
for (const field of ['curiosity_delta', 'obedience_delta', 'risk_delta', 'suspicion_delta']) {
|
||||
if (impact[field] !== undefined && typeof impact[field] !== 'number') {
|
||||
err(`${ctx}: behavior_impact[${branchKey}].${field} must be a number`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hidden_hook shape
|
||||
if (quest.hidden_hook !== undefined && quest.hidden_hook !== null) {
|
||||
if (typeof quest.hidden_hook.id !== 'string') {
|
||||
err(`${ctx}: hidden_hook.id must be a string`);
|
||||
}
|
||||
}
|
||||
|
||||
// access_requirements
|
||||
if (quest.access_requirements?.minimum_access) {
|
||||
for (const [vmId] of Object.entries(quest.access_requirements.minimum_access)) {
|
||||
if (!vmProfiles[vmId]) {
|
||||
err(`${ctx}: access_requirements.minimum_access references unknown VM '${vmId}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ok(`${ctx}: OK`);
|
||||
}
|
||||
|
||||
if (!questsOnly) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// TICKET VALIDATION
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log("\nValidating tickets...");
|
||||
for (const [tid, { data: ticket, fname }] of Object.entries(tickets)) {
|
||||
const ctx = `Ticket ${tid} (${fname})`;
|
||||
|
||||
if (ticket.linked_quest && !quests[ticket.linked_quest]) {
|
||||
warn(`${ctx}: linked_quest '${ticket.linked_quest}' not found`);
|
||||
}
|
||||
|
||||
if (ticket.initial_priority === undefined) {
|
||||
err(`${ctx}: missing 'initial_priority'`);
|
||||
}
|
||||
if (ticket.current_priority === undefined) {
|
||||
err(`${ctx}: missing 'current_priority'`);
|
||||
}
|
||||
if (ticket.initial_priority !== ticket.current_priority) {
|
||||
warn(`${ctx}: initial_priority != current_priority at authoring time`);
|
||||
}
|
||||
ok(`${ctx}: OK`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// INCIDENT VALIDATION
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log("\nValidating incidents...");
|
||||
for (const [iid, { data: incident, fname }] of Object.entries(incidents)) {
|
||||
const ctx = `Incident ${iid} (${fname})`;
|
||||
|
||||
if (!Array.isArray(incident.blast_radius_quests)) {
|
||||
err(`${ctx}: missing 'blast_radius_quests' (must be present, can be [])`);
|
||||
}
|
||||
if (!Array.isArray(incident.blast_radius_incidents)) {
|
||||
err(`${ctx}: missing 'blast_radius_incidents' (must be present, can be [])`);
|
||||
}
|
||||
if (incident.resolution_requirements?.validation) {
|
||||
validateRuleTypes(incident.resolution_requirements.validation, `${ctx} resolution_requirements`);
|
||||
validateVmSpecificSemantics(incident.resolution_requirements.validation, `${ctx} resolution_requirements`, vmProfiles);
|
||||
}
|
||||
ok(`${ctx}: OK`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DIALOGUE VALIDATION
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log("\nValidating dialogue...");
|
||||
const seriesMembers = {};
|
||||
for (const [did, { data: dlg, fname }] of Object.entries(dialogue)) {
|
||||
const ctx = `Dialogue ${did} (${fname})`;
|
||||
|
||||
if (dlg.series_id) {
|
||||
if (dlg.series_position === undefined) {
|
||||
err(`${ctx}: has series_id '${dlg.series_id}' but missing series_position`);
|
||||
}
|
||||
if (!seriesMembers[dlg.series_id]) seriesMembers[dlg.series_id] = [];
|
||||
seriesMembers[dlg.series_id].push(did);
|
||||
}
|
||||
|
||||
// world_flag trigger
|
||||
if (dlg.trigger && dlg.trigger.startsWith("world_flag:")) {
|
||||
const flagId = dlg.trigger.slice("world_flag:".length);
|
||||
if (!flagRegistry[flagId]) {
|
||||
err(`${ctx}: trigger flag '${flagId}' not in world_flags registry`);
|
||||
}
|
||||
}
|
||||
ok(`${ctx}: OK`);
|
||||
}
|
||||
|
||||
// series membership count check
|
||||
for (const [sid, members] of Object.entries(seriesMembers)) {
|
||||
if (members.length < 2) {
|
||||
warn(`Series '${sid}' has only 1 member (${members[0]}) — needs 2+ for series tracking`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WORLD FLAG CROSS-REFERENCE
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log("\nValidating world flag references...");
|
||||
const allContent = [
|
||||
...Object.values(quests).map(v => v.data),
|
||||
...Object.values(tickets).map(v => v.data),
|
||||
...Object.values(incidents).map(v => v.data),
|
||||
...Object.values(dialogue).map(v => v.data),
|
||||
];
|
||||
|
||||
const referencedFlags = new Set();
|
||||
for (const item of allContent) {
|
||||
collectFlagRefs(item, referencedFlags);
|
||||
}
|
||||
|
||||
for (const flagId of referencedFlags) {
|
||||
if (!flagRegistry[flagId]) {
|
||||
err(`World flag '${flagId}' is referenced in content but not declared in world_flags.json`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SUMMARY
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log("\n═══════════════════════════════════════");
|
||||
if (errors === 0 && warnings === 0) {
|
||||
console.log(" ✅ All content valid. Zero errors, zero warnings.");
|
||||
} else {
|
||||
if (errors > 0) console.log(` ❌ ${errors} error(s) found.`);
|
||||
if (warnings > 0) console.log(` ⚠ ${warnings} warning(s) found.`);
|
||||
}
|
||||
console.log("═══════════════════════════════════════\n");
|
||||
process.exit(errors > 0 ? 1 : 0);
|
||||
@@ -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